chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
# Fileserver configuration
Every key below lives under `app.fileserver-platform` (environment form
`APP_FILESERVER_PLATFORM_*`). That namespace is the HTTP platform's alone: `app.fileserver.*`
belongs to the R2 tabular publication capability and `app.file-export.*` to the R1 CSV export, and
the three are deliberately separate so switching one on cannot switch on another.
While `app.fileserver-platform.enabled` is false none of these keys is bound at all — the
auto-configuration that binds them is not processed — so a malformed value in a block nobody
enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is
refused rather than ignored. The defaults are the conservative ones: the
capability is off, the admin plane is off, background reclamation is off, and there is no permissive
authorization fallback. Turning the capability on is a deliberate act, and so is every surface it
exposes.
## Minimum to start
```yaml
ca-skeleton:
fileserver:
enabled: true
instance-id: ${HOSTNAME} # writer-lease owner; must be unique per node
storage:
root: /var/lib/backend/files # absolute, outside any webroot or config dir
security:
access-policy: role-based # or supply your own FileAccessPolicy bean
observability:
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
```
Startup fails, rather than degrading, when any of these is missing or unsafe:
| Condition | Why it is fatal |
| --- | --- |
| `security.access-policy` left at `required` with no `FileAccessPolicy` bean | a file capability that authorizes by default is worse than one that refuses to start |
| `observability.fingerprint-key` unset while metrics are on | an unkeyed digest of an enumerable identifier is reversible |
| the storage root fails a mandatory capability probe | a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded |
| `storage.publish-mode: atomic-move-required` on a volume where the probe could not prove an atomic move | the configured guarantee cannot be delivered |
| `security.access-policy: unenforced` under a `prod` profile | a value that was convenient in development must not survive promotion |
## Authorization — `security`
| Key | Default | Meaning |
| --- | --- | --- |
| `access-policy` | `required` | `required` (supply your own bean), `role-based`, or `unenforced` |
| `read-roles` | `ROLE_FILE_READ` | grants metadata read and download |
| `write-roles` | `ROLE_FILE_WRITE` | grants create, append, finalize, delete, copy, move |
| `admin-roles` | `ROLE_FILE_ADMIN` | grants reverify and force-delete, and gates `/internal/fileserver/**` at the servlet chain |
There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain
before any application policy is consulted, so such a setting could only ever have described a
permission the transport had already refused — a configuration that reads as if it grants access
and does not.
The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role
cannot reach the management plane — a role model where "can delete" implied "can force-delete" would
make the audited plane reachable through the unaudited one.
`unenforced` authorizes everything and exists so a developer can exercise upload and download before
deciding on a role model. It is refused under a production profile.
## Storage — `storage`
| Key | Default | Meaning |
| --- | --- | --- |
| `root` | `/var/lib/backend/files` | absolute path; the only place a path exists |
| `publish-mode` | `atomic-move-preferred` | `atomic-move-required`, `atomic-move-preferred`, `metadata-pointer` |
| `buffer-size` | `128KB` | bounds every transfer allocation; memory never scales with file size |
| `forbidden-root-ancestors` | `/app,/etc,/usr/share/nginx/html` | roots the storage root must not live under (webroot, config dirs) |
`root` must be absolute. A relative root resolves against the process working directory, which is
one path in a container and another in a test, so it is refused at binding time.
Three former keys are gone, pinned as constants instead: staging and content share one FileStore,
symbolic links are never followed, and the object and its directory are synced before READY. Each
is an invariant the atomic publish and the namespace boundary are built on — a deployment that
could switch one off would be running a different capability under the same name and the same
tests.
The storage provider has no selector either. There is exactly one implementation, and a `type` key
with one legal value is a promise of pluggability that nothing keeps.
## Upload, download, transfer
| Key | Default | Meaning |
| --- | --- | --- |
| `upload.max-file-size` | `100MB` | hard ceiling; also drives `spring.servlet.multipart.max-file-size` |
| `upload.max-request-size` | `110MB` | request envelope; must be at least `max-file-size` |
| `upload.initial-reservation` | `8MB` | quota reserved when the length is unknown |
| `upload.ttl` | `1h` | how long a resumable upload stays claimable |
| `upload.reservation-ttl` | `24h` | how long an unsettled quota reservation survives |
| `upload.lease-duration` | `30s` | writer lease; renewed at one third of this |
| `upload.max-parts` | `16` | multipart part ceiling |
| `upload.require-content-length` | `false` | refuse chunked raw uploads |
| `download.cache-control` | `private, no-store` | emitted on every content response |
| `download.inline-allowed` | `false` | scriptable content is always an attachment regardless |
| `download.max-ranges` | `1` | multi-range responses are opt-in |
| `download.max-range-bytes` | `100MB` | total bytes one ranged response may cover |
| `download.zero-copy-enabled` | `true` | hand large plaintext responses to the kernel |
| `download.zero-copy-minimum-bytes` | `16MB` | below this the syscall setup costs more than it saves |
| `transfer.core-size` / `max-size` / `queue-capacity` | `8` / `32` / `64` | blocking transfer pool bounds |
| `transfer.await-seconds` | `300` | how long a transfer may occupy a pool thread |
Zero copy changes no header and no status. When storage declines it — an unreadable region, an
unsupported backend — the response is streamed instead and is byte-identical.
## Verification — `verification`
| Key | Default | Meaning |
| --- | --- | --- |
| `timeout` | `5s` | per-verifier ceiling |
| `require-media-type-verdict` | `false` | refuse a file whose type could not be determined |
| `inline-safe-profile` | `false` | accept scriptable content instead of quarantining it |
Set `inline-safe-profile: true` only when downloads are never served inline from a trusted origin.
## Quota and admission — `quota`
| Key | Default | Meaning |
| --- | --- | --- |
| `instance-upload-permits` | `16` | concurrent uploads this node admits |
| `scope-upload-permits` | `4` | concurrent uploads one namespace admits |
| `direct-download-permits` | `64` | concurrent non-delegated downloads |
| `soft-high-water` | `0.70` | storage fraction at which pressure is reported |
| `hard-high-water` | `0.85` | storage fraction at which uploads are refused |
When the storage fraction cannot be read, admission treats it as unknown and does not apply the
high-water rule — a synthetic `0` would silently disable the guard, and a synthetic `1` would take
the capability down over a failed syscall.
## Background reclamation — `cleanup`
| Key | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | run the cleanup worker on this node |
| `interval` | `60s` | fixed delay between batches, not fixed rate |
| `max-items` | `100` | items one batch may claim |
| `max-bytes` | `1GB` | bytes one batch may reclaim |
| `retry-backoff` | `5m` | how long a failed item waits before it is due again |
The worker deletes physical objects, so it is off until a deployment decides otherwise. A node
without it still queues cleanup items; another node or an operator reclaims them. An item that fails
eight times is abandoned rather than retried forever — it stays visible to an operator, parked
rather than discarded.
## Management plane — `admin`
| Key | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | expose `/internal/fileserver/**` |
| `orphan-minimum-age` | `1h` | how long an unreferenced object must exist before a scan may name it |
Publishing content and committing its record are two steps. Anything younger than
`orphan-minimum-age` is assumed to be mid-commit rather than abandoned; shortening this makes
concurrent uploads look like orphans.
## Front-proxy delegation — `nginx`
| Key | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | emit `X-Accel-Redirect` instead of a body |
| `internal-prefix` | `/__files/` | must be an `internal` location resolving to the content root |
| `object-suffix` | `.bin` | layout suffix the proxy appends |
| `minimum-size` | `16MB` | below this the application serves the transfer itself |
Delegation is decided only after authorization and the READY gate, so an internal redirect can only
ever name content the caller was already allowed to read.
## Protocols — `tus`
| Key | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | expose the tus 1.0 endpoints |
The HTTPbis resumable-upload draft-12 surface is experimental and documented in
[support-matrix.md](support-matrix.md).
+213
View File
@@ -0,0 +1,213 @@
# 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 through `StorageHealthPort` and
`StorageUsageProbe`, which the local platform implements.
- `CopyCapableContentStore` — server-side copy is delivered by `CopyContentGateway`; the local
platform has no cheaper primitive than a streamed copy.
- `DelegatedDownloadStore` — delegation is delivered at the transport boundary by the nginx
`X-Accel-Redirect` strategy, 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.
+105
View File
@@ -0,0 +1,105 @@
# Fileserver HTTP contract
Every public endpoint is listed here. `FileserverDocumentationCoverageTest` scans the controllers
and fails if one is missing, so this file cannot silently fall behind the code.
## Public endpoints
| Method | Path | Success | Notes |
|---|---|---|---|
| POST | `/v1/files` | `201` READY, `202` VERIFYING | multipart single upload |
| POST | `/v1/files:raw` | `201`, `202` | the whole request body is the file |
| POST | `/v1/files:batch` | `200` | ordered per-part results; explicitly non-atomic |
| GET | `/v1/files/{fileId}` | `200` | public metadata; never a content key or path |
| GET | `/v1/files/{fileId}/content` | `200`, `206`, `304` | download |
| HEAD | `/v1/files/{fileId}/content` | `200`, `304` | identical headers, no body |
| DELETE | `/v1/files/{fileId}` | `202`, `204` | logical delete first |
| POST | `/v1/files/{fileId}:copy` | `202` | create-only target |
| POST | `/v1/files/{fileId}:move` | `200` | logical namespace change only |
| OPTIONS | `/v1/uploads` | `204` | tus capability discovery |
| POST | `/v1/uploads` | `201` | tus creation |
| HEAD | `/v1/uploads/{uploadId}` | `204` | tus offset |
| PATCH | `/v1/uploads/{uploadId}` | `204` | tus append |
| DELETE | `/v1/uploads/{uploadId}` | `204` | tus termination |
| POST | `/v1/experimental/draft12/uploads` | `201` | Experimental; off by default |
| PATCH | `/v1/experimental/draft12/uploads/{uploadId}` | `204` | Experimental; off by default |
## Management endpoints
Reachable only where both `app.fileserver-platform.enabled=true` and
`app.fileserver-platform.admin.enabled=true`, gated at the servlet chain on
`app.fileserver-platform.security.admin-roles`, and intended for a management
port rather than the public one.
| Method | Path |
|---|---|
| GET | `/internal/fileserver/storage-health` |
| GET | `/internal/fileserver/capabilities` |
| GET | `/internal/fileserver/orphans` |
| POST | `/internal/fileserver/orphans:reconcile` |
| POST | `/internal/fileserver/files/{fileId}:reverify` |
| POST | `/internal/fileserver/files/{fileId}:force-delete` |
| GET | `/internal/fileserver/uploads/incomplete` |
| POST | `/internal/fileserver/uploads:cleanup` |
## Status codes
| Status | Condition |
|---:|---|
| `200` | metadata, full GET, batch result, move |
| `201` | file or upload created |
| `202` | verification or physical cleanup deferred |
| `204` | append, cancel, bodyless update |
| `206` | satisfiable Range |
| `304` | validator matched on GET or HEAD |
| `400` | malformed header or header combination |
| `401` | unauthenticated |
| `403` / `404` | denied, or hidden under the existence-hiding profile |
| `409` | state, offset, or lease conflict |
| `410` | expired upload resource |
| `411` | `require-content-length` profile with no length |
| `412` | precondition failed |
| `413` | size or quota policy violation |
| `415` | upload media type not accepted |
| `416` | unsatisfiable Range; carries the real length |
| `422` | digest, signature, or scanner rejection |
| `429` | transfer admission or rate limit |
| `503` | storage or scanner unavailable |
| `504` | downstream timeout |
| `507` | out of storage capacity |
## Failure body
Every failure answers `application/problem+json` with a stable code and its URN:
```json
{
"type": "urn:fileserver:problem:upload-offset-mismatch",
"title": "Upload offset mismatch",
"status": 409,
"code": "UPLOAD_OFFSET_MISMATCH",
"retryable": true,
"ambiguous": false,
"reconciliationRequired": false,
"traceId": "..."
}
```
The server-side exception message never appears. `ambiguous` is the field a client must read before
retrying: an ambiguous failure may already have taken effect.
## Header contract
| Header | Contract |
|---|---|
| `Content-Type` | client value is a claim; the verified type is stored separately |
| `Content-Disposition` | `attachment` by default; scriptable types are never inline |
| `Accept-Ranges` | `bytes` |
| `Range` | single range by default; multi-range only under an explicit budget |
| `Content-Range` | actual range on `206`; the unsatisfied form on `416` |
| `ETag` | strong validator derived from the SHA-256 |
| `Last-Modified` | metadata publication instant, never a filesystem timestamp |
| `Cache-Control` | `private, no-store` by default |
| `X-Content-Type-Options` | always `nosniff` on a download |
| `Retry-After` | on retryable `409`, `429`, `503`, and `504` |
| `X-Accel-Redirect` | internal only; never forwarded to a client |
+128
View File
@@ -0,0 +1,128 @@
# Fileserver runbooks
Each runbook names the exact metric that fires it and the exact command that resolves it. A runbook
whose trigger is "someone noticed" is not actionable, so every one below starts from a signal.
## Storage full
**Signal**`fileserver.quota{result="rejected"}` rising, or `507` responses appearing.
Storage capacity is exhausted or the high-water guard tripped. Uploads are rejected before any bytes
are written, so nothing is corrupt; the system is refusing work it cannot complete.
```bash
curl -s $ADMIN/internal/fileserver/storage-health | jq '.usedFraction, .usableBytes'
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
curl -s "$ADMIN/internal/fileserver/orphans?limit=200" | jq '[.[].sizeBytes] | add'
```
Drain the cleanup backlog first — it reclaims space the system already knows is dead. Only then
consider an orphan reconcile, and start with a dry run.
## Orphan growth
**Signal**`fileserver.cleanup{result="skipped"}` climbing, or the orphan scan returning more
objects each run.
Physical objects exist with no metadata record pointing at them. This is not immediately dangerous —
nothing serves them — but it consumes capacity indefinitely.
```bash
# Always look first. A reconcile without dryRun=false is a plan, not an action.
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
-H 'content-type: application/json' -d '{"limit":100}' | jq '.candidates'
# Apply only the fingerprints you were just shown.
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
-H 'content-type: application/json' \
-d '{"dryRun":false,"limit":100,"maxBytes":1073741824,
"expectedFingerprints":["<from the dry run>"],"reasonCode":"ORPHAN_GROWTH_RUNBOOK"}'
```
Echoing the fingerprints is the safety property: an object that changed between the scan and the
apply is skipped rather than deleted.
## Verification backlog
**Signal**`fileserver.verification.queue{age_bucket="old"}` non-zero, or files sitting in
VERIFYING.
A verifier is slow or unavailable. Files stay non-public, which is the correct failure direction: a
`RETRY` verdict never becomes an `ACCEPT`.
```bash
curl -s $ADMIN/internal/fileserver/capabilities | jq '.storageType'
# Once the verifier is healthy, quarantined files can be re-examined individually.
curl -s -X POST "$ADMIN/internal/fileserver/files/$FILE_ID:reverify"
```
Do not clear the backlog by disabling verification. A file that reached READY without an accepting
verdict cannot be distinguished later from one that was verified.
## NFS ambiguity
**Signal** — problem documents carrying `"ambiguous": true`, or
`fileserver.transfer.interruption{reason="stale_handle"}`.
An operation's outcome could not be determined: the response was lost after the write or rename may
have landed. These are never retried automatically.
```bash
# The recovery queue holds the files awaiting a decision.
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" | jq
```
Reconciliation compares the physical size and digest against the record and only confirms READY when
all four of key, size, digest, and version agree. Anything short of that is reported, never guessed.
## PVC remount
**Signal** — startup failure naming "atomic move", "same file store", or "not writable".
The volume was remounted somewhere the probe can no longer prove a required capability. The
application refuses traffic rather than serving from storage it cannot publish to atomically.
```bash
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
kubectl logs job/fileserver-pvc-certification
```
Compare the printed tuple with the certified one in `docs/fileserver/storage-certification.md`. A
mismatch in CSI driver, StorageClass, access mode, or mount options is the cause; the certification
does not carry across it.
## Nginx delegation failure
**Signal**`fileserver.download.delegation{delegated="true"}` with client-visible `404`s.
The internal location is misconfigured, so the proxy cannot resolve the redirect it was handed.
```bash
# The internal prefix must resolve to the content root and must be marked `internal`.
grep -A5 '__files' infra/fileserver/nginx/nginx.conf
curl -s $ADMIN/internal/fileserver/capabilities | jq '.capabilities.delegatedDownload'
```
Turning delegation off is a safe immediate mitigation: the application serves the transfer itself,
slower but correct.
```bash
app.fileserver-platform.nginx.enabled=false
```
## Cleanup backlog
**Signal**`fileserver.cleanup{result="deferred"}` rising, or reclaimed bytes flat while deletes
continue.
Items are being deferred faster than they drain. The usual cause is an active writer lease still
holding staging objects, which is correct behaviour, not a fault.
```bash
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" \
| jq '[.[] | select(.leaseUntil != null)] | length'
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
```
If the deferrals are all `ACTIVE_WRITER_LEASE`, the backlog resolves itself as those uploads expire.
Never delete staging content to clear a backlog: an upload that is mid-flight will corrupt.
+74
View File
@@ -0,0 +1,74 @@
# Fileserver security model
## The rule everything else follows
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
— the filename, the declared media type, the range, the offset — is a value the caller chose.
## Path safety
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
normalizes and re-checks containment after construction rather than trusting the input.
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
between the two, so a check that ran only at path-building time would be a race, not a guard.
## Filename handling
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
in UTF-8 bytes.
The sanitized name is display data. It is never used to build a key, and it reaches a header only
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
form.
## Content type
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
pipeline, and only the verified type is served. A claimed type that contradicts the content is
quarantined rather than corrected.
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
`X-Content-Type-Options: nosniff`.
## Verification precedence
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
publicly readable only after an `ACCEPT`.
## Range safety
The range budget is enforced before content is opened, so a request naming many ranges is rejected
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
opens nothing.
## Authorization
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
run a production profile with an allow-all policy.
## Delegation
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
also strips any client-supplied delegation header so a caller cannot name an internal object.
## Telemetry
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
## Ambiguous failures
A failure whose operation may already have taken effect is reported as ambiguous and is never
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.
+66
View File
@@ -0,0 +1,66 @@
# Storage certification
## Why a certification is per-volume
Atomic rename, same-file-store guarantees, and symlink refusal are properties of a specific
filesystem behind a specific mount — not of "Kubernetes" or "a PVC". Change the CSI driver, the
StorageClass, the access mode, the backend, or the mount options and any of them can differ. A
certification that does not name all five is not transferable.
## What is certified
| Property | Why it matters |
|---|---|
| Same file store for staging and content | A rename across stores is a copy, so publication stops being atomic. |
| Atomic rename | The publish path's default strategy. |
| Atomic create (`O_EXCL`) | Makes a publish create-only rather than a silent overwrite. |
| Symlink refusal | Stops a replaced parent from redirecting a write outside the root. |
| Ranged read | The download contract depends on it. |
## Running the certification
```bash
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
kubectl logs job/fileserver-pvc-certification
```
The job writes a machine-readable result to the claim itself, carrying the full tuple:
```json
{
"kubernetesVersion": "...",
"csiDriver": "...",
"storageClass": "...",
"accessMode": "ReadWriteOnce",
"backend": "ext2/ext3",
"mountOptions": "rw,relatime",
"atomicMove": true,
"sameFileStore": true,
"atomicCreate": true
}
```
The job fails closed: a volume whose staging and content areas are on different stores is not
certified, because its publish would silently degrade to a copy.
## Network filesystems
```bash
docker compose -f infra/fileserver/nfs/compose.yml up -d
FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
```
The mount is `hard`, deliberately. A `soft` mount converts a slow server into a short write, which
is exactly the corruption this design refuses to accept.
## Startup enforcement
`FileserverStartupValidator` re-runs the probe at boot and refuses to accept traffic when a required
capability is missing — `ATOMIC_MOVE_REQUIRED` on a filesystem that cannot prove an atomic move
fails closed rather than degrading silently.
## Adding a new store
Extend `ContentStoreContract` and pass it. A prose claim of compatibility is not accepted; the
contract is executable precisely so a future object-storage adapter has to demonstrate the same
offset, digest, and create-only behaviour the local store does.
+83
View File
@@ -0,0 +1,83 @@
# Fileserver support matrix
A support level here is a claim about evidence, not about intent. Every row names the CI job that
produces that evidence; `FileserverDocumentationCoverageTest` fails the build if a row names a job
that does not exist, so a level can never outlive the test that justified it.
## Levels
| Level | What it means |
|---|---|
| Stable | Certified on every pull request. Contract changes are breaking changes. |
| Beta | Certified nightly. The contract may still change with a deprecation notice. |
| Limited | Certified on the release gate only, under stated constraints. |
| Compatibility | Accepted but not optimized; known caveats are listed inline. |
| Experimental | Off by default, unratified upstream, may change without notice. |
## Runtime profiles
| Profile | Level | CI job |
|---|---|---|
| Local filesystem (ext4) content store | Stable | `fileserver-local-ext4-contract` |
| Spring MVC transport (raw, multipart, batch, download) | Stable | `fileserver-http-contract` |
| Spring WebFlux transport | Experimental | `fileserver-http-contract` |
| Path, filename, range, and problem-detail hardening | Stable | `fileserver-security-suite` |
| Bounded-memory transfer | Stable | `fileserver-bounded-memory` |
| Application and architecture invariants | Stable | `fileserver-unit-and-architecture` |
| Runtime assembly (the capability starts with the flag on) | Stable | `fileserver-unit-and-architecture` |
| tus 1.0 resumable uploads | Stable | `fileserver-http-contract` |
| Crash-recovery matrix | Beta | `fileserver-process-kill-matrix` |
| NFSv4 ambiguity handling | Beta | `fileserver-nfs-ambiguity` |
| Large-file and slow-client performance | Beta | `fileserver-large-file-performance` |
| Multi-instance writer lease | Beta | `fileserver-multi-instance-lease` |
| Kubernetes ReadWriteOnce PVC | Limited | `fileserver-pvc-certification` (manifest checks in CI; cluster run is operator-driven) |
| Nginx `X-Accel-Redirect` delegation | Limited | `fileserver-http-contract` |
| Telemetry sensitive-data suppression | Stable | `fileserver-sensitive-telemetry-scan` |
| Documentation and support-claim coverage | Stable | `fileserver-documentation-gate` |
| Full release verification | Stable | `fileserver-full-verification` |
| HTTP resumable uploads draft-12 | Experimental | `fileserver-http-contract` |
### Why WebFlux is Experimental, not Stable
The reactive router, handlers and readers are now wired: `FileserverReactiveConfiguration`
contributes the scheduler, the handlers and a `RouterFunction` bean under
`@ConditionalOnWebApplication(type = REACTIVE)` plus the platform master switch. Previously nothing
built them at all, so "Stable" described the source tree rather than a running server.
It stays `Experimental` because the shipped composition cannot select it. `adapter:inbound:web`
also puts `DispatcherServlet` on the classpath — deliberately, so adding `spring-webflux` does not
drag a second embedded server onto the runtime — and Boot's application-type deduction therefore
resolves SERVLET. A fork that removes the servlet stack and adds a reactive server gets working
routes without editing any Fileserver code; the shipped template does not exercise that path.
Raising it to Stable requires a contract job that drives the routes over a running reactive server
rather than through direct construction.
## Explicitly not claimed
These have no job, and therefore no claim:
- An automated Kubernetes cluster result. `fileserver-pvc-certification` validates the manifest on
every release and applies it only when a release cluster is configured; without one it warns and
records that nothing was certified. The cluster tuple is produced by an operator and read from
[storage-certification.md](storage-certification.md).
- Kubernetes ReadWriteMany PVC. Concurrent writers across nodes are not certified.
- Windows NTFS as a production storage root. The filename policy strips the characters NTFS
reserves, but no job certifies the publish path there.
- Object storage as a content store. The contract exists (`ContentStoreContract`) but no adapter
implements it yet.
- Server-side malware scanning. The verification pipeline has the port and the verdict precedence;
no scanner is shipped.
## Where the rest is written down
- [configuration.md](configuration.md) — every `app.fileserver-platform.*` key, its default, and the
conditions that fail startup rather than degrade.
- [design-deviations.md](design-deviations.md) — where the implementation departs from the frozen
design, why, and the test that pins each decision.
- [http-contract.md](http-contract.md) — the wire contract.
- [security.md](security.md) — the threat model and what enforces each control.
- [operations.md](operations.md) — runbooks, each starting from a metric.
- [storage-certification.md](storage-certification.md) — how a volume is certified.
- [upgrade-guide.md](upgrade-guide.md) — what changes between versions.
+82
View File
@@ -0,0 +1,82 @@
# Fileserver upgrade guide
## Enabling the capability
The Fileserver ships off. Nothing is registered — no endpoint, no thread pool, no metric — until it
is enabled explicitly.
```yaml
ca-skeleton:
fileserver:
enabled: true
instance-id: ${HOSTNAME}
default-namespace: default
observability:
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
```
`instance-id` must be unique per instance: it is the writer-lease owner, and two nodes sharing one
would both believe they hold the same lease.
`fingerprint-key` is required and has no default. Startup fails without it rather than falling back
to an unkeyed digest, which would be reversible for an enumerable identifier space.
## Optional surfaces
Each is a separate switch, and each defaults to off:
```yaml
ca-skeleton:
fileserver:
admin:
enabled: false # management plane; intended for a management port
tus:
enabled: false # tus 1.0 Stable
httpbis-draft12:
enabled: false # Experimental; unratified, may change without notice
nginx:
enabled: false # front-proxy delegation; needs a validated internal location
```
## Database schema
The metadata schema is installed as a capability migration and starts inactive:
```
V1__create_fileserver_metadata.sql → capability_schema_registry: jpa-fileserver-metadata-v1
```
Activate it deliberately. Enabling the capability without an activated schema fails at startup
rather than at the first upload.
## Choosing a publish mode
| Mode | When |
|---|---|
| `atomic-move-preferred` | Default. Uses an atomic rename when the probe proves one, else a metadata pointer. |
| `atomic-move-required` | Fail closed. Refuses to start on storage that cannot prove an atomic move. |
| `metadata-pointer` | For storage without atomic rename; publication is the metadata commit. |
Pick `atomic-move-required` when the storage is certified and you want a misconfiguration to surface
at boot rather than at publish time.
## Behaviour that will surprise you
- **A delete answers `202`, not `204`, when content still exists.** The file is already unreadable;
the physical reclaim is deferred. Treating `202` as a failure will produce spurious retries.
- **A batch upload answers `200` even when parts failed.** The batch is explicitly non-atomic, and a
single status could not report a partial outcome honestly. Read `results[].problem`.
- **An ambiguous failure must not be retried.** Check `"ambiguous": true` in the problem document.
- **`If-Match` takes the strong ETag, not a version number.** A client can only assert about the
representation it was actually served.
- **Inline rendering is refused for scriptable types** even when the caller asks for it.
## Verifying an upgrade
```bash
cd src
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :application-core:check :adapter:inbound:web:check \
:adapter:outbound:fileserver:check --console=plain
./gradlew :app-bootstrap:test --tests '*Fileserver*' --console=plain
```