Adding an Approval State
There are two independent state machines. Decide first which one the new state belongs to — the changes are not comparable in size.
| State machine | Shape | Where |
|---|---|---|
ApprovalStatus — the decision outcome | PENDING → {APPROVED, DENIED, TIMED_OUT}; everything except PENDING is terminal | tramai-core/src/main/kotlin/dev/tramai/core/approval/ApprovalStatus.kt:9-14 |
ApprovalContinuationStatus — the resume lifecycle | PENDING, CLAIMED, COMPLETED, EXPIRED, CANCELLED_UNCERTAIN, CANCELLED | tramai-core/src/main/kotlin/dev/tramai/core/approval/ApprovalContinuationStatus.kt:3-10 |
Adding a decision outcome is a wide, compatibility-sensitive change across the gateway and gate. Adding a continuation-lifecycle state is narrower — and is what most real changes are.
The Extension Points
- Transitions —
ApprovalTransition(sealedApprove/Deny/Timeout, each withtargetStatus()):tramai-core/.../approval/ApprovalTransition.kt:9-48. - Persistence —
ApprovalStore(createmust produce aPENDINGv0 record;transition(approvalId, expectedVersion, transition)is optimistic-concurrency;consumeApprovedOrReplaydistinguishes strict-fresh from exact-replay) andApprovalContinuationStore(claimForExecutionis the only path exposing raw arguments). - Lifecycle oracles —
ApprovalLifecycleModel.ktandApprovalContinuationLifecycleModel.ktintramai-testing. These are deliberately independent of production code: they are the specification, and the code must match them. - Audit SPI —
ApprovalLifecycleAuditEmitter.ktintramai-core. Never emit raw arguments or tokens.
Files That Change
| Layer | File |
|---|---|
| Enum | tramai-core/.../approval/ApprovalContinuationStatus.kt (or ApprovalStatus.kt) |
| DTO serialization | tramai-persistence-file/.../PersistedDtos.kt |
| Store implementations | InMemoryApprovalContinuationStore.kt (tramai-security), FileApprovalContinuationStore.kt (file), JdbcApprovalContinuationStore.kt (jdbc) |
| Coordinators | ApprovalResumeCoordinator.kt, ContinuationClaimService.kt (validateBindings requires status == PENDING), ApprovalSuspensionCoordinator.kt (creates a hardcoded PENDING) |
| Gate | DefaultApprovalGateCoordinator.kt (authorization requires consumed.status == APPROVED) |
| Gateway mapping | DefaultApprovalGateway.kt (toGatewayResult — an unmapped status silently falls into Suspended) |
| Audit emitter | tramai-security/.../audit/AuditEngineApprovalLifecycleAuditEmitter.kt (fixed enforcement point and decision strings per callback) |
| Lifecycle oracles | ApprovalLifecycleModel.kt, ApprovalContinuationLifecycleModel.kt |
| TCK invariant tables | ApprovalStoreTck.kt, ApprovalContinuationStoreTck.kt |
| Action generators | ApprovalLifecycleActionGenerator.kt, ApprovalContinuationLifecycleActionGenerator.kt |
All four implementation layers plus the DTOs must change together. A state added to one store but not the others is a partial implementation that the TCK will reject.
Mandatory Contract Tests
| Oracle | Where |
|---|---|
ApprovalStoreTck | tramai-testing/src/testFixtures/kotlin/dev/tramai/testing/persistence/approval/ApprovalStoreTck.kt — transition matrix, consumption and exact-replay semantics, a model-based property check, a wrong-version matrix, a race schedule |
ApprovalContinuationStoreTck | .../approval/continuation/ApprovalContinuationStoreTck.kt — claim, exactly-once argument release, expiry, cancel, complete, recovery, sweep, races, model property |
| Lifecycle oracles | .../approval/ApprovalLifecycleModel.kt, .../approval/continuation/ApprovalContinuationLifecycleModel.kt |
| Action generators | ApprovalLifecycleActionGenerator.kt, ApprovalContinuationLifecycleActionGenerator.kt plus their unit tests |
| Enrolment gates | ApprovalStoreTckEnrollmentArchitectureTest.kt, ApprovalContinuationStoreTckEnrollmentArchitectureTest.kt in tramai-testing/src/test/kotlin/dev/tramai/testing/ |
Concrete runners exist for the in-memory, file, and JDBC implementations; the enrolment gates require them to stay named per implementation.
Invariants A New State Must Not Break
- Continuation version ceiling is
≤ 2per continuation, with field shape varying per status. A new state that increments the version breaks existing stored records. - Exactly-once argument release. Raw arguments are exposed once, via claim.
- Rejected actions do not mutate durable state. Late claim and cancel are the exception: they do mutate (
PENDING → EXPIRED) before raising the typed failure. ApprovalSuspensionCoordinatorcreates continuations hardcodedPENDING, andContinuationClaimService.validateBindingsrequiresPENDING. A pre-claim state breaks that path until the check is deliberately relaxed.- Expiry semantics stay as-is unless the new state explicitly needs new rules: decision-legal while
now < expiresAt, timeout-legal whilenow >= expiresAtand stillPENDING. Changing that is a contract change, not a state addition. - Outcome mapping
whens must be explicit.DefaultApprovalGateway.toGatewayResultand the gate coordinator'sAPPROVEDchecks treat unknown statuses as suspended or denied — a new state needs an explicit branch or authorization silently fails. - Audit event strings are a compatibility surface. External tooling consumes the fixed enforcement point and decision strings in
AuditEngineApprovalLifecycleAuditEmitter.kt. - Store methods rethrow
CancellationExceptionunchanged. - Replay-envelope security is untouched by a state addition:
ReplayEnvelopeFactoryandReplayEnvelopeValidatorstay as they are.
Verification
./gradlew verifyPr
./gradlew verifyChangePolicy -PchangeClass=runtime-behaviour
./gradlew :tramai-security:test :tramai-persistence-jdbc:test \
:tramai-persistence-file:test :tramai-testing:test :tramai-engine:test --tests '*Tck*'
What Not To Change In This Pull Request
- The TCKs. They are oracles, not documentation; they fail on drift by design.
- The lifecycle oracles. If the code disagrees, the code is wrong — do not edit the model to match.
config/quality/0.6.0-baseline.json. Adding an enum constant can shift scanner cardinality; ifverifyChangePolicyflags it, stop and report rather than editing the baseline in the same pull request.
Common Mistakes
| Symptom | Cause |
|---|---|
| TCK fails on one backend only | A state added to one store implementation and not the other three, or missing DTO serialization |
| Stored records fail TCK assertions | The continuation version ceiling was exceeded |
New status silently becomes Suspended | No explicit branch in toGatewayResult |
| Authorization always denies | The gate's APPROVED check was not extended for the new state |
| Lifecycle model tests fail while implementation tests pass | The oracle was edited instead of the code |
