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

237 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```java
@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:
```java
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**
```java
@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:
```text
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**
```java
@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:
```bash
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.