feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
@@ -0,0 +1,882 @@
# Fileserver R2 Control Plane and Provider Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox
> (`- [ ]`) syntax for tracking. Repository policy is `human-only`: do not stage, commit, amend, or
> push.
**Goal:** Add an explicit provider-neutral Fileserver R2 control plane and qualify
`local-persistent` as the first provider without making local filesystem the production default.
**Architecture:** `application-core` keeps the existing `FilePublicationPort` and gains only one
provider-neutral achieved-durability value. The fileserver leaf compiles `app.fileserver`
destination/provider settings into an exact registry, routes requests through one port bean, and
coordinates versioned operation, manifest, and reference records. A strict
`local-persistent` provider attests its root before use and advances the durable publication state
machine in forced, recoverable steps.
**Tech Stack:** Java 21, Spring Boot 4 configuration properties/autoconfiguration, JDK NIO/POSIX,
JUnit 5, AssertJ, ApplicationContextRunner, Gradle quality gates.
---
### Task 1: Add the provider-neutral achieved durability
**Files:**
- Modify:
`src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java`
- Modify:
`src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java`
- [x] **Step 1: Write the failing contract test**
Add a test that constructs a receipt with the new achieved value and proves no provider or path type
is introduced:
```java
@Test
void receiptCanReportFileAndDirectorySyncWithoutExposingAProviderType() {
FilePublishReceipt receipt =
receiptWith(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC);
assertThat(receipt.durabilityGuarantee())
.isEqualTo(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC);
assertThat(FilePublishReceipt.class.getDeclaredFields())
.allSatisfy(field -> assertThat(field.getType().getName())
.doesNotContain("java.nio.file", "fileserver", "sftp"));
}
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain
```
Expected: compilation failure because `FILE_AND_DIRECTORY_SYNC` does not exist.
- [x] **Step 3: Implement the minimum contract change**
Add only this enum member:
```java
public enum DurabilityGuarantee {
PROCESS_LOCAL_SYNC,
FILE_AND_DIRECTORY_SYNC,
PROVIDER_ACK_ONLY
}
```
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 2: Compile exact destination/provider settings with no local fallback
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java`
- [x] **Step 1: Write failing exact-binding tests**
Cover:
```java
@Test
void enabledSettingsRequireAnExplicitDestinationAndProvider() {
assertThatThrownBy(() -> FileserverBindingCompiler.compile(enabled(Map.of(), Map.of())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("destination");
}
@Test
void rejectsUnknownOrUnimplementedProviderTypes() {
assertThatThrownBy(() -> compile("shared-mounted"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("local-persistent");
}
@Test
void compilesOnlyAnExactLocalPersistentBinding() {
Map<FileDestinationId, CompiledFileDestination> result =
FileserverBindingCompiler.compile(validSettings());
assertThat(result).containsOnlyKeys(new FileDestinationId("local-export"));
assertThat(result.get(new FileDestinationId("local-export")).providerId())
.isEqualTo("local-primary");
}
```
Also reject blank IDs, unknown `provider-ref`, duplicate normalized IDs, non-absolute root, enabled
`auto-create`, unsupported publication/durability values, and non-positive row/byte bounds.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverBindingCompilerTest' --console=plain
```
Expected: compilation failure because the settings/compiler do not exist.
- [x] **Step 3: Implement typed settings**
Use one public configuration-properties record:
```java
@ConfigurationProperties(prefix = "app.fileserver")
public record FileserverR2Settings(
boolean enabled,
Map<String, DestinationSettings> destinations,
Map<String, ProviderSettings> providers) {
public record DestinationSettings(
String providerRef,
String requiredPublication,
String requiredDurability,
long maximumRows,
long maximumEncodedBytes) {}
public record ProviderSettings(
String type,
String rootDirectory,
boolean autoCreate,
boolean strictPathSecurity,
String expectedFileStoreName,
String expectedFileStoreType,
String mountSentinelName,
String mountSentinelSha256,
String expectedOwner,
String maximumRootMode) {}
}
```
The compiler accepts exactly:
```text
type=local-persistent
required-publication=unique-atomic-create
required-durability=file-and-directory-sync
auto-create=false
strict-path-security=true
```
`CompiledFileDestination` contains validated application destination ID, provider ID, absolute
root, limits, root attestation inputs, and no Spring type.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 3: Attest a pre-provisioned persistent root
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java`
- [x] **Step 1: Write failing attestation tests**
Create a real POSIX temporary root and sentinel. Test successful evidence and each fail-closed
condition:
```java
@Test
void attestsOwnerModeStoreSentinelSecureDirectoryAndSyncPrimitives() {
CompiledFileDestination destination = destinationFor(attestedRoot());
LocalPersistentRootEvidence evidence =
new LocalPersistentRootAttestor().attest(destination);
assertThat(evidence.root()).isEqualTo(root.toRealPath());
assertThat(evidence.secureDirectoryStream()).isTrue();
assertThat(evidence.directorySync()).isTrue();
assertThat(evidence.exclusiveHardLink()).isTrue();
}
```
Separate tests reject:
- relative or missing root;
- symlink root/ancestor;
- owner mismatch;
- group/world-writable root;
- FileStore name/type mismatch;
- missing, symlinked, non-regular, or digest-mismatched sentinel;
- staging/data/control on a different FileStore;
- unavailable `SecureDirectoryStream`, hard-link, or directory-force probe.
Probe collaborators may be package-private injectable functions so negative paths do not depend on
the host filesystem lacking a feature.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentRootAttestorTest' --console=plain
```
Expected: compilation failure because attestation types do not exist.
- [x] **Step 3: Implement strict attestation**
The attestor must:
```text
reject before creating anything when root/sentinel/owner/mode/store mismatch
capture root real path, file key, FileStore name/type, sentinel digest
create private .ca-fileserver, data, staging, operations, manifests, references, probe directories
set newly-created directories to 0700
force each created parent directory
open a SecureDirectoryStream on root
run unique exclusive-create + force + hard-link + directory-force probe
delete probe artifacts and force the probe directory
return immutable evidence used for pre/post identity checks
```
Do not silently downgrade to R1.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS on the supported Linux/POSIX lane.
---
### Task 4: Add strict reference, journal-v2, manifest, and reference records
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java`
- [x] **Step 1: Write failing codec tests**
Test:
```java
@Test
void referenceRoundTripRejectsForgeryUnknownRouteAndTruncation() {
PublishedFileReference reference = codec.encode("routea1", fixedFileId());
assertThat(codec.decode(reference, Set.of("routea1")).fileId()).isEqualTo(fixedFileId());
assertThatThrownBy(() -> codec.decode(tamper(reference), Set.of("routea1")))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> codec.decode(reference, Set.of("routeb2")))
.isInstanceOf(IllegalArgumentException.class);
}
```
For all three records prove:
- canonical encode/decode round trip;
- maximum encoded length;
- exact schema version;
- state and revision invariants;
- single-segment internal locators;
- lowercase SHA-256 fields;
- no absolute path, raw row/cell, credential, URI, or control character;
- newer schema and duplicate/unknown fields fail closed.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverControlRecordCodecTest' --console=plain
```
Expected: compilation failure because R2 records/codecs do not exist.
- [x] **Step 3: Implement bounded canonical records**
Use a strict flat canonical JSON codec owned by this leaf. The record state is:
```java
enum State {
WRITING,
SEALED,
DATA_PUBLISHED,
MANIFEST_PUBLISHED,
REFERENCE_PUBLISHED,
PUBLISHED,
QUARANTINED
}
```
`R2PublishedReferenceCodec` uses:
```text
fsr1.<route-token>.<32-lower-hex-file-id>.<first-12-hex-of-sha256(prefix)>
```
The check digits detect corruption only and are not authentication.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 5: Persist forced control records and operation locks
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java`
- [x] **Step 1: Write failing control-plane tests**
Test direct lookup and forced revision handling:
```java
@Test
void storesAndDirectlyLoadsOperationManifestAndReferenceRecords() {
controlPlane.storeOperation(writingRecord());
controlPlane.storeManifest(manifest());
controlPlane.storeReference(referenceRecord());
assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord());
assertThat(controlPlane.findManifest(FILE_ID)).contains(manifest());
assertThat(controlPlane.findReference(FILE_ID)).contains(referenceRecord());
}
```
Also prove:
- lower/equal incompatible state revision is rejected;
- request fingerprint mismatch is conflict;
- temp file is force-written before atomic replace;
- target parent is forced after replace;
- shard creation forces its parent;
- symlink shard/record is rejected with `NOFOLLOW_LINKS`;
- reads, temporary creation, stat, and delete use attested directory-relative names through
`SecureDirectoryStream`; operations without a portable secure hard-link/flagged atomic-replace
overload remain limited to the private-owner root and require pre/post identity checks;
- same operation is serialized by JVM stripe plus OS `FileLock`;
- record corruption is never treated as absent.
Use a package-private fault-point callback to observe/throw at:
```text
TEMP_FORCED
RECORD_REPLACED
PARENT_FORCED
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentControlPlaneTest' --console=plain
```
Expected: compilation failure because the control plane does not exist.
- [x] **Step 3: Implement durable storage**
All writes follow:
```text
CREATE_NEW sibling temp
write all bytes
FileChannel.force(true)
ATOMIC_MOVE + REPLACE_EXISTING for the control record only
force parent directory
read-back and verify identity/revision/digest
```
Payload publication must never use overwrite-capable move. Control record replacement is safe only
under the operation lock and monotonically increasing `stateRevision`.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 6: Implement the local-persistent R2 provider and deterministic recovery
**Files:**
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java`
- [x] **Step 1: Write failing publication-order tests**
First add failing compiler/control-plane assertions for:
```text
deterministic route token = "r" + first 31 lowercase hex of canonical policy digest
same startup allowlist route-token collision -> startup failure
length-prefixed effective policy/schema/format digest stability
same secure operation lookup -> typed canonical v1 or v2
v1 is read-only; malformed UTF-8/non-canonical/newer schema is indeterminate, never absent
control fault context identifies record kind, identity,
applicable operation state/revision, and force boundary
```
Then use a deterministic file ID/clock and a fault recorder. Prove exact order:
```text
J_WRITING
STAGE_FORCED
J_SEALED
DATA_LINKED
DATA_DIRECTORY_FORCED
J_DATA_PUBLISHED
MANIFEST_FORCED
J_MANIFEST_PUBLISHED
REFERENCE_FORCED
J_REFERENCE_PUBLISHED
J_PUBLISHED
```
Verify the receipt has an opaque `fsr1` reference,
`UNIQUE_ATOMIC_CREATE`, and `FILE_AND_DIRECTORY_SYNC`.
Also test producer once, streaming bounds, formula mitigation, target collision no overwrite,
root-identity change indeterminate, and manifest/reference locator non-disclosure. The stored
`internalLocator` is the generated filename only; its data shard is derived from the first two
hex characters of `fileId`.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverBindingCompilerTest' \
--tests '*LocalPersistentControlPlaneTest' \
--tests '*LocalPersistentPublicationProviderTest' --console=plain
```
Expected: compilation/test failure because the compiled identity, typed compatibility lookup,
contextual fault seam, payload operations, and provider do not exist.
- [x] **Step 3: Implement prerequisites and minimal R2 publication**
Compile one restart-stable destination identity without adding a config key:
```text
effectivePolicyDigest = SHA-256(length-prefixed canonical descriptor fields)
routeToken = "r" + first 31 lowercase hex of effectivePolicyDigest
```
The canonical descriptor includes destination/provider IDs, limits, required guarantees, and the
format/encoder revision. The schema and format policy use the same length-prefixed digest helper.
Reject route-token collisions across the compiled startup allowlist. Keep digest/token derivation
on the production SHA-256 path only. Exercise the otherwise impractical collision branch through
the same package-private pure route-registry check used by production, using two different test
digests whose first 31 hex characters collide; expose no digest/token runtime override.
Extend `LocalPersistentControlPlane` with one secure relative typed operation lookup. It returns
schema-v1 only through strict UTF-8 plus canonical v1 re-encode byte equality and never writes v1;
schema-v2 remains the only write format. Enrich its package-private fault callback with record kind,
identity, operation state/revision, and force boundary so Task 8 can stop at an exact record force.
The provider:
```text
validates destination and request before producer invocation
acquires operation lock
loads operation by direct ID
allocates fileId/name before WRITING
streams with existing StreamingCsvEncoder
forces stage and stores SEALED
exclusive hard-links data and forces data directory
publishes private manifest
publishes reference index
stores terminal receipt snapshot
returns only after terminal journal parent force/read-back
```
`LocalPersistentPayloadOperations` owns restrictive staging/data shard creation, secure relative
stage create/write/force, stable no-follow artifact inspection/digest, exact stage deletion,
exclusive no-replace hard-link, standalone recovery-time data-shard directory force, and
attested-root-relative R1 artifact inspection. Absolute hard-link/directory-force calls are allowed
only inside the attested private-owner boundary with file/root/directory identity checks. An
existing matching data artifact discovered from `SEALED` must have its shard directory forced
again before the journal may advance; it is never republished through a collision path. A
root-level R1 artifact is restored only after bounded SDS-relative no-follow inspection matches the
terminal R1 journal.
Before and after the hard-link commit, compare root real path, file key, FileStore, and sentinel
digest to `LocalPersistentRootEvidence`.
- [x] **Step 4: Write failing recovery matrix tests**
For every non-terminal state construct matching/missing artifacts and retry with a producer that
throws if called. Expected:
```text
SEALED + stage -> resume data publish
SEALED + matching data -> resume manifest
DATA_PUBLISHED -> resume manifest
MANIFEST_PUBLISHED -> resume reference
REFERENCE_PUBLISHED -> finish terminal journal
PUBLISHED + all matching -> restore exact receipt
non-terminal data/manifest/reference mismatch -> QUARANTINED / integrity failure
PUBLISHED artifact/metadata/receipt mismatch -> preserve all terminal evidence; integrity / indeterminate
required artifact missing -> fail-closed indeterminate / quarantine, never success
fingerprint mismatch -> CONFLICT
root identity mismatch -> PUBLISH_INDETERMINATE
WRITING producer/stage failure -> exact cleanup + unsealed QUARANTINED
retry with existing WRITING -> producer is not invoked; indeterminate / quarantine
retry of unsealed QUARANTINED -> producer is not invoked
```
`LocalPersistentRecoveryVerifier` must cross-check the operation, incoming request, stable data
digest, canonical manifest/reference digests, all locators/counts/timestamps, and guarantees.
Because operation schema v2 does not carry a standalone format-policy snapshot, it must require an
exact current compiled effective-policy revision/digest match before using the current
format-policy digest; it must fail closed instead of guessing across an encoder-policy change.
Current configured byte/row limits apply to a new attempt. Recovery inspection is bounded by the
already frozen operation byte size (with overflow-safe equality), so a later lower configuration
limit does not reinterpret a sealed artifact. If both stage and data exist, their stable file keys
must match before exact stage deletion; equal bytes alone are insufficient.
Restore a terminal receipt only when it equals the full receipt reconstructed from the verified
manifest/reference; checking only operation ID/count/SHA is insufficient. Reuse a verified
immutable manifest/reference `publishedAt` after a crash instead of generating a conflicting time.
`QUARANTINED` journal transitions are limited to non-terminal operations. A mismatch discovered
from `PUBLISHED` must not replace the terminal journal or delete/overwrite data, manifest, or
reference records; return typed integrity/indeterminate and preserve all terminal evidence. A
separate immutable quarantine incident record is outside this increment.
- [x] **Step 5: Write failing R1 compatibility tests**
Pre-provision an existing R1 root so it passes every R2 root attestation condition, then configure
that same root as the R2 destination. Place a valid journal schema-v1 terminal record at the shared
hashed operation path and a matching root-level R1 artifact.
The R2 reader may restore its original `PROCESS_LOCAL_SYNC` receipt, but must not create an R2
manifest/reference, change its guarantee, or rewrite the record as schema v2. Newer/corrupt R1
records remain indeterminate. Also prove malformed UTF-8 and a decodable but non-canonical v1
encoding fail, and that simultaneous R1/R2 bean activation is not required for migration.
- [x] **Step 6: Verify compatibility RED, then implement read-only compatibility**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected before implementation: the R1 restoration assertion fails. Reuse the existing schema-v1
model/codec behind an added strict UTF-8 and canonical re-encode equality guard, only as a read-only
compatibility reader; do not add schema-v1 write paths or an unconfigured second root.
- [x] **Step 7: Verify recovery RED, then implement recovery**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected before recovery implementation: failures at each resume assertion. Implement only the
matrix and verifier rules above. When producer or staging fails after `J_WRITING`, preserve the
original exception, attach cleanup/control failures as suppressed, exact-delete the partial stage,
and store unsealed `QUARANTINED` evidence so retry cannot replay the producer. A retry that finds
`WRITING` after a process crash also must not invoke the producer. Then rerun. Expected: PASS.
- [x] **Step 8: Verify provider GREEN**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationProviderTest' \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected: PASS.
---
### Task 7: Add one routing port bean and reject ambiguous R1/R2 activation
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java`
- Rename:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java`
to
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java`
- Modify:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java`
- Modify:
`src/app-bootstrap/build.gradle`
- Modify:
`src/config/architecture/modules.json`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/OptionalAdapterBeanGatingTest.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java`
- [x] **Step 1: Write failing composition/routing tests**
Prove:
```java
@Test
void disabledR2CreatesNoPortOrFilesystemSideEffect() {}
@Test
void enabledR2CreatesExactlyOneRoutingPortForExplicitBindings() {}
@Test
void requestForUnknownDestinationFailsBeforeProducerInvocation() {}
@Test
void enablingLegacyR1AndR2TogetherFailsStartup() {}
@Test
void configuredButUnimplementedSharedOrSftpProviderFailsStartup() {}
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverR2ConfigTest' --console=plain
```
Expected: compilation/test failure because R2 composition does not exist.
Execution note: the production composition skeleton had already been introduced before the
delegated test task returned, so a standalone RED Gradle run was no longer reproducible without
reverting work. The tests still exposed the missing method-level conditional gate through the
bootstrap architecture check; that failure was observed and fixed before GREEN.
- [x] **Step 3: Implement exact routing composition**
`RoutingFilePublicationAdapter` contains an immutable
`Map<FileDestinationId, FilePublicationProvider>` and delegates only after exact lookup.
`FileserverR2Config`:
- is conditional on `app.fileserver.enabled=true`;
- enables `FileserverR2Settings`;
- compiles and attests every configured binding at startup;
- creates one provider instance per provider ID;
- creates exactly one `FilePublicationPort`;
- rejects `ca-skeleton.fileserver.enabled=true` in the same environment before either R1 root
creation or R2 attestation, independently of Spring bean creation order;
- rejects different provider IDs that resolve to the same normalized root;
- never creates directories/connections when disabled.
The same package-private activation validator runs first in both R1 bean factories and the R2
routing factory; conditional precedence is not an acceptable substitute for an ambiguity failure.
Use strict configuration-properties binding (`ignoreUnknownFields = false`). Wire the fileserver
leaf into `app-bootstrap` through the architecture registry and Gradle dependency in this task so
the runtime composition is real, while keeping all local provider/control types private to the
leaf. Rename the legacy configuration-properties type to the repository-required `*Settings`
suffix before exposing this leaf to bootstrap naming checks.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 8: Add process-crash qualification, docs, and full gates
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java`
- Create:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java`
- Modify: `src/adapter/outbound/fileserver/README.md`
- Modify: `src/adapter/outbound/fileserver/CLAUDE.md`
- Modify:
`docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
- Modify:
`docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md`
- Modify:
`docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md`
- Modify: `docs/registries/env-keys.yaml`
- [x] **Step 1: Write the failing forked-process crash test**
Launch a new JVM with the test runtime classpath. The helper receives a fault point and calls
`Runtime.getRuntime().halt(91)` immediately after that point. Cover:
```text
J_WRITING
STAGE_FORCED
J_SEALED
DATA_LINKED
DATA_DIRECTORY_FORCED
MANIFEST_FORCED
MANIFEST_DIRECTORY_FORCED
REFERENCE_FORCED
REFERENCE_DIRECTORY_FORCED
TERMINAL_JOURNAL_FORCED
TERMINAL_JOURNAL_DIRECTORY_FORCED
```
Restart in a second JVM/process and assert exact receipt restoration or a documented typed
indeterminate/quarantine outcome, never producer replay or partial final bytes.
Also run a forked cross-process operation-lock proof using the same attested root and operation ID:
process A acquires and reports the OS lock, process B uses a bounded non-blocking/timed attempt and
must not enter the critical section while A is alive, then must acquire after A releases or is
forcibly terminated. This proof must exercise the OS `FileLock`; the same-JVM stripe test is not a
substitute and every wait requires a timeout.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentCrashRecoveryTest' --console=plain
```
Expected: failure until every fault point is injectable and recoverable.
Execution note: the contextual control-plane and payload fault seams introduced in Task 6 already
covered all eleven boundaries. The first complete forked-process run therefore passed without a
new production hook; no implementation was reverted merely to manufacture a RED result.
- [x] **Step 3: Implement only missing fault hooks/recovery transitions**
Fault hooks remain package-private test collaborators. No runtime setting or production bean may
allow arbitrary process termination.
- [x] **Step 4: Verify focused and module checks**
Run:
```bash
cd src
./gradlew :application-core:check :adapter:outbound:fileserver:check --console=plain
```
Expected: PASS.
- [x] **Step 5: Update readiness documentation**
Record:
- provider-neutral control plane and exact selector implemented;
- `local-persistent` is the only qualified R2 provider;
- `FILE_AND_DIRECTORY_SYNC` does not claim physical device power-loss protection;
- `shared-mounted`, SFTP, reaper/retention/quota/observability remain unimplemented;
- R1 compatibility artifacts are never auto-promoted.
Register the exact local provider environment keys from the design (`ROOT`, expected FileStore
name/type, sentinel digest, expected owner) with restart-only policy and conditional
`app.fileserver.enabled` validation. Do not add SFTP/NFS keys before those providers exist.
- [x] **Step 6: Run full repository gates**
Run:
```bash
cd src
./gradlew check --console=plain
./gradlew \
:application-core:verifyDependencyLocks \
:adapter:outbound:fileserver:verifyDependencyLocks \
:app-bootstrap:verifyDependencyLocks \
:sample-portfolio:verifyDependencyLocks \
verifyCleanArchitectureDependencies \
verifyPublicPathSnapshot \
verifyEnvKeys --console=plain
git diff --check
```
Expected: all commands PASS.
- [x] **Step 7: Request final independent review**
Review against:
- the R2 design spec;
- HARD-STOP rules;
- provider fallback/activation ambiguity;
- path/symlink/mount identity;
- crash ordering and recovery;
- receipt guarantee truthfulness;
- R1 compatibility and no unrelated adapter dependency.
Fix every Critical/Important issue and rerun the affected focused test plus full gates.
@@ -0,0 +1,120 @@
# HTTP Client Canonical Zero-Binding Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans`. Repository policy
> overrides the skill's commit steps: do not stage, commit, amend, or push.
**Goal:** Make HTTP client activation an explicit canonical composition decision and prove that the
default zero-binding state creates no client, executor, shutdown guard, retry/circuit-breaker
registry, or transport resource.
**Architecture:** `adapter:outbound:httpclient` owns strict canonical configuration, immutable
binding/provider/catalog/readiness registries, and a pure activation resolver. `app-bootstrap` owns
the composition root that binds canonical properties and publishes an inert capability descriptor.
The existing JDK `OutboundHttpClient` remains an explicitly constructed R1 migration facade; its
legacy settings and infrastructure configuration must no longer be discovered automatically.
**Scope boundary:** This increment does not add Apache HC5, a provider factory, a real semantic
upstream binding, hard wire cancellation, TLS/DNS/proxy/auth, or an R2 readiness claim. Every current
ACTIVE selection must fail closed because the only derived readiness card remains
`NOT_IMPLEMENTED`.
---
### Task 1: Add strict canonical selection and provider binding models
**Files:**
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java`
- Test:
`src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java`
- [x] Write RED tests for the canonical YAML shape under
`ca-skeleton.capabilities.http-client` and `ca-skeleton.providers.http-client`.
- [x] Reject unknown fields, malformed IDs, unknown expected state, and any legacy input entering
canonical composition, including the DISABLED state.
- [x] Preserve `OutboundHttpSettings` constructors as migration API, but remove its global
`@ConfigurationPropertiesScan` participation.
- [x] Keep provider definitions inert data; configuration alone must not create a transport.
### Task 2: Add catalog/readiness registries and pure fail-closed activation resolution
**Files:**
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java`
- Test:
`src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java`
- [x] Prove `DISABLED + bindings 0 + provider definitions 0` resolves to
`DISABLED_VERIFIED`, selected binding/card count 0.
- [x] Reject `DISABLED` with bindings or provider resources.
- [x] Reject `ACTIVE` with zero bindings.
- [x] For every binding, require an exact provider, provider destination, and registered operation
catalog for the same destination.
- [x] Derive the `httpclient-static-buffered` card from each current buffered classic profile.
- [x] Mark that card `NOT_IMPLEMENTED`; reject ACTIVE before any provider resource/factory exists.
### Task 3: Move HTTP Spring activation to the composition root
**Files:**
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java`
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java`
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java`
- Create:
`src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java`
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
- Test:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java`
- [x] Detach legacy HTTP infrastructure from component/configuration-properties scanning while
preserving direct constructors/factory methods used by forks and existing unit tests.
- [x] Register only canonical configuration, immutable registries, resolver, and inert descriptor
in the composition root.
- [x] Default application YAML to canonical `expected-state: DISABLED`, empty bindings, and empty
provider definitions; keep legacy migration keys out of both main and test application YAML.
- [x] Assert zero `OutboundHttpClient`, `RestClient`, `OutboundCallExecutor`,
`OutboundHttpShutdownGuard`, `OutboundHttpResilience`, `RetryRegistry`, and
`CircuitBreakerRegistry` beans/resources in the default context.
- [x] Assert contradictory/ACTIVE configurations fail startup before resource construction.
- [x] Load the real `application.yml` in composition tests and prove ACTIVE reaches the
`NOT_IMPLEMENTED` readiness card rather than a legacy conflict.
### Task 4: Document exact readiness and verify
**Files:**
- Modify: `src/adapter/outbound/httpclient/README.md`
- Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`
- Modify:
`docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md`
- [x] Mark canonical zero-binding as implemented without marking HTTP R2 complete.
- [x] Keep HC5/provider resources/security/real-network qualification explicitly unimplemented.
- [x] Run focused tests:
```bash
cd src
./gradlew :adapter:outbound:httpclient:check --rerun-tasks --console=plain
./gradlew :app-bootstrap:check --rerun-tasks --console=plain
./gradlew :sample-portfolio:test --rerun-tasks --console=plain
./gradlew verifyCleanArchitectureDependencies verifyConfigurationPropertiesProcessor \
verifyEnvKeys verifyPublicPathSnapshot --console=plain
```
Do not edit unrelated notification, messaging, object-storage, JPA, MongoDB, GraphQL, gRPC, web, or
WebSocket files.
@@ -12,9 +12,10 @@ own feature-specific semantic ports. `adapter:outbound:httpclient` owns destinat
immutable operation descriptors, relative target construction, status/retry/body semantics, and
legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade.
**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical binding
composition, exact readiness tuple registry, Apache HC5 pool, active cancellation, TLS/DNS/proxy,
auth, codec, and real-network qualification remain unimplemented.
**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical zero-binding
composition and active logical cancellation were implemented by later tracked plans. Exact
readiness tuple registry, Apache HC5 pool, TLS/DNS/proxy, auth, codec, and real-network
qualification remain unimplemented.
---
@@ -24,9 +25,9 @@ auth, codec, and real-network qualification remain unimplemented.
- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java`
- Test: `src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java`
- [ ] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection.
- [ ] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types.
- [ ] Verify GREEN.
- [x] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection.
- [x] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types.
- [x] Verify GREEN.
### Task 2: Add typed operation catalog and safe target construction
@@ -41,11 +42,11 @@ auth, codec, and real-network qualification remain unimplemented.
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java`
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilderTest.java`
- [ ] Write RED tests for ID/uniqueness/cross-field operation invariants.
- [ ] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and
- [x] Write RED tests for ID/uniqueness/cross-field operation invariants.
- [x] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and
multi-segment variables.
- [ ] Implement closed immutable descriptors and one-pass path-segment encoding.
- [ ] Verify GREEN.
- [x] Implement closed immutable descriptors and one-pass path-segment encoding.
- [x] Verify GREEN.
### Task 3: Correct characterized legacy provider safety defects
@@ -54,11 +55,11 @@ auth, codec, and real-network qualification remain unimplemented.
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java`
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java`
- [ ] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting.
- [ ] Make streaming validate status before exposing the body and discard error bodies.
- [ ] Put circuit breaker around each physical attempt and retry around the attempt loop.
- [ ] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets.
- [ ] Verify focused regressions and the full legacy test suite.
- [x] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting.
- [x] Make streaming validate status before exposing the body and discard error bodies.
- [x] Put circuit breaker around each physical attempt and retry around the attempt loop.
- [x] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets.
- [x] Verify focused regressions and the full legacy test suite.
### Task 4: Record exact readiness and verify
@@ -67,10 +68,10 @@ auth, codec, and real-network qualification remain unimplemented.
- Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`
- [ ] Mark the implemented foundation and fixed legacy defects.
- [ ] Keep total deadline/cancellation, canonical zero-binding composition, Apache pool, fixed
egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented.
- [ ] Run:
- [x] Mark the implemented foundation and fixed legacy defects.
- [x] Track later total-deadline and canonical-zero-binding increments separately while keeping
Apache pool, fixed egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented.
- [x] Run:
```bash
cd src
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
# Redis Cache Resilience Implementation Plan
> Repository commit policy is human-only. Do not stage, commit, amend or push.
**Goal:** Implement the approved cache-aside, bounded source protection and soft/hard TTL design
without promoting Redis beyond standalone cache R1.
### Task 1: Application cache-aside outcomes and policy
**Files:**
- Create/modify `src/application-core/src/main/java/dev/caskeleton/application/cache/*`
- Test `src/application-core/src/test/java/dev/caskeleton/application/cache/*`
- [x] Write RED tests for fresh/negative/miss/stale/source outcome transitions.
- [x] Add typed loader, failure, result, cancellation and immutable policy contracts.
- [x] Implement cache-aside sequencing; only authoritative absence may be negative-cached.
- [x] Preserve unclassified exceptions and interruption.
- [x] Verify focused application cache tests GREEN.
### Task 2: Bounded local single-flight and source bulkhead
**Files:**
- Create `CacheSingleFlight.java`
- Create `CacheSourceBulkhead.java`
- Test their concurrency behavior through focused unit tests.
- [x] Write RED concurrency tests.
- [x] Bound in-flight keys, waiters, admission wait and load wait.
- [x] Remove completed/failed/abandoned flights and preserve loader failure fan-out.
- [x] Prove Redis outage cannot create unlimited source concurrency.
### Task 3: Redis soft/hard TTL, jitter and stale envelope
**Files:**
- Modify `RedisCacheRegionPolicy.java`
- Modify `RedisCacheEnvelopeCodec.java`
- Modify `RedisStringCacheRegion.java`
- Modify/add focused Redis cache tests.
- [x] Write RED boundary, jitter, minimum and schema-compatibility tests.
- [x] Add an injected `Clock` and deterministic policy-revision jitter.
- [x] Encode absolute soft/hard expiry in envelope version 2.
- [x] Use the encoded hard expiry as physical Redis TTL.
- [x] Verify focused Redis tests GREEN.
### Task 4: Documentation and verification
- [x] Synchronize the completed foundation-plan checkboxes with existing code/evidence.
- [x] Update Redis README/CLAUDE/design readiness truth.
- [ ] Run application and Redis leaf checks.
- [ ] Run dependency locks, architecture, public path, env and diff checks.
- [x] Request independent specification and code-quality review.
@@ -0,0 +1,45 @@
# Redis Distributed Rate-Limit Implementation Plan
> Repository commit policy is human-only. Do not stage, commit, amend or push.
### Task 1: Shared edge rate-limit contract
- [x] Write RED contract/policy tests in `shared-contract`.
- [x] Add bounded request, algorithm parameters, policy, decision, outcome and port types.
- [x] Reject unsupported dedup/failure claims and unsafe fixed-point arithmetic.
- [x] Verify the shared contract without Redis/Spring types.
### Task 2: Structured Redis program execution
- [x] Write RED tests for MULTI reply arity/status/ASCII integer bounds and `NOSCRIPT`.
- [x] Add bounded structured `EVALSHA`/`EVAL` command support without changing scalar primitives.
- [x] Add exact catalog descriptors and resource digests for three rate programs.
### Task 3: Three atomic algorithms and semantic provider
- [x] Implement fixed-window Lua and golden vectors.
- [x] Implement sliding-counter Lua with conservative fixed-point arithmetic.
- [x] Implement token-bucket Lua with saturation and exact ceiling retry.
- [x] Add canonical private keys, policy lookup and typed failure mapping.
- [x] Prove denial does not consume quota and revision changes physical state.
### Task 4: Dedicated runtime and explicit composition
- [x] Add strict `app.rate-limit` settings and disabled-zero-side-effect configuration.
- [x] Use a dedicated coordination runtime rather than cache Redis beans/settings.
- [x] Add exact environment registry/application configuration entries.
- [x] Keep readiness at standalone provider R1.
### Task 5: Verification and review
- [x] Run shared/Redis/bootstrap focused checks.
- [x] Run architecture/dependency/env/diff gates.
- [ ] Run the public-path gate with the final combined change set.
- [x] Run an explicit real Redis lane when a service is available.
- [x] Request independent spec and quality review.
The Redis 7.4 service lane executes the exact-boundary admission after a denied non-consuming
request for all three algorithms, excessive clock-regression state immutability, token refill
remainder carry, malformed hash classification, cache NX, and observation-token compare-replace.
The program manifests therefore declare 7.4 as the minimum qualified version until a lower-version
service lane exists.
@@ -31,10 +31,10 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java`
- Test: `src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java`
- [ ] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata.
- [ ] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`.
- [ ] Implement only framework-free values and ports.
- [ ] Verify GREEN.
- [x] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata.
- [x] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`.
- [x] Implement only framework-free values and ports.
- [x] Verify GREEN.
### Task 2: Add canonical Redis physical keys
@@ -45,12 +45,12 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java`
- [ ] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and
- [x] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and
absence of raw sensitive resource identifiers.
- [ ] Verify RED.
- [ ] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret
- [x] Verify RED.
- [x] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret
copies and length-prefixed component encoding.
- [ ] Verify GREEN.
- [x] Verify GREEN.
### Task 3: Add a typed, versioned atomic-program catalog
@@ -66,11 +66,11 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalogTest.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitivesTest.java`
- [ ] Write failing catalog and facade tests.
- [ ] Verify RED.
- [ ] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic
- [x] Write failing catalog and facade tests.
- [x] Verify RED.
- [x] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic
application-facing execution surface.
- [ ] Verify GREEN.
- [x] Verify GREEN.
### Task 4: Record exact readiness and verify
@@ -79,9 +79,9 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md`
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`
- [ ] Mark only contract/key/program foundation as implemented and all real runtime/capability
- [x] Mark only contract/key/program foundation as implemented and all real runtime/capability
promotion as unimplemented.
- [ ] Run:
- [x] Run:
```bash
cd src
@@ -89,5 +89,5 @@ cd src
./gradlew verifyCleanArchitectureDependencies --console=plain
```
- [ ] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence
- [x] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence
exist.
@@ -0,0 +1,662 @@
# Redis Production Capability Completion Plan
> **Scope:** Redis를 먼저 완료한다. 현재 실행 단위는 deep design Phase 5 전체가 아니라
> `Sentinel-first R2 qualification slice`다. 이 slice의 검증과 보고가 끝나면 멈추고
> fileserver, HTTP client, Redis Cluster/R3 중 다음 우선순위를 다시 정한다.
>
> **Workflow note:** 저장소가 지정한 Superpowers 설계·계획·TDD·디버깅·검증·리뷰 워크플로우를
> 적용한다. agent는 human-only commit 정책에 따라 stage/commit/amend/push하지 않는다.
**Goal:** `2026-07-26-redis-production-capability-design.md`의 Phase 15를 capability별로 구현하고,
standalone 기능의 존재를 production readiness로 오표기하지 않는 Redis platform을 만든다.
**Architecture:** `application-core``shared-contract`는 provider-neutral semantic contract만
소유한다. `adapter:outbound:cache-redis`가 Redis deployment, topology, key, codec, program,
runtime과 capability provider를 소유한다. `adapter:inbound:web`은 HTTP rate/session 보안 매핑만,
`app-bootstrap`은 provider/role/auth-mode composition만 소유한다. `domain-core`에는 Redis 개념을
추가하지 않는다.
**Readiness rule:** Redis leaf 전체에 단일 R2 label을 부여하지 않는다. `redis-cache`,
`redis-edge-rate-limit`, `redis-request-replay-idempotency`,
`redis-cache-refresh-soft-lease`, `redis-fenced-coordination`, `redis-session` card가 독립적으로
승격한다. R3 증거가 없는 failover/reshard/rotation은 R2 범위로 과장하지 않는다.
**Worktree rule:** 현재 `main` worktree의 다른 기술 변경은 사용자 소유다. Redis가 소유하지 않는
fileserver, HTTP client, messaging, notification, object storage 변경을 되돌리거나 포맷하지 않는다.
**Current milestone exit:** agent-side 목표는 `R2-ready candidate`다. clean committed source와
실제 remote GitHub Actions evidence가 없으면 card를 `selected`로 바꾸거나 R2라고 주장하지 않는다.
---
## Task 0 — Baseline과 acceptance registry 고정
**Files**
- Create: `src/config/redis/readiness-cards.yaml`
- Create: `src/gradle/redis-test-images.properties`
- Modify: `src/adapter/outbound/cache-redis/README.md`
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`
**Tests first**
- registry가 canonical card ID 여섯 개를 정확히 한 번 포함하는지 실패 테스트를 작성한다.
- image tag에 exact version과 digest가 없으면 configuration이 실패하는 테스트를 작성한다.
- `selected`, `implemented-candidate`, `not-implemented` 이외 상태를 거절한다.
- 현재 구현과 다른 readiness 표기를 거절한다.
**Implementation**
- 시작 상태는 cache/rate를 `implemented-candidate`, 나머지는 `not-implemented`로 기록한다.
- 실제 required evidence가 생기기 전에는 어떤 card도 `selected` R2로 승격하지 않는다.
- Redis minimum version은 실행 가능한 image/digest와 program manifest를 한 SSOT로 맞춘다.
**Verification**
```bash
cd src
./gradlew :adapter:outbound:cache-redis:test --tests '*RedisReadinessRegistryTest' --console=plain
```
## Task 1 — Canonical deployment/topology/role model
**Files**
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderProperties.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderPropertiesBindingTest.java`
**Tests first**
- topology는 `standalone|sentinel|cluster` 중 정확히 하나다.
- endpoint는 non-empty, unique, bounded host/port다.
- Sentinel은 master name, 최소 3개 discovery endpoint, data/Sentinel auth와 TLS를 분리한다.
- Cluster는 database 0만 허용하고 seed가 비어 있으면 실패한다.
- role은 존재하는 deployment만 참조한다.
- cache와 session/coordination의 incompatible co-location을 startup 전에 거절한다.
- provider 정의만 있고 capability binding이 없으면 runtime side effect가 0이다.
**Implementation**
- Spring binding class와 validated sealed runtime model을 분리한다.
- legacy `app.cache.redis``app.rate-limit`은 migration compiler 입력으로만 허용하고 canonical
model과 동시에 설정되면 precedence를 정하지 않고 실패한다.
- `ClientMode.EXTERNAL`을 topology로 취급하지 않는다.
**Verification**
```bash
cd src
./gradlew :adapter:outbound:cache-redis:test --tests '*RedisDeploymentSettings*' --console=plain
```
## Task 2 — Topology-aware runtime, TLS/ACL과 secret material
**Files**
- Create: `.../redis/runtime/RedisDeploymentRuntime.java`
- Create: `.../redis/runtime/RedisDeploymentRuntimeFactory.java`
- Create: `.../redis/runtime/StandaloneRedisDeploymentRuntime.java`
- Create: `.../redis/runtime/SentinelRedisDeploymentRuntime.java`
- Create: `.../redis/runtime/ClusterRedisDeploymentRuntime.java`
- Create: `.../redis/security/RedisCredentialMaterialProvider.java`
- Create: `.../redis/security/RedisCredentialRotationCoordinator.java`
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
- Modify: `src/adapter/outbound/cache-redis/gradle.lockfile`
**Tests first**
- standalone/Sentinel/Cluster가 각자 다른 native client/runtime을 만든다.
- Sentinel discovery credential/trust와 data-node credential/trust가 섞이지 않는다.
- Cluster client는 periodic+adaptive topology refresh, DB 0, bounded redirect/queue profile을 가진다.
- production profile에서 plaintext, trust-all, hostname verification off를 거절한다.
- named ACL username이 없거나 raw password가 YAML에 있으면 production activation이 실패한다.
- duplicate/out-of-order rotation event, expiry 재조회, new connection 검증 실패가 old traffic을
안전하게 보존한다.
- disabled capability는 client/event-loop/subscriber/scheduler를 만들지 않는다.
**Implementation**
- direct `spring-data-redis`, `lettuce-core` dependency를 leaf가 소유한다.
- deployment별 client resources와 lifecycle을 소유한다.
- connect/TLS/acquire/command/overall/shutdown timeout을 분리한다.
- 기존 no-replay, disconnected reject, finite queue/count/byte admission을 topology runtime에도
보존한다.
- secret value/reference/provider exception을 log/metric에 남기지 않는다.
## Task 3 — Key, codec, program manifest foundation
**Files**
- Create: `src/config/redis/program-set.schema.json`
- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json`
- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json`
- Modify: `.../redis/RedisProgramDescriptor.java`
- Modify: `.../redis/RedisProgramCatalog.java`
- Modify: `.../redis/RedisLuaProgramExecutor.java`
- Create: `.../redis/key/RedisKeyMaterialProvider.java`
- Create: `.../redis/codec/RedisCapabilityCodec.java`
**Tests first**
- 모든 program은 exact source digest, semantic version, ordered KEYS/ARGV, result schema, slot rule,
state/TTL bound, minimum Redis version, retry/certainty, ACL command를 가진다.
- manifest와 Java descriptor가 drift하면 build가 실패한다.
- `NOSCRIPT` recovery는 bounded `SCRIPT LOAD -> EVALSHA`이고 arbitrary source 실행 surface가 없다.
- same-resource multi-key는 real `CLUSTER KEYSLOT`과 같은 slot이다.
- key digest material rotation은 fixed/dual-read-delete/cold-cutover rule을 지킨다.
- cache/idempotency/session codec은 N/N-1, future/corrupt/oversize/forbidden type을 구분한다.
**Implementation**
- foundation/rate manifest를 하나의 versioned registry contract로 통합하되 capability package와
facade는 분리한다.
- raw command, raw key, generic program executor를 Spring/application public surface에 노출하지 않는다.
## Task 4 — Cache consistency spine와 semantic region composition
**Files**
- Modify: `src/application-core/src/main/java/dev/caskeleton/application/cache/*`
- Create: `.../redis/cache/RedisCacheGenerationStore.java`
- Create: `.../redis/cache/RedisCacheRegionCompiler.java`
- Add resources: `region-generation-init-v1.lua`, `region-generation-bump-v1.lua`,
`cache-record-if-generation-v1.lua`
- Modify: `.../redis/RedisStringCacheRegion.java`
- Tests: application barrier tests, Redis real-service concurrency tests, binding tests
**Tests first**
- source load 중 generation bump가 일어나면 old result가 visible하지 않다.
- captured generation과 source revision이 바뀌면 stale writer가 새 값을 덮어쓰지 않는다.
- generation init race에서 하나의 canonical generation만 선택된다.
- operation ID가 같은 bump replay는 한 번만 적용된다.
- 여러 semantic region의 duplicate/missing binding은 fail-fast다.
- 실제 consumer가 semantic `CacheRegionPort``CacheAsideExecutor`를 사용하고 legacy fail-open
router와 암묵적으로 섞이지 않는다.
**Implementation decision**
- source revision은 opaque하므로 lexical “newer” 비교를 하지 않는다.
- region generation은 mass invalidation fence다.
- per-key invalidation은 해당 key의 revision/tombstone fence를 사용해 region 전체를 bump하지 않는다.
- write는 captured generation/revision condition을 만족할 때만 기록한다.
## Task 5 — Distributed refresh soft lease, L1/L2와 cache observability
**Files**
- Create application cache refresh coordination contracts without Redis types.
- Create Redis refresh claim/release programs and semantic provider.
- Create bounded L1 cache decorator and invalidation subscriber/reconciler.
- Create framework-free cache observation events and Micrometer adapter instrumentation.
- Update `docs/registries/metrics.yaml`.
**Tests first**
- 두 pod simulation에서 정상 시 refresh owner는 하나다.
- lease expiry에서는 duplicate load를 허용하지만 generation guard가 stale write를 차단한다.
- disconnected invalidation subscriber는 L1을 flush하고 generation을 재확인한다.
- Pub/Sub event loss에도 L1 TTL/generation reconciliation으로 stale bound를 지킨다.
- L1 max weight/cardinality/TTL, subscriber queue, refresh scheduler가 모두 bounded다.
- Redis liveness는 애플리케이션 liveness를 내리지 않는다.
- optional cache outage는 `DEGRADED`, required coordination/session outage는 `NOT_READY`다.
- cache role eviction/OOM에서 source concurrency와 queue가 bounded다.
## Task 6 — Edge rate limit end-to-end
**Files**
- Modify: `src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/*`
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/*`
- Modify: `src/adapter/outbound/cache-redis/src/main/java/.../redis/*rate*`
- Modify: `src/app-bootstrap` composition
**Tests first**
- inbound가 process-local map이 아니라 `EdgeRateLimitPort`를 호출한다.
- subject는 raw principal/IP가 아닌 bounded pseudonymous digest다.
- fixed/sliding-counter/token-bucket reference/property/concurrency vector를 통과한다.
- evaluation ID replay가 quota를 두 번 소비하지 않는다.
- bounded local emergency는 configured degraded provider일 때만 동작한다.
- Redis/local/disabled provider exclusivity, shadow/degraded source, 429/503와 `Retry-After` mapping을
검증한다.
- legacy unbounded map과 silent primary fallback을 제거한다.
## Task 7 — Idempotency v2와 Redis provider
**Files**
- Replace/extend `src/application-core/.../idempotency` with owner-safe v2 contracts.
- Add Redis idempotency state programs/provider/codec.
- Migrate the existing JPA provider to the same semantic contract only after checking its separate
worktree changes; never overwrite concurrent persistence work.
**Tests first**
- atomic claim, fingerprint mismatch, owner/attempt-safe start/renew/complete/fail/release/inspect.
- processing TTL과 replay TTL 분리.
- expired `CLAIMED` takeover, expired `EXECUTING -> RECOVERY_REQUIRED`.
- response-loss replay/reconciliation, conflicting response digest reject.
- unverified cross-store effect는 자동 discard/re-execution하지 않는다.
- JDBC/Redis provider가 같은 scope를 동시에 claim하지 않는다.
**Implementation**
- Redis가 cross-store exactly-once를 보장한다고 표현하지 않는다.
- JPA migration 충돌이 있으면 Redis completion의 명시적 integration blocker로 보고하고 해당
worktree의 결과와 재대조한다.
## Task 8 — Efficiency lease와 optional fenced coordination
**Tests first**
- acquire/inspect/renew/release가 owner+operation token을 비교한다.
- response loss는 `UNKNOWN/INDETERMINATE`이며 same token inspect로 reconcile한다.
- expired old owner는 renew/release할 수 없다.
- watchdog는 bounded scheduler와 cancellation을 사용하고 lost 상태를 전달한다.
- fenced card를 선택하면 durable epoch/high-watermark 등록과 protected-resource stale-token reject를
실제 fixture로 증명한다.
**Implementation**
- close-only `DistributedLock`은 compatibility facade로 유지하되 새 코드가 strong lock으로
오해하지 않게 guarantee를 명명한다.
- fencing 없는 Redis lease를 business correctness lock으로 광고하지 않는다.
## Task 9 — Redis Session과 JWT/session exclusive composition
**Files**
- Add direct `spring-session-core` and `spring-session-data-redis` to Redis leaf.
- Add adapter-internal versioned session store/programs/serializer.
- Add inbound web cookie/CSRF/fixation settings and security configuration.
- Add app-bootstrap `jwt|redis-session` exclusive composition.
**Tests first**
- JWT mode는 session Redis connection/bean/thread side effect가 0이다.
- pod A create/save, pod B read/touch/logout.
- idle/absolute expiry, rotation, old ID reject, stale save after logout reject.
- explicit allowlisted serializer N/N-1 and corrupt payload re-auth.
- secure/httpOnly/SameSite/host-only cookie, CSRF enabled, fixation rotation.
- repository outage/noeviction OOM/failover는 fail-open 인증으로 바뀌지 않는다.
- indexed repository는 별도 opt-in이며 Cluster event cleanup 한계를 독립 검증한다.
## Task 10 — Real-service, topology, fault와 readiness Gradle tasks
**Files**
- Create: `src/adapter/outbound/cache-redis/src/redisTest/**`
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
- Modify: `src/build.gradle`
- Create/update Redis test topology resources and sanitized evidence reporter
**Public tasks**
- `redisStandaloneTest`, `redisSecurityTest`, `redisSentinelTest`, `redisClusterTest`,
`redisFaultTest`, `redisCompatibilityTest`
- capability card test/readiness tasks named exactly as Redis deep design §37.22
- root `redisProductionReadiness`, `redisAllImplementedCandidates`
**Rules**
- selected evidence에서 Docker/service 부재나 0 discovered tests는 failure다.
- unselected card는 skipped가 아니라 `not selected`다.
- image/program/config digest와 sanitized JUnit/topology timeline을 evidence artifact로 남긴다.
## Task 11 — Container topology와 3-node k3s qualification
이번 실행은 deep design §37.13/Phase 5A의 Sentinel-first slice만 다룬다. Cluster, fenced
coordination, R3 long chaos/soak, k3s control-plane HA, physical host/AZ failure, full
credential/certificate rotation은 후속 작업이다.
### Task 11.1 — Lab lifecycle contract와 host isolation RED
이 작업은 리뷰 경계를 다음처럼 분리한다. 두 하위 작업이 모두 독립 리뷰를 통과하기 전에는 부모
Task 11.1을 완료로 표시하지 않는다.
- `Task 11.1A-1`: VM lifecycle, ownership marker/state, lock/signal/handoff cleanup, host
fingerprint와 bounded command. 현재 구현을 동결한다.
- `Task 11.1A-2`: pinned K3s generated-kubeconfig strict validator/renderer. 실행 계획은
`docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md`를 따른다.
2026-07-30 상태: `Task 11.1A-1` lifecycle/ownership과 `Task 11.1A-2` strict renderer는
whole-task 독립 review에서 Critical `0`, Important `0`, Minor `0`, SPEC PASS /
QUALITY APPROVED를 받았다. fresh direct/Gradle fake-only 검증도 통과해 부모 `Task 11.1A`
fake-only 범위는 완료다. 이는 live VM/k3s/kubectl/network/host qualification이나 Redis
R2 readiness 완료를 의미하지 않는다.
**Tracked files**
- Create: `infra/redis-lab/README.md`
- Create: `infra/redis-lab/versions.env`
- Create: `infra/redis-lab/bin/redis-lab`
- Create: `infra/redis-lab/cloud-init/node.yaml`
- Create: `infra/redis-lab/test/redis-lab-contract.sh`
- Modify: Redis Gradle VM-free lifecycle contract task
**Tests first**
- VM 이름은 `ca-redis-lab-server`, `ca-redis-lab-agent-1`,
`ca-redis-lab-agent-2` exact allowlist만 허용한다.
- server 1 + agent 2, resource `2/3GiB/12GiB`, `2/2.5GiB/12GiB`,
`2/2.5GiB/12GiB`, pod CIDR `10.52.0.0/16`, service CIDR
`10.53.0.0/16`, context `ca-redis-lab`을 검증한다.
- host 관측은 default kubeconfig의 run-scoped copy와 원래 host context를 사용하고 read-only
allowlist만 허용한다. lab 호출은 별도 ignored `src/build/redis-lab/kubeconfig`와 exact
`ca-redis-lab` context를 사용한다.
- default kubeconfig merge/write, host context mutation, wildcard VM cleanup, global
`multipass purge`를 정적/동적 contract가 거절한다.
- preflight/postflight host kubeconfig/context/node/workload fingerprint가 다르면 실패한다.
- CI는 retain-on-failure를 거절하고, local opt-in만 exact VM 보존을 허용한다.
- fake `multipass`/`kubectl`을 주입하는 shell contract는 partial-create cleanup과 exact command
allowlist를 VM 생성 없이 검증하고 `redisLabContractTest`로 module `check`에 연결한다.
- launch 전 exact name을 run-owned `PENDING`으로 atomic 예약하고 성공 직후 `CREATED`
승격한다. timeout/실패/상태 승격 실패는 이 run이 예약한 exact name만 정리한다.
- private run-scoped rendered cloud-init은 non-secret `RUN_ID|VM_NAME` ownership marker를
기록한다. cleanup/down은 bounded marker read가 state owner와 exact name 일치를 증명할
때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create poll로
처리하며 absent/unreadable/mismatch는 delete/state removal 없이 fail-closed한다.
- lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`
`run` 모두 첫 launch 전 emergency cleanup을 활성화하고, signal/concurrent 실행이 다른
run state나 VM을 채택·삭제하지 못한다. user command에는 lock file descriptor를 상속하지
않으며 기본 bounded external child도 FD를 닫고 lock acquisition만 예외로 유지한다.
`run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag가 연속 유지돼
zero-ownership handoff gap이 없어야 한다.
- host kubeconfig copy는 fingerprint/CIDR 관측 범위가 끝나면 성공/실패와 무관하게 제거한다.
- lab kubeconfig renderer는 denylist/generic-count 보강을 사용하지 않는다. pinned K3s의
canonical block-style one-cluster/context/user grammar를 별도 tracked AWK state machine으로
allowlist하며, catch-all pass-through 없이 duplicate/extra/reordered/unknown/flow-style
identity와 모든 비허용 구조를 fail-closed로 거절한다.
- external command와 3-node Ready 대기는 bounded이고, host service CIDR은 assigned
ClusterIP에서 추측하지 않고 명시적 validated input 또는 신뢰 가능한 host 설정에서 얻는다.
- mutable `curl | sudo sh` installer는 금지한다. exact K3s release URL과 SHA-256을 repository에
pin하고 host download와 각 VM transfer 뒤 다시 검증한 후에만 install/start한다.
- shell contract는 별도 fixture repository에서 실행하고 actual `src/build/redis-lab` canary를
byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다.
### Task 11.2A — Sentinel manifest와 security static contract GREEN
**Tracked files**
- Create: `infra/redis-lab/config/redis.conf.tmpl`
- Create: `infra/redis-lab/config/sentinel.conf.tmpl`
- Create: `infra/redis-lab/config/redis-users.acl.tmpl`
- Create: `infra/redis-lab/config/sentinel-users.acl.tmpl`
- Create: `infra/redis-lab/k3s/namespace.yaml`
- Create: `infra/redis-lab/k3s/redis-data.yaml`
- Create: `infra/redis-lab/k3s/redis-sentinel.yaml`
- Create: `infra/redis-lab/k3s/network-policy.yaml`
- Create:
`src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLabManifestContractTest.java`
- Modify: Redis Gradle manifest contract task
- static contract와 live security evidence를 분리한다. YAML/템플릿 정적 통과는 TLS handshake,
ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다.
- data Redis 3개와 Sentinel 3개는 각각 stable ordinal/headless DNS가 필요한 StatefulSet으로
구성하고 `kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule`
topology spread, `podManagementPolicy: Parallel`을 적용한다.
- data는 PVC + AOF `appendfsync everysec`를 사용한다. Sentinel은 공식 동작상 writable config에
discovery/failover 상태를 rewrite하므로, bootstrap source를 pod별 writable PVC config로
최초 1회 atomic init-copy하고 restart 때 기존 rewritten config를 덮어쓰지 않는다.
비어 있거나 손상된 기존 config는 자동 복구로 덮지 않고 startup을 실패시킨다.
- Redis image SSOT는 `src/gradle/redis-test-images.properties`
`redis.minimum.image` exact tag+digest다. `redis.approved.image`나 임의 YAML image를 이
minimum-version Sentinel slice에 섞지 않는다.
- plaintext port는 data/Sentinel 모두 0이고 TLS port만 연다. `tls-replication yes`,
hostname resolution/announcement와 certificate SAN용 stable DNS를 사용한다. data plane과
Sentinel plane은 서로 다른 CA/leaf material을 가지며, peer 연결에 필요한 root만 명시적
trust bundle로 교차 포함한다.
- ACL identity를 하나의 `redis-user`로 합치지 않는다.
- application data user: 선택 capability/program command/key/channel만;
- replica user: `+psync +replconf +ping`;
- Sentinel-to-data user: 공식 최소 Sentinel control command/channel set;
- Sentinel peer user: Sentinel 간 통신에 필요한 동일 superuser credential;
- application Sentinel discovery user: auth/hello/ping/role과 allowlisted read-only
`SENTINEL` subcommand만.
default user는 off이며 application/data/discovery user에 `+@all`, `allkeys`,
`allchannels`를 주지 않는다.
- Redis data ACL과 Sentinel ACL은 별도 template/projection이다. Sentinel peer superuser가
data Redis에, data capability user가 Sentinel에 존재하면 static contract가 실패한다.
- Secret/CA/private key/rendered config는 run별 `umask 077` 아래 생성하고 tracked manifest에는
Secret value, PEM, password가 없다. probe/command line에 `--pass`를 쓰지 않는다.
- exec probe를 사용해 kubelet source CIDR 예외를 만들지 않는다. default-deny ingress/egress
뒤 data 6379, Sentinel 26379, kube-dns와 exact qualification/application pod selector만
허용한다.
- Service는 headless/ClusterIP만, PDB는 data/Sentinel 각각 `minAvailable: 2`, container는
non-root, read-only root filesystem, privilege escalation false, capabilities drop ALL,
seccomp RuntimeDefault, explicit requests/limits를 요구한다.
- structural positive test와 한 필드씩 제거/변조한 mutation-negative fixture가
anti-affinity, spread, PDB, probes, TLS-only, ACL separation, Secret reference,
NetworkPolicy, image SSOT를 실제로 fail시키는지 검증한다.
- `hostPath`, `hostNetwork`, `hostPID`, `hostIPC`, privileged, NodePort, LoadBalancer,
tracked Secret data/stringData/PEM과 implicit latest image를 거절한다.
- static validator는 exact document inventory, duplicate YAML key/identity, selector/template
일치, exact NetworkPolicy edge graph를 검증한다. 정적 ordinal bootstrap은 최초
`redis-data-0` primary와 두 replica만 증명하며, failover 뒤 old-primary 재합류와 stale
direct write 차단은 live gate에 남긴다.
### Task 11.2B — Sentinel workload와 live security baseline GREEN
- Redis primary 1 + replica 2와 Sentinel 3/quorum 2를 세 node에 분산한다.
- anti-affinity/topology spread, PDB, NetworkPolicy, separate data/Sentinel CA와 named ACL을
적용한다.
- secret/certificate/k3s token은 매 run `umask 077` transient material로 생성하고 tracked
manifest에는 값/PEM을 넣지 않는다. Sentinel bootstrap config는 Secret volume에서 pod별
writable PVC로 최초 1회 atomic init-copy하며, 기존 rewritten config를 덮어쓰지 않는다.
- Redis image는 `redis.minimum.image` exact image/digest를 render하고 실제 pod image
ID/digest가 일치하는지 수집한다.
- data credential/CA로 Sentinel discovery가 실패하고 Sentinel material로 data command가
실패하는 negative test, untrusted CA/hostname mismatch/plaintext rejection을 실행한다.
- `SENTINEL CKQUORUM`, writable config rewrite/restart, exact 3 Ready placement, PDB,
default-deny/explicit-allow NetworkPolicy enforcement를 live k3s에서 검증한다.
- failover 중 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지
않고 새 primary의 replica로 수렴하는지 live 검증한다.
### Task 11.3 — Sentinel client runtime TDD
- current `UnsupportedOperationException`을 먼저 고정하는 test를 quorum-consistent discovery와
분리된 discovery/data material contract로 교체한다.
- 2-of-3 Sentinel이 같은 primary를 보고할 때만 후보를 만들고 loopback/wildcard/unexpected
endpoint를 거절한다.
- active Sentinel role이 있을 때만 registry당 daemon worker 1개, role당 fixed-delay task 1개를
만들고 `sentinel-discovery-refresh-period`(기본 30초, 5초..5분)를 적용한다.
- scheduled poll과 command failure-triggered immediate rediscovery는 role별 같은 single-flight를
공유한다. `snapshot()`은 보조 trigger일 뿐 정상 polling을 대신하지 않는다.
- 정상 poll은 Sentinel material만 해석하고 현재 route identity와 같으면 data material/client를
만들지 않는다. 바뀐 quorum-approved endpoint에만 data candidate를 연다.
- command failure listener는 route lease 반환 뒤 topology/connectivity `UNAVAILABLE`에만
동작하며 listener 실패가 원래 certainty를 덮어쓰지 않는다.
- 새 data runtime은 version/program/semantic readiness를 통과한 뒤 router에 install한다.
- opaque route identity와 monotonic generation token으로 stale/same-primary candidate를
거절하고, install된 경우 old runtime은 new admission을 닫고 bounded drain/close한다.
- close는 task/worker를 bounded 종료하고 late candidate를 install하지 않고 정확히 한 번 닫는다.
- mutation을 자동 replay하지 않고 실행 여부가 불명확하면 `INDETERMINATE`를 보존한다.
### Task 11.4 — Multi-pod normal/failover qualification
1. host/lab preflight와 3 node/Sentinel quorum readiness를 수집한다.
2. 서로 다른 application pod에서 rate limit evaluation replay, idempotency
claim/start/renew/complete, session create/read/touch/rotate/revoke를 검증한다.
3. current primary pod를 kill하고 readiness unavailable timestamp를 기록한다.
4. Sentinel quorum election, client rediscovery, runtime generation swap/drain, semantic
readiness recovery를 실제 순서대로 기록한다.
5. election 60초, 추가 rediscovery/swap 30초, 총 recovery 90초의 regression limit을 적용한다.
6. rate state가 조용히 reset되지 않고 idempotency owner/terminal 결과가 중복되지 않으며
confirmed session state가 유지되는지 확인한다.
7. old primary의 replica 재합류와 모든 actor의 동일 generation 관측을 확인한다.
correctness role에는 bounded `min-replicas-to-write`/`min-replicas-max-lag`와 명시적 replica
acknowledgement policy를 사용한다. zero-data-loss/strong consistency를 주장하지 않으며
response-only cut 등 실행 여부가 불확실한 mutation은 `INDETERMINATE`이고 blind retry하지 않는다.
### Task 11.5 — Evidence와 exact teardown
- actual image digest/image ID, config/program digest, sanitized fault/election/recovery timeline,
capability별 outcome/certainty, Kubernetes/Sentinel 관측을 allowlist schema로 생성한다.
- `NOT_CAPTURED` placeholder는 qualification 성공으로 인정하지 않는다.
- sanitizer/reconciler 성공 뒤에도 human clean commit/remote CI 전에는
`releaseQualification=NOT_CLAIMED`를 유지한다.
- 성공/실패 모두 exact VM allowlist를 teardown하고 lab resource가 0인지 확인한다. local
retain-on-failure opt-in은 명시된 경우만 허용하고 CI에서는 금지한다.
## Task 12 — CI, runbook, verification와 Wiki capture
**CI**
- PR blocking `redis-standalone` job을 `release-gate.needs`와 result loop에 실제 포함한다.
- nightly/release Redis production readiness workflow를 추가한다.
- workflow contract test로 blocking job/aggregator 집합 동등성을 검증한다.
**Verification**
```bash
cd src
./gradlew :application-core:redisPolicyContractTest --console=plain
./gradlew :shared-contract:edgeRateLimitContractTest --console=plain
./gradlew :adapter:outbound:cache-redis:check --console=plain
./gradlew :app-bootstrap:redisCompositionTest --console=plain
./gradlew redisProductionReadiness --console=plain
./gradlew test --console=plain
./gradlew check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
```
**Documentation**
- capability별 실제 readiness와 남은 R3 한계를 README/spec/runbook에 동기화한다.
- 실행 명령, image/config/program digest, 실패/차단을 public LLM Wiki
`/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/main.md`
기록하고 실제 파생 오류/면접/블로그 raw 문서를 양방향 링크한다.
**Completion gate**
- Task 11의 exit gate를 통과하면 `Sentinel-first R2-ready candidate`라고만 보고한다.
- clean committed source와 실제 remote CI가 없으면 selected/R2로 승격하지 않는다.
- 이 milestone 보고 뒤 멈추고 Cluster/R3/fenced coordination 또는 fileserver/HTTP client 중
다음 작업을 사용자와 다시 정한다.
## Task 13 — Resume blocker: selection-driven role activation과 default boot
**Problem**
- provider definition뿐 아니라 role binding도 capability가 선택되지 않으면 inert여야 한다.
- 현재 구현은 role binding 전체를 runtime으로 열고 health contributor도 role property 존재만으로
활성화한다.
- local 기본값에서 inbound rate-limit은 provider 없이 활성화되면 안 된다.
**Tests first**
- CACHE/COORDINATION/SESSION deployment와 role을 모두 사전 선언해도 cache/rate/idempotency/lease/
session capability가 비활성이면 credential/trust resolution, native client, scheduler/subscriber,
Redis health contributor가 모두 0이다.
- 각 capability가 `redis`를 선택할 때만 해당 role이 활성화된다.
- 같은 role을 쓰는 coordination capability 둘 이상은 하나의 runtime만 공유한다.
- 선택 capability의 role binding이 빠지면 material resolution 전에 startup이 실패한다.
- shipped `.env`와 실제 `application.yml`은 transport disabled/provider disabled 조합으로 기동
가능하고 중복 legacy rate-limit block이 없다.
**Implementation**
- deployment/role registry validation과 runtime activation을 분리한다.
- `selectedCapabilities`가 비어 있는 role은 registry/router/health에서 제외한다.
- bootstrap health condition도 role property가 아니라 effective selected capability로 판단한다.
- provider 설정은 inert 후보로 남기되 선택된 capability의 잘못된 role은 fail closed 한다.
## Task 14 — Resume blocker: capability-aware semantic readiness
**Problem**
- PING만으로 `AVAILABLE/PROBE_SUCCEEDED`를 선언하지 않는다.
- required coordination/session은 실제 선택 capability의 program ACL과 최소 read/write 계약이
동작해야 ready다.
**Tests first**
- PING은 성공하지만 `SCRIPT LOAD`/`EVALSHA`가 ACL로 거절된 coordination/session user는
`redisRequired=DOWN`이다.
- capability별 representative program의 실제 key count와 command-to-key mapping을 그대로
검증한다. rate-limit의 state/dedup/order key와 session tombstone key 중 하나만 ACL pattern에서
빠져도 semantic readiness는 실패한다.
- Redis 7.2 미만 server는 metadata 표기만으로 통과하지 않고 bounded runtime handshake에서
sanitized unsupported-version 상태가 된다.
- 대표 program과 ACL probe script가 이미 warm인 상태에서도 runtime user의 `SCRIPT LOAD`
권한 누락을 별도로 탐지한다.
- cache optional role에서 semantic probe 실패는 application liveness/readiness를 내리지 않고
`DEGRADED`만 보고한다.
- 선언된 optional cache가 cold-start connect/PING에 일시 실패해도 context는 bounded unavailable
route로 시작하고, health-triggered bounded single-flight reconnect 뒤 재시작 없이 복구한다.
invalid configuration/material/program/schema는 계속 startup failure이며 required
coordination/session은 fail closed다.
- probe는 raw key/value, credential, server exception을 health detail에 노출하지 않는다.
- probe key는 bounded, namespaced, TTL이 있고 성공/실패 후 잔여 상태가 없다.
- saturation/recent command failure/closed route를 distinct sanitized reason으로 분류한다.
- health scrape는 role별 minimum cadence와 single-flight로 full semantic suite 실행을 제한하고,
cached observation의 시각/age를 노출해 stale success를 숨기지 않는다.
**Implementation**
- role별 선택 capability를 입력으로 immutable semantic probe plan을 만든다.
- probe는 catalog-owned bounded program과 capability-safe ephemeral operation만 사용한다.
- optional cold-start outage는 resource-free unavailable runtime과 bounded on-demand reconnect로
표현하며 별도 unbounded scheduler/thread를 만들지 않는다. L1 invalidation subscription은
route recovery 시 실제 runtime에 다시 연결된다.
- eviction은 runtime `CONFIG` 권한을 열지 않고 `CONFIGURED_EXPECTATION_ONLY`로 유지하며 외부
attestation 미완료를 readiness detail에 명시한다.
## Task 15 — Resume blocker: bounded common primitive catalog
**Problem**
- Deep design §14.6–§14.9의 자주 쓰는 race-safe helper가 아직 compare/delete 중심 R0 foundation에
머물러 있다.
**Tests first**
- String, counter, hash, set, sorted-set, list baseline은 typed/versioned key, value/count/byte/deadline,
role, slot, TTL, certainty bound를 강제한다.
- bitmap/HLL/geo는 billing/auth correctness에 사용할 수 없는 explicit semantic classification과
offset/result/fan-in bound를 강제한다.
- `INCR -> EXPIRE`, set/list admission, revision-CAS는 실제 Redis concurrency에서 atomic하다.
- unbounded `HGETALL`, `SMEMBERS`, `LRANGE`, arbitrary command/script surface는 제공하지 않는다.
**Implementation**
- package-private `RedisPrimitiveCatalog`과 structure별 bounded facade를 Redis leaf 내부에 둔다.
- application/shared public API에는 Redis command나 raw key를 노출하지 않는다.
- 아직 실제 semantic consumer가 없는 primitive는 Spring bean/public capability로 노출하지 않는다.
## Task 16 — Resume blocker: capability observability와 graceful lifecycle
**Tests first**
- cache/rate/idempotency/lease/session의 operation, outcome, certainty, role, queue/latency가 bounded
low-cardinality metric/event로 관측된다.
- raw key, subject, session/idempotency/lease token, secret reference/value, exception message는
tag/log/trace에 들어가지 않는다.
- optional cache와 required coordination/session의 failure signal이 health와 metric에서 일치한다.
- shutdown은 subscriber/scheduler/router/runtime 순서로 bounded drain되고 새 command를 거절한다.
**Implementation**
- framework-neutral observation event/port와 Micrometer rendering을 계층 소유권에 맞게 둔다.
- trace/log는 기존 skeleton observability 경계를 재사용하고 Redis native type을 core에 유출하지
않는다.
- `docs/registries/metrics.yaml`과 runbook을 실제 emitted metric과 동기화한다.
## Task 17 — Resume final review, readiness truth, verification와 Wiki
- Task 1316을 task별 spec/code-quality review한다.
- Redis deep design §39/§40을 독립 재검토해 selected/implemented-candidate/not-implemented를 실제
evidence와 일치시킨다.
- Sentinel/Cluster/k3s/R3 evidence가 없으면 지원/완료로 표기하지 않는다.
- Task 12의 전체 검증을 실행하고 동시 작업의 비-Redis 실패는 소유 파일과 증거를 분리한다.
- Redis README/spec/runbook, readiness registry, CI artifact 계약을 동기화한다.
- LLM Wiki branch-note와 실제 파생 raw 문서를 양방향 링크로 캡처한다.
@@ -0,0 +1,241 @@
# Redis Lab Strict Kubeconfig Renderer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox
> (`- [ ]`) syntax for tracking.
**Goal:** Complete parent Task 11.1A by replacing mutation-by-mutation kubeconfig filtering with a
pinned-K3s, strict block-grammar validator/renderer and passing an independent safety review.
**Architecture:** Freeze the already-reviewed lifecycle/ownership state machine as Task 11.1A-1.
Move kubeconfig validation/rendering into one tracked AWK program, Task 11.1A-2. The program accepts
only the exact single-cluster/context/user block grammar emitted by the pinned K3s slice, transforms
only lab identity fields, and rejects every non-allowlisted structure before any lab `kubectl`
command.
**Tech Stack:** Bash 5 strict mode, POSIX-compatible AWK features already used by the repository,
the fake-command shell contract, Gradle 9, Java 21.
## Global Constraints
- Do not create a VM, run real Multipass/k3s/kubectl, inspect host inventory, or access the network.
- Do not modify Task 11.1A-1 ownership, state, signal, lock, cleanup or fingerprint behavior.
- Do not add `yq`, PyYAML, Ruby, Java YAML runtime, or another downloadable parser dependency.
- The only accepted source grammar is the pinned K3s admin kubeconfig block-style shape defined in
deep design §37.13.4.1.
- `preferences: {}` is the only permitted flow collection.
- Validation failure removes the destination, emits only `redis-lab: lab kubeconfig invalid`, and
occurs before lab `kubectl`.
- Preserve prior `CREATED|RECONCILE` state and delete only exact marker-proven current-run VMs.
- Tests must show RED against the current implementation before production changes.
- Human-only Git policy applies: do not stage, commit, amend or push.
---
### Task 1: Extract a strict generated-kubeconfig renderer
**Files:**
- Create: `infra/redis-lab/lib/render-kubeconfig.awk`
- Modify: `infra/redis-lab/bin/redis-lab`
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
**Interfaces:**
- Consumes: `awk -v address=<validated IPv4> -v target=ca-redis-lab -f <renderer> <source>`.
- Produces: rendered kubeconfig on stdout and exit `0`, or no accepted output and non-zero exit.
- Integration: `render_lab_kubeconfig <source> <destination> <server-address>` performs atomic
temporary render, mode `0600`, destination replacement only after renderer success.
- [x] **Step 1: Add realistic positive and sibling-flow RED fixtures**
Change the fake `valid` kubeconfig to this complete credential-data shape, using canary values
rather than real certificate material:
```yaml
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
namespace: team-default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
```
Add separate public `up` variants containing, after their canonical item:
```yaml
cluster : {server: https://foreign.invalid:6443}
```
and:
```yaml
context : {cluster: foreign, user: foreign}
```
Each variant must assert failure, zero lab `kubectl`, three exact marker-proven deletes, removed
rendered kubeconfig, and no forbidden fake invocation.
- [x] **Step 2: Run the direct contract and verify RED**
Run:
```bash
bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh
bash infra/redis-lab/test/redis-lab-contract.sh
```
Expected: syntax succeeds and the first new sibling-flow case fails because the current renderer
unexpectedly accepts it.
- [x] **Step 3: Implement the strict AWK state machine**
`render-kubeconfig.awk` must use an explicit `state` transition for every accepted line. It must
not print from a catch-all rule. The accepted transition sequence is:
```text
apiVersion -> clusters -> cluster-item -> ca-data -> server -> cluster-name
-> contexts -> context-item -> context-cluster -> optional-namespace -> context-user
-> context-name -> current-context -> kind -> preferences -> users -> user-name
-> user-body -> client-cert -> client-key -> EOF
```
Exact identity transitions print these replacements:
```awk
print " server: https://" address ":6443"
print " name: " target
print " cluster: " target
print " user: " target
print "current-context: " target
print "- name: " target
```
CA/client credential and namespace transitions print `$0` unchanged. Any unmatched line sets
`invalid=1`; `END` exits non-zero unless the final state is `client-key`, every required
transition occurred once, the input had no tab/CR/YAML marker, and no trailing line exists.
- [x] **Step 4: Integrate the renderer fail-closed**
Add:
```bash
KUBECONFIG_RENDERER="${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk"
```
`validate_static_contract` must require a readable regular non-symlink renderer at that exact
canonical path. Replace the inline AWK body with:
```bash
local render_next="${destination_file}.next"
rm -f -- "${render_next}"
if ! awk -v address="${server_address}" -v target="${CONTEXT_NAME}" \
-f "${KUBECONFIG_RENDERER}" "${source_file}" >"${render_next}"; then
rm -f -- "${render_next}" "${destination_file}"
fail 'lab kubeconfig invalid'
return 1
fi
chmod 0600 -- "${render_next}"
mv -f -- "${render_next}" "${destination_file}"
```
Add the `.next` destination to symlink-child validation. Propagate `rm`, `chmod` and `mv`
failures with the same sanitized error and without retaining a partially accepted destination.
- [x] **Step 5: Run focused GREEN**
Run the direct contract again. Expected: `redis-lab-contract: PASS`, exit `0`.
### Task 2: Complete the mutation matrix and parent acceptance
**Files:**
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
- Modify: `infra/redis-lab/README.md`
- Modify: `docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md`
- Modify:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/progress.md`
- Create:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-brief.md`
- Create:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-report.md`
**Interfaces:**
- Consumes: Task 1 strict renderer and existing lifecycle fake runtime.
- Produces: parent Task 11.1A review package with no open Critical/Important finding.
- [x] **Step 1: Add one mutation per grammar boundary**
Add table-driven fixture variants for missing, duplicate, reordered and unknown keys; whitespace
before colon; quoted/tagged/explicit keys; anchor/alias/merge; unexpected `{}`/`[]`; tab, CRLF,
`---`/`...`, and trailing content. Every case must assert failure before lab `kubectl`, exact
current-run cleanup and removed render output.
- [x] **Step 2: Prove scalar preservation and exact transformation**
The positive case must assert:
```text
server: https://192.0.2.10:6443
name/current-context: ca-redis-lab
namespace: team-default
preserve-default-ca-canary
preserve-default-client-cert-canary
preserve-default-client-key-canary
```
It must also assert that no `name: default`, `cluster: default`, `user: default`,
`current-context: default` or loopback server remains.
- [x] **Step 3: Re-run the full fake-only verification**
Run:
```bash
bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh
bash infra/redis-lab/test/redis-lab-contract.sh
cd src
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain
./gradlew :adapter:outbound:cache-redis:test --console=plain
./gradlew :adapter:outbound:cache-redis:check --dry-run --console=plain
```
Expected: direct `PASS`; both Gradle executions `BUILD SUCCESSFUL`; dry-run includes
`redisLabContractTest`.
- [x] **Step 4: Run an independent scoped review**
Reviewer acceptance:
- strict renderer has no catch-all pass-through;
- the valid pinned fixture reaches EOF exactly once;
- every non-allowlisted structural line fails;
- destination publication is atomic/fail-closed;
- Task 11.1A-1 lifecycle code is unchanged except the renderer call and static path checks;
- Critical `0`, Important `0`, both spec and quality PASS.
- [x] **Step 5: Close the parent task**
Only after Step 4 passes, replace the ledger `BLOCKED` state with an additive resolution line:
```text
Task 11.1A-2: complete (human-only commit policy; strict renderer review clean)
Task 11.1A: complete (11.1A-1 lifecycle + 11.1A-2 renderer; fake-only evidence)
```
Do not claim live readiness, R2 or VM/k3s qualification.
@@ -1,12 +1,14 @@
# Fileserver Production Capability Deep Design
- 작성일: 2026-07-26
- 상태: 상세 설계 완료, Phase 01 및 Phase 2 일부 local R1 구현, R2 이상 미구현
- 상태: 상세 설계 완료, Phase 01 및 Phase 2 `local-persistent` R2 구현, 후속 provider/운영
capability 미구현
- 독립 아키텍처 재리뷰: blocker/high 0건
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 대상 leaf: `adapter-outbound-fileserver`
- 구현 추적: 이 문서의 목표 전체가 아니라 framework-free port, local staged CSV, single-node
operation journal/recovery까지만 적용되었다.
- 구현 추적: 이 문서의 장기 목표 전체가 아니라 provider-neutral application/control 계약,
exact selector, pre-provisioned local filesystem을 위한 `local-persistent` R2 provider까지만
적용되었다.
- 상위 문서:
[Production Capability Platform Design](2026-07-26-production-capability-platform-design.md)
@@ -26,24 +28,45 @@
- operation-scoped JVM/OS file lock과 hard-link-only publication protocol;
- overwrite-capable legacy port의 별도 opt-in/root 및 canonical overlap 차단;
- 안전한 commit primitive가 없을 때 copy-to-final로 downgrade하지 않는 fail-closed 동작.
- `app.fileserver` exact destination/provider selector와 producer 호출 전 unknown destination
거부;
- provider ID별 singleton runtime과 서로 다른 provider ID의 동일 normalized root 소유 거부;
- provider-neutral canonical operation v2/private manifest/reference index와 opaque
`fsr1.<route-token>.<file-id>.<check-digits>` direct lookup;
- strict UTF-8/canonical schema-v1 terminal record의 read-only compatibility와 schema-v2-only
write;
- absolute/pre-provisioned root, ancestor/root symlink, real path, owner/mode, FileStore
name/type, mount sentinel, `SecureDirectoryStream`, exclusive-create/hard-link/file·directory
force startup attestation;
- `WRITING -> SEALED -> DATA_PUBLISHED -> MANIFEST_PUBLISHED -> REFERENCE_PUBLISHED ->
PUBLISHED` durable publication ordering;
- data/manifest/reference/receipt 전체 교차검증과 deterministic resume/quarantine;
- terminal mismatch에서 journal과 모든 artifact를 불변 보존하는 fail-closed recovery;
- `FILE_AND_DIRECTORY_SYNC` receipt와 forked-process force-boundary/OS operation-lock
qualification seam;
- `app-bootstrap` opt-in composition과 disabled-default/no-filesystem-side-effect gating.
아직 구현되지 않은 범위:
- Phase 2의 cross-node fencing, reference/private-manifest index, exhaustive crash/symlink-race
qualification;
- 운영 cleanup/quota/retention과 effective capability probe인 Phase 3;
- `shared-mounted`/NFS multi-client semantics와 cross-node producer fencing;
- 운영 background reconciliation/reaper, retention, quota/backpressure인 Phase 3;
- Fileserver 전용 readiness/health, metrics, tracing, structured audit;
- SFTP provider인 Phase 4;
- NFS/HA/bootstrap evidence인 Phase 5;
- NFS/HA/operator topology evidence인 Phase 5;
- optional delete/read/scan operation인 Phase 6.
따라서 현재 journal은 single-node local recovery seam이며 Fileserver R2 완료 증거가 아니다.
기존 `FileExportPort`도 호환성을 위해
따라서 현재 R2 claim은 `local-persistent`에만 한정한다. `FILE_AND_DIRECTORY_SYNC`는 attested
filesystem 안에서 file과 관련 directory force가 성공했다는 뜻이며 physical device,
storage-controller cache, volume replica, backup/site의 power-loss protection을 뜻하지 않는다.
그 축은 deployment/storage evidence가 별도로 소유한다. 기존 `FileExportPort`도 호환성을 위해
남아 있으며, 전체 행 materialization과 absolute path receipt를 사용하는 legacy 경로다.
기존 R1 terminal artifact는 strict read-only로 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하고
manifest/reference 생성, schema-v2 rewrite, R2 guarantee 자동 승격을 하지 않는다.
## 1. 설계 판정
현재 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의 로컬
CSV 예제다.
설계 시작 당시 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의
로컬 CSV 예제였다. 현재의 increment 상태와 보장 경계는 §0을 따른다.
```text
List<List<String>>
@@ -109,7 +132,11 @@ List<List<String>>
이번 문서는 위 항목을 구현 계획을 작성할 수 있는 수준까지 확정한다.
## 3. 현재 코드의 증거 기반 진단
## 3. 초기 코드의 증거 기반 진단
아래 표는 설계가 시작된 2026-07-26의 baseline을 보존한 역사적 진단이다. 현재 구현 상태는
§0이 권위이며, 아래 결함 중 streaming/opaque receipt/exclusive publication/control plane/local
attestation/composition은 후속 increment에서 해소되었다.
| 영역 | 현재 구현 | 운영상 의미 |
| --- | --- | --- |
@@ -134,13 +161,14 @@ List<List<String>>
- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java`
- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java`
- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java`
- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java`
- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java`
- `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java`
- `src/config/architecture/modules.json`
- `src/app-bootstrap/build.gradle`
현재 7개 fileserver unit test와 leaf `check`는 성공한다. 이는 현재 문서화된 로컬 happy-path
계약이 동작한다는 증거일 뿐 production readiness 증거는 아니다.
당시 7개 fileserver unit test와 leaf `check` 성공은 로컬 happy-path만 증명했다. 현재의
`local-persistent` claim은 별도 root attestation, control/payload/recovery, forked crash와
cross-process OS lock qualification suite의 통과를 요구한다.
## 4. 범위와 명시적 비범위
@@ -1927,22 +1955,26 @@ ca-skeleton:
`docs/registries/env-keys.yaml`, `application.yml`, typed settings, conditional beans를 end-to-end
검증한다.
Template baseline에 필요한 key:
현재 구현된 `local-persistent` composition에 등록하는 key:
```text
APP_FILESERVER_PRIMARY_ROOT
APP_FILESERVER_PRIMARY_MOUNT_ID
APP_FILESERVER_SFTP_HOST
APP_FILESERVER_SFTP_USERNAME
APP_FILESERVER_SFTP_PRIVATE_KEY_SECRET_REF
APP_FILESERVER_SFTP_KNOWN_HOSTS_SECRET_REF
APP_FILESERVER_SFTP_CONTROL_ROOT
APP_FILESERVER_SFTP_SPOOL_ROOT
APP_FILESERVER_SECRET_CONFIG_ROOT
APP_FILESERVER_ENABLED
APP_FILESERVER_LOCAL_ROOT
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE
APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256
APP_FILESERVER_LOCAL_EXPECTED_OWNER
```
모두 restart-only다. `APP_FILESERVER_ENABLED=false`가 shipped default이며, 나머지 다섯
attestation 값은 `app.fileserver.enabled=true`일 때 모두 필요하다. Root는 absolute/existing
directory, FileStore name/type과 owner는 non-blank exact match, sentinel digest는 64-character
lowercase SHA-256여야 한다.
Dynamic destination topology는 YAML/config tree가 소유하고 secret value는 secret source가
제공한다.
제공한다. 앞의 broader topology 예시에 있는 SFTP/NFS key는 아직 env registry나 shipped
`application.yml`에 등록하지 않는다. 실제 provider, dependency, real-service qualification이
추가되는 후속 increment에서만 등록한다.
## 24. Health와 observability
@@ -2228,9 +2260,10 @@ Nightly:
### 27.1 Dependency ownership
현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK, external dependency
없음, NFS/SFTP stand-in만을 허용한다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수
없다. 구현 Phase 0에서 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다.
현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK filesystem과 Spring
configuration baseline만 허용하고 `local-persistent`만 구현 대상으로 인정한다. NFS/SFTP
stand-in이나 SDK는 허용하지 않는다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 없다.
후속 SFTP 구현에서는 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다.
- local `CLAUDE.md`의 책임을 local-only demo에서 provider-based publication으로 변경;
- external `NONE` 규칙을 exact allowlist로 변경;
@@ -2240,7 +2273,7 @@ Nightly:
이 rule migration 전 SFTP dependency 추가나 runtime activation은 HARD-STOP이다.
`adapter-outbound-fileserver`:
후속 provider rule migration의 후보 allowlist이며 현재 dependency가 아니다:
- JDK NIO local/mounted provider;
- Spring autoconfigure;
@@ -2260,7 +2293,8 @@ starter를 추가하지 않는다.
### 27.2 Bootstrap composition
안전한 explicit binding/gating과 config test가 먼저 구현된 후:
`local-persistent`에 대한 안전한 explicit binding/gating과 config test가 구현되었고 다음
composition을 적용했다.
1. `modules.json`의 `app-bootstrap.allowed_dependencies`에
`adapter-outbound-fileserver` 추가;
@@ -2271,7 +2305,8 @@ starter를 추가하지 않는다.
6. disabled-adapter architecture scan에 fileserver 추가;
7. env/settings/readiness contract 추가.
Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된다.
`application.yml`의 `app.fileserver.enabled=false`가 shipped default다. Classpath에 들어왔다는
이유만으로 local provider가 활성화되거나 filesystem side effect가 발생하지 않는다.
### 27.3 SDK split trigger
@@ -2287,9 +2322,11 @@ Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된
### Phase 0 — Truthful topology와 contract freeze
- 현재 Fileserver를 R1 local CSV demo로 명시;
상태: 완료. 현재 문서는 provider별 구현 상태와 보장 경계를 분리한다.
- 초기 Fileserver를 R1 local CSV demo로 명시하고 후속 R2 범위를 분리;
- Fileserver `CLAUDE.md`와 README의 responsibility/dependency/registry SSOT drift 수정;
- current bootstrap 미합성 상태 명시;
- 초기 bootstrap 미합성 상태와 후속 disabled-default opt-in composition을 함께 기록;
- v2 contract와 error registry 승인;
- journal/reference/control-plane schema 승인;
- accepted-attempt와 global coordination guarantee 분리;
@@ -2306,6 +2343,8 @@ Acceptance:
### Phase 1 — Streaming application contract와 CSV
상태: 완료. Framework-free `FilePublicationPort`와 bounded streaming CSV 경로가 구현되었다.
- `FilePublicationPort`;
- operation ID/fingerprint;
- effective policy snapshot;
@@ -2321,6 +2360,9 @@ Acceptance:
### Phase 2 — Secure local/mounted publication
상태: `local-persistent` 완료. `shared-mounted`/NFS multi-client profile과 cross-node fencing은
미구현이다.
- staging;
- digest/manifest;
- sealed journal과 protocol별 artifact ordering;
@@ -2336,6 +2378,8 @@ Acceptance:
### Phase 3 — Resource/maintenance/observability
상태: 미구현.
- concurrency/byte quota;
- timeout/cancel/shutdown;
- staging reaper/report;
@@ -2348,6 +2392,8 @@ Acceptance:
### Phase 4 — SFTP provider
상태: 미구현. SFTP setting/env/dependency/bean도 등록하지 않는다.
- Spring Integration/Apache MINA;
- host key/secrets;
- bounded pool/timeouts;
@@ -2362,6 +2408,9 @@ Acceptance:
### Phase 5 — NFS/HA evidence와 bootstrap
상태: `app-bootstrap`의 disabled-default opt-in composition과 local env mapping만 완료.
NFS/HA/operator topology evidence는 미구현이다.
- multi-client NFS profile;
- operator attestation;
- app-bootstrap composition;
@@ -2374,6 +2423,8 @@ Acceptance:
### Phase 6 — Optional read/delete와 module split review
상태: 미구현.
- opaque content transfer;
- expected-version managed delete;
- provider split 조건 재평가;
@@ -2381,6 +2432,9 @@ Acceptance:
## 29. 완료 기준
아래는 이 장기 설계 전체의 완료 기준이며 현재 충족되지 않았다. 현재 완료 claim은 §0의
`local-persistent` R2 범위로 제한한다.
Fileserver R2 완료를 주장하려면:
- application contract에 path/provider/SDK가 없음;
@@ -949,6 +949,13 @@ and durable interfaces are explicit.
### 13.3 Object storage
The authoritative implementation-level design for this capability is
[Object Storage Production Capability Deep Design](2026-07-28-objectstorage-production-capability-design.md).
Its ordered REDGREEN execution batches and promotion gates are in the
[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md).
This subsection is only the cross-capability baseline; the dedicated design governs when details
differ.
Replace whole-object `byte[]` as the only path with:
- streaming upload/download and range reads;
@@ -1,7 +1,7 @@
# Redis Production Capability Deep Design
- Date: 2026-07-26
- Status: 상세 설계 완료, Phase 0 및 Phase 1 일부 standalone R1 구현, R2 미구현
- Status: 상세 설계 완료, 5개 standalone `implemented-candidate`, selected/R2 없음
- Scope: Redis 전용 production capability와 단계적 구현 설계
- Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template
- Parent:
@@ -9,7 +9,7 @@
## 0. 구현 상태
2026-07-28 기준 구현된 범위:
2026-07-30 기준 구현된 범위:
- `application-core`의 provider-neutral `CacheRegionPort`와 hit/negative/miss/schema/unavailable
결과 구분;
@@ -21,9 +21,17 @@
- generic application API가 아닌 package-private `RedisAtomicPrimitives` internal R0 foundation과
compatibility failure;
- managed Lettuce standalone connection lifecycle과 finite command timeout;
- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 `EVAL` fallback하는 production executor;
- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 catalog script를 `SCRIPT LOAD`하고 digest를 검증한 뒤
`EVALSHA`를 한 번 재시도하는 production executor;
- versioned digest-protected bounded binary cache envelope, positive/negative TTL, invalidate와
corrupt/future/unavailable 구분을 제공하는 `CacheRegionPort<String,String>` reference adapter;
- envelope v2의 absolute soft/hard expiry, injected clock freshness 판정, deterministic
policy-revision/key jitter, hard minimum과 physical Redis TTL 일치;
- framework-free `CacheAsideExecutor`와 typed source/result/cancellation contract;
- maximum in-flight key/waiter/source concurrency/admission/load deadline을 제한하는 local
single-flight와 source bulkhead, abandoned-flight opportunistic reaping;
- authoritative absence만 negative-cache하고 classified transient failure에만 hard-expiry 전
stale fallback을 허용하는 application policy;
- HMAC key secret/namespace/value bound typed settings와 disabled zero-connection composition;
- `managed`/`external` client mode를 통한 결정적 runtime 선택;
- reconnect command replay 차단, finite Lettuce request queue와 client-side admission;
@@ -32,27 +40,44 @@
- managed runtime 활성화 시 Redis host 누락을 `localhost`로 숨기지 않는 startup fail-fast;
- generic Lua executor/descriptor와 raw-key typed primitive를 package-private collaborator로
닫고 Spring composition에는 semantic cache port만 노출;
- 명시적 Redis 7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua,
oversized bulk-reply 차단 검증.
- 명시적 Redis 7.2/7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua,
oversized bulk-reply 차단 검증;
- `shared-contract`의 provider-neutral edge rate-limit request/policy/decision/outcome/port;
- fixed window, sliding-window counter, token bucket의 versioned one-key Lua와 bounded
structured MULTI reply parser;
- private HMAC key, Redis server time, clock regression clamp, denial-no-consume, finite state
TTL과 pre-send/post-dispatch failure certainty를 보존하는 semantic provider;
- cache와 endpoint/connection/admission/settings를 공유하지 않는 coordination-role 전용
`app.rate-limit` composition과 disabled zero-side-effect gating;
- 세 알고리즘을 실제 standalone Redis에 실행하도록 선택 가능한 service qualification lane;
- request-replay idempotency, cache refresh soft lease, versioned session repository semantic
provider와 각 card-owned standalone/security/fault/compatibility evidence;
- cache generation/revision invalidation, bounded local L1, authenticated invalidation hint,
semantic health/metrics와 standalone TLS+named ACL evidence.
아직 구현되지 않은 범위:
- cache jitter, soft/hard TTL, cache-aside/single-flight/source bulkhead;
- refresh-ahead와 probabilistic early refresh;
- Redis Functions 배포와 program upgrade/rollback compatibility matrix;
- health/metrics/TLS/ACL/secret/topology/eviction 검증;
- distributed rate limit, idempotency, lease/fencing, session;
- Sentinel runtime, Cluster production qualification, k3s/multi-node/failover/rotation,
effective eviction/persistence attestation;
- fenced coordination과 multi-process/pod session 및 L1/L2 distributed qualification;
- Phase 1의 전체 acceptance와 R2/R3 승격 증거.
따라서 standalone runtime/string cache는 R1 evidence를 가지지만 Redis capability 전체 또는
어떤 production topology도 R2가 아니다. raw-key Lua foundation
rate/idempotency/lease/session은 semantic composition이 없어 여전히 R0다.
현재 registry의 cache, edge rate limit, request-replay idempotency, cache refresh soft lease,
session card는 standalone promotion topology`implemented-candidate`다. fenced coordination
`not-implemented`다. `implemented-candidate`는 구현과 card-owned evidence lane을 뜻할 뿐 release
selection이나 R2 qualification이 아니다. checked-in `selected` card가 0개이므로 Redis capability
전체 또는 어떤 production topology에도 R2 release claim을 하지 않는다.
## 1. 설계 판정
설계 착수 당시 `adapter:outbound:cache-redis`는 실제 Redis client, connection, topology, TTL,
codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-28 구현으로
standalone managed Lettuce runtime과 semantic string cache는 R1까지 올라왔지만, topology,
TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-ready adapter는 아니다.
codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-30 현재 위 5개
semantic provider는 standalone `implemented-candidate`이며 standalone TLS+named ACL과 bounded
fault evidence도 있다. 그러나 selection, Sentinel/Cluster, multi-node/failover/rotation,
effective eviction/persistence attestation과 R3 증거가 없으므로 production-ready/R2라는 단일
label을 붙이지 않는다.
이번 설계는 다음 구조를 선택한다.
@@ -75,16 +100,16 @@ TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-re
| Capability | 현재 | 목표 |
| --- | --- | --- |
| Redis runtime | managed Lettuce standalone R1 + explicit external-client mode | Spring Data Redis + Lettuce 기반 typed runtime |
| Cache | `Optional<String> get`, `void put` | typed region, TTL, negative/stale, invalidate, cache-aside |
| Rate limit | inbound-web single-node fixed window | policy별 fixed/sliding/token/GCRA Redis provider |
| Idempotency | JPA 전제, owner token 없음 | atomic claim, owner-safe complete, execution/replay TTL 분리 |
| Lock | JDBC efficiency lock | Redis efficiency lease + 별도 fenced contract |
| Session | JWT stateless 고정 | JWT 또는 isolated Redis Session의 명시적 profile |
| Atomic helper | 없음 | versioned Function/Lua program registry |
| Topology | 없음 | standalone, Sentinel, Cluster의 typed exclusive profile |
| Failure | 모든 cache exception을 miss로 변환 | capability별 fail-open/closed/degraded/indeterminate |
| CI | fake unit test | real Redis, topology, concurrency, failure, compatibility matrix |
| Redis runtime | canonical role router와 managed/external Lettuce runtime, standalone candidate | Sentinel runtime과 Cluster production qualification |
| Cache | standalone `implemented-candidate`; generation/soft lease/bounded L1과 TLS/ACL/fault lane | multi-process L1/L2와 HA/persistence/eviction attestation |
| Rate limit | fixed/sliding-counter/token-bucket standalone `implemented-candidate` | HA topology, failover와 R3 evidence |
| Idempotency | owner-safe Redis V2 standalone `implemented-candidate`; JDBC provider와 명시적 선택 | actual-used image/event evidence와 selected promotion |
| Lock | Redis efficiency lease candidate; fenced coordination은 `not-implemented` | protected-resource stale fencing-token rejection |
| Session | JWT isolated Redis Session profile; Redis는 standalone `implemented-candidate` | multi-process/pod와 failover/rotation qualification |
| Atomic helper | versioned closed Lua catalog와 typed internal facade | Redis Functions upgrade/rollback matrix |
| Topology | standalone candidate; Cluster code seam; Sentinel runtime 미구현 | Sentinel/Cluster/k3s multi-node qualification |
| Failure | capability별 typed degraded/unavailable/indeterminate와 bounded fault lane | 실제 topology event chain과 persistence/restart evidence |
| CI | strict registry matrix, real candidate lanes, sanitized artifact/reconciler | actual-used image attestation과 actual fault-event capture |
설계가 완료되었다는 뜻은 구현 계약과 단계가 결정되었다는 뜻이다. 현재 Redis runtime이
production-ready가 되었다는 뜻은 아니다.
@@ -145,7 +170,11 @@ production-ready가 되었다는 뜻은 아니다.
표의 링크 대상보다 예시 YAML이나 migration alias가 우선하지 않는다. 상충하는 두 설정이
존재하면 임의 precedence를 선택하지 않고 startup을 실패시킨다.
## 3. 증거 기반 현재 상태
## 3. 설계 착수 당시 증거 기반 baseline
이 절 전체는 구현 전 repository를 조사한 2026-07-26 역사적 baseline이다. 아래의 “현재”는 그
조사 시점을 가리키며 2026-07-30 구현 상태를 설명하지 않는다. 최신 구현/readiness truth는 §0,
§1의 현재 열, checked-in `src/config/redis/readiness-cards.yaml`, Redis leaf README를 따른다.
### 3.1 실제 Redis client가 없다
@@ -5904,8 +5933,8 @@ indexed repository는 Cluster/node-specific event와 orphan index cleanup을 별
최소 실제 topology:
- primary;
- replica;
- independent Sentinel quorum.
- replica 2개;
- 서로 다른 k3s node에 배치한 Sentinel 3개와 quorum 2.
test:
@@ -5921,6 +5950,260 @@ test:
단일 fake Sentinel endpoint로 HA를 증명하지 않는다.
#### 37.13.1 Sentinel discovery와 data runtime 분리
Sentinel discovery channel과 Redis data-node channel은 같은 Lettuce client/SSL context로
합치지 않는다. 각각 독립된 named material과 lifecycle을 갖는다.
| Channel | 책임 | 허용 material |
| --- | --- | --- |
| Sentinel discovery | master name 조회와 quorum 관측 | Sentinel ACL username/password reference, Sentinel CA/trust, discovery timeout |
| Redis data | capability command/program 실행 | data-node ACL username/password reference, data CA/trust, command/admission/drain timeout |
discovery는 다음 조건을 모두 만족할 때만 새 primary 후보를 반환한다.
- 구성된 Sentinel endpoint 최소 3개 중 2개 이상이 같은 master host/port를 보고한다;
- 응답한 Sentinel 수와 동의 수가 각각 bounded deadline 안에서 기록된다;
- master name이 exact configured name과 같다;
- 반환 endpoint가 loopback, wildcard, unspecified address가 아니고 allowlisted deployment
identity/member에 속한다;
- TLS hostname/SAN 검증을 통과한다;
- Sentinel credential 또는 trust를 data connection에, data material을 Sentinel connection에
재사용하지 않는다.
한 Sentinel의 응답, 최초 응답 또는 DNS 문자열 일치만으로 primary를 바꾸지 않는다. discovery
실패 detail에는 endpoint, username, secret reference/value, certificate subject를 남기지 않고
sanitized reason과 동의 수만 남긴다.
#### 37.13.2 bounded rediscovery와 runtime swap
정상 polling은 bounded single-flight로 실행하며, write/read command의 topology failure가
발생하면 같은 single-flight에 bounded immediate rediscovery를 요청한다. 새 primary가
qualification을 통과하면:
1. 새 data runtime을 생성한다;
2. version/program/semantic readiness를 검증한다;
3. 기존 `RedisRoleCommandRouter`에 한 번만 install한다;
4. 기존 runtime은 새 admission을 닫고 in-flight command를 bounded drain한다;
5. drain timeout 뒤에는 강제 close하되 완료되지 않은 mutation을 성공/미실행으로 추정하지 않는다.
failover 직전 또는 도중의 mutation은 자동 replay하지 않는다. transport가 실행 여부를 증명하지
못하면 capability가 `INDETERMINATE`를 반환하고, idempotency/session은 같은 operation token의
inspect/reconcile 또는 재인증 경로를 사용한다. read-only command도 semantic contract가 허용하는
경우에만 새 runtime에서 재시도한다.
`snapshot()`/readiness scrape는 정상 polling의 실행 엔진으로 사용하지 않는다. scrape나 command가
없는 동안에도 primary 변경을 발견해야 하므로, active Sentinel role이 하나 이상일 때만 registry가
다음 bounded poller를 소유한다.
- registry당 daemon worker 1개와 active Sentinel role당 fixed-delay task 1개만 만든다;
- 기본 polling period는 30초이고 typed setting은 5초 이상 5분 이하만 허용한다;
- scheduled poll과 command-failure trigger는 role별 같은 single-flight를 공유하며 한 role에
discovery/install 작업은 최대 1개만 실행하거나 대기한다;
- Standalone/Cluster만 선택되거나 Redis capability가 비활성이면 poller/thread/task를 0개 만든다;
- close는 새 trigger를 거절하고 scheduled task를 취소한 뒤 worker를 bounded shutdown하며,
close와 경합해 늦게 생성된 candidate는 install하지 않고 정확히 한 번 닫는다.
command failure signal은 route lease가 반환된 뒤 발행한다. connection/timeout/topology 계열의
`UNAVAILABLE`만 immediate rediscovery를 요청하고, overload, ACL denial, validation/size rejection은
요청하지 않는다. signal listener의 실패는 원래 command의 `NOT_APPLIED`/`INDETERMINATE` 판정을
절대 덮어쓰지 않는다.
정상 poll은 Sentinel discovery credential/CA만 사용해 endpoint를 조회한다. 현재 route와 같은
primary면 data credential/CA를 해석하거나 새 data connection을 열지 않는다. primary가 달라졌을
때만 이미 quorum-approved/allowlisted 된 exact endpoint로 data candidate를 열어 TOCTOU 성격의
이중 discovery를 피한다. route는 endpoint를 출력하지 않는 package-private identity와 monotonic
generation token을 가진다. candidate qualification 중 다른 rotation이 먼저 완료되면 stale
generation candidate를 닫고 install하지 않는다. 같은 identity도 candidate를 닫고 no-op 처리한다.
#### 37.13.3 replication 보장과 판정
Sentinel은 primary election을 제공하지만 asynchronous replication의 zero-data-loss를 보장하지
않는다. qualification 환경은 correctness role에 `min-replicas-to-write`와 bounded
`min-replicas-max-lag`를 설정하고, 중요한 mutation은 명시된 replica acknowledgement 정책을
사용한다. 이 설정도 strong consistency나 cross-store exactly-once 증거가 아니다.
failover 판정은 다음을 구분한다.
- 응답과 요구된 replica acknowledgement가 확인된 mutation: 새 primary에서 보존되어야 한다;
- response-only cut 또는 acknowledgement 결과를 확인할 수 없는 mutation:
`INDETERMINATE`, blind retry 금지;
- acknowledgement 전 명확한 connection/admission 실패: `NOT_APPLIED`가 wire evidence로
증명되는 경우에만 미실행으로 판정한다.
#### 37.13.4 Sentinel-first R2 qualification lab
이번 Phase 5의 첫 실행 slice는 기존 host k3s를 변경하지 않는 disposable Multipass lab이다.
```text
ca-redis-lab-server 2 CPU / 3 GiB / 12 GiB k3s server
ca-redis-lab-agent-1 2 CPU / 2.5 GiB / 12 GiB k3s agent
ca-redis-lab-agent-2 2 CPU / 2.5 GiB / 12 GiB k3s agent
pod CIDR 10.52.0.0/16
service CIDR 10.53.0.0/16
kube context ca-redis-lab
```
lab kubeconfig와 transient material/raw observation은 Gradle root의 ignored
`src/build/redis-lab` 아래에만 쓰며 사용자의 default kubeconfig에 merge하거나 덮어쓰지 않는다.
host 관측에는 default kubeconfig의 run-scoped copy와 시작 시점의 exact host context를
사용하지만, fingerprint/CIDR 관측이 끝난 즉시 성공/실패와 무관하게 copy를 제거한다. 모든
lab mutating command는 별도 lab kubeconfig와 `ca-redis-lab` context를 함께 요구한다.
VM 이름은 위 exact allowlist만 허용한다. launch 전에 exact name을 run-owned state에
`PENDING`으로 atomic 예약하고 성공 직후 `CREATED`로 승격한다. timeout, partial create,
state 승격 실패는 이 run이 예약한 exact name만 delete/purge한다. global `multipass purge`,
host `kubectl delete`, default-context write는 금지한다.
run-scoped rendered cloud-init은 secret이 아닌 exact `RUN_ID|VM_NAME` ownership marker를
instance에 기록한다. cleanup/down은 bounded marker read가 state owner와 name 일치를
증명할 때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create
poll로 처리한다. instance가 끝까지 없거나 marker가 unreadable/mismatch면 외부 same-name
instance를 추측해 삭제하지 않고 state를 유지한 채 fail-closed한다.
lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`
`run` 모두 첫 launch 전에 emergency cleanup을 활성화하며 signal/concurrent invocation이
다른 run의 state 또는 VM을 채택·삭제하지 못한다. `run -- <command>`에는 lifecycle lock file
descriptor를 상속하지 않는다. K3s는 mutable installer를 pipe로 실행하지 않고 exact release
URL/SHA-256을 repository에 pin한다. host download와 각 VM transfer 뒤 checksum/version을
다시 확인한 후에만 start한다.
기본 bounded external child도 lifecycle lock descriptor를 닫으며 lock acquisition만
명시적인 keep-lock 경로를 사용한다.
`run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag는 연속 유지되며,
signal handler가 ownership을 0으로 보는 handoff gap을 허용하지 않는다.
lab kubeconfig renderer는 one-cluster/context/user schema의 모든 identity-bearing key를
generic count하며 duplicate/extra server, context cluster/user, item/name,
current-context를 last-key-wins로 남기지 않고 fail-closed한다.
#### 37.13.4.1 lab lifecycle 완료 경계와 strict kubeconfig renderer
`Task 11.1A`는 하나의 리뷰 단위로 너무 많은 책임을 가졌으므로 다음 두 하위 작업으로 분리한다.
- `Task 11.1A-1`: VM 이름/소유권 marker, `PENDING|CREATED|RECONCILE` state, lock FD,
signal/handoff cleanup, host fingerprint와 bounded external command를 소유한다.
- `Task 11.1A-2`: pinned K3s admin kubeconfig의 strict validation과 lab 전용 rename/render만
소유한다.
`11.1A-1` 코드는 `11.1A-2` 동안 동결한다. `11.1A-2`가 독립 테스트와 독립 리뷰를 통과하기
전에는 부모 `11.1A`를 완료로 표시하지 않으며 VM 생성도 허용하지 않는다.
`11.1A-2`는 범용 YAML parser가 아니다. 입력은 pinned K3s가 생성하는 admin kubeconfig의
canonical block-style 문서 하나로 제한한다. 별도 tracked
`infra/redis-lab/lib/render-kubeconfig.awk`가 line/indentation/state allowlist를 적용하며,
identity-bearing key를 찾는 denylist나 발견된 mutation별 정규식 패치를 사용하지 않는다.
허용 grammar는 다음을 모두 만족해야 한다.
- top-level `apiVersion`, `clusters`, `contexts`, `current-context`, `kind`, `preferences`,
`users`는 canonical 순서와 exact spelling/indentation으로 한 번만 존재한다;
- cluster/context/user list는 각각 한 항목만 가지며 identity는 모두 exact `default`다;
- cluster는 exact loopback `server: https://127.0.0.1:6443`와 하나의
`certificate-authority-data` scalar만 가진다;
- context는 exact `cluster: default`, `user: default`와 optional single `namespace` scalar만
가진다;
- user는 하나의 `client-certificate-data``client-key-data` scalar만 가진다;
- `preferences: {}`만 유일한 flow collection 예외다. 그 밖의 `{}`, `[]`, quoted/tagged/
explicit key, anchor, alias, merge key, tab, CRLF, YAML document marker, unknown key,
duplicate/reordered identity, trailing content는 fail-closed한다;
- source `server`, cluster/context/user name과 current-context만 변환한다. CA/client material,
namespace와 그 밖의 허용 scalar는 byte-preserving pass-through다;
- renderer source 자체와 destination의 canonical parent/symlink/permission 계약을 lifecycle
static validation에 포함한다. validation 또는 render 실패 시 destination을 제거하고
constant sanitized failure만 출력한다.
정상 fixture는 pinned K3s admin kubeconfig의 certificate-data shape를 사용한다. negative
mutation은 duplicate/extra identity뿐 아니라 canonical item 아래의 sibling
`cluster : {...}`, `context : {...}`, whitespace-before-colon, flow collection, quoted/tagged/
anchor/alias/merge, unknown/reordered/missing key를 포함한다. 모든 실패는 lab `kubectl` 전에
발생하고 현재 invocation이 marker로 증명한 VM만 cleanup하며 prior
`CREATED|RECONCILE` state는 byte-for-byte 보존한다.
tracked `infra/redis-lab`에는 lifecycle script, cloud-init template, Redis/Sentinel config
template, Kubernetes manifest와 secret 없는 contract test만 둔다. 실행 시 생성하는 k3s token,
ACL password, data/Sentinel/untrusted CA와 private key, rendered Secret/config, raw observation은
`umask 077`인 transient directory에만 둔다. `redis-cli --pass`, tracked PEM/Secret data,
`hostPath`/`hostNetwork`/privileged/NodePort/LoadBalancer는 사용하지 않는다.
host isolation은 preflight/postflight의 canonical projection을 비교한다. default kubeconfig
digest, current context/API, sorted node/providerID/podCIDR, controller replica, Service NodePort,
host interface/route CIDR와 Multipass inventory가 대상이다. host service CIDR은 현재 할당된
ClusterIP만 보고 추측하지 않고, 명시적으로 검증한 input 또는 신뢰할 수 있는 host 설정에서
읽는다. 외부 명령과 exact 3-node Ready 대기는 bounded다. 불일치 시 qualification을
실패시키되 script가 host 상태를 추측해 되돌리려고 mutate하지 않는다.
workload는 Redis primary 1 + replica 2, Sentinel 3/quorum 2를 서로 다른 node에 배치한다.
data와 Sentinel은 stable ordinal/headless DNS가 필요한 별도 StatefulSet이며
`kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule` topology spread를
사용하고 `podManagementPolicy: Parallel`을 명시한다. data는 PVC와 AOF
`appendfsync everysec`를 사용한다. Sentinel config는 discovery/failover 시 rewrite되므로
bootstrap 원본을 pod별 writable PVC config로 최초 1회 atomic init-copy하되 restart 때 이미
존재하는 rewritten config를 덮어쓰지 않는다. 비어 있거나 손상된 기존 config도 자동으로
덮지 않고 startup을 실패시켜 증거를 보존한다.
data/Sentinel plaintext port는 0이며 TLS port만 연다. `tls-replication yes`, hostname
resolution/announcement와 stable DNS SAN을 사용한다. data plane과 Sentinel plane의 CA/leaf
material은 분리하며 peer 연결에 필요한 root만 explicit trust bundle에 포함한다. ACL은
application data, replica, Sentinel-to-data, Sentinel peer, application Sentinel discovery
identity로 나눈다. Redis data ACL과 Sentinel ACL은 별도 template/projection이며 plane
identity를 서로 노출하지 않는다. default user는 off이며 application/data/discovery
identity에는 `+@all`, `allkeys`, `allchannels`를 주지 않는다. replica는
`+psync +replconf +ping`, Sentinel-to-data identity는 Sentinel control에 필요한 최소
command/channel set만 가진다.
exec probe를 사용하고 default-deny NetworkPolicy 뒤 data 6379, Sentinel 26379, kube-dns,
exact qualification/application pod selector만 허용한다. data/Sentinel PDB는 각각
`minAvailable: 2`이며 non-root, read-only root filesystem, privilege-escalation false,
capability drop ALL, seccomp RuntimeDefault, requests/limits를 요구한다. `hostPath`,
host namespaces, privileged, NodePort/LoadBalancer와 tracked Secret/PEM은 금지한다.
정적 lifecycle contract와 manifest/security contract는 VM 없이 blocking check에서 검증하고,
한 필드씩 제거/변조하는 mutation-negative fixture로 실제 방어력을 확인한다. 이 정적 통과는
TLS handshake, ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다.
shell contract는 별도 fixture repository만 사용하며 actual `src/build/redis-lab` state를
byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다.
live lab에서는 TLS/ACL negative test, `SENTINEL CKQUORUM`, writable config rewrite/restart,
exact 3 Ready placement, PDB/NetworkPolicy enforcement와 image ID/digest를 별도로 검증한다.
Redis image는 `src/gradle/redis-test-images.properties``redis.minimum.image` exact
tag+digest를 사용한다.
ordinal bootstrap은 최초 `redis-data-0` primary와 두 replica만 정적으로 증명한다.
failover 동안 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지
않고 새 primary의 replica로 수렴하는지는 live gate다. PDB 선언은 voluntary eviction
제약일 뿐 node/AZ failure 증거가 아니다.
k3s control-plane HA, physical host/AZ failure, Redis Cluster는 이 lab의 증거가 아니다.
hosted GitHub Actions에서는 Multipass를 설치하거나 실행하지 않는다. 실제 lab qualification은
trusted dedicated runner 또는 local explicit execution에서만 허용한다. 외부 PR 코드를
self-hosted lab에서 실행하지 않는다.
초기 test budget은 운영 SLA가 아니라 bounded regression limit이다.
- Sentinel election: 60초 이내;
- client rediscovery와 runtime swap: election 뒤 추가 30초 이내;
- required semantic readiness 복구: fault injection 뒤 총 90초 이내.
실제 측정값을 evidence timeline에 기록하며 limit만 기록한 문서는 증거가 아니다.
#### 37.13.5 Sentinel-first capability acceptance
이 slice는 correctness-sensitive cross-pod state를 우선 검증한다.
- edge rate limit: failover 전 quota state가 조용히 reset되지 않고 evaluation replay가 일관된다;
- request-replay idempotency: claim/start/renew/complete와 terminal replay가 owner-safe하며
불확실 mutation은 중복 실행하지 않는다;
- Redis session: create/read/touch/rotate/revoke가 서로 다른 application pod에서 보이고,
failover 뒤 confirmed state가 유지되며 stale session이 부활하지 않는다;
- cache refresh soft lease와 optional cache는 공통 runtime 회귀를 확인하되 이 slice만으로
Cluster scaling 또는 distributed L1 invalidation R2를 주장하지 않는다.
fault 순서는 baseline qualification 뒤 current primary pod를 kill하고 readiness unavailable,
Sentinel quorum election, client rediscovery, runtime swap/drain, semantic readiness recovery를
실제 timestamp로 수집한다. old primary는 replica로 재합류해야 하고, recovery 뒤 모든 actor가
같은 runtime generation을 관측해야 한다.
evidence bundle은 실제 실행 image digest/image ID, config/program digest, fault/election/recovery
timeline, capability별 outcome/certainty, sanitized Kubernetes/Sentinel observation, lab teardown
결과를 포함한다. manifest의 `NOT_CAPTURED`를 문자열로 바꾸는 것만으로 증거를 만들 수 없다.
### 37.14 Cluster topology
최소 multi-primary Cluster와 replica에서:
@@ -6115,20 +6398,9 @@ canonical card ID와 Gradle task mapping:
registry key, capability descriptor ID, `card-<id>` tag, evidence artifact의 card ID는 이 표와 byte-for-byte
같아야 한다. short alias를 허용하지 않는다.
```yaml
cards:
redis-cache:
state: selected # selected | implemented-candidate | not-implemented
selected-topology: sentinel # standalone | sentinel | cluster
required-evidence:
- standalone
- security
- fault
- compatibility
- selected-topology
redis-session:
state: not-implemented
```
현재 card 상태와 topology/evidence는 이 문서에 복제하지 않으며
`src/config/redis/readiness-cards.yaml`만을 따른다. 현재 `selected` card는 없으며,
`implemented-candidate`는 release selection 또는 R2 qualification을 뜻하지 않는다.
`redis<Card>Readiness` task는 이 registry의 해당 card tag와 required evidence tag의 교집합을
실행하고, category마다 test count > 0, 성공 artifact, image/program/config digest를 요구한다.
@@ -6224,10 +6496,14 @@ nightly `redis-all-candidates`는 `redisAllImplementedCandidates`를 실행한
품질 신호/승격 blocker지만 현재 selected card의 이미 존재하는 release evidence를 다른 card
미구현 때문에 자동 취소하지 않는다.
각 job은 JUnit XML/HTML, container logs, sanitized topology/fault timeline,
`program-set.json`/digest, effective capability card, image digest attestation을 artifact로 올린다.
secret, raw Redis key/value, session/idempotency token은 artifact에 포함하지 않는다. PR artifact
retention은 짧게, release evidence는 조직의 audit retention 정책에 맞춘다.
각 job은 `build/redis-evidence` 아래에서 allowlist schema로 다시 생성한 bounded manifest,
capability card, sanitized test summary만 artifact로 올린다. Gradle의 raw JUnit XML/HTML,
`system-out`/`system-err`, stack trace, container log/inspect, TLS/ACL fixture material은 업로드하지
않는다. 실제 사용 image attestation과 실제 topology/fault event chain을 수집하지 못한 현재
artifact는 각각 `NOT_CAPTURED``releaseQualification=NOT_CLAIMED`를 기록하며, reconciler는
이 상태의 future `selected` 승격을 실패시킨다. secret/reference value, raw endpoint/key/value,
session/idempotency/lease token은 artifact에 포함하지 않는다. Candidate artifact retention은
짧게, 실제 release evidence는 조직의 audit retention 정책에 맞춘다.
### 37.24 no silent skip
@@ -6526,6 +6802,36 @@ Acceptance:
- no silent skip;
- program/ACL/schema conformance.
#### Phase 5A — Sentinel-first R2 qualification slice
Phase 5 전체를 한 번에 구현하지 않는다. 먼저 §37.13의 disposable 3-node k3s Sentinel 환경에서
다음 순서로 진행한다.
1. lab lifecycle/preflight/host-isolation contract를 테스트 우선으로 고정한다;
2. Sentinel discovery와 Redis data runtime을 별도 auth/trust/lifecycle로 구현한다;
3. quorum-consistent discovery, bounded rediscovery, qualified runtime swap와 bounded drain을
구현한다;
4. security positive/negative test 후 rate limit, idempotency, session의 multi-pod 정상 경로를
실행한다;
5. primary kill과 response-loss fault를 주입하고 capability invariant와 `INDETERMINATE`
semantics를 검증한다;
6. image/fault timeline을 실제 관측에서 생성하고 sanitizer/reconciler를 통과시킨다;
7. focused/full Gradle verification과 독립 review를 마친 뒤 이 slice에서 멈춘다.
이번 slice에 포함하지 않는 항목:
- Redis Cluster와 Cluster cache scaling;
- fenced coordination;
- R3 capacity soak/long chaos/reshard;
- k3s control-plane HA, physical host/AZ failure;
- full credential/certificate rotation drill;
- optional cache의 Sentinel release promotion.
이번 slice의 agent-side 종료 상태는 `R2-ready candidate`다. repository가 human-only commit
policy를 사용하므로 clean committed source와 실제 remote GitHub Actions evidence는 사람이
수행하는 최종 promotion gate다. 이 두 증거가 없으면 readiness card를 `selected`로 바꾸거나
R2라고 표시하지 않는다.
### Phase 6 — R3와 split review
- actual Cluster reshard/failover;
@@ -6573,6 +6879,28 @@ Acceptance:
- runbook/capability card;
- LLM Wiki capture.
### 40.2 Sentinel-first slice 종료 게이트
§37.13과 Phase 5A의 작업은 아래가 모두 충족된 경우에만 `R2-ready candidate`로 종료한다.
- exact VM inventory와 dedicated kubeconfig로 lab create/verify/destroy가 반복 가능하다;
- host k3s context, node, workload와 default kubeconfig의 전/후 fingerprint가 같다;
- Sentinel discovery와 Redis data auth/trust가 분리되고 negative security test가 통과한다;
- primary kill 뒤 quorum election, qualified runtime swap, bounded drain과 semantic readiness
recovery의 실제 timeline이 있다;
- rate limit, idempotency, session을 서로 다른 pod에서 검증하고 failover 뒤 invariant가
유지된다;
- confirmed acknowledgement와 `INDETERMINATE`를 구분하며 blind mutation replay가 없다;
- actual image/config/program digest와 sanitized evidence가 reconciler를 통과한다;
- focused test, Redis readiness 관련 task, repository `test`/`check`, architecture/env/public-path
gate와 독립 review가 통과한다;
- exact allowlist VM teardown과 lab resource 정리 결과가 기록된다.
위 조건은 clean committed source와 실제 remote CI를 대신하지 않는다. 두 최종 promotion
증거가 없으면 card 상태는 `implemented-candidate`, `releaseQualification=NOT_CLAIMED`
유지한다. 종료 뒤 Redis Cluster/R3/fenced coordination 또는 fileserver/HTTP client로 자동으로
넘어가지 않고 다음 우선순위를 다시 결정한다.
R3는 추가로:
- failover/partition;
@@ -1,7 +1,7 @@
# HTTP Client Production Capability Deep Design
- 작성일: 2026-07-27
- 상태: 상세 설계 완료, Phase 0/1 기반legacy deadline R1 구현, R2 미구현
- 상태: 상세 설계 완료, Phase 0/1 기반·legacy deadline R1·canonical zero-binding 구현, R2 미구현
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 대상 leaf: `adapter-outbound-httpclient`
- 구현 추적: typed operation/target foundation, legacy JDK 안전 결함과 active logical deadline
@@ -28,11 +28,16 @@
- client별 bounded live-worker admission, non-cooperative worker의 slot retention, shutdown 시
active task cancellation과 신규 admission 차단;
- worker MDC 복사/정리와 retry ThreadLocal lifecycle 정렬.
- strict canonical expected-state/binding/provider map binder와 exact provider/destination/catalog
resolver;
- `DISABLED_VERIFIED` descriptor와 zero-binding HTTP runtime resource 0 composition;
- `httpclient-static-buffered=NOT_IMPLEMENTED` fail-closed ACTIVE admission;
- legacy settings/configuration의 global Spring scan 분리와 explicit migration binder.
아직 구현되지 않은 범위:
- application feature-specific production port와 실제 upstream anti-corruption adapter;
- canonical binding/expected-state/full profile tuple/card registry와 zero-binding resource 0 계약;
- full compatibility profile tuple/scenario registry와 release-eligible readiness evidence;
- Apache HC5 pool/acquire/lifetime/idle provider;
- Apache engine phase별 deadline 전달, wire hard cancellation과 connection quarantine;
- DNS/address/SSRF/TLS/mTLS/proxy/auth/secret lifecycle;
@@ -42,7 +47,8 @@
따라서 현재 `OutboundHttpClient`는 migration용 JDK R1 이하 facade이며 HTTP capability R2가 아니다.
legacy 실행 경로는 active logical deadline을 사용하지만 operation catalog와 engine phase
deadline을 아직 사용하지 않는다. 이 단면만으로 hard cancellation이나 R2를 주장하지 않는다.
deadline을 아직 사용하지 않는다. Canonical ACTIVE도 현재 `NOT_IMPLEMENTED` card에서 실패한다.
이 단면만으로 hard cancellation이나 R2를 주장하지 않는다.
## 1. 설계 판정
@@ -147,11 +153,12 @@ production capability는 아니다.
dependency별 configuration에서 `baseline(...)`을 직접 호출하도록 안내한다. repository 전체에서
이를 호출하는 production consumer는 없다.
다만 “binding 0개”가 HTTP 관련 bean 0개라는 뜻은 아니다. Component scan이 이 configuration을
읽으면 `OutboundHttpSettings`, shutdown guard, `RestClient`/builder 차단 BeanPostProcessor,
error mapper, logger와 retry policy 같은 global infrastructure bean은 생성된다. Named
client/semantic-port binding은 없는데 required global timeout 설정과 전역 부작용은 존재하는
비대칭 상태다.
초기 조사 시점에는 component scan이 `OutboundHttpSettings`, shutdown guard,
`RestClient`/builder 차단 BeanPostProcessor, error mapper, logger와 retry policy를 생성해
“binding 0개”와 “HTTP resource 0개”가 일치하지 않았다. Phase 1 구현에서 이 결함은 폐쇄됐다.
현재 settings와 두 legacy configuration은 global scan 대상이 아니며 canonical composition은
immutable configuration, registry, resolver와 sanitized `DISABLED_VERIFIED` descriptor만 만든다.
기본 `application.yml``application-test.yml`도 legacy `app.outbound.http.*`를 선언하지 않는다.
다만 sample에는 이미 다음 seam이 있다.
@@ -4893,6 +4900,11 @@ inbound/use-case budget
## 33. Configuration design
2026-07-28 구현 단면은 canonical expected-state/binding/provider map의 strict binding, exact
provider/destination/code-owned catalog resolution과 `httpclient-static-buffered` card derivation까지
포함한다. 아래 full provider tuple의 pool/security/TLS/auth 필드는 아직 bind/runtime model로
구현되지 않았다.
### 33.1 Canonical activation shape
상위 capability platform과 같은 canonical prefix를 사용한다.
@@ -5229,7 +5241,15 @@ Base URI, proxy endpoint, SSL bundle/secret reference 변경은 운영 영향이
### 33.7 Legacy migration
현재 `app.outbound.http.*`는 migration-only alias다.
`app.outbound.http.*` canonical application configuration에 포함되지 않는 migration-only
입력이다.
현재 구현은 global `@ConfigurationPropertiesScan`을 제거하고
`OutboundHttpSettings.bindLegacy(Binder)`/직접 생성자만 남겼다. Canonical composition은
expected state가 DISABLED여도 legacy property가 하나라도 보이면 silent no-op 대신
fail-closed한다. Legacy fork는 canonical composition 밖에서 migration binder와 configuration을
명시적으로 import해야 한다. 아래 deprecation warning, one-destination conversion,
release-window removal은 후속 migration 단계다.
1. legacy만 있으면 deprecation warning과 함께 immutable legacy settings로 변환;
2. canonical과 legacy가 동시에 있으면 값이 같아도 startup failure;
@@ -5257,6 +5277,10 @@ Application은 adapter type, `RestClient`, Apache type을 알지 못한다.
### 34.2 Zero-binding contract
이 절의 resource 0 계약은 `HttpClientCompositionConfigTest`
`OptionalAdapterBeanGatingTest`로 구현됐다. 기본 composition은 inert registry/resolver/descriptor
외에 HTTP runtime bean을 만들지 않으며 `DISABLED_VERIFIED`만 게시한다.
Binding이 없으면 다음이 모두 0개여야 한다.
- engine client와 connection manager;
@@ -1,7 +1,7 @@
# Fileserver R2 Control Plane and Provider Selection Design
- Date: 2026-07-28
- Status: 승인된 설계, 구현 전
- Status: 구현·전체 repository gate·독립 spec/quality review 완료
- Scope: provider-neutral R2 control plane, explicit destination/provider selection, first
`local-persistent` qualification provider
- Parent:
@@ -106,7 +106,9 @@ FILE_AND_DIRECTORY_SYNC
fsr1.<route-token>.<file-id>.<check-digits>
```
- `route-token`: startup에서 생성된 bounded destination route allowlist 값;
- `route-token`: destination binding의 canonical policy digest에서 재시작 안정적으로 파생한
bounded route allowlist 값. 형식은 `r` + digest의 첫 31 lowercase hex이며 startup에서 token
collision을 거부한다;
- `file-id`: CSPRNG 128-bit 이상;
- `check-digits`: accidental truncation/corruption 검출;
- provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다.
@@ -146,6 +148,9 @@ app:
- `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다.
- 모든 destination은 존재하는 provider 하나를 참조한다.
- provider ID별로 provider/control/payload runtime을 정확히 하나만 만들며 같은 provider를
참조하는 destination은 그 인스턴스를 공유한다. 서로 다른 provider ID가 같은 normalized
root를 가리키면 동일 control namespace의 이중 소유가 되므로 startup에서 거부한다.
- request destination에 binding이 없으면 producer 호출 전에 실패한다.
- provider type의 기본값은 없다.
- `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다.
@@ -156,12 +161,34 @@ app:
- container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과
같은 guarantee를 공유하지 않는다.
- 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과
동시에 활성화되면 startup을 실패시킨다. 암묵 migration이나 precedence를 두지 않는다.
동시에 활성화되면 어느 쪽 filesystem 초기화보다 먼저 startup을 실패시킨다. 양쪽 bean
factory가 같은 ambiguity validator를 호출해 Spring bean 생성 순서에 의존하지 않으며, 암묵
migration이나 conditional precedence를 두지 않는다.
- R2 settings는 unknown field를 거부해 provider/destination 키 오타를 silent fallback으로
취급하지 않는다.
## 7. Startup capability compilation
application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다.
Descriptor compilation과 first reservation은 새 설정 키 없이 같은 canonical digest helper를
사용한다.
- startup descriptor는 destination ID, provider ID, limits, required guarantees,
format/encoder revision을 length-prefixed canonical encoding으로 직렬화한
`effectivePolicyDigest`를 freeze한다;
- ordered schema ID/version/column contract를 같은 canonical encoding 규칙으로 계산하는
request별 `schemaDigest`는 first reservation에서 계산한다;
- startup descriptor는 format/encoder revision과 canonical options의 `formatPolicyDigest`
freeze한다;
- `r` + `effectivePolicyDigest`의 첫 31 lowercase hex로 만든 32-character deterministic route
token.
문자열 단순 연결이나 JVM/JSON map iteration order에 digest를 의존시키지 않는다. 같은 startup
allowlist 안에서 route token이 충돌하면 더 긴 prefix로 임의 복구하지 않고 startup을 실패시킨다.
기존 operation은 journal에 freeze된 revision/digest/token으로만 복구하며 현재 설정으로 조용히
재해석하지 않는다.
`local-persistent`는 다음을 모두 검증한다.
1. root와 모든 ancestor가 symbolic link가 아니다.
@@ -206,7 +233,9 @@ data/<prefix>/<generated-file-name>
```
모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다.
Caller path를 받지 않는다.
Caller path를 받지 않는다. Manifest/reference의 `internalLocator`는 generated filename 한
segment만 저장하고, data shard는 `fileId`의 첫 두 hex에서 파생한다. 따라서 실제 lookup은
`data/<file-id-prefix>/<internalLocator>`이며 control record에 slash를 저장하지 않는다.
### 8.1 Operation journal v2
@@ -267,6 +296,21 @@ relative locator로 direct lookup한다. Directory scan은 receipt restoration
순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다.
Operation schema v2는 별도 `formatPolicyDigest` snapshot을 저장하지 않으므로 recovery는 저장된
`effectivePolicyRevision``effectivePolicyDigest`가 현재 compiled destination과 정확히 같을
때만 현재 format-policy digest를 사용한다. Encoder/policy 변경으로 digest가 달라지면 과거
format을 추정하지 않고 indeterminate로 중단한다. 여러 format revision에 대한 forward
recovery는 non-secret policy snapshot을 포함하는 후속 operation schema에서만 지원한다.
Operation direct lookup은 같은 secure relative read에서 schema를 typed dispatch한다. Schema v2는
현재 R2 record로만 decode/write하고, schema v1은 strict UTF-8 decode 후 canonical v1 re-encode
byte equality를 만족하는 terminal compatibility record만 read-only로 반환한다. Unknown/newer
schema, malformed UTF-8, non-canonical v1은 absent로 취급하지 않는다.
Crash qualification을 위해 control-plane fault context는 package-private로 record kind,
record identity, 해당하는 경우 operation state/revision, force boundary를 함께 전달한다.
Production 기본 callback은 no-op이며 runtime 설정이나 public bean으로 노출하지 않는다.
## 9. Publication ordering
```text
@@ -290,6 +334,18 @@ J-PUBLISHED
- terminal journal force 전에는 receipt를 반환하지 않는다.
- target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다.
- final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다.
- staging/data shard 생성, stage force, stable no-follow read/digest, exact delete는
package-private `PayloadOperations`를 통해 `SecureDirectoryStream` 상대 연산으로 수행한다.
Portable relative primitive가 없는 hard-link와 directory force만 private-owner boundary 안에서
root/directory/file identity pre/post 검증으로 감싼다.
- hard-link 뒤 journal 갱신 전에 중단된 `SEALED + matching data` 복구는 기존 data shard를 다시
identity 검증하고 directory force한 뒤에만 `DATA_PUBLISHED`로 전이한다. 이미 존재하는 data를
overwrite-capable publication 경로에 다시 넣지 않는다.
- `WRITING` 저장 뒤 producer 또는 stage/write가 실패하면 partial stage를 exact cleanup하고
unsealed `QUARANTINED` evidence를 남긴다. 원래 producer exception은 보존하고 cleanup/control
failure는 suppressed로 연결한다. Retry 진입 시 기존 `WRITING` 또는 unsealed
`QUARANTINED`가 보이면 producer를 다시 호출하지 않고 indeterminate/quarantine으로
fail-closed한다.
## 10. Deterministic recovery
@@ -303,7 +359,9 @@ Recovery는 operation ID direct lookup으로 실행하며 startup full scan에
| DATA_PUBLISHED + matching data | manifest publication 재개 |
| MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 |
| REFERENCE_PUBLISHED + all matching | terminal journal 완성 |
| data digest mismatch | `QUARANTINED`, integrity failure |
| non-terminal data/manifest/reference digest mismatch | `QUARANTINED`, integrity failure |
| `PUBLISHED` artifact/metadata/receipt mismatch | terminal journal과 artifacts를 불변 보존하고 typed integrity/indeterminate |
| required manifest/reference/data 누락 | 성공 복원 금지, fail-closed indeterminate/quarantine |
| marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine |
| fingerprint conflict | typed conflict, 기존 artifact 보존 |
| root/mount identity change | indeterminate, write/recovery 중단 |
@@ -317,12 +375,35 @@ matching data + private manifest + reference
> in-memory state
```
모순이 있으면 임의 성공이나 삭제 대신 quarantine evidence를 기록한다.
모순이 있으면 임의 성공이나 삭제를 하지 않는다. Non-terminal operation은 기존 operation
journal을 `QUARANTINED`로 전이할 수 있다. 이미 `PUBLISHED`인 operation은 terminal
receipt snapshot을 지우거나 journal을 덮지 않고 관련 data/manifest/reference도 보존한 채 typed
integrity/indeterminate로 실패한다. 별도 immutable quarantine incident record는 후속 설계 전까지
가정하지 않는다.
Recovery verifier는 operation, incoming request, data, manifest, reference, receipt snapshot의
identity/digest/locator/count/time/guarantee를 모두 교차검증한다. Terminal receipt는 verified
manifest/reference에서 재구성한 expected receipt와 전체 equality가 확인될 때만 반환한다.
Operation record의 일부 필드만 맞거나 durability/publication guarantee, file version,
format/media/charset가 다르면 terminal success가 아니다. Crash 뒤 먼저 발견한 immutable
manifest/reference의 verified `publishedAt`은 새 clock 값으로 덮지 않고 recovery context로
재사용한다. 새 attempt에만 현재 configured maximum을 적용하고, sealed recovery artifact는
operation에 freeze된 exact byte size로 bounded inspection한다. Stage와 data가 함께 있으면
digest equality만이 아니라 stable file key가 같은 hard-link인지 확인한 뒤에만 stage를
exact-delete한다.
## 11. Compatibility
- R1 journal schema v1은 읽을 수 있어야 한다.
- R1 compatibility는 별도 미설정 root나 동시에 활성화된 legacy bean이 아니다. Operator가 기존
R1 root를 owner/mode/FileStore/sentinel 등 R2 attestation 조건에 맞춰 명시적으로
pre-provision한 뒤, 그 root를 R2 destination으로 전환하는 in-place read-only migration이다.
- R1과 R2 operation journal은 같은 hashed path를 사용하므로 secure relative typed schema
dispatch로 schema v1을 읽고 schema v2만 쓴다.
- R1 journal schema v1은 strict UTF-8와 canonical re-encode byte equality를 만족하는 terminal
record만 읽을 수 있어야 한다.
- R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다.
- R1 root-level artifact도 attested root의 `SecureDirectoryStream` 상대 no-follow bounded
streaming inspection으로 journal의 byte size와 SHA-256을 확인한 뒤에만 receipt를 복원한다.
- R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다.
- R2 writer는 journal v2만 생성한다.
- 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지
@@ -334,9 +415,12 @@ matching data + private manifest + reference
- 설정/보장 mismatch: startup failure;
- destination 없음: producer 전 deterministic request failure;
- stage 이전 capacity/validation failure: not applied;
- stage/write failure: failed, partial stage는 recovery evidence가 아니면 정리;
- stage/write failure: failed, partial stage는 recovery evidence가 아니면 exact cleanup하고
unsealed `QUARANTINED`로 producer replay를 차단;
- sealed 이후 filesystem timeout/IO/root identity change: indeterminate;
- published data와 metadata 불일치: integrity/quarantine;
- non-terminal published data와 metadata 불일치: integrity/quarantine;
- terminal `PUBLISHED` data/metadata/receipt 불일치: terminal evidence 불변 보존 후 typed
integrity/indeterminate;
- journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate;
- guarantee를 낮춰 성공시키는 fallback은 없다.
@@ -348,6 +432,8 @@ matching data + private manifest + reference
- R1/R2 simultaneous activation rejection;
- reference grammar/check digits/forged route rejection;
- journal v2, manifest, reference canonical round-trip;
- deterministic route token collision rejection과 canonical policy/schema/format digest;
- same operation path의 strict canonical R1 read-only/v2 write-only typed dispatch;
- state revision과 fingerprint conflict;
- achieved durability value invariants.
@@ -360,8 +446,10 @@ matching data + private manifest + reference
- successful capability probe와 cleanup;
- partial final visibility 0건;
- same operation concurrency와 producer once;
- unsealed `WRITING` failure quarantine와 retry producer 0회;
- target collision no overwrite;
- data/manifest/reference digest mismatch quarantine.
- non-terminal data/manifest/reference digest mismatch quarantine;
- terminal mismatch의 PUBLISHED journal/artifact 불변 보존과 typed integrity/indeterminate.
### 13.3 Crash qualification
@@ -390,6 +478,11 @@ terminal journal directory force
partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다.
같은 attested root와 operation ID에 대해 process A가 OS operation lock을 보유하는 동안 forked
process B의 bounded non-blocking/timed acquire가 critical section에 진입하지 못하고, A의
release 또는 강제 종료 뒤 B가 획득하는지도 별도로 증명한다. 이 증거는 동일 JVM stripe 테스트로
대체하지 않는다.
### 13.4 플랫폼
- Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만
@@ -413,3 +506,41 @@ partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용
8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다.
후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다.
## 15. 구현 및 readiness 판정
2026-07-28 구현은 다음 경계를 만족한다.
- application에는 provider/path/framework 타입이 없는 `FilePublicationPort`만 유지한다.
- adapter 내부의 canonical operation/manifest/reference model, opaque reference, provider SPI,
exact destination router는 provider-neutral control/selection boundary로 구현되었다.
- `app.fileserver.enabled`는 disabled-default이며, enable 시 destination/provider를 exact
compile한다. Unknown destination은 producer 호출 전에 실패하고 implicit local fallback은
없다.
- 같은 provider ID를 참조하는 destination은 하나의 provider/control/payload runtime을
공유한다. 서로 다른 provider ID가 같은 normalized root를 소유하면 startup에서 실패한다.
- R2 provider는 `local-persistent` 하나만 구현·qualification한다. Absolute/existing
pre-provisioned root와 owner/mode/FileStore/sentinel/path/capability attestation이 모두
성공해야 bean이 구성된다.
- operation v2, private manifest, direct reference index, ordered force publication과
deterministic recovery를 구현했다. Forked-process qualification은 각 force boundary와 OS
operation lock을 대상으로 하며, focused/module/full gate 결과와 함께 완료 증거를 판정한다.
- 기존 schema-v1 terminal record와 root-level R1 artifact는 strict UTF-8/canonical/direct
read-only compatibility다. 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하며 schema-v2 rewrite,
manifest/reference 생성, `FILE_AND_DIRECTORY_SYNC` 자동 승격을 하지 않는다.
`FILE_AND_DIRECTORY_SYNC`는 attested local filesystem protocol에서 file과 관련 directory
force가 성공했다는 의미다. Physical device, volatile storage-controller cache, volume replica,
backup 또는 site 단위 power-loss protection을 주장하지 않는다. 그 보장은 Fileserver 코드가
아니라 선택한 storage/deployment의 별도 evidence가 필요하다.
다음 capability는 구현되지 않았고 setting/env/bean으로 노출하지 않는다.
- `shared-mounted`/NFS multi-client correctness와 cross-node producer fencing;
- SFTP SDK, connection/session pool, host-key/credential, remote reconciliation;
- background reconcile/reaper, managed retention/delete;
- quota reservation, backpressure, capacity admission;
- Fileserver 전용 readiness/health, metrics, tracing, audit.
따라서 이 increment의 운영 claim은 “모든 Fileserver topology가 R2”가 아니라
“strictly attested `local-persistent` profile만 R2”다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
# Redis Cache Resilience Increment Design
**Status:** approved for implementation
**Parent:** `2026-07-26-redis-production-capability-design.md` §§15, 16, 18
## Goal
Complete one coherent production-facing cache increment on top of the current standalone R1 Redis
runtime:
1. a framework-free cache-aside policy in `application-core`;
2. bounded local single-flight and source bulkhead protection;
3. deterministic TTL jitter plus soft/hard expiry and stale lookup semantics in the Redis adapter.
This increment does not promote Redis beyond standalone cache R1. Distributed refresh leases,
generation invalidation, rate limiting, owner-safe locks, idempotency, sessions, Sentinel/Cluster,
TLS/ACL and fault qualification remain later increments.
## Architecture boundary
- `application-core` owns lookup interpretation, source-result classification, cache-aside
sequencing, stale-if-error, local coalescing and source admission policy.
- `adapter:outbound:cache-redis` owns physical TTL, envelope timestamps, deterministic jitter,
serialization and Redis command outcomes.
- The application contract contains no Redis/Lettuce/Lua/Spring type.
- Cache fallback never becomes unlimited source fallback. A miss, provider outage and waiter burst
all pass through the same bounded source path.
## Application contract
`CacheSourceLoader<K,V>` returns a typed `SourceLoadOutcome<V>`:
- `Loaded(value, sourceRevision)`;
- `AuthoritativeAbsent(reason, sourceRevision)`;
- `TransientFailure(SourceFailure)`;
- `PermanentFailure(SourceFailure)`;
- `Cancelled`.
`SourceFailure` carries a bounded code and the original cause. It never serializes the cause message
into Redis or metric tags. An unclassified thrown exception is rethrown unchanged and is never
negative-cached or converted to stale success.
`CacheResult<V>` distinguishes:
- fresh cache hit;
- source-loaded value and its cache-record outcome;
- authoritative absence and its cache-record outcome;
- stale fallback after a classified transient source failure;
- source failure;
- bounded overload/timeout rejection;
- cancellation.
`CacheAsidePolicy` is immutable and constructed once per semantic region. It contains maximum
in-flight source keys, waiter limit per key, source concurrency, admission wait, load deadline and
whether transient source failure may serve stale.
## Cache-aside state machine
1. `Hit(FRESH)` returns immediately.
2. `NegativeHit` returns immediately.
3. `Hit(STALE)` retains the value and attempts a bounded refresh.
4. `Miss`, an `IncompatibleSchema(QUARANTINE_AND_RELOAD)` carrying a usable opaque observation
token, and `Unavailable` enter the same bounded source path. `FAIL_FAST` schema results and
unobservable incompatible values are not overwritten.
5. A local single-flight elects one leader per semantic key. Waiters share the typed source outcome.
6. The leader must acquire the source bulkhead before calling the loader.
7. A miss records with `ONLY_IF_ABSENT`. A stale or quarantined observation records with
`ONLY_IF_OBSERVED`, which atomically compares the digest captured by lookup before replacing the
value. No lookup-then-delete sequence is used, so a concurrent writer is never deleted.
8. Only `AuthoritativeAbsent` records a negative entry, using the same absent/observed condition as
a positive source result.
9. `TransientFailure` may return the retained stale value when policy allows it.
10. `PermanentFailure`, unclassified exceptions and cancellation are never hidden by negative cache.
11. Entries are removed from the flight map after success or failure. In-flight keys and waiters are
bounded; waiting uses a finite deadline and preserves thread interruption.
The loader is synchronous and cancellation is cooperative. Its token exposes deadline/interruption;
the executor bounds admission and waiter time but cannot safely terminate arbitrary source code.
## Redis envelope and TTL policy
The positive envelope moves to version 2 and stores:
- source revision;
- `softExpiresAt` epoch milliseconds;
- `hardExpiresAt` epoch milliseconds;
- payload and SHA-256 integrity digest.
Negative envelopes store only the hard expiry. Lookup behavior is:
- `now < softExpiresAt`: `Hit(FRESH)`;
- `softExpiresAt <= now < hardExpiresAt`: `Hit(STALE)`;
- `now >= hardExpiresAt`: `Miss(EXPIRED)`;
- negative `now < hardExpiresAt`: `NegativeHit`;
- expired negative: `Miss(EXPIRED)`.
Version 1 becomes an explicit retired schema result. Future versions and corrupt envelopes fail
fast. Digest-valid retired/unknown envelopes carry an opaque observation token so an approved
quarantine reload can compare-and-replace the exact observation. Structurally invalid current
envelopes remain corrupt/fail-fast even when their digest is valid. Unknown envelopes remain typed
incompatibility results and are not silently treated as misses. Envelope integrity is checked
before the version byte is trusted.
The policy contains positive soft TTL, positive hard TTL, negative TTL, jitter ratio, minimum hard
TTL and maximum value bytes. Construction rejects:
- non-positive or over-30-day TTLs;
- soft TTL greater than hard TTL;
- jitter outside `0.0..0.5`;
- minimum hard TTL greater than either configured hard TTL.
- configured hard TTL plus maximum positive jitter greater than 30 days.
Jitter is deterministic from the HMAC-derived physical key and the compiled policy revision. It
uses a symmetric bounded factor. The actual positive soft/hard TTLs use the same factor so ordering
is preserved. Physical Redis TTL equals the encoded hard expiry duration in the same `SET`.
Negative TTL is jittered independently and also respects the hard minimum.
## Evidence
Tests must prove:
- fresh/negative hits do not call the source;
- concurrent same-key misses call the loader once;
- in-flight-key, waiter, bulkhead and deadline bounds;
- completion/failure cleanup and exception/interruption behavior;
- only authoritative absence is negative-cached;
- stale is served only after a classified transient failure;
- fresh/stale/expired boundaries with an injected `Clock`;
- deterministic bounded jitter and hard minimum;
- version 1/future/corrupt envelope behavior;
- Redis physical TTL matches the encoded hard expiry.
- observed replace reads only the trailing digest and never overwrites a concurrent writer;
- the exact 16MiB opt-in payload is accepted while 16MiB+1 is rejected before dispatch;
- mutation interruption restores the thread flag and maps to indeterminate certainty.
Focused checks run before the repository-wide architecture, dependency, env and public-path gates.
@@ -0,0 +1,144 @@
# Redis Distributed Rate-Limit Increment Design
**Status:** implemented as standalone R1
**Parent:** `2026-07-26-redis-production-capability-design.md` §§1921
## Goal and readiness
Provide three selectable, bounded distributed rate-limit algorithms:
- fixed window;
- sliding-window counter;
- token bucket.
This increment is a standalone Redis R1 provider. It does not claim R2 topology/security/failover
qualification and does not implement sliding log, GCRA, leaky bucket, evaluation dedup, hierarchical
all-or-nothing policies or local emergency fallback.
## Ownership
- `shared-contract` owns the edge-enforcement semantic port and provider-neutral request, policy,
decision and failure outcomes. Business quotas remain application use-case policy and do not use
this port.
- `adapter:outbound:cache-redis` owns Redis keys, atomic Lua programs, structured reply parsing,
failure certainty and the provider implementation.
- `app-bootstrap` owns the explicit provider/policy selection.
- The existing inbound-web local limiter remains a compatibility path until a separate inbound
migration. Its types do not cross into the Redis provider.
The rate-limit runtime does not reuse `app.cache.redis`, the cache connection or cache fail-open
decorators. Coordination has different failure and deployment semantics.
## Shared semantic contract
`EdgeRateLimitPort.evaluate(RateLimitRequest)` accepts:
- bounded `policyId`;
- already pseudonymized/bounded `subjectDigest`;
- positive request cost;
- optional evaluation ID (rejected in this non-deduplicating revision);
- finite caller deadline.
`RateLimitPolicy` freezes policy ID/revision, one algorithm-specific parameter subtype, maximum
cost, cleanup grace, maximum clock regression and `FAIL_CLOSED`. Construction rejects mismatched
algorithm/parameters, arithmetic outside Lua's exact integer range and unsupported failure/dedup
claims.
The outcome is one of:
- `Evaluated(decision)`;
- `Unavailable(policyId, retryAfter, category)` for known pre-send/no-mutation failures and unsafe
server clock;
- `Indeterminate(policyId, retryAfter)` for post-dispatch uncertain mutation;
- `Incompatible(policyId, category)` for state/program/reply mismatch.
`RateLimitDecision` includes allow/deny, limit, remaining, retry-after, reset-at, policy ID/revision,
`GLOBAL_REDIS` source and certainty. Fixed window and token bucket are `CERTAIN`;
sliding-window counter is `APPROXIMATE_ALGORITHM`.
## Atomic programs
Each v1 program uses one versioned hash key and calls Redis `TIME` exactly once.
```text
rate-fixed-window-v1.lua
rate-sliding-counter-v1.lua
rate-token-bucket-v1.lua
```
Every program returns exactly seven bounded scalar fields:
```text
status, serverNowMillis, effectiveNowMillis,
limit, remaining, retryAfterMillis, resetAtMillis
```
Statuses are `ALLOWED`, `DENIED`, `CLOCK_UNSAFE`, `STATE_INCOMPATIBLE`, `INVALID`.
Unknown arity/status/numeric syntax/range is a compatibility failure, never allow/fail-open.
Common rules:
- Redis server time drives enforcement;
- small backward movement clamps to stored `lastObservedMillis`;
- regression beyond policy threshold returns `CLOCK_UNSAFE` without consuming state;
- policy/schema/algorithm mismatch returns `STATE_INCOMPATIBLE`;
- denied requests do not consume quota;
- state receives a finite TTL;
- all arithmetic stays within `2^53-1`;
- raw principal/IP/API-key/route never appears in the physical key.
The existing scalar Lua executor stays intact. A structured program path adds bounded MULTI reply
support and uses `EVALSHA`, falling back to the exact compiled source only on `NOSCRIPT`.
## Algorithm rules
Fixed window stores window ID and consumed count. Allow increments only when
`consumed + cost <= limit`; retry/reset points to the current window end.
Sliding counter stores previous/current window IDs and counts, using scale `1_000_000` and
conservative ceiling weight. It reports approximate certainty and a bounded conservative retry.
Token bucket stores scaled tokens, last refill time and the sub-token division remainder. Refill is
therefore independent of evaluation frequency, uses quotient/remainder arithmetic without an
unsafe `numerator + denominator - 1` intermediate, and saturates at capacity. Denial does not
subtract tokens; retry and full-reset use integer ceiling.
## Physical key
The existing canonical builder is reused with:
```text
capability=rate
region=<policyId>
kind=state
digest(policyId, policyRevision, algorithm, subjectDigest)
```
Policy revision appears in both digest input and stored state. A policy revision therefore rolls to
a new key while old state expires naturally.
## Runtime and composition
`app.rate-limit` is disabled by default. Enabling requires:
- `provider=redis`;
- one default policy and an exact policy definition;
- a dedicated Redis coordination endpoint and HMAC secret;
- finite command/admission bounds.
Only `role=coordination` and `failure-policy=fail-closed` are accepted in v1. Disabled mode creates
no connection, thread or semantic port. Cache Redis settings/beans are never an implicit fallback.
## Evidence
Unit tests cover contract bounds, policy arithmetic, key privacy/revision, structured reply
validation, `NOSCRIPT`, boundary vectors, denial-no-consume, clock regression, pre/post-dispatch
failure certainty and disabled composition. The explicit Redis 7.4 service lane executes all three
programs, exact-boundary admission after a denied non-consuming request, excessive clock-regression
state immutability, `TYPE` response normalization, token refill-remainder carry, malformed hash-state
classification, cache `NX`, and observation-token compare-and-replace. Redis 7.4 is the minimum
version declared by the program manifests until a lower-version service lane exists. The caller
deadline is an admission precheck against the fixed command timeout; R1 does not claim per-command
dynamic timeout or hard cancellation after dispatch. Missing TLS/ACL, Sentinel/Cluster, failover and
persistence/eviction evidence keeps the provider at R1.