Files
tech-log-backend/docs/superpowers/plans/2026-07-28-fileserver-production-capability-foundation.md
T

10 KiB
Raw Blame History

Fileserver Production Capability Foundation 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: Replace the list-materializing CSV demo boundary with the Phase 1 framework-free publication contract and a bounded, staged local CSV R1 provider without claiming crash-safe R2 guarantees.

Architecture: application-core owns typed publication requests, rows, cells, producer/sink callbacks, opaque references, and receipts. adapter:outbound:fileserver owns CSV encoding, spreadsheet-formula mitigation, staging, digest/count limits, and local atomic publication. The legacy FileExportPort remains temporarily for compatibility and is explicitly documented as deprecated R0/R1 behavior.

Tech Stack: Java 21, JUnit 5, AssertJ, Spring Boot configuration properties, JDK NIO filesystem and SHA-256.


Task 1: Add the framework-free publication contract

Files:

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java

  • Create: src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java

  • Test: src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java

  • Step 1: Write the failing contract test

@Test
void requestRejectsPathLikeLogicalNamesAndSchemaRejectsDuplicateColumns() {
  assertThatThrownBy(() -> new LogicalFileName("../report.csv"))
      .isInstanceOf(IllegalArgumentException.class);
  assertThatThrownBy(
          () ->
              new ExportSchema(
                  "worklog-v1",
                  1,
                  List.of(
                      new ExportSchema.Column(
                          "id", ExportSchema.CellType.INTEGER, false,
                          ExportSchema.FormulaPolicy.REJECT, 64),
                      new ExportSchema.Column(
                          "id", ExportSchema.CellType.TEXT, false,
                          ExportSchema.FormulaPolicy.MITIGATE, 128))))
      .isInstanceOf(IllegalArgumentException.class);
}
  • Step 2: Verify RED

Run: cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain

Expected: compilation failure because the filepublication contract does not exist.

  • Step 3: Implement immutable validated values

The contract must expose this shape and no Path, File, stream, Spring, or provider type:

public interface FilePublicationPort {
  FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
}

@FunctionalInterface
public interface TabularRowProducer {
  void produce(TabularRowSink sink);
}

public interface TabularRowSink {
  void write(TabularRow row);
  void checkpoint();
}

TabularCell is a sealed interface with nested records for text, integer, decimal, boolean, date, instant, and null. ExportSchema owns ordered columns, cell type, nullability, formula policy, and per-cell byte bounds. Records reject null/blank IDs, path separators in LogicalFileName, duplicate column names, empty schemas, and non-positive limits.

  • Step 4: Verify GREEN

Run: cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain

Expected: PASS.

Task 2: Add streaming CSV encoding and staged local publication

Files:

  • Create: src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java

  • Create: src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java

  • Create: src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java

  • Test: src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java

  • Step 1: Write the failing streaming publication tests

@Test
void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() {
  AtomicInteger calls = new AtomicInteger();
  FilePublishReceipt receipt =
      adapter.publish(
          request(),
          sink -> {
            calls.incrementAndGet();
            sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))));
          });

  assertThat(calls).hasValue(1);
  assertThat(receipt.reference().value()).doesNotContain(tempDir.toString());
  assertThat(Files.readString(publishedFile(receipt), UTF_8)).contains("1,'=cmd");
}

@Test
void abortsBeforeFinalPublicationWhenTheByteLimitIsExceeded() {
  assertThatThrownBy(
          () -> adapter.publish(request(), sink -> sink.write(oversizedRow())))
      .isInstanceOf(FilePublicationException.class);
  assertThat(finalArtifacts()).isEmpty();
}
  • Step 2: Verify RED

Run: cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain

Expected: compilation failure because the staged provider does not exist.

  • Step 3: Implement the minimum staged provider

LocalFilePublicationPolicy validates a fixed destination ID, base directory, maximum rows, maximum encoded bytes, and the only initial format profile csv-rfc4180-v1.

LocalFilePublicationAdapter must:

validate request/schema before producer invocation
create a private .staging directory
exclusive-create an operation-scoped .part file
write header and each row directly through StreamingCsvEncoder
enforce schema/cell/row/byte limits at each sink call
prefix dangerous spreadsheet text with a single quote when policy is MITIGATE
compute SHA-256 and counts while writing
flush and FileChannel.force(true)
move staging to the final operation-scoped file with ATOMIC_MOVE
delete staging on pre-publish failure
return an opaque reference and never an absolute path

The first release is labelled local R1. Existing final artifacts cause a typed conflict; durable operation journals, crash reconciliation, replace semantics, and SFTP/NFS remain unimplemented and must not be advertised.

  • Step 4: Verify GREEN

Run: cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain

Expected: PASS.

Task 3: Add opt-in R1 composition and truthful documentation

Files:

  • Modify: src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java

  • Modify: src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java

  • Create: src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java

  • Modify: src/adapter/outbound/fileserver/README.md

  • Modify: src/adapter/outbound/fileserver/CLAUDE.md

  • Step 1: Write the failing composition test

@Test
void disabledConfigurationCreatesNoPublicationPort() {
  contextRunner
      .withUserConfiguration(FileExportConfig.class)
      .run(context -> assertThat(context).doesNotHaveBean(FilePublicationPort.class));
}

@Test
void enabledConfigurationCreatesExactlyOneLocalR1PublicationPort() {
  contextRunner
      .withUserConfiguration(FileExportConfig.class)
      .withPropertyValues(
          "ca-skeleton.fileserver.enabled=true",
          "ca-skeleton.fileserver.destination-id=local-export",
          "ca-skeleton.fileserver.base-directory=" + tempDir)
      .run(context -> assertThat(context).hasSingleBean(FilePublicationPort.class));
}
  • Step 2: Verify RED

Run: cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*FilePublicationConfigTest' --console=plain

Expected: FAIL because the new port is not composed.

  • Step 3: Wire only the local R1 provider

Add validated destination ID, row limit, byte limit, and format-profile settings. Contribute FilePublicationPort only when explicitly enabled. Keep FileExportPort as a deprecated compatibility bean and document that it materializes caller rows and is not R2 evidence.

  • Step 4: Verify module and architecture gates

Run:

cd src
./gradlew :application-core:test :adapter:outbound:fileserver:check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain

Expected: all commands PASS.

Task 4: Record the unfinished R2 boundary

Files:

  • Modify: docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md

  • Step 1: Update implementation status without weakening completion criteria

Record Phase 01/local R1 foundation as implemented. Keep Phase 2 durable journal/reconciliation, Phase 3 operations, Phase 4 SFTP, Phase 5 NFS/HA/bootstrap, and Phase 6 optional operations marked unimplemented. The document must still say that local R1 is not Fileserver R2.

  • Step 2: Verify documentation structure

Run: rg -n 'R1|R2|구현 상태|미구현' docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md

Expected: explicit R1 implementation and remaining R2 gaps are both present.