Files
clean-architecture-backend-…/fileserver-superpowers-package/fileserver-platform-implementation-plan.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

128 KiB

Fileserver Platform Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Spring 기반 Backend Skeleton에 로컬 파일시스템·PVC·제한형 NFS를 대상으로 안전한 streaming upload, 상태 기반 publish, HTTP Range 다운로드, MVC·WebFlux, Nginx 위임, tus 1.0을 제공하는 운영 가능한 Fileserver 플랫폼을 구현한다.

Architecture: fileserver-core-api는 저장소 구현과 Spring 타입이 새지 않는 ID·상태·Port를 정의하고, fileserver-application이 metadata와 content store를 조정한다. 로컬 저장소는 staging과 immutable content를 분리하고, 관계형 metadata DB의 version·lease·READY 상태가 공개 가능 여부를 결정한다. HTTP adapter, 검사, Nginx, 재개 업로드는 별도 모듈로 분리한다.

Tech Stack: Java 21, Gradle Kotlin DSL, Spring MVC, Spring WebFlux, Spring Data JPA, Flyway, Reactor, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Awaitility, BlockHound, Nginx.

Global Constraints

  • 공개 API에는 Path, 실제 파일명, mount 경로를 노출하지 않는다.
  • 공개 식별자는 opaque FileIdUploadId다.
  • metadata store가 상태와 공개 가능 여부의 authoritative source다.
  • READY가 아닌 파일은 direct와 Nginx 경로 모두에서 다운로드할 수 없다.
  • 로컬 staging·content·quarantine은 동일 FileStore에 둔다.
  • create-only가 기본이며 overwrite에는 If-Match 또는 metadata version이 필요하다.
  • 서버 계산 SHA-256과 actual size를 저장한다.
  • client filename과 Content-Type은 비신뢰 metadata다.
  • Spring MVC streaming은 bounded 전용 executor를 사용한다.
  • Spring WebFlux event loop에서 filesystem, JDBC, scanner blocking call을 실행하지 않는다.
  • multi-instance upload는 DB writer lease와 optimistic version을 사용한다.
  • NFS lock을 단독 정합성 근거로 사용하지 않는다.
  • timeout 후 write는 blind retry하지 않고 ambiguous completion을 표현한다.
  • tus 1.0은 Stable 모듈, HTTPbis draft-12는 Experimental 모듈이다.
  • arbitrary path, symlink follow, hard link 생성, recursive delete는 구현하지 않는다.
  • 실제 file ID, filename, path, checksum 원문을 metric label에 기록하지 않는다.
  • 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 진행한다.
  • 각 작업은 독립 검토가 가능한 하나의 커밋으로 종료한다.

1. 확정 파일 구조

backend-skeleton/
├── settings.gradle.kts
├── build.gradle.kts
├── build-logic/
│   └── src/main/kotlin/fileserver-library-conventions.gradle.kts
├── modules/fileserver/
│   ├── fileserver-core-api/
│   ├── fileserver-application/
│   ├── fileserver-metadata-jpa/
│   ├── fileserver-storage-local/
│   ├── fileserver-verification/
│   ├── fileserver-mvc/
│   ├── fileserver-webflux/
│   ├── fileserver-nginx/
│   ├── fileserver-admin/
│   ├── fileserver-tus/
│   ├── fileserver-resumable-httpbis-draft12/
│   ├── fileserver-spring-boot-starter/
│   └── fileserver-testkit/
├── infra/fileserver/
│   ├── nginx/
│   ├── nfs/
│   └── kubernetes/
├── docs/fileserver/
│   ├── support-matrix.md
│   ├── http-contract.md
│   ├── storage-certification.md
│   ├── security.md
│   ├── operations.md
│   └── upgrade-guide.md
└── docs/superpowers/specs/2026-08-07-fileserver-platform-design.md

2. 핵심 패키지

io.backend.skeleton.fileserver.api
io.backend.skeleton.fileserver.api.content
io.backend.skeleton.fileserver.api.error
io.backend.skeleton.fileserver.api.metadata
io.backend.skeleton.fileserver.api.security
io.backend.skeleton.fileserver.api.transfer
io.backend.skeleton.fileserver.application
io.backend.skeleton.fileserver.jpa
io.backend.skeleton.fileserver.local
io.backend.skeleton.fileserver.verification
io.backend.skeleton.fileserver.mvc
io.backend.skeleton.fileserver.webflux
io.backend.skeleton.fileserver.nginx
io.backend.skeleton.fileserver.admin
io.backend.skeleton.fileserver.tus
io.backend.skeleton.fileserver.httpbisdraft12
io.backend.skeleton.fileserver.autoconfigure
io.backend.skeleton.fileserver.testkit

Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성

Files:

  • Modify: settings.gradle.kts
  • Create: build-logic/src/main/kotlin/fileserver-library-conventions.gradle.kts
  • Create: modules/fileserver/fileserver-core-api/build.gradle.kts
  • Create: modules/fileserver/fileserver-application/build.gradle.kts
  • Create: modules/fileserver/fileserver-metadata-jpa/build.gradle.kts
  • Create: modules/fileserver/fileserver-storage-local/build.gradle.kts
  • Create: modules/fileserver/fileserver-verification/build.gradle.kts
  • Create: modules/fileserver/fileserver-mvc/build.gradle.kts
  • Create: modules/fileserver/fileserver-webflux/build.gradle.kts
  • Create: modules/fileserver/fileserver-nginx/build.gradle.kts
  • Create: modules/fileserver/fileserver-admin/build.gradle.kts
  • Create: modules/fileserver/fileserver-tus/build.gradle.kts
  • Create: modules/fileserver/fileserver-resumable-httpbis-draft12/build.gradle.kts
  • Create: modules/fileserver/fileserver-spring-boot-starter/build.gradle.kts
  • Create: modules/fileserver/fileserver-testkit/build.gradle.kts
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ModuleSmokeTest.java

Interfaces:

  • Produces all Gradle project paths used by later tasks.

  • fileserver-core-api must have no Spring MVC, WebFlux, JPA, NIO filesystem implementation dependency.

  • Java toolchain is 21.

  • Step 1: Write the failing core module smoke test

package io.backend.skeleton.fileserver.api;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ModuleSmokeTest {
    @Test
    void coreApiModuleLoads() {
        assertThat(ModuleSmokeTest.class.getPackageName())
            .isEqualTo("io.backend.skeleton.fileserver.api");
    }
}
  • Step 2: Register module paths and verify the build fails before module build files exist

Add to settings.gradle.kts:

include(
    ":modules:fileserver:fileserver-core-api",
    ":modules:fileserver:fileserver-application",
    ":modules:fileserver:fileserver-metadata-jpa",
    ":modules:fileserver:fileserver-storage-local",
    ":modules:fileserver:fileserver-verification",
    ":modules:fileserver:fileserver-mvc",
    ":modules:fileserver:fileserver-webflux",
    ":modules:fileserver:fileserver-nginx",
    ":modules:fileserver:fileserver-admin",
    ":modules:fileserver:fileserver-tus",
    ":modules:fileserver:fileserver-resumable-httpbis-draft12",
    ":modules:fileserver:fileserver-spring-boot-starter",
    ":modules:fileserver:fileserver-testkit"
)

Run:

./gradlew :modules:fileserver:fileserver-core-api:test

Expected: FAIL because the registered module build files do not exist.

  • Step 3: Add the convention plugin and module dependency boundaries

Create fileserver-library-conventions.gradle.kts:

plugins {
    `java-library`
    id("java-test-fixtures")
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
    failFast = false
}

dependencies {
    "testImplementation"(platform("org.junit:junit-bom:5.12.2"))
    "testImplementation"("org.junit.jupiter:junit-jupiter")
    "testImplementation"("org.assertj:assertj-core:3.27.3")
}

Apply it to every Fileserver module. Add only these directed dependencies:

application → core-api
metadata-jpa → core-api
storage-local → core-api
verification → core-api
mvc → application, core-api
webflux → application, core-api
nginx → application, core-api
admin → application, core-api
tus → application, core-api
httpbis-draft12 → application, core-api
starter → all runtime modules
testkit → core-api, application
  • Step 4: Run module tests and dependency report
./gradlew :modules:fileserver:fileserver-core-api:test \
  :modules:fileserver:fileserver-core-api:dependencies

Expected: PASS; dependency report contains no Spring MVC, WebFlux, Hibernate, or java.nio.file.Path-specific adapter library.

  • Step 5: Commit
git add settings.gradle.kts build-logic modules/fileserver
git commit -m "build: add fileserver module boundaries"

Task 2: 식별자, 상태, 범위 값 객체 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileId.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/UploadId.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ContentKey.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/StorageNamespace.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileState.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ByteRange.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileStateMachine.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/DefaultFileStateMachine.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/FileStateMachineTest.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ValueObjectTest.java

Interfaces:

  • Produces FileId, UploadId, ContentKey, StorageNamespace, FileState, ByteRange.

  • Later persistence and HTTP tasks use these exact types.

  • Step 1: Write failing value object and transition tests

class FileStateMachineTest {
    private final FileStateMachine stateMachine = new DefaultFileStateMachine();

    @Test
    void allowsUploadedToVerifying() {
        assertThat(stateMachine.canTransition(FileState.UPLOADED, FileState.VERIFYING))
            .isTrue();
    }

