12 KiB
Fileserver — deviations from the design
The design specification and the implementation plan are frozen documents. Where implementation found them under-specified or self-contradicting, the resolution is recorded here rather than by editing the specification, and every entry names the test that pins the decision.
Resolved inconsistencies in the state machine and contracts
1. CREATED → FAILED has no edge in the transition table
A create that fails after the record exists must end in FAILED, but the table has no direct edge.
The record therefore walks CREATED → UPLOADING → FAILED, which is also the honest reading: the
upload had been admitted before it failed.
Pinned by UploadApplicationServiceTest (application-core).
2. ContentKey's alphabet admits a leading separator
The design's key pattern [a-z0-9/_-]{16,200} matches /etc/passwd/.... Rejecting an absolute path
at the value type would change a design-fixed contract, so the stricter shape check lives in
PhysicalPathResolver, per §12.2 rule 1 — the only place that turns an identifier into a path.
Pinned by PhysicalPathResolverTest.
3. VERIFYING → DELETING has no edge
Deleting a file that is mid-verification has no legal transition. The lifecycle service refuses it
with 409 FILE_NOT_READY rather than inventing an edge, which matches the allowed-state list the
JPA markDeleting statement already enforced.
Pinned by FileLifecycleServiceTest.
4. If-Match is specified as an ETag but the lifecycle was designed around the row version
The HTTP contract sends an entity tag; the metadata store guards on a numeric version. The service
takes Optional<String> expectedEtag and compares against the record's strong validator, so the
precondition a client sends is the precondition that is checked.
Pinned by FileLifecycleServiceTest.
5. The filename policy left : intact
C:\Windows\system.ini sanitized to C:Windowssystem.ini — a drive-qualified name surviving into
display text and headers. : joined the structural strip set.
Pinned by FileserverHardeningContractTest and AmbiguousFilesystemOperationDetectorTest.
Additions the design implies but does not specify
6. fs_recovery_item
§10.2 lists five core tables and none of them can hold the recovery queue, yet §29.3 requires one:
reconciliation reports files whose bytes and metadata disagree, and holding that list in memory
would lose exactly the cases a restart interrupted. Added in
V2__fileserver_recovery_and_staging_cleanup.sql with one open item per file, so repeated sweeps
update a worklist rather than accumulating a log.
Pinned by PostgreSqlFileserverReclamationIntegrationTest.
7. fs_cleanup_item.upload_id
A staging object is addressed by upload, not by file. Without this column a queued staging cleanup could name only already-published content, so a cancelled or expired upload left bytes nothing could find. Added in the same migration, with a check constraint that an item names exactly one target.
Pinned by PostgreSqlFileserverReclamationIntegrationTest.
8. ContentReferenceLedger and StagingUploadLocator
The orphan scan must ask whether a record still claims a physical object, and reconciliation must
map a file back to the upload that last staged it. Neither question is answerable through the
design's FileMetadataStore or UploadSessionStore as written. Rather than widen those
design-fixed interfaces, both are narrow single-method ports.
Pinned by PostgreSqlFileserverReclamationIntegrationTest and LocalOrphanScanAdapterTest.
Interpretations
9. Quota settlement is FIFO within a scope
Nothing links a reservation row to the upload that took it, and the design deliberately reclaims
stragglers by TTL and the STALE_QUOTA_RESERVATION cleanup type rather than threading a reservation
id through the upload session. QuotaCommitGateway therefore settles the oldest live reservation in
the file's namespace.
Which row closes does not change any quota decision: enforcement sums reserved and committed bytes per scope and never reads an individual row. Concurrent uploads of different sizes can leave the reserved total transiently high or low, and it converges as each settles. Durable usage with no live reservation behind it — an upload that outlived its TTL — is still recorded, because a ledger that silently under-counts is worse than one that is briefly imprecise.
Pinned by PostgreSqlFileserverReclamationIntegrationTest.
10. Zero copy is a channel transfer, not a file handoff
Task 22 asks for zero copy on local files; §19 forbids a Path leaving the storage adapter, and §5
of the plan forbids adding Path to the content store. WebFlux's zero-copy API takes a Path, so
that route is closed.
The servlet path takes the other one: ZeroCopyDownloadGateway receives a WritableByteChannel from
the transport and the storage adapter performs FileChannel.transferTo into it. That is a genuine
kernel-level transfer with no filesystem concept leaving storage. The reactive path continues to
stream with bounded demand.
Zero copy is an optimization with no observable difference: when storage declines, the response is streamed and is byte-identical.
Pinned by LocalStorageGatewayContractTest and ZeroCopyEligibilityTest.
11. The Fileserver JPA stores are gated on the capability switch
The metadata store, session store, quota service, queues, ledger, and staging locator carry
@ConditionalOnProperty(app.fileserver-platform.enabled) even though the rest of
adapter:outbound:persistence-jpa is unconditional.
Without the gate, every composition root that includes the persistence module built these beans —
including sample-portfolio, which has no Fileserver — and each of them needs collaborators only
the Fileserver configuration provides. That is the same rule the design states for the transport
surface, applied to persistence: no surface appears merely because the dependency is present.
FileStateMachine is bound alongside them, in FileserverStorageConfiguration. It had no
production binding at all before, which made the metadata store unconstructible in any
component-scanned context.
Pinned by SampleApplicationContextTest (the capability off) and
FileserverRuntimeAssemblyTest (the capability on).
12. Transaction boundaries are owned by the application services, and are deliberately narrow
The design does not say where a transaction begins. The repository does:
adapter:outbound:persistence-jpa forbids a repository adapter from owning a @Transactional
boundary, and application-core owns them through TransactionPort. The Fileserver follows that
rule — every Jpa* store here declares no @Transactional of its own.
What is specific to this capability is how narrow the boundaries are. A boundary covers a contiguous run of metadata writes and stops before every storage call, because a filesystem operation inside a database transaction would hold a connection for the length of a byte transfer. The upload path therefore has three boundaries, not one: acquire the lease, transfer the bytes, commit the offset.
Where several stores must agree, they share one boundary:
| Unit | Why it is one boundary |
|---|---|
| reserve quota + insert record + create session | a reservation that outlived a failed insert holds capacity for a file that never existed |
| READY transition + quota commit | a finished file whose reservation was never converted holds capacity until the reservation expires |
markDeleting + enqueue cleanup |
a file that stopped being reachable with nothing queued to reclaim it is never collected |
| content delete settlement: reclaim + retire record + close queue item | half of it leaves the item to be retried against content that no longer exists |
What this cannot make atomic is the storage/metadata seam itself — no database boundary could. That seam is exactly what the ambiguous-completion path and the reconciler exist for, and the one hand-written compensation that remains (staging creation failing after the records committed) is there for the same reason.
Pinned by FileserverRoundTripContractTest against real PostgreSQL; the application tests use
DirectTransactions, which runs a boundary inline and counts it.
Not implemented
AsyncContentStore, CapacityAwareContentStore, CopyCapableContentStore, DelegatedDownloadStore
Four optional content-store SPIs are declared in application-core with no implementation. Each is
an extension point for a backend this template does not ship:
AsyncContentStore— for a backend whose native client is non-blocking. The local platform is blocking, and the reactive transport bridges to it on a dedicated I/O scheduler.CapacityAwareContentStore— capacity is reported throughStorageHealthPortandStorageUsageProbe, which the local platform implements.CopyCapableContentStore— server-side copy is delivered byCopyContentGateway; the local platform has no cheaper primitive than a streamed copy.DelegatedDownloadStore— delegation is delivered at the transport boundary by the nginxX-Accel-Redirectstrategy, which needs no store participation.
ContentStoreCapabilities reports what the running store actually supports, so no unimplemented SPI
is advertised as available.
Known deviation from the repository's application-layer contract
5. Fileserver application services are not CommandUseCase / QueryUseCase
src/application-core/CLAUDE.md requires every inbound port implementation to extend
CommandUseCase or QueryUseCase and to carry @UseCaseCapability, which declares its transaction
mode, idempotency and repository access. The Fileserver instead exposes multi-method services —
UploadApplicationService, DownloadApplicationService, FileLifecycleService,
FileserverAdminService and their Default* implementations.
This is a real deviation, not an oversight, and it is unenforced: the ArchUnit rules
inbound_port_implementations_end_with_use_case and
inbound_port_implementations_declare_capability only match types that implement UseCase, so a
service that never does is silently exempt. The capability contract that every other feature in
this repository declares is therefore absent here.
Two things follow from it. The transaction mode of each operation is expressed only by which
TransactionPort method the body happens to call, rather than declared and checked. And the
application layer holds transport policy it would not hold if each operation were a use case with
its own command: HTTP status codes on FileserverErrorCode, Range and conditional-request
parsing in api.transfer, and Content-Disposition construction.
The status mapping in particular is a deliberate trade rather than an accident. It lives in
application-core so the servlet transport, the reactive transport and the Nginx delegation path
cannot answer the same failure with three different statuses. Moving it to the transport layer
resolves the layering complaint and reintroduces exactly that drift, which is why this is an
architecture decision rather than a cleanup.
Status: open, deliberately unresolved in this change set. Closing it means roughly thirty command/query use cases, a decision about where the shared status vocabulary lives, and a change to the ArchUnit rules so a service that bypasses the contract fails the build instead of being exempt from it. That belongs in its own ADR with its own review, and doing it inside a correctness patch would mix a large mechanical refactor into changes that need to be readable.
Nothing here is pinned by a test, because the deviation is the absence of a constraint. The next step is the ADR, not another test.