    @Test
    void rejectsCreatedToReady() {
        assertThatThrownBy(() ->
            stateMachine.requireTransition(FileState.CREATED, FileState.READY))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("CREATED -> READY");
    }
}
class ValueObjectTest {
    @Test
    void rejectsInvalidContentKey() {
        assertThatThrownBy(() -> new ContentKey("../../etc/passwd"))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void calculatesInclusiveRangeLength() {
        assertThat(new ByteRange(10, 19).length()).isEqualTo(10);
    }
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*FileStateMachineTest' --tests '*ValueObjectTest'

Expected: FAIL because the types do not exist.

  • Step 3: Implement exact state transitions and validation
public final class DefaultFileStateMachine implements FileStateMachine {
    private static final Map<FileState, Set<FileState>> ALLOWED = Map.ofEntries(
        Map.entry(FileState.CREATED, Set.of(FileState.UPLOADING)),
        Map.entry(FileState.UPLOADING, Set.of(
            FileState.UPLOADED, FileState.FAILED, FileState.EXPIRED, FileState.DELETING)),
        Map.entry(FileState.UPLOADED, Set.of(
            FileState.VERIFYING, FileState.FAILED, FileState.DELETING)),
        Map.entry(FileState.VERIFYING, Set.of(
            FileState.READY, FileState.QUARANTINED, FileState.REJECTED, FileState.FAILED)),
        Map.entry(FileState.QUARANTINED, Set.of(
            FileState.VERIFYING, FileState.READY, FileState.REJECTED, FileState.DELETING)),
        Map.entry(FileState.READY, Set.of(FileState.DELETING)),
        Map.entry(FileState.REJECTED, Set.of(FileState.DELETING)),
        Map.entry(FileState.FAILED, Set.of(
            FileState.UPLOADING, FileState.VERIFYING, FileState.DELETING, FileState.EXPIRED)),
        Map.entry(FileState.DELETING, Set.of(FileState.DELETED, FileState.FAILED)),
        Map.entry(FileState.EXPIRED, Set.of(FileState.DELETING)),
        Map.entry(FileState.DELETED, Set.of())
    );

    @Override
    public boolean canTransition(FileState current, FileState target) {
        return ALLOWED.getOrDefault(current, Set.of()).contains(target);
    }

    @Override
    public void requireTransition(FileState current, FileState target) {
        if (!canTransition(current, target)) {
            throw new IllegalStateException("illegal file transition: " + current + " -> " + target);
        }
    }
}

Implement ID records with non-null validation and ContentKey/namespace regex exactly as the design document.

  • Step 4: Run the module tests
./gradlew :modules:fileserver:fileserver-core-api:test

Expected: PASS.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api
git commit -m "feat: add fileserver core value objects and state machine"

Task 3: 안정된 오류 모델과 failure context 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverFailureContext.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadOffsetMismatchException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AmbiguousCompletionException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotReadyException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageFullException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/IntegrityMismatchException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotFoundException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAlreadyExistsException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/InvalidPathException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PathOutsideNamespaceException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAccessDeniedException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/QuotaExceededException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileTooLargeException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UnsupportedMediaTypeException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadExpiredException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AtomicPublishUnsupportedException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferTimeoutException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PartialWriteException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageUnavailableException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/ConcurrentFileModificationException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/MalwareDetectedException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/RangeNotSatisfiableException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferAdmissionRejectedException.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverErrorCode.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error/FileserverExceptionTest.java

Interfaces:

  • Produces FileserverException#context() and stable FileserverErrorCode values.

  • HTTP adapters map these errors without inspecting storage-driver exceptions.

  • Step 1: Write a failing ambiguous execution test

class FileserverExceptionTest {
    @Test
    void ambiguousCompletionCarriesReconciliationFlag() {
        AmbiguousCompletionException exception = new AmbiguousCompletionException(
            "publish result is unknown",
            FileserverFailureContext.forUpload(
                FileserverErrorCode.AMBIGUOUS_COMPLETION,
                new UploadId(UUID.randomUUID()),
                false,
                true,
                true
            )
        );

        assertThat(exception.context().ambiguous()).isTrue();
        assertThat(exception.context().reconciliationRequired()).isTrue();
        assertThat(exception.context().retryable()).isFalse();
    }
}
  • Step 2: Run the test to verify it fails
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*FileserverExceptionTest'

Expected: FAIL because the exception hierarchy does not exist.

  • Step 3: Implement the hierarchy and context
public abstract class FileserverException extends RuntimeException {
    private final FileserverFailureContext context;

    protected FileserverException(String message, FileserverFailureContext context) {
        super(message);
        this.context = Objects.requireNonNull(context, "context");
    }

    public final FileserverFailureContext context() {
        return context;
    }
}
public record FileserverFailureContext(
    FileserverErrorCode code,
    boolean retryable,
    boolean ambiguous,
    boolean reconciliationRequired,
    Optional<FileId> fileId,
    Optional<UploadId> uploadId,
    OptionalLong expectedOffset,
    OptionalLong currentOffset,
    Optional<FileState> currentState
) {}

Add all design error codes, including FILE_NOT_FOUND, FILE_NOT_READY, FILE_TOO_LARGE, QUOTA_EXCEEDED, STORAGE_FULL, UPLOAD_OFFSET_MISMATCH, INTEGRITY_MISMATCH, CONCURRENT_MODIFICATION, STORAGE_UNAVAILABLE, and AMBIGUOUS_COMPLETION.

  • Step 4: Run error tests
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*FileserverExceptionTest'

Expected: PASS.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error \
  modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error
git commit -m "feat: define fileserver failure semantics"

Task 4: Content Store capability와 blocking·async Port 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentStoreCapabilities.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/BlockingContentStore.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AsyncContentStore.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/UploadHandle.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/CreateContentCommand.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/FinalizeContentCommand.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AppendResult.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/StoredContent.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentMetadata.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeletePrecondition.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeleteResult.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/content/ContentStoreApiArchitectureTest.java

Interfaces:

  • Produces the exact storage SPI consumed by application and implemented by local storage.

  • No public signature may include Path, Resource, DataBuffer, Flux, or provider SDK types.

  • Step 1: Write a failing architecture test

class ContentStoreApiArchitectureTest {
    @Test
    void publicContentApiDoesNotExposeFrameworkOrFilesystemTypes() {
        Set<String> forbidden = Set.of(
            "java.nio.file.Path",
            "org.springframework.core.io.Resource",
            "org.springframework.core.io.buffer.DataBuffer",
            "reactor.core.publisher.Flux"
        );

        for (Method method : BlockingContentStore.class.getMethods()) {
            assertThat(method.getReturnType().getName()).isNotIn(forbidden);
            assertThat(Arrays.stream(method.getParameterTypes()).map(Class::getName))
                .doesNotContainAnyElementsOf(forbidden);
        }
    }
}
  • Step 2: Run the test to verify it fails
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*ContentStoreApiArchitectureTest'

Expected: FAIL because the interfaces do not exist.

  • Step 3: Implement the blocking and async contracts

Use these signatures exactly:

public interface BlockingContentStore {
    UploadHandle createUpload(CreateContentCommand command);
    AppendResult append(UploadHandle handle, long expectedOffset,
                        ReadableByteChannel source, long contentLength);
    StoredContent finalizeUpload(UploadHandle handle, FinalizeContentCommand command);
    ContentMetadata stat(ContentKey key);
    ReadableByteChannel openRead(ContentKey key, ByteRange range);
    DeleteResult delete(ContentKey key, DeletePrecondition precondition);
    ContentStoreCapabilities capabilities();
}
public interface AsyncContentStore {
    CompletionStage<UploadHandle> createUpload(CreateContentCommand command);
    CompletionStage<AppendResult> append(
        UploadHandle handle, long expectedOffset, Flow.Publisher<ByteBuffer> content);
    CompletionStage<StoredContent> finalizeUpload(
        UploadHandle handle, FinalizeContentCommand command);
    CompletionStage<ContentMetadata> stat(ContentKey key);
    Flow.Publisher<ByteBuffer> openRead(ContentKey key, ByteRange range);
    CompletionStage<DeleteResult> delete(
        ContentKey key, DeletePrecondition precondition);
    ContentStoreCapabilities capabilities();
}
  • Step 4: Run API and architecture tests
./gradlew :modules:fileserver:fileserver-core-api:test

Expected: PASS; jdeps or ArchUnit output confirms no forbidden adapter dependency.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api
git commit -m "feat: define content store ports"

Task 5: Metadata Store, upload session, lease, quota Port 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecord.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordDraft.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordMutation.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileDescriptor.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecoveryQuery.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileMetadataStore.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSession.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionDraft.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionStore.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/WriterLease.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/QuotaReservation.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileQuotaService.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/metadata/MetadataPortContractTest.java

Interfaces:

  • Produces optimistic transition and writer lease signatures used by Tasks 6, 12, 15, and 24.

  • Offset commit always requires a lease token and expected offset.

  • Step 1: Write failing port signature tests

class MetadataPortContractTest {
    @Test
    void offsetCommitRequiresLeaseAndExpectedOffset() throws Exception {
        Method method = UploadSessionStore.class.getMethod(
            "commitOffset",
            UploadId.class,
            WriterLease.class,
            long.class,
            long.class
        );

        assertThat(method.getReturnType()).isEqualTo(UploadSession.class);
    }

    @Test
    void fileTransitionRequiresExpectedVersionAndState() throws Exception {
        Method method = FileMetadataStore.class.getMethod(
            "transition",
            FileId.class,
            long.class,
            FileState.class,
            FileState.class,
            FileRecordMutation.class
        );

        assertThat(method).isNotNull();
    }
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*MetadataPortContractTest'

Expected: FAIL because the port types do not exist.

  • Step 3: Implement metadata records and exact methods
public interface FileMetadataStore {
    FileRecord insert(FileRecordDraft draft);
    Optional<FileRecord> find(FileId fileId);
    FileRecord transition(
        FileId fileId,
        long expectedVersion,
        FileState expectedState,
        FileState targetState,
        FileRecordMutation mutation
    );
    FileRecord markDeleting(FileId fileId, long expectedVersion);
    List<FileRecord> findRecoverable(FileRecoveryQuery query);
}
public interface UploadSessionStore {
    UploadSession create(UploadSessionDraft draft);
    Optional<UploadSession> find(UploadId uploadId);
    WriterLease acquireLease(
        UploadId uploadId,
        String owner,
        Instant now,
        Duration leaseDuration,
        long expectedVersion
    );
    UploadSession commitOffset(
        UploadId uploadId,
        WriterLease lease,
        long expectedOffset,
        long committedOffset
    );
    void releaseLease(UploadId uploadId, WriterLease lease);
    List<UploadSession> findExpired(Instant cutoff, int limit);
}
  • Step 4: Run the core API tests
./gradlew :modules:fileserver:fileserver-core-api:test

Expected: PASS.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api
git commit -m "feat: define fileserver metadata and lease ports"

Task 6: Flyway metadata schema와 JPA entity 구성

Files:

  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/resources/db/migration/fileserver/V1__create_fileserver_metadata.sql
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/FileEntity.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/UploadSessionEntity.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/VerificationResultEntity.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/QuotaReservationEntity.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/CleanupItemEntity.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaFileRepository.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaUploadSessionRepository.java
  • Test: modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/FileserverMigrationTest.java

Interfaces:

  • Consumes FileState, IDs, and metadata records from Tasks 2 and 5.

  • Produces database tables and JPA repositories used by Task 7.

  • Step 1: Write a failing migration test

@Testcontainers
class FileserverMigrationTest {
    @Container
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:17-alpine");

    @Test
    void createsFileserverTablesAndVersionColumns() throws Exception {
        Flyway.configure()
            .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
            .locations("classpath:db/migration/fileserver")
            .load()
            .migrate();

        try (Connection connection = DriverManager.getConnection(
                 POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) {
            assertThat(columnExists(connection, "fs_file", "version")).isTrue();
            assertThat(columnExists(connection, "fs_upload_session", "lease_until")).isTrue();
            assertThat(columnExists(connection, "fs_quota_reservation", "reserved_bytes")).isTrue();
        }
    }
}
  • Step 2: Run the migration test to verify it fails
./gradlew :modules:fileserver:fileserver-metadata-jpa:test \
  --tests '*FileserverMigrationTest'

Expected: FAIL because the migration does not exist.

  • Step 3: Create the schema and entity mappings

Use the following core DDL shape:

create table fs_file (
    file_id uuid primary key,
    namespace varchar(63) not null,
    state varchar(32) not null,
    content_key varchar(200),
    original_name varchar(255) not null,
    claimed_media_type varchar(255),
    verified_media_type varchar(255),
    expected_size bigint,
    actual_size bigint,
    sha256 char(64),
    strong_etag varchar(80),
    published_at timestamptz,
    last_error_code varchar(64),
    version bigint not null default 0,
    created_at timestamptz not null,
    updated_at timestamptz not null,
    constraint ck_fs_file_size check (actual_size is null or actual_size >= 0)
);

create table fs_upload_session (
    upload_id uuid primary key,
    file_id uuid not null references fs_file(file_id),
    protocol varchar(32) not null,
    expected_length bigint,
    committed_offset bigint not null default 0,
    expires_at timestamptz not null,
    lease_owner varchar(128),
    lease_token uuid,
    lease_until timestamptz,
    version bigint not null default 0,
    created_at timestamptz not null,
    updated_at timestamptz not null,
    constraint ck_fs_upload_offset check (committed_offset >= 0)
);

Add the verification, quota, and cleanup tables from the design with indexes on state, expiry, lease, and cleanup schedule. Map optimistic version with @Version.

  • Step 4: Run migration and JPA schema validation
./gradlew :modules:fileserver:fileserver-metadata-jpa:test \
  --tests '*FileserverMigrationTest'

Expected: PASS; Hibernate schema validation reports no mismatch.

  • Step 5: Commit
git add modules/fileserver/fileserver-metadata-jpa
git commit -m "feat: add fileserver metadata schema"

Task 7: JPA Metadata Store와 optimistic transition 구현

Files:

  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStore.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStore.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/FileEntityMapper.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/FileTransitionRepository.java
  • Create: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/UploadLeaseRepository.java
  • Test: modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStoreTest.java
  • Test: modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStoreTest.java

Interfaces:

  • Consumes metadata ports from Task 5 and schema from Task 6.

  • Produces transactional implementations used by the application layer.

  • Step 1: Write failing concurrent transition and lease tests

@Test
void onlyOneReadyTransitionWinsForTheSameVersion() {
    FileRecord record = fixture.insertVerifyingFile();

    CompletableFuture<FileRecord> first = async(() -> store.transition(
        record.fileId(), record.version(), FileState.VERIFYING, FileState.READY,
        FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag())));
    CompletableFuture<FileRecord> second = async(() -> store.transition(
        record.fileId(), record.version(), FileState.VERIFYING, FileState.READY,
        FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag())));

    assertThat(successCount(first, second)).isEqualTo(1);
    assertThat(concurrentModificationCount(first, second)).isEqualTo(1);
}
@Test
void onlyOneWriterLeaseIsValid() {
    UploadSession session = fixture.insertActiveUpload();
    Instant now = Instant.parse("2026-08-07T10:00:00Z");

    WriterLease first = store.acquireLease(
        session.uploadId(), "node-a", now, Duration.ofSeconds(30), session.version());

    assertThatThrownBy(() -> store.acquireLease(
        session.uploadId(), "node-b", now.plusSeconds(1), Duration.ofSeconds(30), session.version()))
        .isInstanceOf(ConcurrentFileModificationException.class);
    assertThat(first.owner()).isEqualTo("node-a");
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-metadata-jpa:test \
  --tests '*JpaFileMetadataStoreTest' --tests '*JpaUploadSessionStoreTest'

Expected: FAIL because store implementations do not exist.

  • Step 3: Implement conditional update repositories

Use an update query that includes both state and version:

@Modifying
@Query("""
    update FileEntity f
       set f.state = :targetState,
           f.contentKey = :contentKey,
           f.actualSize = :actualSize,
           f.sha256 = :sha256,
           f.strongEtag = :strongEtag,
           f.publishedAt = :publishedAt,
           f.version = f.version + 1,
           f.updatedAt = :updatedAt
     where f.fileId = :fileId
       and f.state = :expectedState
       and f.version = :expectedVersion
    """)
int transition(...);

Lease acquisition must update only when lease_until is null or lease_until < now and the expected version matches. commitOffset must require matching lease_token, current offset, and unexpired lease.

  • Step 4: Run all JPA tests
./gradlew :modules:fileserver:fileserver-metadata-jpa:test

Expected: PASS; repeated concurrency runs produce one winner only.

  • Step 5: Commit
git add modules/fileserver/fileserver-metadata-jpa
git commit -m "feat: implement fileserver metadata stores"

Task 8: 원본 파일명 sanitization과 path 정책 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicy.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/SanitizedFilename.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageLayout.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PhysicalPathResolver.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/DefaultPhysicalPathResolver.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicyTest.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/PhysicalPathResolverTest.java

Interfaces:

  • Produces sanitized display names and package-private physical path resolution.

  • No controller may call PhysicalPathResolver directly.

  • Step 1: Write failing malicious filename and root escape tests

class OriginalFilenamePolicyTest {
    private final OriginalFilenamePolicy policy = new OriginalFilenamePolicy(255);

    @Test
    void removesPathAndHeaderInjectionCharacters() {
        SanitizedFilename result = policy.sanitize("../report\r\nX-Test: yes.pdf");

        assertThat(result.value()).doesNotContain("..", "/", "\\", "\r", "\n");
        assertThat(result.value()).endsWith(".pdf");
    }

    @Test
    void replacesWindowsReservedName() {
        assertThat(policy.sanitize("CON").value()).isEqualTo("_CON");
    }
}
class PhysicalPathResolverTest {
    @TempDir Path root;

    @Test
    void generatedContentPathAlwaysStaysBelowContentRoot() {
        DefaultPhysicalPathResolver resolver = new DefaultPhysicalPathResolver(root);
        Path result = resolver.contentPath(new ContentKey("ab/cd/0123456789abcdef"));

        assertThat(result.normalize()).startsWith(root.resolve("content").normalize());
    }
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-core-api:test \
  :modules:fileserver:fileserver-storage-local:test \
  --tests '*OriginalFilenamePolicyTest' --tests '*PhysicalPathResolverTest'

Expected: FAIL because policy and resolver do not exist.

  • Step 3: Implement sanitization and server-generated layout

OriginalFilenamePolicy must:

strip path separators and NUL
replace control and bidi override characters
remove CR/LF and quote injection
trim trailing dot and space
prefix Windows reserved names with `_`
truncate by UTF-8 byte length, preserving the final extension when possible
return `file` when the normalized name becomes empty

DefaultPhysicalPathResolver must only accept validated IDs and construct:

staging/<first-two>/<next-two>/<upload-id>.part
content/<first-two>/<next-two>/<content-key>.bin
quarantine/<first-two>/<next-two>/<content-key>.bin
  • Step 4: Run filename and path tests
./gradlew :modules:fileserver:fileserver-core-api:test \
  :modules:fileserver:fileserver-storage-local:test

Expected: PASS.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-storage-local
git commit -m "feat: enforce fileserver filename and path policy"

Task 9: Local staging 생성과 CREATE_NEW 경쟁 제어 구현

Files:

  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProperties.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/SafeFileChannelFactory.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalUploadHandle.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadTest.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadConcurrencyTest.java

Interfaces:

  • Implements BlockingContentStore#createUpload from Task 4.

  • Produces LocalUploadHandle used by append and finalize tasks.

  • Step 1: Write failing create-only and concurrent-create tests

@Test
void createsStagingFileWithZeroLengthAndNoOriginalName() {
    UploadHandle handle = store.createUpload(commandFor("../../secret.pdf"));

    Path staging = testSupport.pathOf(handle);
    assertThat(staging).exists().isEmptyFile();
    assertThat(staging.getFileName().toString()).doesNotContain("secret.pdf");
}
@Test
void exactlyOneConcurrentCreateWinsForSameUploadId() {
    CreateContentCommand command = fixture.commandWithFixedUploadId();

    List<Throwable> failures = runConcurrently(2, () -> store.createUpload(command));

    assertThat(failures).hasSize(1);
    assertThat(failures.getFirst()).isInstanceOf(FileAlreadyExistsException.class);
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*LocalCreateUploadTest' --tests '*LocalCreateUploadConcurrencyTest'

Expected: FAIL because local store is not implemented.

  • Step 3: Implement safe staging creation

Open the staging file with:

Set<OpenOption> options = Set.of(
    StandardOpenOption.CREATE_NEW,
    StandardOpenOption.WRITE,
    LinkOption.NOFOLLOW_LINKS
);

Create parent directories from server-generated components only. Before and after open, verify that no parent is a symbolic link. Set owner-only permissions on POSIX providers. Convert FileAlreadyExistsException, AccessDeniedException, and FileSystemException into stable Fileserver errors.

  • Step 4: Run local storage creation tests repeatedly
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*LocalCreateUpload*' --rerun-tasks

Expected: PASS for 20 repeated runs; exactly one concurrent create succeeds.

  • Step 5: Commit
git add modules/fileserver/fileserver-storage-local
git commit -m "feat: create safe local upload staging files"

Task 10: Storage capability probe와 startup gate 구현

Files:

  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbe.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProbeResult.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/PublishMode.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidator.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbeTest.java
  • Test: modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidatorTest.java

Interfaces:

  • Produces runtime ContentStoreCapabilities and selected PublishMode.

  • Later finalize logic must consume this result instead of assuming atomic move.

  • Step 1: Write failing same-FileStore and required-atomic tests

@Test
void reportsAtomicCreateAndSameFileStore() {
    LocalStorageProbeResult result = probe.run();

    assertThat(result.atomicCreate()).isTrue();
    assertThat(result.sameFileStore()).isTrue();
    assertThat(result.symlinkNoFollow()).isTrue();
}
@Test
void requiredAtomicModeRejectsUnsupportedStorage() {
    LocalStorageProbeResult result = fixture.resultWithAtomicMove(false);

    assertThatThrownBy(() -> validator.validate(
        PublishMode.ATOMIC_MOVE_REQUIRED, result))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("atomic move");
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-storage-local:test \
  :modules:fileserver:fileserver-spring-boot-starter:test \
  --tests '*LocalStorageCapabilityProbeTest' \
  --tests '*FileserverStartupValidatorTest'

Expected: FAIL because probe and validator do not exist.

  • Step 3: Implement real filesystem probes

The probe must create files below ${root}/probe and verify:

writable root
concurrent CREATE_NEW
staging/content/quarantine FileStore equality
ATOMIC_MOVE
replace semantics
NOFOLLOW_LINKS
open-delete behavior
capacity access

Delete all probe artifacts in finally. In ATOMIC_MOVE_PREFERRED, return METADATA_POINTER as fallback when atomic move is unavailable. In ATOMIC_MOVE_REQUIRED, fail startup.

  • Step 4: Run probe tests and a local integration probe
./gradlew :modules:fileserver:fileserver-storage-local:test \
  :modules:fileserver:fileserver-spring-boot-starter:test

Expected: PASS; probe directory is empty after completion.

  • Step 5: Commit
git add modules/fileserver/fileserver-storage-local \
  modules/fileserver/fileserver-core-api \
  modules/fileserver/fileserver-spring-boot-starter
git commit -m "feat: probe fileserver storage capabilities"

Task 11: Streaming append, size 제한, SHA-256 계산 구현

Files:

  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalAppendEngine.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/StreamingDigest.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/TransferBufferPool.java
  • Modify: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendEngineTest.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendMemoryTest.java

Interfaces:

  • Implements BlockingContentStore#append.

  • Produces AppendResult(committedOffset, appendedBytes, sha256Snapshot).

  • Uses 128 KiB default buffer and never allocates proportional to file size.

  • Step 1: Write failing offset, digest, and bounded-buffer tests

@Test
void appendsAtExpectedOffsetAndCalculatesDigest() throws Exception {
    UploadHandle handle = fixture.emptyUpload();
    byte[] payload = "fileserver".getBytes(StandardCharsets.UTF_8);

    AppendResult result = store.append(
        handle, 0, Channels.newChannel(new ByteArrayInputStream(payload)), payload.length);

    assertThat(result.committedOffset()).isEqualTo(payload.length);
    assertThat(result.appendedBytes()).isEqualTo(payload.length);
    assertThat(result.sha256()).isEqualTo(sha256Hex(payload));
}

@Test
void rejectsOffsetMismatchWithoutWriting() throws Exception {
    UploadHandle handle = fixture.uploadContaining("abc");

    assertThatThrownBy(() -> store.append(
        handle, 2, Channels.newChannel(new ByteArrayInputStream("d".getBytes())), 1))
        .isInstanceOf(UploadOffsetMismatchException.class);

    assertThat(fixture.readBytes(handle)).isEqualTo("abc".getBytes());
}
@Test
void maxObservedBufferDoesNotGrowWithPayload() throws Exception {
    fixture.appendGeneratedBytes(256L * 1024 * 1024);
    assertThat(bufferPool.maxBorrowedBytes()).isLessThanOrEqualTo(128 * 1024);
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*LocalAppendEngineTest' --tests '*LocalAppendMemoryTest'

Expected: FAIL because append engine and digest tracking do not exist.

  • Step 3: Implement sequential channel append
public AppendResult append(
    Path staging,
    long expectedOffset,
    ReadableByteChannel source,
    long contentLength,
    long maximumFileSize
) {
    try (FileChannel target = FileChannel.open(
            staging, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
        long actualOffset = target.size();
        if (actualOffset != expectedOffset) {
            throw UploadOffsetMismatchException.of(expectedOffset, actualOffset);
        }
        target.position(expectedOffset);
        return copyAndDigest(target, source, contentLength, maximumFileSize);
    }
}

copyAndDigest must:

borrow one bounded buffer
update SHA-256 for every written byte
stop immediately when maximumFileSize would be exceeded
verify fixed contentLength when non-negative
return only after bytes are written to the channel
release the buffer in finally
  • Step 4: Run append tests and inspect heap allocation
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*LocalAppend*'

Expected: PASS; 256 MiB test uses at most the configured transfer buffer plus test harness overhead.

  • Step 5: Commit
git add modules/fileserver/fileserver-storage-local
git commit -m "feat: stream local file appends with sha256"

Task 12: Quota reservation과 transfer admission control 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/QuotaScope.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionController.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/DefaultTransferAdmissionController.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferPermit.java
  • Modify: modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionControllerTest.java
  • Test: modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaServiceTest.java

Interfaces:

  • Consumes FileQuotaService from Task 5.

  • Produces TransferPermit required before create or append.

  • Default standard profile: 100 MiB file, 16 instance uploads, 4 scope uploads, soft 70%, hard 85%.

  • Step 1: Write failing quota and concurrency tests

@Test
void rejectsWhenScopeConcurrencyIsExhausted() {
    TransferPermit first = controller.acquire(scope("tenant-a"), 10);
    TransferPermit second = controller.acquire(scope("tenant-a"), 10);
    TransferPermit third = controller.acquire(scope("tenant-a"), 10);
    TransferPermit fourth = controller.acquire(scope("tenant-a"), 10);

    assertThatThrownBy(() -> controller.acquire(scope("tenant-a"), 10))
        .isInstanceOf(QuotaExceededException.class);

    Stream.of(first, second, third, fourth).forEach(TransferPermit::close);
}
@Test
void reservationCommitUsesActualBytesAndReleasesRemainder() {
    QuotaReservation reservation = quota.reserve(scope, 1000, Duration.ofHours(1));
    quota.commit(reservation, 600);

    assertThat(fixture.committedBytes(scope)).isEqualTo(600);
    assertThat(fixture.reservedBytes(scope)).isZero();
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-metadata-jpa:test \
  --tests '*TransferAdmissionControllerTest' --tests '*JpaFileQuotaServiceTest'

Expected: FAIL because admission control is not implemented.

  • Step 3: Implement reservation and bounded permits

Use DB conditional updates for quota bytes and JVM semaphores for per-instance transfer concurrency. A create request with unknown length reserves the configured initial chunk; append extends the reservation before writing additional bytes. On cancellation or failure, release the reservation in finally or cleanup recovery.

public interface TransferAdmissionController {
    TransferPermit acquireUpload(QuotaScope scope, long requestedBytes);
    TransferPermit acquireDirectDownload(QuotaScope scope);
}

A hard storage high-water condition maps to StorageFullException; scope limit maps to QuotaExceededException; temporary permit exhaustion maps to TransferAdmissionRejectedException with retryable=true.

  • Step 4: Run quota and concurrency tests
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-metadata-jpa:test

Expected: PASS; no permit or reservation remains after test cleanup.

  • Step 5: Commit
git add modules/fileserver/fileserver-application \
  modules/fileserver/fileserver-metadata-jpa
git commit -m "feat: enforce fileserver quota and transfer admission"

Task 13: Atomic move와 metadata pointer publish 전략 구현

Files:

  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/ContentPublisher.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisher.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisher.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PublishResult.java
  • Modify: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisherTest.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisherTest.java

Interfaces:

  • Consumes PublishMode and probe results from Task 10.

  • Implements BlockingContentStore#finalizeUpload.

  • Produces immutable StoredContent and never exposes a partial final target.

  • Step 1: Write failing publish strategy tests

@Test
void atomicPublisherMovesStagingToCreateOnlyTarget() throws Exception {
    LocalUploadHandle handle = fixture.uploadContaining("ready");

    PublishResult result = publisher.publish(handle, fixture.finalizeCommand());

    assertThat(result.contentPath()).exists();
    assertThat(handle.stagingPath()).doesNotExist();
    assertThat(Files.readString(result.contentPath())).isEqualTo("ready");
}
@Test
void pointerPublisherKeepsImmutableObjectAndReturnsNewContentKey() throws Exception {
    LocalUploadHandle handle = fixture.uploadContaining("ready");

    PublishResult result = pointerPublisher.publish(handle, fixture.finalizeCommand());

    assertThat(result.contentKey()).isNotNull();
    assertThat(result.contentPath()).exists();
    assertThat(result.atomicMoveUsed()).isFalse();
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*ContentPublisherTest'

Expected: FAIL because publishers do not exist.

  • Step 3: Implement publish strategies

AtomicMoveContentPublisher must use ATOMIC_MOVE and omit REPLACE_EXISTING for create-only. MetadataPointerContentPublisher must complete an immutable physical object under a fresh ContentKey; public visibility remains false until the application commits metadata READY.

Both implementations must:

verify expected length
verify SHA-256
optionally force the channel according to durability profile
stat the final object
return actual size and content key
map uncertain filesystem results to AmbiguousCompletionException
  • Step 4: Run publish tests including process-visible observer checks
./gradlew :modules:fileserver:fileserver-storage-local:test \
  --tests '*ContentPublisherTest' --rerun-tasks

Expected: PASS; observers see no partial final target.

  • Step 5: Commit
git add modules/fileserver/fileserver-storage-local
git commit -m "feat: publish files with atomic or pointer strategy"

Task 14: Finalize orchestration과 READY invariant 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileVerificationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFinalizeUploadService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadRequest.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileView.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FinalizeUploadServiceTest.java

Interfaces:

  • Consumes metadata stores, content store, state machine, quota service.

  • Consumes the FileVerificationService Port created in this Task; Task 16 provides its production coordinator implementation. Tests use a deterministic ACCEPT stub.

  • Produces READY or non-public VERIFYING/REJECTED results.

  • Step 1: Write failing READY and checksum mismatch tests

@Test
void publishesAndTransitionsToReadyOnlyAfterPhysicalVerification() {
    FileView result = service.finalizeUpload(
        fixture.uploadedSession(),
        new FinalizeUploadRequest(Optional.of(fixture.sha256()), false),
        fixture.context());

    assertThat(result.state()).isEqualTo(FileState.READY);
    assertThat(fixture.metadata(result.fileId()).contentKey()).isPresent();
    assertThat(fixture.contentExists(result.fileId())).isTrue();
}
@Test
void digestMismatchNeverTransitionsToReady() {
    assertThatThrownBy(() -> service.finalizeUpload(
        fixture.uploadedSession(),
        new FinalizeUploadRequest(Optional.of("0".repeat(64)), false),
        fixture.context()))
        .isInstanceOf(IntegrityMismatchException.class);

    assertThat(fixture.fileState()).isEqualTo(FileState.REJECTED);
    assertThat(fixture.publicDownloadAvailable()).isFalse();
}
  • Step 2: Run the test to verify it fails
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FinalizeUploadServiceTest'

Expected: FAIL because finalize service does not exist.

  • Step 3: Implement the finalize sequence

Implement this exact order:

load upload and file
validate expected length
transition UPLOADING → UPLOADED when final append is complete
compare client digest if supplied
transition UPLOADED → VERIFYING
run verifier coordinator
on ACCEPT call contentStore.finalizeUpload
stat published object
transition VERIFYING → READY with content key, size, digest, etag, publishedAt
commit quota with actual bytes
release writer lease

On REJECT, transition to REJECTED and enqueue cleanup. On QUARANTINE, transition to QUARANTINED. Do not return READY when metadata transition fails after physical publish; enqueue reconciliation and throw AmbiguousCompletionException.

  • Step 4: Run finalize tests
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FinalizeUploadServiceTest'

Expected: PASS; every READY fixture has readable content and matching size/digest.

  • Step 5: Commit
git add modules/fileserver/fileserver-application
git commit -m "feat: finalize uploads with ready invariants"

Task 15: Ambiguous completion과 파일 reconciliation 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/DefaultFileReconciliationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationResult.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationStatus.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/RecoveryQueue.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationServiceTest.java

Interfaces:

  • Consumes content stat, metadata version/state, expected size/digest.

  • Produces CONFIRMED_SUCCESS, CONFIRMED_NOT_APPLIED, RECOVERABLE_PARTIAL, QUARANTINE_REQUIRED, or UNRESOLVED.

  • Step 1: Write failing ambiguous publish recovery tests

@Test
void confirmsSuccessWhenPhysicalObjectAndMetadataMatch() {
    fixture.preparePhysicalObjectAndVerifyingMetadata();

    ReconciliationResult result = service.reconcile(fixture.fileId());

    assertThat(result.status()).isEqualTo(ReconciliationStatus.CONFIRMED_SUCCESS);
    assertThat(fixture.fileState()).isEqualTo(FileState.READY);
}
@Test
void neverGuessesReadyWhenDigestCannotBeVerified() {
    fixture.prepareUnknownPhysicalObject();

    ReconciliationResult result = service.reconcile(fixture.fileId());

    assertThat(result.status()).isEqualTo(ReconciliationStatus.UNRESOLVED);
    assertThat(fixture.fileState()).isNotEqualTo(FileState.READY);
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FileReconciliationServiceTest'

Expected: FAIL because reconciliation is absent.

  • Step 3: Implement deterministic reconciliation

Use the following decision rules:

metadata READY + physical size/digest match → CONFIRMED_SUCCESS
metadata pre-publish + no physical target → CONFIRMED_NOT_APPLIED
staging exists + known committed offset → RECOVERABLE_PARTIAL
physical exists + expected key/size/digest match + version unchanged → transition READY
physical exists but key/size/digest differ → QUARANTINE_REQUIRED
insufficient evidence → UNRESOLVED

Never perform blind write retry from this service. Store recovery attempts and reason codes in the cleanup/recovery queue.

  • Step 4: Run recovery tests
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FileReconciliationServiceTest'

Expected: PASS; no unresolved case changes the file to READY.

  • Step 5: Commit
git add modules/fileserver/fileserver-application
git commit -m "feat: reconcile ambiguous fileserver operations"

Task 16: Verification pipeline과 quarantine 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileVerifier.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationRequest.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationResult.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationVerdict.java
  • Create: modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationCoordinator.java
  • Create: modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/Sha256Verifier.java
  • Create: modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/MediaTypeVerifier.java
  • Create: modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationPolicyCombiner.java
  • Test: modules/fileserver/fileserver-verification/src/test/java/io/backend/skeleton/fileserver/verification/VerificationCoordinatorTest.java

Interfaces:

  • Produces VerificationCoordinator#verify(VerificationRequest) consumed by Task 14.

  • Verifiers return only safe metadata and stable reason codes.

  • Step 1: Write failing accept, quarantine, and retry tests

@Test
void rejectDominatesAccept() {
    VerificationCoordinator coordinator = coordinator(
        verifier("digest", VerificationVerdict.ACCEPT),
        verifier("malware", VerificationVerdict.REJECT));

    VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join();

    assertThat(result.verdict()).isEqualTo(VerificationVerdict.REJECT);
    assertThat(result.code()).isEqualTo("MALWARE_REJECTED");
}

@Test
void scannerTimeoutDoesNotBecomeAccept() {
    VerificationCoordinator coordinator = coordinator(timeoutVerifier("scanner"));

    VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join();

    assertThat(result.verdict()).isEqualTo(VerificationVerdict.RETRY);
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-verification:test \
  --tests '*VerificationCoordinatorTest'

Expected: FAIL because verification types do not exist.

  • Step 3: Implement ordered verification and policy combination

Run verifiers in this order:

length
sha256
filename policy
media-type detection
signature/parser
optional malware scanner
optional CDR

Combination precedence is REJECT > QUARANTINE > RETRY > ACCEPT. Apply per-verifier timeout and record started/completed timestamps through the metadata adapter. Never log content samples or scanner raw payloads.

  • Step 4: Run verification tests
./gradlew :modules:fileserver:fileserver-verification:test

Expected: PASS; timeout, reject, quarantine, and accept paths are deterministic.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-verification
git commit -m "feat: add fileserver verification pipeline"

Task 17: Authorization hook과 upload application service 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessPolicy.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileOperation.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessSubject.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/RequestContext.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/UploadProtocol.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadApplicationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultUploadApplicationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/CreateUploadRequest.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadSessionView.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/AppendUploadResult.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/UploadApplicationServiceTest.java

Interfaces:

  • Consumes metadata, content store, quota, state machine, filename policy, access policy.

  • Produces create, append, status, cancel methods used by HTTP adapters.

  • Step 1: Write failing authorization, create, append, cancel tests

@Test
void authorizationRunsBeforeQuotaAndStorageMutation() {
    accessPolicy.deny(FileOperation.CREATE);

    assertThatThrownBy(() -> service.create(fixture.createRequest(), fixture.context()))
        .isInstanceOf(FileAccessDeniedException.class);

    assertThat(fixture.fileRecordCount()).isZero();
    assertThat(fixture.stagingFileCount()).isZero();
}
@Test
void createAppendAndCancelMaintainStateAndOffset() throws Exception {
    UploadSessionView created = service.create(fixture.createRequest(), fixture.context());
    AppendUploadResult appended = service.append(
        created.uploadId(), 0, fixture.channel("abc"), 3, fixture.context());
    service.cancel(created.uploadId(), fixture.context());

    assertThat(appended.committedOffset()).isEqualTo(3);
    assertThat(fixture.fileState(created.fileId())).isEqualTo(FileState.DELETING);
    assertThat(fixture.publicDownloadAvailable(created.fileId())).isFalse();
}
  • Step 2: Run the tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*UploadApplicationServiceTest'

Expected: FAIL because upload orchestration is absent.

  • Step 3: Implement create, append, status, cancel

Create sequence:

authorize CREATE
sanitize original filename
validate expected length
acquire admission permit
reserve quota
insert CREATED file
insert upload session
create staging
transition CREATED → UPLOADING
return offset 0 and expiry

Append sequence:

authorize APPEND
load non-expired session
acquire writer lease
validate metadata offset and physical length
extend quota reservation if needed
stream append
commit offset with lease token
release lease and transfer permit

Cancel sequence transitions to DELETING first, then queues cleanup. It does not synchronously remove large content from the request thread.

  • Step 4: Run upload application tests
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*UploadApplicationServiceTest'

Expected: PASS; authorization denial creates no side effect and offset commits are monotonic.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-application
git commit -m "feat: implement fileserver upload application flow"

Task 18: HTTP Range, validator, header contract core 구현

Files:

  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolver.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DefaultHttpRangeResolver.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/RangeBudget.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ResolvedRanges.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluator.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DownloadDecision.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ContentDispositionFactory.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolverTest.java
  • Test: modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluatorTest.java

Interfaces:

  • Produces a framework-neutral DownloadDecision used by MVC, WebFlux, and Nginx.

  • Default public budget is one range; optional multi-range budget is eight merged ranges.

  • Step 1: Write failing Range and conditional tests

@ParameterizedTest
@CsvSource({
    "bytes=0-9,0,9",
    "bytes=90-,90,99",
    "bytes=-10,90,99"
})
void resolvesSingleRanges(String header, long start, long end) {
    ResolvedRanges result = resolver.resolve(header, 100, RangeBudget.single());
    assertThat(result.ranges()).containsExactly(new ByteRange(start, end));
}

@Test
void unsatisfiableRangeCarriesRepresentationLength() {
    assertThatThrownBy(() -> resolver.resolve("bytes=100-200", 100, RangeBudget.single()))
        .isInstanceOf(RangeNotSatisfiableException.class)
        .extracting("representationLength")
        .isEqualTo(100L);
}
@Test
void mismatchedIfRangeFallsBackToFullResponse() {
    DownloadDecision result = evaluator.evaluate(fixture.requestWithIfRange("\"old\""),
        fixture.representation("\"new\"", 100));

    assertThat(result.status()).isEqualTo(200);
    assertThat(result.ranges()).isEmpty();
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*HttpRangeResolverTest' --tests '*ConditionalRequestEvaluatorTest'

Expected: FAIL because HTTP contract utilities do not exist.

  • Step 3: Implement parsing and decision order

Implement:

If-Match / If-Unmodified-Since
If-None-Match / If-Modified-Since
Range syntax and budget
If-Range
200 / 206 / 304 / 412 / 416

Merge overlapping ranges only when multi-range is enabled. Reject more than eight ranges or a total requested byte count above the configured budget. ContentDispositionFactory must emit sanitized ASCII filename and UTF-8 filename* without CR/LF.

  • Step 4: Run all transfer contract tests
./gradlew :modules:fileserver:fileserver-core-api:test \
  --tests '*transfer*'

Expected: PASS for first, middle, suffix, open-ended, empty, invalid, conditional, and If-Range cases.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api
git commit -m "feat: implement fileserver HTTP range contract"

Task 19: Spring MVC raw·multipart upload adapter 구현

Files:

  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileUploadController.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/RawUploadRequestMapper.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MultipartUploadRequestMapper.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcTransferExecutorConfiguration.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/BatchUploadResponse.java
  • Test: modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileUploadControllerTest.java
  • Test: modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/MvcUploadExecutorSaturationTest.java

Interfaces:

  • Consumes UploadApplicationService and FinalizeUploadService.

  • Implements POST /v1/files, POST /v1/files:raw, POST /v1/files:batch.

  • Step 1: Write failing MVC endpoint tests

@Test
void rawUploadStreamsWithoutCallingReadAllBytes() throws Exception {
    mockMvc.perform(post("/v1/files:raw")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .header("X-Filename", "report.bin")
            .content("abc"))
        .andExpect(status().isCreated())
        .andExpect(header().exists("Location"))
        .andExpect(jsonPath("$.state").value("READY"));

    verify(uploadService).append(any(), eq(0L), any(ReadableByteChannel.class), eq(3L), any());
}
@Test
void batchReturnsPerPartResultsAndIsExplicitlyNonAtomic() throws Exception {
    mockMvc.perform(multipart("/v1/files:batch")
            .file(new MockMultipartFile("files", "a.txt", "text/plain", "a".getBytes()))
            .file(new MockMultipartFile("files", "b.txt", "text/plain", "b".getBytes())))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.results.length()").value(2));
}
  • Step 2: Run MVC tests to verify they fail
./gradlew :modules:fileserver:fileserver-mvc:test \
  --tests '*FileUploadControllerTest' --tests '*MvcUploadExecutorSaturationTest'

Expected: FAIL because the controller and executor are absent.

  • Step 3: Implement controllers with bounded streaming executor

Use ServletInputStream through Channels.newChannel. Do not call getBytes() on MultipartFile. Submit blocking transfer work to a ThreadPoolTaskExecutor configured with core 8, max 32, queue 64. Convert rejection to retryable 429 or 503 with Retry-After.

Batch behavior:

maximum 16 parts
one independent upload per part
successes are retained when another part fails
return 200 with ordered result array
never expose container temp path
  • Step 4: Run MVC upload and saturation tests
./gradlew :modules:fileserver:fileserver-mvc:test

Expected: PASS; saturation does not create unbounded threads or queues.

  • Step 5: Commit
git add modules/fileserver/fileserver-mvc
git commit -m "feat: add MVC streaming upload endpoints"

Task 20: Spring MVC GET·HEAD·Range download adapter 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadApplicationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultDownloadApplicationService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadDescriptor.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileDownloadController.java
  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcDownloadResponseWriter.java
  • Test: modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileDownloadControllerContractTest.java

Interfaces:

  • Consumes authorization, metadata, HttpRangeResolver, conditional evaluator, content store.

  • Produces identical headers for GET and HEAD and exact 200/206/304/412/416 behavior.

  • Step 1: Write failing GET, HEAD, Range, and READY-gate tests

@Test
void headMatchesGetHeadersWithoutBody() throws Exception {
    MvcResult get = mockMvc.perform(get(contentUrl()).header("Authorization", token()))
        .andExpect(status().isOk())
        .andReturn();

    MvcResult head = mockMvc.perform(head(contentUrl()).header("Authorization", token()))
        .andExpect(status().isOk())
        .andExpect(content().bytes(new byte[0]))
        .andReturn();

    assertThat(head.getResponse().getHeader("ETag"))
        .isEqualTo(get.getResponse().getHeader("ETag"));
    assertThat(head.getResponse().getHeader("Content-Length"))
        .isEqualTo(get.getResponse().getHeader("Content-Length"));
}
@Test
void returnsPartialContentForSingleRange() throws Exception {
    mockMvc.perform(get(contentUrl())
            .header("Authorization", token())
            .header("Range", "bytes=2-4"))
        .andExpect(status().isPartialContent())
        .andExpect(header().string("Content-Range", "bytes 2-4/10"))
        .andExpect(content().bytes(new byte[]{2, 3, 4}));
}
@Test
void nonReadyFileIsNeverOpened() throws Exception {
    fixture.fileInState(FileState.VERIFYING);

    mockMvc.perform(get(contentUrl()).header("Authorization", token()))
        .andExpect(status().isConflict());

    verify(contentStore, never()).openRead(any(), any());
}
  • Step 2: Run MVC download tests to verify they fail
./gradlew :modules:fileserver:fileserver-mvc:test \
  --tests '*FileDownloadControllerContractTest'

Expected: FAIL because download service and controller do not exist.

  • Step 3: Implement application decision and MVC writer

DefaultDownloadApplicationService must authorize before opening content, require READY, evaluate validators and Range, then return a descriptor with status, headers, content key, and normalized ranges. MvcDownloadResponseWriter uses a StreamingResponseBody or repeatable file resource; it must not use InputStreamResource for Range.

Add headers:

ETag
Last-Modified
Accept-Ranges
Content-Type
Content-Disposition
Cache-Control
Content-Length or Content-Range

For 416, include Content-Range: bytes */<size>.

  • Step 4: Run full MVC HTTP contract tests
./gradlew :modules:fileserver:fileserver-mvc:test

Expected: PASS for full, HEAD, first, middle, suffix, unsatisfiable, ETag, If-Range, and non-READY cases.

  • Step 5: Commit
git add modules/fileserver/fileserver-application modules/fileserver/fileserver-mvc
git commit -m "feat: add MVC fileserver download contract"

Task 21: Spring WebFlux raw·multipart upload adapter 구현

Files:

  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveUploadApplicationService.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileUploadHandler.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/PartEventUploadReader.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/DataBufferByteBufferPublisher.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverIoScheduler.java
  • Test: modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileUploadHandlerTest.java
  • Test: modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/DataBufferReleaseTest.java
  • Test: modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/WebFluxBlockingCallTest.java

Interfaces:

  • Consumes AsyncContentStore when available or adapts the blocking application service on a dedicated bounded scheduler.

  • Every received pooled DataBuffer is forwarded or released exactly once.

  • Step 1: Write failing upload, cancellation, and buffer-release tests

@Test
void rawUploadConsumesFluxWithoutJoiningWholeBody() {
    webTestClient.post()
        .uri("/v1/files:raw")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .header("X-Filename", "large.bin")
        .body(Flux.just(buffer("abc"), buffer("def")), DataBuffer.class)
        .exchange()
        .expectStatus().isCreated()
        .expectBody()
        .jsonPath("$.state").isEqualTo("READY");

    assertThat(testBufferFactory.joinInvocationCount()).isZero();
}
@Test
void cancellationReleasesAllObservedBuffers() {
    StepVerifier.create(handler.consume(fixture.cancellableBuffers()))
        .thenCancel()
        .verify();

    assertThat(fixture.allocatedBufferCount()).isEqualTo(fixture.releasedBufferCount());
}
  • Step 2: Run WebFlux tests to verify they fail
./gradlew :modules:fileserver:fileserver-webflux:test \
  --tests '*FileUploadHandlerTest' --tests '*DataBufferReleaseTest' \
  --tests '*WebFluxBlockingCallTest'

Expected: FAIL because handlers and buffer adapters do not exist.

  • Step 3: Implement streaming adapters and dedicated scheduler

PartEventUploadReader must process windowed multipart events sequentially and enforce part count and byte limits. Use DataBufferUtils.release(buffer) in every discard, error, and cancellation path. For a blocking local store, schedule filesystem work on a fixed bounded scheduler named fileserver-io; never use the Reactor Netty event loop.

public final class FileserverIoScheduler implements AutoCloseable {
    private final Scheduler scheduler;

    public FileserverIoScheduler(int workers, int queueCapacity) {
        this.scheduler = Schedulers.newBoundedElastic(
            workers, queueCapacity, "fileserver-io", 60, false);
    }

    public Scheduler scheduler() {
        return scheduler;
    }
}
  • Step 4: Run WebFlux tests with leak detection and BlockHound
./gradlew :modules:fileserver:fileserver-webflux:test

Expected: PASS; no unreleased buffers and no blocking call on event-loop threads.

  • Step 5: Commit
git add modules/fileserver/fileserver-webflux
git commit -m "feat: add WebFlux streaming upload adapter"

Task 22: Spring WebFlux download와 zero-copy capability 구현

Files:

  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandler.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveDownloadResponseWriter.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ZeroCopyEligibility.java
  • Test: modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandlerContractTest.java
  • Test: modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/SlowClientBackpressureTest.java

Interfaces:

  • Reuses the exact DownloadDecision from Task 18.

  • Produces HTTP parity with Task 20.

  • Step 1: Write failing parity and backpressure tests

@Test
void rangeHeadersMatchMvcContract() {
    webTestClient.get()
        .uri(contentUrl())
        .header("Authorization", token())
        .header("Range", "bytes=2-4")
        .exchange()
        .expectStatus().isEqualTo(206)
        .expectHeader().valueEquals("Content-Range", "bytes 2-4/10")
        .expectBody().isEqualTo(new byte[]{2, 3, 4});
}
@Test
void slowSubscriberDoesNotExceedInFlightBufferLimit() {
    StepVerifier.withVirtualTime(() -> fixture.slowDownload())
        .thenAwait(Duration.ofSeconds(10))
        .thenCancel()
        .verify();

    assertThat(fixture.maxInFlightBuffers()).isLessThanOrEqualTo(8);
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-webflux:test \
  --tests '*FileDownloadHandlerContractTest' --tests '*SlowClientBackpressureTest'

Expected: FAIL because download handler is absent.

  • Step 3: Implement reactive write and optional zero-copy

For async stores, map Flow.Publisher<ByteBuffer> to Flux<DataBuffer> with bounded demand. For local files, use zero-copy only when the response implementation supports it, no body transformation is required, and TLS/runtime constraints allow it. Zero-copy remains an optimization and does not alter the public contract.

  • Step 4: Run WebFlux download contract tests
./gradlew :modules:fileserver:fileserver-webflux:test

Expected: PASS; MVC and WebFlux golden HTTP snapshots are equal for shared scenarios.

  • Step 5: Commit
git add modules/fileserver/fileserver-webflux
git commit -m "feat: add WebFlux fileserver downloads"

Task 23: Nginx X-Accel-Redirect 위임 구현

Files:

  • Create: modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapper.java
  • Create: modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/DefaultNginxInternalUriMapper.java
  • Create: modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDownloadStrategy.java
  • Create: modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDelegationProperties.java
  • Create: infra/fileserver/nginx/nginx.conf
  • Test: modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapperTest.java
  • Test: modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxDownloadIntegrationTest.java

Interfaces:

  • Consumes an authorized READY DownloadDescriptor.

  • Produces a validated relative internal URI, never an absolute physical path.

  • Default threshold is 16 MiB.

  • Step 1: Write failing URI mapping and internal-path tests

@Test
void mapsValidatedContentKeyWithoutExposingAbsolutePath() {
    String internalUri = mapper.map(new ContentKey("ab/cd/0123456789abcdef"));

    assertThat(internalUri).isEqualTo("/__files/ab/cd/0123456789abcdef.bin");
    assertThat(internalUri).doesNotContain("/var/lib", "..", "\");
}

@Test
void rejectsMalformedContentKeyEvenWhenCalledInternally() {
    assertThatThrownBy(() -> mapper.mapUnchecked("../../etc/passwd"))
        .isInstanceOf(InvalidPathException.class);
}
@Test
void directAccessToInternalLocationIsRejected() {
    nginxClient.get("/__files/ab/cd/0123456789abcdef.bin")
        .expectStatus(404);
}
  • Step 2: Run unit and integration tests to verify they fail
./gradlew :modules:fileserver:fileserver-nginx:test \
  --tests '*NginxInternalUriMapperTest' --tests '*NginxDownloadIntegrationTest'

Expected: FAIL because URI mapper and Nginx configuration do not exist.

  • Step 3: Implement safe relative mapping and Nginx internal location

DefaultNginxInternalUriMapper accepts only a validated ContentKey, rebuilds the shard components, and returns a URI below /__files/. Configure Nginx:

location /__files/ {
    internal;
    alias /srv/files/content/;
    sendfile on;
    sendfile_max_chunk 2m;
    add_header X-Content-Type-Options nosniff always;
}

The application response includes X-Accel-Redirect only after authorization and READY gate. Ensure the header is consumed by Nginx and not copied to the client. The resulting URI path after /__files/ must map exactly to the local content layout.

  • Step 4: Run direct-vs-Nginx HTTP parity tests
./gradlew :modules:fileserver:fileserver-nginx:test

Expected: PASS for full GET, HEAD, Range, ETag, Content-Disposition, private cache headers, and external internal-location rejection.

  • Step 5: Commit
git add modules/fileserver/fileserver-nginx infra/fileserver/nginx
git commit -m "feat: delegate large downloads to nginx"

Task 24: Delete, copy, move, cleanup lifecycle 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileLifecycleService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFileLifecycleService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/DefaultCleanupService.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupItem.java
  • Modify: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FileLifecycleServiceTest.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/cleanup/CleanupServiceTest.java

Interfaces:

  • Implements logical delete first, bounded asynchronous physical cleanup.

  • Public move changes logical namespace metadata only.

  • Copy defaults to create-only target.

  • Step 1: Write failing delete and cleanup-race tests

@Test
void logicalDeleteBlocksDownloadBeforePhysicalDeleteCompletes() {
    fixture.readyFileWithSlowPhysicalDelete();

    service.delete(fixture.fileId(), fixture.version(), fixture.context());

    assertThat(fixture.fileState()).isEqualTo(FileState.DELETING);
    assertThat(fixture.publicDownloadAvailable()).isFalse();
    assertThat(fixture.physicalObjectExists()).isTrue();
}
@Test
void cleanupDoesNotDeleteContentOwnedByAnActiveLease() {
    fixture.cleanupItemForActiveUpload();

    CleanupBatchResult result = cleanup.runBatch(100, 1L << 30);

    assertThat(result.skippedActiveLease()).isEqualTo(1);
    assertThat(fixture.physicalObjectExists()).isTrue();
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest'

Expected: FAIL because lifecycle services do not exist.

  • Step 3: Implement lifecycle operations

Delete:

authorize DELETE
validate If-Match/version
transition to DELETING
enqueue cleanup
return 202 or 204
worker deletes physical content
release quota
transition to DELETED

Copy creates a new FileRecord and physical target; partial target is queued for cleanup on failure. Move changes logical namespace metadata without moving immutable physical content. Cleanup verifies state, version, lease, and content key before deleting.

  • Step 4: Run lifecycle tests
./gradlew :modules:fileserver:fileserver-application:test \
  --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest'

Expected: PASS; active content is never deleted and logical delete blocks reads immediately.

  • Step 5: Commit
git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local
git commit -m "feat: implement fileserver lifecycle and cleanup"

Task 25: 별도 Admin Plane 구현

Files:

  • Create: modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/FileserverAdminController.java
  • Create: modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/StorageHealthView.java
  • Create: modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/OrphanAdminService.java
  • Create: modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/AdminAuditService.java
  • Test: modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/FileserverAdminControllerTest.java
  • Test: modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/OrphanAdminServiceTest.java

Interfaces:

  • Exposes management-only health, capabilities, orphan dry-run/apply, reverify, force-delete, incomplete upload cleanup.

  • Never returns physical root, filename, raw scanner data, or signed tokens.

  • Step 1: Write failing management-isolation and dry-run tests

@Test
void publicApplicationPortDoesNotExposeAdminEndpoints() {
    publicWebClient.get().uri("/internal/fileserver/capabilities")
        .exchange()
        .expectStatus().isNotFound();
}

@Test
void orphanReconcileDefaultsToDryRun() {
    managementWebClient.post().uri("/internal/fileserver/orphans:reconcile")
        .bodyValue(Map.of("limit", 100))
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .jsonPath("$.dryRun").isEqualTo(true);

    assertThat(fixture.deletedObjectCount()).isZero();
}
  • Step 2: Run admin tests to verify they fail
./gradlew :modules:fileserver:fileserver-admin:test \
  --tests '*FileserverAdminControllerTest' --tests '*OrphanAdminServiceTest'

Expected: FAIL because the admin module is not implemented.

  • Step 3: Implement management-only endpoints and audit

Implement endpoints from the design. force-delete requires an explicit reason and a second authorization predicate. Orphan apply requests require dryRun=false, expected object fingerprint, and bounded byte budget. Audit records operation, reason code, actor fingerprint, result, and trace ID without path or filename.

  • Step 4: Run admin isolation and behavior tests
./gradlew :modules:fileserver:fileserver-admin:test

Expected: PASS; admin routes exist only on the management context and all mutating actions emit audit records.

  • Step 5: Commit
git add modules/fileserver/fileserver-admin
git commit -m "feat: add isolated fileserver admin plane"

Task 26: 다중 인스턴스 writer lease와 NFS ambiguity 처리 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/WriterLeaseCoordinator.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/DefaultWriterLeaseCoordinator.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/LeaseHeartbeat.java
  • Create: modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetector.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/concurrency/MultiInstanceWriterLeaseTest.java
  • Test: modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetectorTest.java

Interfaces:

  • Builds on DB lease methods from Task 7.

  • A writer whose lease token expired or changed may not commit offset or READY state.

  • Filesystem timeout with possible server-side completion becomes AmbiguousCompletionException.

  • Step 1: Write failing two-node and expired-writer tests

@Test
void onlyOneNodeCanAppendTheSameUpload() {
    UploadId uploadId = fixture.activeUpload();

    CompletableFuture<AppendUploadResult> nodeA = node("a").append(uploadId, 0, "abc");
    CompletableFuture<AppendUploadResult> nodeB = node("b").append(uploadId, 0, "xyz");

    assertThat(successCount(nodeA, nodeB)).isEqualTo(1);
    assertThat(conflictCount(nodeA, nodeB)).isEqualTo(1);
    assertThat(fixture.committedOffset(uploadId)).isEqualTo(3);
}
@Test
void pausedWriterCannotCommitAfterLeaseTakeover() {
    WriterLease stale = coordinator.acquire(fixture.uploadId(), "node-a");
    clock.advance(Duration.ofMinutes(1));
    WriterLease current = coordinator.acquire(fixture.uploadId(), "node-b");

    assertThatThrownBy(() -> coordinator.commitOffset(stale, 0, 3))
        .isInstanceOf(ConcurrentFileModificationException.class);
    assertThat(current.owner()).isEqualTo("node-b");
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-storage-local:test \
  --tests '*MultiInstanceWriterLeaseTest' \
  --tests '*AmbiguousFilesystemOperationDetectorTest'

Expected: FAIL because coordinator and ambiguity classification are absent.

  • Step 3: Implement lease heartbeat and ambiguity classification

Heartbeat renews at one third of the lease duration. Every commit validates upload ID, owner, token, expiry, expected offset, and metadata version. Do not use FileLock as a correctness dependency.

Classify NFS-style outcomes:

request definitely not sent → retryable failure
server explicitly rejected → definite failure
response lost after possible rename/write → ambiguous completion
stale handle with physical evidence available → reconciliation required
  • Step 4: Run multi-instance tests with repeated scheduling jitter
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-storage-local:test \
  --tests '*MultiInstanceWriterLeaseTest' \
  --tests '*AmbiguousFilesystemOperationDetectorTest' --rerun-tasks

Expected: PASS; no run commits bytes from a stale lease.

  • Step 5: Commit
git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local
git commit -m "feat: enforce multi-instance fileserver leases"

Task 27: tus 1.0 Stable 모듈 구현

Files:

  • Create: modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusController.java
  • Create: modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusRequestParser.java
  • Create: modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusResponseHeaders.java
  • Create: modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusProperties.java
  • Create: modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusChecksumVerifier.java
  • Test: modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusProtocolContractTest.java
  • Test: modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusOffsetConcurrencyTest.java

Interfaces:

  • Consumes UploadApplicationService create/status/append/cancel.

  • Supports creation, HEAD, PATCH, checksum, expiration, termination.

  • Concatenation is Beta and feature-flagged.

  • Step 1: Write failing tus creation, HEAD, PATCH, mismatch tests

@Test
void createsAndAppendsTusUpload() {
    String location = client.post("/v1/uploads")
        .header("Tus-Resumable", "1.0.0")
        .header("Upload-Length", "6")
        .expectStatus(201)
        .returnHeader("Location");

    client.patch(location)
        .header("Tus-Resumable", "1.0.0")
        .header("Upload-Offset", "0")
        .contentType("application/offset+octet-stream")
        .body("abc")
        .expectStatus(204)
        .expectHeader("Upload-Offset", "3");

    client.head(location)
        .header("Tus-Resumable", "1.0.0")
        .expectStatus(204)
        .expectHeader("Upload-Offset", "3");
}
@Test
void mismatchedOffsetReturns409WithoutMutation() {
    fixture.uploadAtOffset(3);

    client.patch(fixture.location())
        .header("Tus-Resumable", "1.0.0")
        .header("Upload-Offset", "1")
        .contentType("application/offset+octet-stream")
        .body("x")
        .expectStatus(409);

    assertThat(fixture.offset()).isEqualTo(3);
}
  • Step 2: Run tus tests to verify they fail
./gradlew :modules:fileserver:fileserver-tus:test \
  --tests '*TusProtocolContractTest' --tests '*TusOffsetConcurrencyTest'

Expected: FAIL because tus endpoints do not exist.

  • Step 3: Implement tus 1.0 protocol mapping

Implement:

POST creation with Location
HEAD with Upload-Offset and Upload-Length
PATCH application/offset+octet-stream
409 on offset mismatch without body mutation
Upload-Checksum validation
Upload-Expires
DELETE termination
Tus-Resumable validation on every protocol request

Use one writer lease per upload. Return 410 after expiration and release quota on termination. Concatenation uses independent part resources and verifies each part before final combine.

  • Step 4: Run tus protocol suite
./gradlew :modules:fileserver:fileserver-tus:test

Expected: PASS for create, append, resume after restart, checksum, expiry, termination, and concurrent offset conflict.

  • Step 5: Commit
git add modules/fileserver/fileserver-tus
git commit -m "feat: add tus 1.0 resumable uploads"

Task 28: HTTPbis resumable upload draft-12 Experimental 모듈 구현

Files:

  • Create: modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12UploadController.java
  • Create: modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Headers.java
  • Create: modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProblemDetails.java
  • Create: modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Properties.java
  • Test: modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProtocolTest.java
  • Test: modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/DraftIsolationTest.java

Interfaces:

  • Reuses application upload services but has a distinct endpoint namespace and media types.

  • Module is disabled by default and its package, properties, and docs include draft12.

  • Step 1: Write failing draft protocol and isolation tests

@Test
void disabledDraftDoesNotRegisterEndpoints() {
    contextRunner.withPropertyValues("backend.fileserver.httpbis-draft12.enabled=false")
        .run(context -> assertThat(context).doesNotHaveBean(Draft12UploadController.class));
}
@Test
void offsetMismatchReturnsDraftProblemDetail() {
    fixture.uploadAtOffset(10);

    client.patch(fixture.draftLocation())
        .header("Upload-Offset", "5")
        .contentType("application/partial-upload")
        .body("abc")
        .expectStatus(409)
        .expectJsonPath("$.expectedOffset", 10)
        .expectJsonPath("$.providedOffset", 5);
}
  • Step 2: Run tests to verify they fail
./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test

Expected: FAIL because the Experimental module is absent.

  • Step 3: Implement draft-12 behind an explicit feature flag

Implement only the researched draft-12 contract: Upload-Offset, Upload-Complete, application/partial-upload, offset mismatch problem detail, and runtime capability for 104 interim response. Do not share controller paths or DTOs with tus. Add an ExperimentalApi marker annotation and runtime warning on enablement.

  • Step 4: Run isolation and protocol tests
./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test

Expected: PASS; disabled mode registers no endpoints and Stable modules have no dependency on draft types.

  • Step 5: Commit
git add modules/fileserver/fileserver-resumable-httpbis-draft12
git commit -m "feat: add experimental HTTP resumable draft12"

Task 29: HTTP Problem Detail과 보안 hardening 통합 구현

Files:

  • Create: modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileserverMvcExceptionHandler.java
  • Create: modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverWebFluxExceptionHandler.java
  • Create: modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverProblem.java
  • Create: modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/ScriptableContentPolicy.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/PathTraversalSecurityTest.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SymlinkRaceSecurityTest.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/FilenameInjectionSecurityTest.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/RangeBombSecurityTest.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/ScriptableContentSecurityTest.java

Interfaces:

  • Maps the same core failure context to MVC and WebFlux application/problem+json.

  • Security tests run against both adapters.

  • Step 1: Write failing problem-detail and attack tests

@Test
void offsetMismatchProblemDoesNotExposePath() {
    ProblemResponse response = client.patchOffsetMismatch();

    assertThat(response.status()).isEqualTo(409);
    assertThat(response.json("code")).isEqualTo("UPLOAD_OFFSET_MISMATCH");
    assertThat(response.body()).doesNotContain("/var/lib", "staging", "java.nio.file");
}
@ParameterizedTest
@ValueSource(strings = {"../x", "%2e%2e%2fx", "/etc/passwd", "C:\\Windows\\system.ini"})
void rejectsPathShapedInputs(String input) {
    client.uploadWithFilename(input).expectNoStorageEscape();
}
@Test
void excessiveRangesAreRejectedBeforeContentOpen() {
    client.getWithRange("bytes=0-0,2-2,4-4,6-6,8-8,10-10,12-12,14-14,16-16")
        .expectClientError();
    assertThat(fixture.contentOpenCount()).isZero();
}
  • Step 2: Run security tests to verify they fail
./gradlew :modules:fileserver:fileserver-testkit:test \
  --tests '*security*'

Expected: FAIL because unified error mapping and all guards are not connected.

  • Step 3: Implement error mapping and hardening

Map every FileserverErrorCode to the design status code and emit:

{
  "type": "urn:fileserver:problem:<code>",
  "title": "stable title",
  "status": 409,
  "code": "UPLOAD_OFFSET_MISMATCH",
  "retryable": true,
  "traceId": "..."
}

Add X-Content-Type-Options: nosniff; default scriptable content to attachment; enforce range budget before content open; ensure symlink checks occur at open time, not only at path construction.

  • Step 4: Run MVC, WebFlux, and security suites
./gradlew :modules:fileserver:fileserver-mvc:test \
  :modules:fileserver:fileserver-webflux:test \
  :modules:fileserver:fileserver-testkit:test \
  --tests '*security*' --tests '*ExceptionHandler*'

Expected: PASS; MVC and WebFlux problem JSON is equivalent and contains no sensitive path data.

  • Step 5: Commit
git add modules/fileserver/fileserver-core-api \
  modules/fileserver/fileserver-mvc \
  modules/fileserver/fileserver-webflux \
  modules/fileserver/fileserver-verification \
  modules/fileserver/fileserver-testkit
git commit -m "feat: harden fileserver HTTP and error handling"

Task 30: Metric, trace, audit와 민감정보 차단 구현

Files:

  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverMetrics.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverTracing.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/SafeFileFingerprint.java
  • Create: modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverAuditEvent.java
  • Test: modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/observability/FileserverObservabilityTest.java
  • Test: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SensitiveTelemetryLeakTest.java

Interfaces:

  • Produces metric names and spans defined in the design.

  • High-cardinality IDs and raw metadata are prohibited.

  • Step 1: Write failing metric and leak tests

@Test
void uploadMetricUsesBoundedTags() {
    metrics.recordUpload(
        UploadProtocol.RAW,
        "LOCAL",
        "READY",
        SizeBucket.MEDIUM,
        Duration.ofMillis(10),
        1024);

    Meter meter = registry.find("fileserver.upload.duration").meter();
    assertThat(meter.getId().getTags())
        .extracting(Tag::getKey)
        .containsExactlyInAnyOrder("protocol", "storage", "result", "size_bucket");
}
@Test
void telemetryNeverContainsFilenamePathOrRawIds() {
    fixture.runUpload("private-name.pdf", "/var/lib/backend/files", fixture.fileId());

    assertThat(fixture.allTelemetryText())
        .doesNotContain("private-name.pdf", "/var/lib/backend/files", fixture.fileId().toString());
}
  • Step 2: Run observability tests to verify they fail
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-testkit:test \
  --tests '*FileserverObservabilityTest' --tests '*SensitiveTelemetryLeakTest'

Expected: FAIL because instrumentation is absent.

  • Step 3: Implement bounded metrics, spans, and audit

Add timers/counters for upload, download, active transfer, interruption, offset mismatch, checksum, verification queue, temp/orphan, quota, cleanup, delegation, and access denial. Add spans named exactly as the design. When correlation is required, use a keyed HMAC fingerprint; never emit the raw file ID or checksum.

  • Step 4: Run observability and sensitive-log tests
./gradlew :modules:fileserver:fileserver-application:test \
  :modules:fileserver:fileserver-testkit:test \
  --tests '*Observability*' --tests '*SensitiveTelemetryLeakTest'

Expected: PASS; all tags belong to the approved bounded vocabulary.

  • Step 5: Commit
git add modules/fileserver/fileserver-application modules/fileserver/fileserver-testkit
git commit -m "feat: add safe fileserver observability"

Task 31: Spring Boot properties와 auto-configuration 구현

Files:

  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverProperties.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfiguration.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverMvcAutoConfiguration.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverWebFluxAutoConfiguration.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverNginxAutoConfiguration.java
  • Create: modules/fileserver/fileserver-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  • Test: modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfigurationTest.java
  • Test: modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverPropertiesValidationTest.java

Interfaces:

  • Binds the exact backend.fileserver.* property tree from the design.

  • Creates MVC or WebFlux adapters only when their runtime is present.

  • Production startup must fail without a real FileAccessPolicy.

  • Step 1: Write failing default-binding and invalid-startup tests

@Test
void bindsStandardProfileDefaults() {
    contextRunner.withPropertyValues(
            "backend.fileserver.enabled=true",
            "backend.fileserver.storage.root=" + tempDir)
        .withUserConfiguration(TestAccessPolicyConfiguration.class)
        .run(context -> {
            FileserverProperties properties = context.getBean(FileserverProperties.class);
            assertThat(properties.upload().maxFileSize()).isEqualTo(DataSize.ofMegabytes(100));
            assertThat(properties.storage().bufferSize()).isEqualTo(DataSize.ofKilobytes(128));
            assertThat(properties.upload().maxParts()).isEqualTo(16);
        });
}
@Test
void productionRejectsNoOpAuthorizationPolicy() {
    contextRunner.withPropertyValues(
            "spring.profiles.active=prod",
            "backend.fileserver.enabled=true",
            "backend.fileserver.storage.root=" + tempDir)
        .run(context -> assertThat(context).hasFailed());
}
  • Step 2: Run starter tests to verify they fail
./gradlew :modules:fileserver:fileserver-spring-boot-starter:test \
  --tests '*FileserverAutoConfigurationTest' \
  --tests '*FileserverPropertiesValidationTest'

Expected: FAIL because properties and auto-configurations do not exist.

  • Step 3: Implement typed properties and conditional beans

Bind these groups exactly:

storage
upload
download
nginx
verification
quota
cleanup
tus
httpbis-draft12
mvc.executor
webflux

Validate:

root is absolute and outside configured webroot/config roots
maxRequestSize >= maxFileSize
soft limit < hard limit
maxRanges between 1 and 8
ATOMIC_MOVE_REQUIRED matches probe
scanner-required has a verifier bean
nginx enabled has token service and internal prefix
tus and draft endpoints do not collide

Use @ConditionalOnWebApplication and @ConditionalOnClass so MVC and WebFlux adapters do not appear together accidentally unless an explicit dual-adapter test application requests both.

  • Step 4: Run starter context tests
./gradlew :modules:fileserver:fileserver-spring-boot-starter:test

Expected: PASS; invalid property combinations fail during context startup with stable validation messages.

  • Step 5: Commit
git add modules/fileserver/fileserver-spring-boot-starter
git commit -m "feat: add fileserver Spring Boot starter"

Task 32: Filesystem, HTTP, fault, performance Testkit 구현

Files:

  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ContentStoreContract.java
  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/HttpDownloadContract.java
  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/CrashPoint.java
  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ProcessCrashHarness.java
  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/NfsTestEnvironment.java
  • Create: modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/PvcCertificationDescriptor.java
  • Create: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LocalContentStoreContractTest.java
  • Create: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/CrashRecoveryMatrixTest.java
  • Create: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LargeFileBoundedMemoryTest.java
  • Create: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/NfsAmbiguityIntegrationTest.java
  • Create: infra/fileserver/nfs/compose.yml
  • Create: infra/fileserver/kubernetes/pvc-certification-job.yaml

Interfaces:

  • Produces reusable contracts for future Object Storage adapters.

  • Provides crash points before/after append, publish, and metadata commit.

  • Certification descriptors identify Kubernetes, CSI, StorageClass, access mode, backend, and mount options.

  • Step 1: Write failing contract and crash-matrix tests

abstract class ContentStoreContract {
    protected abstract BlockingContentStore store();

    @Test
    void createAppendFinalizeStatReadDeleteRoundTrip() throws Exception {
        UploadHandle handle = store().createUpload(fixture.createCommand());
        store().append(handle, 0, fixture.channel("abcdef"), 6);
        StoredContent content = store().finalizeUpload(handle, fixture.finalizeCommand());

        assertThat(store().stat(content.contentKey()).size()).isEqualTo(6);
        assertThat(fixture.read(store().openRead(content.contentKey(), new ByteRange(1, 3))))
            .isEqualTo("bcd");
        assertThat(store().delete(content.contentKey(), DeletePrecondition.none()).deleted())
            .isTrue();
    }
}
@ParameterizedTest
@EnumSource(CrashPoint.class)
void readyInvariantSurvivesEveryCrashPoint(CrashPoint crashPoint) {
    harness.runUploadAndKillAt(crashPoint);
    harness.restartAndReconcile();

    assertThat(harness.readyFiles())
        .allSatisfy(file -> {
            assertThat(file.physicalContentExists()).isTrue();
            assertThat(file.digestMatches()).isTrue();
        });
}
  • Step 2: Run testkit tests to verify they fail
./gradlew :modules:fileserver:fileserver-testkit:test \
  --tests '*ContentStoreContract*' --tests '*CrashRecoveryMatrixTest'

Expected: FAIL because the testkit contracts and harness do not exist.

  • Step 3: Implement reusable certification harnesses

Implement contract scenarios for:

create-only race
append offset
range read
checksum
finalize
logical and physical delete
symlink no-follow
disk full
permission denied
process kill at every crash point
slow client
network interruption
NFS rename ambiguity
large-file bounded heap and direct memory

The NFS environment must support server restart and a network cut. The PVC job writes a machine-readable result containing the full certification tuple and probe results.

  • Step 4: Run local, NFS, and large-file suites
./gradlew :modules:fileserver:fileserver-testkit:test

Expected: PASS for local tests; NFS tests are tagged and run when FILESERVER_NFS_TESTS=true. Large-file test confirms heap does not scale with file size.

  • Step 5: Commit
git add modules/fileserver/fileserver-testkit infra/fileserver/nfs infra/fileserver/kubernetes
git commit -m "test: add fileserver certification harness"

Task 33: CI matrix, 지원 문서, 운영 Runbook, release gate 연결

Files:

  • Create: .github/workflows/fileserver-pr.yml
  • Create: .github/workflows/fileserver-nightly.yml
  • Create: .github/workflows/fileserver-release.yml
  • Create: docs/fileserver/support-matrix.md
  • Create: docs/fileserver/http-contract.md
  • Create: docs/fileserver/storage-certification.md
  • Create: docs/fileserver/security.md
  • Create: docs/fileserver/operations.md
  • Create: docs/fileserver/upgrade-guide.md
  • Create: modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/DocumentationCoverageTest.java

Interfaces:

  • Connects every support claim to a CI job or certification artifact.

  • Documents Stable, Beta, Limited, Compatibility, and Experimental levels.

  • Step 1: Write a failing documentation coverage test

class DocumentationCoverageTest {
    @Test
    void everyRuntimeProfileHasAReferencedCiJob() throws Exception {
        SupportMatrix matrix = SupportMatrix.load(Path.of("docs/fileserver/support-matrix.md"));
        WorkflowIndex workflows = WorkflowIndex.load(Path.of(".github/workflows"));

        assertThat(matrix.requiredProfiles())
            .allMatch(profile -> workflows.containsJob(profile.ciJob()));
    }

    @Test
    void everyPublicEndpointAppearsInHttpContract() throws Exception {
        Set<String> endpoints = EndpointScanner.scanPublicFileserverEndpoints();
        String contract = Files.readString(Path.of("docs/fileserver/http-contract.md"));

        assertThat(endpoints).allMatch(contract::contains);
    }
}
  • Step 2: Run the coverage test to verify it fails
./gradlew :modules:fileserver:fileserver-testkit:test \
  --tests '*DocumentationCoverageTest'

Expected: FAIL because workflows and docs do not exist.

  • Step 3: Add workflows and complete operational documentation

PR workflow runs:

unit and architecture tests
local ext4 contract
MVC Tomcat contract
WebFlux Reactor Netty contract
security suite
bounded-memory regression

Nightly runs:

XFS
NFSv4.1 and server restart
Windows NTFS compatibility
large-file performance
slow client
process-kill matrix

Release runs:

Spring Framework 6.2 and 7.0 compatible lines
Nginx stable
PVC RWO certification
optional PVC RWX certification
multi-instance lease
fault injection
sensitive telemetry scan
support matrix diff

operations.md must include storage-full, orphan growth, verification backlog, NFS ambiguity, PVC remount, Nginx delegation failure, and cleanup backlog runbooks with exact metric names and recovery commands.

  • Step 4: Run documentation coverage and full release verification
./gradlew clean test
./gradlew :modules:fileserver:fileserver-testkit:test \
  --tests '*DocumentationCoverageTest'

Expected: PASS; every support claim maps to a concrete workflow job and every public endpoint is documented.

  • Step 5: Commit
git add .github/workflows docs/fileserver modules/fileserver/fileserver-testkit
git commit -m "docs: connect fileserver support claims to CI"

3. 작업 간 의존 순서

Task 1
├─ Task 2
│  ├─ Task 3
│  ├─ Task 4
│  └─ Task 5
│     └─ Task 6
│        └─ Task 7
├─ Task 8
│  └─ Task 9
│     ├─ Task 10
│     └─ Task 11
├─ Task 12
├─ Task 13
│  └─ Task 14
│     └─ Task 15
├─ Task 16
│  └─ Task 14 integration
├─ Task 17
├─ Task 18
│  ├─ Task 20
│  ├─ Task 22
│  └─ Task 23
├─ Task 19
├─ Task 21
├─ Task 24
│  └─ Task 25
├─ Task 26
│  ├─ Task 27
│  └─ Task 28
├─ Task 29
├─ Task 30
├─ Task 31
├─ Task 32
└─ Task 33

권장 직렬 실행 순서는 Task 1부터 Task 33까지다. 병렬 실행은 다음 묶음에서만 허용한다.

Task 16 verification ↔ Task 18 HTTP contract
Task 19 MVC upload ↔ Task 21 WebFlux upload
Task 20 MVC download ↔ Task 22 WebFlux download
Task 27 tus ↔ Task 28 draft12, 단 Task 26 완료 후
Task 29 security ↔ Task 30 observability, 공통 API가 안정된 후

4. 단계별 Release 기준

Milestone A — Core Alpha

완료 작업:

Task 1~15

Gate:

  • core module dependency boundary 통과
  • metadata migration·optimistic locking 통과
  • local create·append·digest·publish contract 통과
  • READY invariant와 ambiguous reconciliation 통과
  • 100 MiB upload에서 bounded memory 확인

Milestone B — HTTP Beta

완료 작업:

Task 16~22, Task 29

Gate:

  • raw·multipart upload
  • GET·HEAD·single Range
  • conditional request
  • MVC·WebFlux parity
  • DataBuffer leak 0
  • path·symlink·filename·range security suite 통과

Milestone C — Distributed RC

완료 작업:

Task 23~26, Task 30~32

Gate:

  • Nginx parity
  • logical delete와 cleanup
  • admin isolation
  • two-node writer lease
  • PVC RWO certification
  • process-kill matrix
  • sensitive telemetry scan

Milestone D — Extended Release

완료 작업:

Task 27~28, Task 33

Gate:

  • tus 1.0 protocol suite
  • draft12 isolation
  • NFS limited profile fault tests
  • support matrix와 CI mapping
  • operations runbook review

5. 구현자가 임의로 변경하면 안 되는 결정

  • ContentStorePath 또는 provider SDK 타입을 추가하지 않는다.
  • public endpoint에 path query parameter를 추가하지 않는다.
  • state 변경을 JPA entity setter로 우회하지 않는다.
  • READY gate를 controller마다 복제하지 않고 application service에서 강제한다.
  • create-only 기본을 overwrite 기본으로 바꾸지 않는다.
  • atomic move 지원을 설정값만으로 가정하지 않는다.
  • Files.exists 후 create하는 TOCTOU 패턴을 사용하지 않는다.
  • WebFlux body를 DataBufferUtils.join으로 전체 적재하지 않는다.
  • MVC에서 MultipartFile#getBytes()를 사용하지 않는다.
  • filename 또는 client MIME을 physical key·보안 verdict로 사용하지 않는다.
  • scanner timeout을 ACCEPT로 변환하지 않는다.
  • multi-instance 정확성을 FileLock 또는 NFS lock에 맡기지 않는다.
  • Nginx internal URI에 physical path를 넣지 않는다.
  • tus와 HTTPbis draft DTO·endpoint를 공유하지 않는다.
  • cleanup이 version·lease 확인 없이 삭제하지 않는다.
  • AmbiguousCompletionException을 일반 retryable exception으로 낮추지 않는다.

6. 계획 자체 검증 체크리스트

  • 설계서의 포함 범위가 최소 하나의 Task에 매핑된다.
  • 설계서의 비지원 범위를 구현하는 Task가 없다.
  • Task 1~33 번호가 연속적이다.
  • 모든 Task에 Files, Interfaces, 실패 테스트, 실패 확인, 구현, 통과 확인, commit이 있다.
  • later Task가 사용하는 공개 타입은 earlier Task에서 정의된다.
  • MVC·WebFlux·Nginx가 동일한 DownloadDecision을 사용한다.
  • READY transition은 physical stat·digest 검증 뒤에만 실행된다.
  • multi-instance append는 lease token과 expected offset을 요구한다.
  • tus Stable과 draft Experimental이 분리돼 있다.
  • security suite가 traversal, symlink, filename, Range, scriptable content를 포함한다.
  • CI와 support matrix가 자동 coverage test로 연결된다.
  • 문서에 미확정 표식, 빈 구현 지시, 무정의 type이 없다.

7. 실행 인계

계획 실행 시 권장 방식은 superpowers:subagent-driven-development다. 각 Task마다 새 작업자를 사용하고 다음 두 단계 review를 적용한다.

1. 요구사항·설계 일치 review
2. 코드 품질·테스트 evidence review

동일 세션에서 실행할 경우 superpowers:executing-plans를 사용하고 Milestone A, B, C, D마다 전체 test·diff·문서 gate를 확인한다.