From 07ab1cb21c52adb78f213d5919eb697828fc4e45 Mon Sep 17 00:00:00 2001 From: Giona Granchelli Date: Sun, 13 Sep 2026 17:56:29 +0200 Subject: [PATCH 1/3] Epic/0.7.1 control plane authority (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add workload identity contract vocabulary (0.7.1b) (#402) * feat(core): add typed workload/configuration/deployment/run identity contract (0.7.1b) * docs(0.7.1): record 0.7.1b workload identity contract task * fix(core): expose identity contract as plain JVM classes for Java interop (0.7.1b review) * docs(0.7.1): correct 0.7.1b task evidence wording after Java-interop review --------- Co-authored-by: TramAI Test * feat(control-plane): add authoritative workload registration and state boundary (0.7.1c) (#403) * feat(control-plane): add authoritative workload registration and state boundary (0.7.1c) * fix(control-plane): enforce CAS immutability, truthful stale versions, clean static analysis (0.7.1c review) * chore(0.7.1c): remove unused import, clarify race-handshake KDoc * fix(control-plane): advance state version only on genuine mutation (0.7.1c review) next() now runs only inside the mutation branch of updateMetadata and transitionLifecycle — stale, same-state, no-op and invalid commands are observational and never require a future version. A genuine mutation at Long.MAX_VALUE still fails closed on overflow. - InMemory CAS aligned to the JDBC concurrency token (deployment scope + state version); SPI wording makes the token explicit, TCK pins it so the two stores cannot drift semantically. - Authority test seeds Long.MAX_VALUE records and pins all non-mutation outcomes at the boundary; TCK gains the scope+version token discriminator. - Module/TASK docs test counts corrected (authority 26, TCK 17/store). * fix(control-plane): verify immutable witness in store CAS (0.7.1c review) A fabricated expected carrying a different configuration or fingerprint for the right scope+version previously passed CAS: InMemory stored the whole updated record (rewriting immutable authority), while JDBC returned true but never touched immutable columns. Same SPI call, two outcomes. CAS now requires the stored immutable authority to match expected's witness, alongside the scope+stateVersion token: - InMemory: predicate checks current.identity and current.configurationFingerprint against expected's. - JDBC: single UPDATE gains configuration_id/version predicates plus an EXISTS witness over tramai_configuration_revision fingerprint. - SPI KDoc states the token/witness split explicitly. - TCK: 2 new discriminators — fabricated configuration and fabricated fingerprint at the correct scope+version both return false and leave the registration unchanged (19 cases/store). Mutable metadata/lifecycle in expected remain deliberately ignored. * fix(persistence-jdbc): use non-deprecated PostgreSQLContainer in 0.7.1c tests The registration store tests added by 0.7.1c imported the deprecated org.testcontainers.containers.PostgreSQLContainer, producing compiler warnings not covered by the warnings baseline (additions forbidden). Switch to org.testcontainers.postgresql.PostgreSQLContainer (2.0.5 relocation, non-generic, same fluent API) so verifyCompilerWarnings stays clean without a baseline change. --------- Co-authored-by: TramAI Test * fix(0.7.1c): enforce identity/metadata bounds in V8 migration Review follow-up on #404: both control-plane tables stored ids, owner and purpose as unrestricted TEXT, so a hand-written or corrupted row could hold state the typed contracts reject and fail during typed reconstruction on read. - V8: CHECK constraints for the identity columns (length 1..128) and for owner/purpose (1..256 / 1..512) on both tables. - JdbcWorkloadRegistrationStoreTest: a row exactly at each contract bound is accepted and round-trips through the typed mapper; one character over each bound is rejected with SQLSTATE 23514. Proven mutation-sensitive: removing the constraints fails the test. - 0.7.1c task doc records the database-side bound defence. * docs(0.7.1c): update module-card coverage table for the new control-plane module --------- Co-authored-by: TramAI Test --- config/quality/module-catalog.yml | 9 + docs/modules/README.md | 8 +- docs/modules/tramai-control-plane.md | 77 +++ docs/modules/tramai-persistence-jdbc.md | 3 +- docs/reference/module-matrix.md | 1 + .../EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md | 2 +- .../TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md | 135 ++++ ...THORITATIVE-REGISTRATION-STATE-BOUNDARY.md | 120 ++++ .../javaconsumer/JavaConsumerSmoke.java | 31 + settings.gradle.kts | 1 + .../api/tramai-control-plane.api | 284 ++++++++ tramai-control-plane/build.gradle.kts | 17 + .../controlplane/ConfigurationFingerprint.kt | 43 ++ .../InMemoryWorkloadRegistrationStore.kt | 127 ++++ .../tramai/controlplane/RegisteredWorkload.kt | 29 + .../controlplane/WorkloadLifecycleState.kt | 39 ++ .../WorkloadRegistrationAuthority.kt | 262 +++++++ .../controlplane/WorkloadRegistrationStore.kt | 102 +++ .../controlplane/WorkloadStateVersion.kt | 43 ++ ...nMemoryWorkloadRegistrationStoreTckTest.kt | 15 + .../WorkloadRegistrationAuthorityTest.kt | 639 ++++++++++++++++++ .../testing/WorkloadRegistrationFixtures.kt | 60 ++ .../testing/WorkloadRegistrationStoreTck.kt | 443 ++++++++++++ .../WorkloadRegistrationStoreTckHarness.kt | 16 + tramai-core/api/tramai-core.api | 122 ++++ .../tramai/core/identity/ConfigurationId.kt | 19 + .../core/identity/ConfigurationVersion.kt | 19 + .../dev/tramai/core/identity/DeploymentId.kt | 21 + .../dev/tramai/core/identity/EnvironmentId.kt | 19 + .../core/identity/GovernedRunIdentity.kt | 21 + .../core/identity/IdentityValidation.kt | 44 ++ .../kotlin/dev/tramai/core/identity/RunId.kt | 27 + .../identity/WorkloadConfigurationIdentity.kt | 21 + .../identity/WorkloadDeploymentIdentity.kt | 20 + .../dev/tramai/core/identity/WorkloadId.kt | 27 + .../tramai/core/identity/WorkloadMetadata.kt | 32 + .../identity/JavaIdentityInteropTest.java | 61 ++ .../core/identity/IdentityIdValidationTest.kt | 112 +++ .../WorkloadIdentityCompositionTest.kt | 99 +++ .../core/identity/WorkloadMetadataTest.kt | 86 +++ .../api/tramai-persistence-jdbc.api | 7 + tramai-persistence-jdbc/build.gradle.kts | 2 + .../jdbc/JdbcWorkloadRegistrationStore.kt | 360 ++++++++++ ...8__control_plane_workload_registration.sql | 65 ++ .../JdbcWorkloadRegistrationStoreTckTest.kt | 84 +++ .../jdbc/JdbcWorkloadRegistrationStoreTest.kt | 326 +++++++++ 46 files changed, 4094 insertions(+), 6 deletions(-) create mode 100644 docs/modules/tramai-control-plane.md create mode 100644 docs/roadmap/0.7.0/TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md create mode 100644 docs/roadmap/0.7.0/TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md create mode 100644 tramai-control-plane/api/tramai-control-plane.api create mode 100644 tramai-control-plane/build.gradle.kts create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/ConfigurationFingerprint.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStore.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/RegisteredWorkload.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadLifecycleState.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationStore.kt create mode 100644 tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadStateVersion.kt create mode 100644 tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStoreTckTest.kt create mode 100644 tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt create mode 100644 tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationFixtures.kt create mode 100644 tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTck.kt create mode 100644 tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTckHarness.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationId.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationVersion.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/DeploymentId.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/EnvironmentId.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunIdentity.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/IdentityValidation.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/RunId.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadConfigurationIdentity.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadDeploymentIdentity.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadId.kt create mode 100644 tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadMetadata.kt create mode 100644 tramai-core/src/test/java/dev/tramai/core/identity/JavaIdentityInteropTest.java create mode 100644 tramai-core/src/test/kotlin/dev/tramai/core/identity/IdentityIdValidationTest.kt create mode 100644 tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadIdentityCompositionTest.kt create mode 100644 tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadMetadataTest.kt create mode 100644 tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore.kt create mode 100644 tramai-persistence-jdbc/src/main/resources/tramai/persistence/jdbc/postgres/V8__control_plane_workload_registration.sql create mode 100644 tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTckTest.kt create mode 100644 tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTest.kt diff --git a/config/quality/module-catalog.yml b/config/quality/module-catalog.yml index 953ce09f7..7ec319f00 100644 --- a/config/quality/module-catalog.yml +++ b/config/quality/module-catalog.yml @@ -114,6 +114,15 @@ modules: publishability: published apiStability: stable + - path: ":tramai-control-plane" + description: "Authoritative control-plane registration and lifecycle state boundary for governed workloads." + <<: *core + rationale: "Canonical mutable authority binding workload identity to governed configuration, metadata and lifecycle; consumed by persistence adapters and later 0.7 control-plane candidates." + maturity: preview + layer: core-contracts + publishability: published + apiStability: preview + - path: ":tramai-core" description: "Core annotations, request models, provider registry, and exception types for Tramai." <<: *core diff --git a/docs/modules/README.md b/docs/modules/README.md index 8a1fbe6a3..3c2e39256 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -28,13 +28,13 @@ Below the architecture section, cards may carry long-form usage/design content ( ## Coverage -Coverage is computed against the 60-module authoritative manifest (`module-catalog.yml`). `sovereign-runtime-module-matrix.md` is a reference document, not a module card, and is not counted. +Coverage is computed against the 61-module authoritative manifest (`module-catalog.yml`). `sovereign-runtime-module-matrix.md` is a reference document, not a module card, and is not counted. | Metric | Count | |--------|-------| -| Manifest modules | 60 | -| Module cards | 60 | -| Conforming cards | 60 | +| Manifest modules | 61 | +| Module cards | 61 | +| Conforming cards | 61 | | Existing non-conforming | 0 | | Missing cards | 0 | | Orphans | 0 | diff --git a/docs/modules/tramai-control-plane.md b/docs/modules/tramai-control-plane.md new file mode 100644 index 000000000..0c1c89666 --- /dev/null +++ b/docs/modules/tramai-control-plane.md @@ -0,0 +1,77 @@ +# Module: `tramai-control-plane` + +> **One-liner:** authoritative workload-registration and lifecycle-state boundary for the TramAI control plane (Epic 0.7.1). +> **Classification / maturity / publishability / release:** see [`config/quality/module-catalog.yml`](../../config/quality/module-catalog.yml) + +## Architecture + +### Responsibility + +Owns the first canonical MUTABLE authority of the 0.7 control plane: the registration boundary that binds the immutable identity vocabulary +(`dev.tramai.core.identity`) to a governed configuration fingerprint, safe owner/purpose metadata, a registration lifecycle and a monotonic +state version. It answers *"what workload deployment is authoritatively registered, against which governed configuration revision, with what +lifecycle/metadata and state version?"* — durably and without silent overwrites. + +### Public entry points + +- `dev.tramai.controlplane.WorkloadRegistrationAuthority` — the rules state machine: register (never an upsert), metadata mutation, lifecycle + transitions, stale-writer rejection. Rules live here, never in a persistence implementation. +- `dev.tramai.controlplane.WorkloadRegistrationStore` — persistence boundary (`find` / atomic `create` / atomic `compareAndSet`), shared by the + InMemory reference implementation and the JDBC durable implementation. +- Types: `RegisteredWorkload`, `ConfigurationFingerprint`, `WorkloadLifecycleState` (ACTIVE/SUSPENDED/RETIRED, RETIRED terminal), + `WorkloadStateVersion` (starts at 1, monotonic, overflow-safe). + +### Internal extension points + +- `InMemoryWorkloadRegistrationStore` — reference implementation used by tests and the authority suite. +- `WorkloadRegistrationStoreTck` + harness + fixtures (testFixtures source set) — the shared contract executed against every store implementation; + a new persistence implementation only needs a runner. + +### Significant dependencies + +- `api(project(":tramai-core"))` only. No `tramai-server`, `tramai-platform`, Spring, dashboard, Jackson or JDBC in this module. +- Persistence adapters (`tramai-persistence-jdbc`) depend TOWARD this authority contract, never the reverse. + +### Lifecycle ownership + +`WorkloadLifecycleState` (ACTIVE/SUSPENDED/RETIRED) is the lifecycle of the REGISTRATION — deliberately distinct from workflow-run lifecycle. +Suspending/retiring a registration never cancels running workflows. RETIRED is terminal. `WorkloadStateVersion` advances on every successful +authoritative mutation and never changes identity. + +### Thread-safety and concurrency + +- Every store mutation is atomic; every authority mutation is a version-guarded compare-and-set — stale writers lose, no last-writer-wins. +- `InMemoryWorkloadRegistrationStore` serializes under a single lock (reference semantics; `ponytail:` per-scope locks if it ever leaves the + reference role). +- `JdbcWorkloadRegistrationStore` (in `tramai-persistence-jdbc`) runs registration inside an explicit transaction; the database primary keys + enforce one registration per deployment scope and one fingerprint per configuration revision. + +### Failure semantics + +- Registration is not an upsert: identical declaration → `AlreadyRegistered`; conflicting declaration → typed `Rejected` with + `RegistrationConflictReason` (configuration rebinding is rejected from ANY deployment scope). +- Stale mutations return typed `Stale` outcomes; the authoritative record is never partially applied. +- Database errors are wrapped (fail closed) without leaking SQL/vendor diagnostics; validation failures are `IllegalArgumentException`. + +### Contract tests / TCKs + +- `WorkloadRegistrationStoreTck` (19 cases) runs against BOTH `InMemoryWorkloadRegistrationStore` and `JdbcWorkloadRegistrationStore` + (runners: `InMemoryWorkloadRegistrationStoreTckTest`, `JdbcWorkloadRegistrationStoreTckTest`). +- `WorkloadRegistrationAuthorityTest` (26 cases) pins registration idempotency/conflict semantics, the lifecycle graph, version semantics and + concurrent-writer races. +- `JdbcWorkloadRegistrationStoreTest` proves durability across store instances (restart) and cross-instance rebinding rejection. + +### Do not + +- Do not make registration an upsert or a configuration-update mechanism for startup/bootstrap code. +- Do not cancel running workflows when a registration is suspended/retired. +- Do not embed `WorkloadStateVersion` in any identity type. +- Do not replace `WorkflowRegistry` executable-definition lookup. +- Do not add REST/query surfaces, If-Match/ETag, projection consistency classes or arbitrary metadata maps here (later candidates). + +### Related architecture + +- Identity vocabulary: `tramai-core` / `dev.tramai.core.identity` (0.7.1b) — immutable ids consumed here. +- Baseline audit and Epic decomposition: `docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md` and + `docs/roadmap/0.7.0/TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md`. +- Durable store: `tramai-persistence-jdbc` (migration `V8__control_plane_workload_registration.sql`). diff --git a/docs/modules/tramai-persistence-jdbc.md b/docs/modules/tramai-persistence-jdbc.md index 844d944bc..8b9af12df 100644 --- a/docs/modules/tramai-persistence-jdbc.md +++ b/docs/modules/tramai-persistence-jdbc.md @@ -7,11 +7,12 @@ ### Responsibility -Implements the sovereign store SPIs on PostgreSQL/JDBC: approval stores, continuation stores, audit store, suspended-invocation store, replay envelope codecs. Consumed directly or via `tramai-spring-boot-starter-sovereign-persistence-jdbc`. +Implements the sovereign store SPIs on PostgreSQL/JDBC: approval stores, continuation stores, audit store, suspended-invocation store, replay envelope codecs, and the 0.7.1c control-plane workload registration store (`JdbcWorkloadRegistrationStore`, tables in migration `V8__control_plane_workload_registration.sql`). Consumed directly or via `tramai-spring-boot-starter-sovereign-persistence-jdbc`. ### Public entry points - `dev.tramai.persistence.jdbc.JdbcApprovalStore`, `JdbcApprovalContinuationStore`, `JdbcAuditStore`, `JdbcSuspendedInvocationStore` +- `dev.tramai.persistence.jdbc.JdbcWorkloadRegistrationStore` — durable implementation of the [`WorkloadRegistrationStore`](../roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) contract (Epic 0.7.1c) - `JdbcReplayEnvelopeCodec`, `JdbcAuditPayloadCodec`, `JdbcContinuationArgumentsCodec` Verify the full public surface against `tramai-persistence-jdbc/api/tramai-persistence-jdbc.api` (`SovereignJdbcPersistence` is internal). diff --git a/docs/reference/module-matrix.md b/docs/reference/module-matrix.md index 083e8e03e..b5897be40 100644 --- a/docs/reference/module-matrix.md +++ b/docs/reference/module-matrix.md @@ -17,6 +17,7 @@ | tramai-azure-openai | provider-adapters | preview | preview | Yes | providers | included | | tramai-bedrock | provider-adapters | preview | preview | Yes | providers | included | | tramai-bom | core-contracts | stable | stable | Yes | core | included | +| tramai-control-plane | core-contracts | preview | preview | Yes | core | included | | tramai-core | core-contracts | stable | stable | Yes | core | included | | tramai-dashboard | operations-observability | internal | internal | No | runtime | internal_only | | tramai-deepseek | provider-adapters | preview | preview | Yes | providers | included | diff --git a/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md b/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md index 221aed026..59d7fb70e 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md @@ -1,7 +1,7 @@ # Epic 0.7.1 — Control-Plane Authority & Workload Identity **Branch:** `epic/0.7.1-control-plane-authority` -**Status:** 🟡 Active — 0.7.1a audit recorded in [TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md](TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md) +**Status:** 🟡 Active — 0.7.1a audit recorded in [TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md](TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md); 0.7.1b identity contract implemented in [TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md](TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md); 0.7.1c authoritative registration/state boundary implemented in [TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md](TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md) **Dependencies:** NONE ## Executive decision diff --git a/docs/roadmap/0.7.0/TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md b/docs/roadmap/0.7.0/TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md new file mode 100644 index 000000000..73afa4d55 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md @@ -0,0 +1,135 @@ +# Task 0.7.1b — Workload Identity Contract + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/0.7.1b-workload-identity-contract` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic branch at `f9d2e6b4` (epic head containing the 0.7.1a audit) + +**Change class:** `public-api` (additive; no breaking change) + +**Status:** Implemented; pending exact-head certification. + +## Decision + +Create the canonical typed identity vocabulary for a governed TramAI workload +and run — without yet creating lifecycle authority, persistence, registration, +or propagation. 0.7.1a established the required semantic fields +(workload, configuration identity/version, environment, deployment, run, plus +bounded owner/purpose metadata) and explicitly deferred lifecycle versioning +to 0.7.1c/e. + +The vocabulary lives in `tramai-core` under `dev.tramai.core.identity` because +orchestration, engine and security already expose `tramai-core` as an API +dependency; later control-plane modules can consume the same types without new +dependency edges. `tramai-server` is rejected (runtime identity does not belong +to Spring/server infrastructure) and a new `tramai-control-plane-core` module +is rejected for six value types before the control-plane boundaries exist. + +## Contract + +### Atomic ids (opaque, validated, case-preserving) + +| Type | Semantics | +|---|---| +| `WorkloadId` | Stable identity of one independently governed workload. Not required to be a UUID; generation policy belongs to 0.7.1c. | +| `ConfigurationId` | Stable identity of a governed configuration family. | +| `ConfigurationVersion` | One immutable revision of that configuration. Opaque — no SemVer assumption. | +| `EnvironmentId` | Logical governance environment (dev/staging/production/tenant). Not a deployment. | +| `DeploymentId` | One independently distinguishable deployment of a workload/configuration in an environment. | +| `RunId` | One immutable identifier for one authoritative execution. Resume/retry/restart/approval-resume reuse it; a new execution gets a new id. The type never generates ids. Distinct from `EngineExecutionIdentity.correlationId`, which remains the diagnostic/cross-operation correlation. | + +Shared fail-closed validation: non-blank; no leading/trailing whitespace; +no ISO control characters; bounded length (128). No character grammar is +imposed (`claims`, `org.example.claims`, `production/eu`, `urn:...`, `Payments` +vs `payments` are all legal distinct values). No silent normalization: inputs +that differ must never canonicalize to the same key. + +All identity types are plain JVM classes, not `@JvmInline` value classes. +The vocabulary is the canonical public tramai-core contract that 0.7.1d will +push to execution boundaries, and TramAI is Kotlin-first but Java-friendly: +`new WorkloadId("claims")`, getters, and equality must work naturally from +Java. Inline value classes would make the contract Kotlin-only or dependent on +experimental boxed exposure (`@JvmExposeBoxed`) — rejected for a stable public +surface. Enforced by `JavaIdentityInteropTest` (compiles and runs in +`:tramai-core:test`) and by identity usage in the `java-consumer-smoke` +example. + +### Composition + +```text +WorkloadConfigurationIdentity(configurationId, version) +WorkloadDeploymentIdentity(workloadId, configuration, environmentId, deploymentId) +GovernedRunIdentity(deployment, runId) +``` + +Invariants encoded by the types: + +- distinct deployments never collapse to one identity, even when they share + workload, configuration and environment (the 0.7.1a finding-2 invariant); +- within an authority domain, the same `(configurationId, version)` must never + refer to two different governed configurations (declared here; collision + enforcement belongs to 0.7.1c, which requires an authority/store); +- owner/purpose metadata is NOT part of identity equality. `WorkloadMetadata` + is a separate bounded type (`owner` ≤ 256, `purpose` ≤ 512, non-blank, no + control characters); the authoritative association of metadata to identity + belongs to 0.7.1c. No free-form `Map` — that would recreate + the untyped `WorkflowContext.attributes` problem identified by 0.7.1a. + +## Non-goals + +0.7.1b does NOT: + +- register workloads or persist identities; +- modify `WorkflowContext`, `WorkflowCheckpoint`, `EngineExecutionIdentity`, + `ApprovalBinding`, `RuntimeEvidenceRecord`, or scheduler records; +- add lifecycle state or a lifecycle version; +- add stale-command preconditions, APIs/controllers, or JSON schemas; +- add Dashboard behaviour; +- generate `RunId`s (creation at the execution boundary is 0.7.1d); +- migrate existing runs; +- add a new module or dependency edge. + +## Verification + +- `./gradlew :tramai-core:test` — passed (identity suites: + `IdentityIdValidationTest`, `WorkloadIdentityCompositionTest`, + `WorkloadMetadataTest`, and the Java-source `JavaIdentityInteropTest`). +- `./gradlew spotlessApply` / `spotlessCheck` — no changes required, passed. +- `./gradlew :tramai-core:apiDump` — regenerated; additive only (net +122 lines + vs epic base), with plain JVM ``/getter surface after the Java-interop + review fix. +- `./gradlew :examples:java-consumer-smoke:compileJava` — passed with identity + vocabulary usage. +- `./gradlew verifyPr -PchangeClass=public-api` — see completion report. + +## Mutation expectations + +- remove blank/whitespace/length validation → rejected by + `IdentityIdValidationTest`; +- ignore `deploymentId`/`environmentId`/`configuration`/`runId` in composition + equality → rejected by `WorkloadIdentityCompositionTest`; +- embed metadata into identity equality → rejected by the structural guard in + `WorkloadMetadataTest`. + +Full Epic adversarial/mutation certification remains assigned to 0.7.1g. + +## Evidence index + +- `tramai-core/src/main/kotlin/dev/tramai/core/identity/IdentityValidation.kt` + — shared fail-closed validation and length bounds. +- `tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadId.kt`, + `ConfigurationId.kt`, `ConfigurationVersion.kt`, `EnvironmentId.kt`, + `DeploymentId.kt`, `RunId.kt` — atomic ids. +- `tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadConfigurationIdentity.kt`, + `WorkloadDeploymentIdentity.kt`, `GovernedRunIdentity.kt` — composition. +- `tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadMetadata.kt` + — bounded owner/purpose metadata, separate from identity. +- Tests: `tramai-core/src/test/kotlin/dev/tramai/core/identity/`. + +## Candidate definition of done + +0.7.1b is complete when TramAI has exactly one canonical typed vocabulary for +workload, configuration revision, environment, deployment and run identity, +with fail-closed validation and documented identity semantics — and no +lifecycle/store/propagation authority has been introduced. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md b/docs/roadmap/0.7.0/TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md new file mode 100644 index 000000000..ac7ee5b3c --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md @@ -0,0 +1,120 @@ +# Task 0.7.1c — Authoritative Registration / State Boundary + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/0.7.1c-authoritative-registration` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic head `4434b2fa` (0.7.1b identity vocabulary merged) + +**Change class:** `public-api` (additive), plus new-module registration (`settings.gradle.kts`, `module-catalog.yml`) + +**Status:** Implemented; pending exact-head certification. + +## Decision + +Introduce exactly one authoritative workload-registration boundary that binds +the immutable 0.7.1b identity to safe metadata, the governed-configuration +revision, lifecycle state and a monotonic mutable-state version, with durable +atomic storage semantics — and no runtime propagation or REST API yet. + +New published module `tramai-control-plane` (depends only on `tramai-core`) +owns the contract and the rules. The JDBC durable implementation lives in the +existing `tramai-persistence-jdbc` (persistence adapters depend TOWARD the +authority contract, never the reverse). `tramai-server`, `tramai-platform`, +Spring, Jackson, the dashboard and JDBC are all absent from the new module. + +## Contract + +- `RegisteredWorkload` — immutable portion: `WorkloadDeploymentIdentity` + + `ConfigurationFingerprint`; mutable authoritative portion: `WorkloadMetadata`, + `WorkloadLifecycleState`, `WorkloadStateVersion`. State version is never part + of any identity type: a state update cannot change the identity of + historical runs. +- `ConfigurationFingerprint` — opaque, deterministic witness over the complete + governed configuration; opaque to the store. Enforces the invariant 0.7.1b + deferred: `(configurationId, version)` can never be rebound to a different + fingerprint, from ANY deployment scope (database-level authority via the + `tramai_configuration_revision` primary key). +- `WorkloadLifecycleState` — ACTIVE / SUSPENDED / RETIRED only; RETIRED is + terminal; suspending never cancels running workflows (registration lifecycle + and run lifecycle are separate authorities). +- `WorkloadStateVersion` — starts at 1; every successful authoritative mutation + advances with overflow-safe `addExact`; reads, rejected mutations, stale CAS + and idempotent re-registration leave it unchanged. +- `WorkloadRegistrationAuthority` owns every rule — registration is NOT an + upsert, configuration revisions cannot be rebound, one deployment scope has + one registration, metadata changes preserve identity, stale writers lose + (version-guarded compare-and-set). +- Store boundary `WorkloadRegistrationStore` (`find` / atomic `create` / + atomic `compareAndSet`) implemented by `InMemoryWorkloadRegistrationStore` + (reference) and `JdbcWorkloadRegistrationStore` (durable, Postgres + migrations `V8__control_plane_workload_registration.sql`). +- Persisted rows obey the same bounds as the typed values: the migration + constrains identity columns and `owner`/`purpose` lengths, so a hand-written + or corrupted row cannot exist in a shape that fails typed reconstruction on + read (the database defends the bounds; character grammar stays owned by the + typed values). + +## Shared contract test + +The same store contract runs against both implementations via +`WorkloadRegistrationStoreTck` (testFixtures of `tramai-control-plane`): +19 contract cases per store, including the same-environment/different-deployment +invariant, global configuration-rebinding rejection, stale-CAS rejection and a +real concurrency race (5 iterations, ready/release handshake on +`Dispatchers.Default`). JDBC runners execute the full migration chain +V1→V8 against `postgres:17-alpine`; a second suite proves durability across +store instances (restart), cross-instance rebinding rejection, DB-level +atomic create races and migration-level rejection (SQLSTATE 23514) of rows +outside the bounded identity/metadata contracts. + +## Non-goals + +0.7.1c does NOT: propagate identity into `WorkflowContext`/engine/approval/ +evidence/scheduler (0.7.1d); add REST query/mutation APIs, If-Match/ETag or +projection consistency classes (0.7.1e); define safe exposure categories +(0.7.1f); replace `WorkflowRegistry` executable-definition lookup; cancel runs; +or add arbitrary metadata maps. + +## Verification + +- `./gradlew :tramai-control-plane:test` — passed (26 authority-rule tests, + 19 InMemory TCK cases). +- `./gradlew :tramai-persistence-jdbc:test --tests "*WorkloadRegistration*"` — + passed (19 JDBC TCK cases + 5 JDBC-specific tests). +- `./gradlew :tramai-control-plane:apiDump :tramai-persistence-jdbc:apiDump` — + additive. +- `./gradlew spotlessApply` / `spotlessCheck` — passed. +- `./gradlew verifyPr -PchangeClass=public-api` — see completion report. + +## Mutation expectations + +- remove fingerprint/whitespace/length validation → `ConfigurationFingerprint` + constructor tests; +- ignore deployment scope or configuration in idempotency/conflict + classification → TCK + authority tests; +- allow register to silently overwrite an existing registration → `re-register + ... is rejected, not upserted` + JDBC `registration is never silently + replaced`; +- allow a `(configurationId, version)` pair to rebind to another fingerprint → + global rebinding tests (both stores); +- permit RETIRED resurrection → lifecycle terminal tests; +- drop the state-version guard → stale CAS + concurrent-writer tests. + +Full Epic adversarial/mutation certification remains assigned to 0.7.1g. + +## Evidence index + +- `tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/` — authority + contract and InMemory store (see module doc). +- `tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/` + — shared `WorkloadRegistrationStoreTck` + fixtures + harness. +- `tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore.kt` +- `tramai-persistence-jdbc/src/main/resources/.../postgres/V8__control_plane_workload_registration.sql` + +## Candidate definition of done + +0.7.1c is complete when TramAI can durably answer "what workload deployment is +authoritatively registered, against which governed configuration revision, +with what lifecycle/metadata and state version?" — and two concurrent or +conflicting writers cannot silently change that answer. diff --git a/examples/java-consumer-smoke/src/main/java/dev/tramai/examples/javaconsumer/JavaConsumerSmoke.java b/examples/java-consumer-smoke/src/main/java/dev/tramai/examples/javaconsumer/JavaConsumerSmoke.java index 64adc567e..41eb1ae0b 100644 --- a/examples/java-consumer-smoke/src/main/java/dev/tramai/examples/javaconsumer/JavaConsumerSmoke.java +++ b/examples/java-consumer-smoke/src/main/java/dev/tramai/examples/javaconsumer/JavaConsumerSmoke.java @@ -5,6 +5,15 @@ import dev.tramai.core.annotations.AiRange; import dev.tramai.core.annotations.AiService; import dev.tramai.core.annotations.AiTool; +import dev.tramai.core.identity.ConfigurationId; +import dev.tramai.core.identity.ConfigurationVersion; +import dev.tramai.core.identity.DeploymentId; +import dev.tramai.core.identity.EnvironmentId; +import dev.tramai.core.identity.GovernedRunIdentity; +import dev.tramai.core.identity.RunId; +import dev.tramai.core.identity.WorkloadConfigurationIdentity; +import dev.tramai.core.identity.WorkloadDeploymentIdentity; +import dev.tramai.core.identity.WorkloadId; import java.util.List; @@ -30,4 +39,26 @@ String greet( public static final String MARKER = GreetingService.class.getAnnotation(AiService.class) != null ? "ok" : "missing"; + + /** + * Java consumer proof for the 0.7.1b identity vocabulary: plain JVM + * classes are constructible and readable from Java with normal semantics. + * The discriminator assertion mirrors the canonical same-environment + * deployment invariant. + */ + public static final String IDENTITY_MARKER = identityVocabularyWorks() ? "ok" : "missing"; + + private static boolean identityVocabularyWorks() { + WorkloadId workload = new WorkloadId("claims"); + WorkloadConfigurationIdentity configuration = new WorkloadConfigurationIdentity( + new ConfigurationId("claims-prod"), new ConfigurationVersion("17")); + WorkloadDeploymentIdentity amsterdam = new WorkloadDeploymentIdentity( + workload, configuration, new EnvironmentId("production"), + new DeploymentId("eu-west-amsterdam-01")); + WorkloadDeploymentIdentity frankfurt = new WorkloadDeploymentIdentity( + workload, configuration, new EnvironmentId("production"), + new DeploymentId("eu-central-frankfurt-01")); + GovernedRunIdentity run = new GovernedRunIdentity(amsterdam, new RunId("run-1")); + return !amsterdam.equals(frankfurt) && run.getDeployment().equals(amsterdam); + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 40a760782..2e49c2da4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( "tramai-azure-openai", "tramai-bedrock", "tramai-bom", + "tramai-control-plane", "tramai-core", "tramai-deepseek", "tramai-embedding", diff --git a/tramai-control-plane/api/tramai-control-plane.api b/tramai-control-plane/api/tramai-control-plane.api new file mode 100644 index 000000000..5e3de6f33 --- /dev/null +++ b/tramai-control-plane/api/tramai-control-plane.api @@ -0,0 +1,284 @@ +public final class dev/tramai/controlplane/ConfigurationFingerprint { + public static final field Companion Ldev/tramai/controlplane/ConfigurationFingerprint$Companion; + public static final field FINGERPRINT_MAX_LENGTH I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/controlplane/ConfigurationFingerprint; + public static synthetic fun copy$default (Ldev/tramai/controlplane/ConfigurationFingerprint;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/controlplane/ConfigurationFingerprint; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/ConfigurationFingerprint$Companion { +} + +public abstract interface class dev/tramai/controlplane/CreateResult { +} + +public final class dev/tramai/controlplane/CreateResult$Conflicting : dev/tramai/controlplane/CreateResult { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun component2 ()Ldev/tramai/controlplane/RegistrationConflictReason; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)Ldev/tramai/controlplane/CreateResult$Conflicting; + public static synthetic fun copy$default (Ldev/tramai/controlplane/CreateResult$Conflicting;Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;ILjava/lang/Object;)Ldev/tramai/controlplane/CreateResult$Conflicting; + public fun equals (Ljava/lang/Object;)Z + public final fun getExisting ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getReason ()Ldev/tramai/controlplane/RegistrationConflictReason; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/CreateResult$Created : dev/tramai/controlplane/CreateResult { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/CreateResult$Created; + public static synthetic fun copy$default (Ldev/tramai/controlplane/CreateResult$Created;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/CreateResult$Created; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/CreateResult$Idempotent : dev/tramai/controlplane/CreateResult { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/CreateResult$Idempotent; + public static synthetic fun copy$default (Ldev/tramai/controlplane/CreateResult$Idempotent;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/CreateResult$Idempotent; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/InMemoryWorkloadRegistrationStore : dev/tramai/controlplane/WorkloadRegistrationStore { + public fun ()V + public fun compareAndSet (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun create (Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun find (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class dev/tramai/controlplane/LifecycleTransitionOutcome { +} + +public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Applied : dev/tramai/controlplane/LifecycleTransitionOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/LifecycleTransitionOutcome$InvalidTransition : dev/tramai/controlplane/LifecycleTransitionOutcome { + public fun (Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadLifecycleState;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun component2 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun copy (Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadLifecycleState;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$InvalidTransition; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$InvalidTransition;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadLifecycleState;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$InvalidTransition; + public fun equals (Ljava/lang/Object;)Z + public final fun getFrom ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun getTo ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/LifecycleTransitionOutcome$NotFound : dev/tramai/controlplane/LifecycleTransitionOutcome { + public static final field INSTANCE Ldev/tramai/controlplane/LifecycleTransitionOutcome$NotFound; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Stale : dev/tramai/controlplane/LifecycleTransitionOutcome { + public fun (Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun component2 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun copy (Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Stale; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Stale;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Stale; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrentVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun getExpectedVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged : dev/tramai/controlplane/LifecycleTransitionOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/tramai/controlplane/MetadataUpdateOutcome { +} + +public final class dev/tramai/controlplane/MetadataUpdateOutcome$Applied : dev/tramai/controlplane/MetadataUpdateOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; + public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/MetadataUpdateOutcome$NotFound : dev/tramai/controlplane/MetadataUpdateOutcome { + public static final field INSTANCE Ldev/tramai/controlplane/MetadataUpdateOutcome$NotFound; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/MetadataUpdateOutcome$Stale : dev/tramai/controlplane/MetadataUpdateOutcome { + public fun (Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun component2 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun copy (Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Stale; + public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Stale;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadStateVersion;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Stale; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrentVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun getExpectedVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/MetadataUpdateOutcome$Unchanged : dev/tramai/controlplane/MetadataUpdateOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; + public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/tramai/controlplane/RegisterOutcome { +} + +public final class dev/tramai/controlplane/RegisterOutcome$AlreadyRegistered : dev/tramai/controlplane/RegisterOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/RegisterOutcome$Created : dev/tramai/controlplane/RegisterOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/RegisterOutcome$Created; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Created;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Created; + public fun equals (Ljava/lang/Object;)Z + public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/RegisterOutcome$Rejected : dev/tramai/controlplane/RegisterOutcome { + public fun (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)V + public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun component2 ()Ldev/tramai/controlplane/RegistrationConflictReason; + public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Rejected;Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; + public fun equals (Ljava/lang/Object;)Z + public final fun getExisting ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getReason ()Ldev/tramai/controlplane/RegistrationConflictReason; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/RegisteredWorkload { + public fun (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun component2 ()Ldev/tramai/controlplane/ConfigurationFingerprint; + public final fun component3 ()Ldev/tramai/core/identity/WorkloadMetadata; + public final fun component4 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun component5 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun copy (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;)Ldev/tramai/controlplane/RegisteredWorkload; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisteredWorkload; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfigurationFingerprint ()Ldev/tramai/controlplane/ConfigurationFingerprint; + public final fun getIdentity ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun getLifecycle ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun getMetadata ()Ldev/tramai/core/identity/WorkloadMetadata; + public final fun getStateVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/RegistrationConflictReason : java/lang/Enum { + public static final field CONFIGURATION_REBINDING Ldev/tramai/controlplane/RegistrationConflictReason; + public static final field CONFLICTING_REGISTRATION Ldev/tramai/controlplane/RegistrationConflictReason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/tramai/controlplane/RegistrationConflictReason; + public static fun values ()[Ldev/tramai/controlplane/RegistrationConflictReason; +} + +public final class dev/tramai/controlplane/WorkloadDeploymentScope { + public fun (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadId; + public final fun component2 ()Ldev/tramai/core/identity/EnvironmentId; + public final fun component3 ()Ldev/tramai/core/identity/DeploymentId; + public final fun copy (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;)Ldev/tramai/controlplane/WorkloadDeploymentScope; + public static synthetic fun copy$default (Ldev/tramai/controlplane/WorkloadDeploymentScope;Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;ILjava/lang/Object;)Ldev/tramai/controlplane/WorkloadDeploymentScope; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeploymentId ()Ldev/tramai/core/identity/DeploymentId; + public final fun getEnvironmentId ()Ldev/tramai/core/identity/EnvironmentId; + public final fun getWorkloadId ()Ldev/tramai/core/identity/WorkloadId; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/WorkloadLifecycleState : java/lang/Enum { + public static final field ACTIVE Ldev/tramai/controlplane/WorkloadLifecycleState; + public static final field RETIRED Ldev/tramai/controlplane/WorkloadLifecycleState; + public static final field SUSPENDED Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun canTransitionTo (Ldev/tramai/controlplane/WorkloadLifecycleState;)Z + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/tramai/controlplane/WorkloadLifecycleState; + public static fun values ()[Ldev/tramai/controlplane/WorkloadLifecycleState; +} + +public final class dev/tramai/controlplane/WorkloadRegistrationAuthority { + public fun (Ldev/tramai/controlplane/WorkloadRegistrationStore;)V + public final fun register (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun transitionLifecycle (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadLifecycleState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun updateMetadata (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class dev/tramai/controlplane/WorkloadRegistrationStore { + public abstract fun compareAndSet (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun create (Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun find (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/controlplane/WorkloadStateVersion { + public static final field Companion Ldev/tramai/controlplane/WorkloadStateVersion$Companion; + public fun (J)V + public final fun component1 ()J + public final fun copy (J)Ldev/tramai/controlplane/WorkloadStateVersion; + public static synthetic fun copy$default (Ldev/tramai/controlplane/WorkloadStateVersion;JILjava/lang/Object;)Ldev/tramai/controlplane/WorkloadStateVersion; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()J + public fun hashCode ()I + public final fun next ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/WorkloadStateVersion$Companion { + public final fun getINITIAL ()Ldev/tramai/controlplane/WorkloadStateVersion; +} + diff --git a/tramai-control-plane/build.gradle.kts b/tramai-control-plane/build.gradle.kts new file mode 100644 index 000000000..2b8452f73 --- /dev/null +++ b/tramai-control-plane/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` + id("tramai.test-fixtures") + alias(libs.plugins.kotlin.jvm) + id("tramai.kotlin-library") + id("tramai.testing") +} + +dependencies { + api(project(":tramai-core")) + + testFixturesApi(project(":tramai-core")) + testFixturesApi(libs.assertj.core) + testFixturesApi(platform(libs.junit.bom)) + testFixturesApi(libs.kotlin.test.junit5) + testFixturesApi(libs.coroutines.core) +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/ConfigurationFingerprint.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/ConfigurationFingerprint.kt new file mode 100644 index 000000000..f2c85dcfb --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/ConfigurationFingerprint.kt @@ -0,0 +1,43 @@ +package dev.tramai.controlplane + +/** + * Opaque witness over the complete governed configuration of a workload + * deployment. + * + * The registration authority cannot parse a governed configuration (policy, + * routing, provider deployment, tool governance and workflow composition all + * contribute later); it only needs a deterministic fingerprint to enforce the + * 0.7.1b configuration invariant: + * + * ```text + * (configurationId, version) -> exactly one fingerprint, ever + * ``` + * + * `C/17/AAA` may be re-registered; `C/17/BBB` must be rejected as + * configuration rebinding. Fingerprint values are opaque, non-blank, bounded, + * case-preserving and never silently normalized — mirroring the identity + * validation rules. How the complete governed configuration is canonicalized + * into this fingerprint is deliberately unspecified here. + */ +data class ConfigurationFingerprint( + val value: String, +) { + init { + require(value.isNotBlank()) { "ConfigurationFingerprint must not be blank" } + require(value == value.trim()) { + "ConfigurationFingerprint must not contain leading or trailing whitespace" + } + require(value.length <= FINGERPRINT_MAX_LENGTH) { + "ConfigurationFingerprint must not exceed $FINGERPRINT_MAX_LENGTH characters" + } + require(value.none(Char::isISOControl)) { + "ConfigurationFingerprint must not contain control characters" + } + } + + override fun toString(): String = value + + companion object { + const val FINGERPRINT_MAX_LENGTH: Int = 128 + } +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStore.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStore.kt new file mode 100644 index 000000000..160643a60 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStore.kt @@ -0,0 +1,127 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId + +/** + * In-memory [WorkloadRegistrationStore] reference implementation. + * + * Single lock for the whole store keeps every operation atomic and the + * semantics trivially auditable; the store is an authority contract reference, + * not a throughput path. + * + * ponytail: single global lock; per-scope locks would matter only if this + * store ever leaves the test/reference role. + */ +class InMemoryWorkloadRegistrationStore : WorkloadRegistrationStore { + private val lock = Any() + private val registrations = LinkedHashMap() + private val configurationBindings = HashMap() + + private data class ConfigurationBindingKey( + val configurationId: String, + val configurationVersion: String, + ) + + private fun WorkloadConfigurationIdentity.toBindingKey(): ConfigurationBindingKey = + ConfigurationBindingKey( + configurationId = id.value, + configurationVersion = version.value, + ) + + override suspend fun find( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): RegisteredWorkload? = + synchronized(lock) { + registrations[WorkloadDeploymentScope(workloadId, environmentId, deploymentId)] + } + + override suspend fun create(registration: RegisteredWorkload): CreateResult = + synchronized(lock) { + val scope = registration.identity.toScope() + val bindingKey = registration.identity.configuration.toBindingKey() + + // 1) The (configurationId, version) binding is fixed forever, globally: + // the same pair with a different fingerprint fails from ANY scope. + configurationBindings[bindingKey]?.let { boundFingerprint -> + if (boundFingerprint != registration.configurationFingerprint) { + val context = + registrations[scope] + ?: registrations.entries + .firstOrNull { + it.value.identity.configuration + .toBindingKey() == bindingKey + }?.value + if (context == null) { + throw IllegalStateException( + "Corrupt in-memory store: configuration binding exists " + + "without an owning registration", + ) + } + return@synchronized CreateResult.Conflicting( + context, + RegistrationConflictReason.CONFIGURATION_REBINDING, + ) + } + } + + // 2) One authoritative registration per deployment scope. Never an + // upsert: identical declaration is idempotent, anything else is a + // conflict. + registrations[scope]?.let { existing -> + return@synchronized if (existing.isSameDeclaration(registration)) { + CreateResult.Idempotent(existing) + } else { + CreateResult.Conflicting(existing, RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + } + + // 3) Insert both records atomically under the store lock. + configurationBindings[bindingKey] = registration.configurationFingerprint + registrations[scope] = registration + CreateResult.Created(registration) + } + + override suspend fun compareAndSet( + expected: RegisteredWorkload, + updated: RegisteredWorkload, + ): Boolean = + synchronized(lock) { + require(updated.identity == expected.identity) { + "compareAndSet must not change registration identity" + } + require(updated.configurationFingerprint == expected.configurationFingerprint) { + "compareAndSet must not change configuration fingerprint" + } + val scope = expected.identity.toScope() + val current = registrations[scope] + // CAS concurrency token: scope + stateVersion. The immutable + // witness (identity + fingerprint) carried by expected must equal + // the stored record — a caller may hold stale MUTABLE fields, but + // never a fabricated immutable authority at the same version. + val tokenAndWitnessMatch = + current != null && + current.stateVersion == expected.stateVersion && + current.identity == expected.identity && + current.configurationFingerprint == expected.configurationFingerprint + if (tokenAndWitnessMatch) { + registrations[scope] = updated + true + } else { + false + } + } + + private fun WorkloadDeploymentIdentity.toScope(): WorkloadDeploymentScope = + WorkloadDeploymentScope(workloadId, environmentId, deploymentId) + + private fun RegisteredWorkload.isSameDeclaration(other: RegisteredWorkload): Boolean = + identity == other.identity && + configurationFingerprint == other.configurationFingerprint && + metadata == other.metadata +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/RegisteredWorkload.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/RegisteredWorkload.kt new file mode 100644 index 000000000..c22d450e8 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/RegisteredWorkload.kt @@ -0,0 +1,29 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadMetadata + +/** + * One authoritative registration record. + * + * Immutable portion: + * - [identity] — workload/configuration/environment/deployment identity; + * - [configurationFingerprint] — witness binding [identity.configuration] to + * exactly one governed configuration. + * + * Mutable authoritative portion (mutated only through + * [WorkloadRegistrationAuthority], never through registration): + * - [metadata] — owner/purpose, outside identity equality; + * - [lifecycle] — registration lifecycle state; + * - [stateVersion] — monotonic version advanced by every successful mutation. + * + * Invariant: [stateVersion] is not part of any identity type; a state update + * never changes the identity of historical runs. + */ +data class RegisteredWorkload( + val identity: WorkloadDeploymentIdentity, + val configurationFingerprint: ConfigurationFingerprint, + val metadata: WorkloadMetadata, + val lifecycle: WorkloadLifecycleState, + val stateVersion: WorkloadStateVersion, +) diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadLifecycleState.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadLifecycleState.kt new file mode 100644 index 000000000..c84b5dd85 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadLifecycleState.kt @@ -0,0 +1,39 @@ +package dev.tramai.controlplane + +/** + * Lifecycle of a workload REGISTRATION — deliberately distinct from the + * lifecycle of any individual workflow run. Suspending or retiring a workload + * registration does not cancel already-running workflows (that is runtime + * control, not registration authority). + * + * Transition graph: + * + * ```text + * ACTIVE <-> SUSPENDED + * ACTIVE -> RETIRED + * SUSPENDED -> RETIRED + * RETIRED -> (terminal) + * ``` + */ +enum class WorkloadLifecycleState { + /** Registration is eligible to admit new governed runs. */ + ACTIVE, + + /** No new governed runs should be admitted. */ + SUSPENDED, + + /** Terminal registration; cannot admit new runs or reactivate. */ + RETIRED, + ; + + /** + * Whether a transition from this state to [target] is part of the + * authoritative lifecycle graph. + */ + fun canTransitionTo(target: WorkloadLifecycleState): Boolean = + when (this) { + ACTIVE -> target == SUSPENDED || target == RETIRED + SUSPENDED -> target == ACTIVE || target == RETIRED + RETIRED -> false + } +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt new file mode 100644 index 000000000..9de63a473 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt @@ -0,0 +1,262 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata + +/** + * Authoritative workload-registration rules over a [WorkloadRegistrationStore]. + * + * Every rule lives here, never in a persistence implementation: + * - registration is NOT an upsert — establish the fact if absent, verify it if + * present, fail if it conflicts; + * - a `(configurationId, version)` pair can never be rebound to a different + * fingerprint; + * - one deployment scope has exactly one authoritative registration; + * - metadata and lifecycle mutations preserve identity and advance + * [WorkloadStateVersion]; + * - stale writers lose (every mutation is a compare-and-set on the version + * the caller observed). + * + * This authority does NOT cancel running workflows, replace [WorkflowRegistry] + * executable-definition lookup, or expose any REST/query surface — those + * belong to later candidates. + */ +class WorkloadRegistrationAuthority( + private val store: WorkloadRegistrationStore, +) { + /** + * Registers a workload deployment as ACTIVE at state version 1. + * + * Idempotent for an identical declaration; conflicting declarations are + * rejected, never silently applied. + */ + suspend fun register( + identity: WorkloadDeploymentIdentity, + configurationFingerprint: ConfigurationFingerprint, + metadata: WorkloadMetadata, + ): RegisterOutcome { + val existing = store.find(identity.workloadId, identity.environmentId, identity.deploymentId) + if (existing != null) { + return if (existing.isSameDeclaration(identity, configurationFingerprint, metadata)) { + RegisterOutcome.AlreadyRegistered(existing) + } else { + RegisterOutcome.Rejected( + existing = existing, + reason = existing.rejectionReason(identity, configurationFingerprint), + ) + } + } + + val desired = + RegisteredWorkload( + identity = identity, + configurationFingerprint = configurationFingerprint, + metadata = metadata, + lifecycle = WorkloadLifecycleState.ACTIVE, + stateVersion = WorkloadStateVersion.INITIAL, + ) + return when (val result = store.create(desired)) { + is CreateResult.Created -> RegisterOutcome.Created(result.registration) + is CreateResult.Idempotent -> RegisterOutcome.AlreadyRegistered(result.registration) + is CreateResult.Conflicting -> RegisterOutcome.Rejected(result.existing, result.reason) + } + } + + /** + * Updates owner/purpose metadata of a registration. Identity is + * unchanged; a successful change advances the state version. + * + * @param expectedVersion version observed by the caller — a mutation based + * on a stale version fails with [MetadataUpdateOutcome.Stale]. + */ + suspend fun updateMetadata( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + expectedVersion: WorkloadStateVersion, + metadata: WorkloadMetadata, + ): MetadataUpdateOutcome { + val current = + store.find(workloadId, environmentId, deploymentId) + ?: return MetadataUpdateOutcome.NotFound + return when { + current.stateVersion != expectedVersion -> { + MetadataUpdateOutcome.Stale(current.stateVersion, expectedVersion) + } + + current.metadata == metadata -> { + MetadataUpdateOutcome.Unchanged(current) + } + + else -> { + // next() belongs to the actual mutation: stale and no-op + // commands are observational and must never require a future + // version — at Long.MAX_VALUE that requirement would overflow + // without any mutation happening. + val updated = + current.copy( + metadata = metadata, + stateVersion = current.stateVersion.next(), + ) + if (store.compareAndSet(current, updated)) { + MetadataUpdateOutcome.Applied(updated) + } else { + // Lost the race: report the CURRENT authoritative version, + // not the version observed before the race (0.7.1e command + // preconditions will consume exactly this value). + val latest = + store.find(workloadId, environmentId, deploymentId) + if (latest != null) { + MetadataUpdateOutcome.Stale(latest.stateVersion, expectedVersion) + } else { + MetadataUpdateOutcome.NotFound + } + } + } + } + } + + /** + * Applies an authoritative lifecycle transition. RETIRED is terminal; + * suspending a workload never touches running workflows. + */ + suspend fun transitionLifecycle( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + expectedVersion: WorkloadStateVersion, + target: WorkloadLifecycleState, + ): LifecycleTransitionOutcome { + val current = + store.find(workloadId, environmentId, deploymentId) + ?: return LifecycleTransitionOutcome.NotFound + return when { + current.stateVersion != expectedVersion -> { + LifecycleTransitionOutcome.Stale(current.stateVersion, expectedVersion) + } + + current.lifecycle == target -> { + LifecycleTransitionOutcome.Unchanged(current) + } + + !current.lifecycle.canTransitionTo(target) -> { + LifecycleTransitionOutcome.InvalidTransition(current.lifecycle, target) + } + + else -> { + // next() belongs to the actual mutation: stale, same-state and + // invalid commands are observational and must never require a + // future version — at Long.MAX_VALUE that requirement would + // overflow without any mutation happening. + val updated = + current.copy( + lifecycle = target, + stateVersion = current.stateVersion.next(), + ) + if (store.compareAndSet(current, updated)) { + LifecycleTransitionOutcome.Applied(updated) + } else { + // Lost the race: report the CURRENT authoritative version. + val latest = + store.find(workloadId, environmentId, deploymentId) + if (latest != null) { + LifecycleTransitionOutcome.Stale(latest.stateVersion, expectedVersion) + } else { + LifecycleTransitionOutcome.NotFound + } + } + } + } + } +} + +/** Outcome of an authoritative [WorkloadRegistrationAuthority.register]. */ +sealed interface RegisterOutcome { + /** A new registration was created (ACTIVE, state version 1). */ + data class Created( + val registration: RegisteredWorkload, + ) : RegisterOutcome + + /** The identical declaration was already authoritative; nothing changed. */ + data class AlreadyRegistered( + val registration: RegisteredWorkload, + ) : RegisterOutcome + + /** The declaration conflicts with the authoritative record; nothing changed. */ + data class Rejected( + val existing: RegisteredWorkload, + val reason: RegistrationConflictReason, + ) : RegisterOutcome +} + +/** Outcome of [WorkloadRegistrationAuthority.updateMetadata]. */ +sealed interface MetadataUpdateOutcome { + /** Metadata changed; identity unchanged; state version advanced. */ + data class Applied( + val registration: RegisteredWorkload, + ) : MetadataUpdateOutcome + + /** Requested metadata equals current metadata; no authoritative mutation occurred. */ + data class Unchanged( + val registration: RegisteredWorkload, + ) : MetadataUpdateOutcome + + /** Caller's expected version is stale; the store moved on. */ + data class Stale( + val currentVersion: WorkloadStateVersion, + val expectedVersion: WorkloadStateVersion, + ) : MetadataUpdateOutcome + + data object NotFound : MetadataUpdateOutcome +} + +/** Outcome of [WorkloadRegistrationAuthority.transitionLifecycle]. */ +sealed interface LifecycleTransitionOutcome { + /** Lifecycle changed; state version advanced. */ + data class Applied( + val registration: RegisteredWorkload, + ) : LifecycleTransitionOutcome + + /** Target state equals current state; no authoritative mutation occurred. */ + data class Unchanged( + val registration: RegisteredWorkload, + ) : LifecycleTransitionOutcome + + /** The requested transition is outside the authoritative lifecycle graph. */ + data class InvalidTransition( + val from: WorkloadLifecycleState, + val to: WorkloadLifecycleState, + ) : LifecycleTransitionOutcome + + /** Caller's expected version is stale; the store moved on. */ + data class Stale( + val currentVersion: WorkloadStateVersion, + val expectedVersion: WorkloadStateVersion, + ) : LifecycleTransitionOutcome + + data object NotFound : LifecycleTransitionOutcome +} + +private fun RegisteredWorkload.isSameDeclaration( + identity: WorkloadDeploymentIdentity, + configurationFingerprint: ConfigurationFingerprint, + metadata: WorkloadMetadata, +): Boolean = + this.identity == identity && + this.configurationFingerprint == configurationFingerprint && + this.metadata == metadata + +private fun RegisteredWorkload.rejectionReason( + identity: WorkloadDeploymentIdentity, + configurationFingerprint: ConfigurationFingerprint, +): RegistrationConflictReason = + if (this.identity.configuration == identity.configuration && + this.configurationFingerprint != configurationFingerprint + ) { + RegistrationConflictReason.CONFIGURATION_REBINDING + } else { + RegistrationConflictReason.CONFLICTING_REGISTRATION + } diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationStore.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationStore.kt new file mode 100644 index 000000000..d707d3e33 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationStore.kt @@ -0,0 +1,102 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId + +/** + * Persistence boundary for the authoritative workload registration. Storage + * semantics only — registration, configuration-rebinding, lifecycle and + * metadata rules live in [WorkloadRegistrationAuthority] so no persistence + * implementation ever re-implements the state machine. + * + * Implementations must provide atomic storage semantics: + * - [create] is atomic — two concurrent creates for the same deployment scope + * produce exactly one authoritative outcome; + * - [compareAndSet] is atomic — a stale writer never overwrites newer state. + */ +interface WorkloadRegistrationStore { + /** Returns the authoritative registration for the deployment scope, if any. */ + suspend fun find( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): RegisteredWorkload? + + /** + * Creates the registration atomically. Never an upsert: a conflicting + * existing registration or configuration binding must be reported, not + * silently replaced. + */ + suspend fun create(registration: RegisteredWorkload): CreateResult + + /** + * Atomically replaces the record stored at [expected]'s deployment scope + * with [updated] iff: + * + * - the stored record still carries [expected]'s state version + * (deployment scope + state version are the CAS concurrency token), AND + * - the stored immutable authority matches the witness carried by + * [expected] — identity and configuration fingerprint. A caller that + * fabricated a different configuration or fingerprint for the scope + * must not gain mutation authority merely by guessing the version. + * + * Mutable fields carried by [expected] (metadata, lifecycle) are not + * re-verified; only the token and the immutable witness are. Returns + * false when the scope is absent, the stored version has moved on, or the + * stored immutable authority differs from [expected]'s. + * + * The immutable portion — identity and configuration fingerprint — must + * match between [expected] and [updated]; implementations reject a + * violation with [IllegalArgumentException]. + */ + suspend fun compareAndSet( + expected: RegisteredWorkload, + updated: RegisteredWorkload, + ): Boolean +} + +/** Outcome of an atomic [WorkloadRegistrationStore.create]. */ +sealed interface CreateResult { + /** The record was inserted. */ + data class Created( + val registration: RegisteredWorkload, + ) : CreateResult + + /** An identical record (same scope, configuration, fingerprint, metadata) already exists. */ + data class Idempotent( + val registration: RegisteredWorkload, + ) : CreateResult + + /** An existing record blocks creation; nothing was written. */ + data class Conflicting( + val existing: RegisteredWorkload, + val reason: RegistrationConflictReason, + ) : CreateResult +} + +/** + * Why an authoritative registration was rejected. The configuration binding is + * global: if `(configurationId, version)` was ever registered anywhere with a + * fingerprint, re-registering the same pair with a different fingerprint fails + * even from a different deployment scope. + */ +enum class RegistrationConflictReason { + /** Same `(configurationId, version)` pair, different fingerprint — the binding is fixed forever. */ + CONFIGURATION_REBINDING, + + /** Same deployment scope, different configuration, fingerprint or metadata. */ + CONFLICTING_REGISTRATION, +} + +/** + * The registration slot: one authoritative registration per deployment scope. + * This is a store-level key, not a second canonical identity — the + * authoritative identity remains [WorkloadDeploymentIdentity]. + */ +data class WorkloadDeploymentScope( + val workloadId: WorkloadId, + val environmentId: EnvironmentId, + val deploymentId: DeploymentId, +) diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadStateVersion.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadStateVersion.kt new file mode 100644 index 000000000..4a03b67a8 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadStateVersion.kt @@ -0,0 +1,43 @@ +package dev.tramai.controlplane + +/** + * Monotonic version of the authoritative mutable registration record. + * + * - a fresh registration starts at 1; + * - a successful authoritative mutation advances N -> N + 1; + * - reads, rejected mutations, failed/stale CAS and idempotent re-registration + * leave N unchanged. + * + * The version lives on the mutable authoritative record, never on the + * immutable [dev.tramai.core.identity.WorkloadDeploymentIdentity] or + * [dev.tramai.core.identity.GovernedRunIdentity]: a state update must never + * change the identity of historical runs. Metadata changes (e.g. owner team + * handover) preserve identity but advance this version. + */ +data class WorkloadStateVersion( + val value: Long, +) { + init { + require(value >= 1L) { "WorkloadStateVersion must be at least 1, got $value" } + } + + /** + * Advances the version for the next authoritative mutation, failing + * closed on overflow rather than silently wrapping. + */ + fun next(): WorkloadStateVersion { + val advanced = + try { + Math.addExact(value, 1L) + } catch (e: ArithmeticException) { + throw IllegalStateException("WorkloadStateVersion overflow at $value", e) + } + return WorkloadStateVersion(advanced) + } + + override fun toString(): String = value.toString() + + companion object { + val INITIAL: WorkloadStateVersion = WorkloadStateVersion(1L) + } +} diff --git a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStoreTckTest.kt b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStoreTckTest.kt new file mode 100644 index 000000000..b72aaf924 --- /dev/null +++ b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStoreTckTest.kt @@ -0,0 +1,15 @@ +package dev.tramai.controlplane + +import dev.tramai.controlplane.testing.WorkloadRegistrationStoreTck +import dev.tramai.controlplane.testing.WorkloadRegistrationStoreTckHarness + +/** + * The InMemory reference implementation must satisfy the shared + * [WorkloadRegistrationStore] contract. + */ +class InMemoryWorkloadRegistrationStoreTckTest : WorkloadRegistrationStoreTck() { + override val harness = + object : WorkloadRegistrationStoreTckHarness { + override fun createStore(): WorkloadRegistrationStore = InMemoryWorkloadRegistrationStore() + } +} diff --git a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt new file mode 100644 index 000000000..4b2714170 --- /dev/null +++ b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt @@ -0,0 +1,639 @@ +package dev.tramai.controlplane + +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import dev.tramai.controlplane.testing.runInParallel +import dev.tramai.core.identity.WorkloadMetadata +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Authoritative registration rules (0.7.1c). The state machine lives in + * [WorkloadRegistrationAuthority], above the store; these tests pin every + * rule in the candidate brief, including the adversarial matrix. + */ +class WorkloadRegistrationAuthorityTest { + private lateinit var store: InMemoryWorkloadRegistrationStore + private lateinit var authority: WorkloadRegistrationAuthority + + @BeforeEach + fun setUp() { + store = InMemoryWorkloadRegistrationStore() + authority = WorkloadRegistrationAuthority(store) + } + + private val identity = WorkloadRegistrationFixtures.identity() + private val fingerprint = WorkloadRegistrationFixtures.fingerprint() + private val metadata = WorkloadRegistrationFixtures.metadata() + + private suspend fun current(): RegisteredWorkload? = + store.find( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + ) + + // ── Registration ──────────────────────────────────────────────── + + @Test + fun `register creates ACTIVE registration at version 1`() = + runBlocking { + val outcome = authority.register(identity, fingerprint, metadata) + + assertThat(outcome).isInstanceOf(RegisterOutcome.Created::class.java) + val created = (outcome as RegisterOutcome.Created).registration + assertThat(created.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) + assertThat(created.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + + @Test + fun `registering the identical declaration is idempotent at version 1`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val again = authority.register(identity, fingerprint, metadata) + + assertThat(again).isInstanceOf(RegisterOutcome.AlreadyRegistered::class.java) + val existing = (again as RegisterOutcome.AlreadyRegistered).registration + assertThat(existing.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) + assertThat(existing.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) + } + + @Test + fun `re-registering the same scope with a different configuration is rejected, not upserted`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + val nextConfiguration = + identity.copy( + configuration = + identity.configuration.copy( + version = + dev.tramai.core.identity + .ConfigurationVersion("18"), + ), + ) + + val outcome = authority.register(nextConfiguration, fingerprint, metadata) + + assertThat(outcome).isInstanceOf(RegisterOutcome.Rejected::class.java) + (outcome as RegisterOutcome.Rejected).let { rejected -> + assertThat(rejected.reason) + .isEqualTo(RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + assertThat(current()).isEqualTo( + WorkloadRegistrationFixtures.registration(identity = identity), + ) + } + + @Test + fun `re-registering with different metadata is rejected, never a silent update`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val outcome = + authority.register( + identity, + fingerprint, + WorkloadRegistrationFixtures.metadata(owner = "Risk Platform Team"), + ) + + assertThat(outcome).isInstanceOf(RegisterOutcome.Rejected::class.java) + (outcome as RegisterOutcome.Rejected).let { rejected -> + assertThat(rejected.reason) + .isEqualTo(RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + assertThat(current()).isEqualTo( + WorkloadRegistrationFixtures.registration(identity = identity), + ) + } + + @Test + fun `same configuration pair with a different fingerprint is configuration rebinding`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val rebinding = + authority.register( + identity, + WorkloadRegistrationFixtures.fingerprint(value = "sha256:bbbb"), + metadata, + ) + + assertThat(rebinding).isInstanceOf(RegisterOutcome.Rejected::class.java) + (rebinding as RegisterOutcome.Rejected).let { rejected -> + assertThat(rejected.reason) + .isEqualTo(RegistrationConflictReason.CONFIGURATION_REBINDING) + } + } + + @Test + fun `configuration rebinding is rejected from a different deployment scope`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + val frankfurtIdentity = + WorkloadRegistrationFixtures.identity( + deployment = "eu-central-frankfurt-01", + ) + + val outcome = + authority.register( + frankfurtIdentity, + WorkloadRegistrationFixtures.fingerprint(value = "sha256:bbbb"), + metadata, + ) + + assertThat(outcome).isInstanceOf(RegisterOutcome.Rejected::class.java) + (outcome as RegisterOutcome.Rejected).let { rejected -> + assertThat(rejected.reason) + .isEqualTo(RegistrationConflictReason.CONFIGURATION_REBINDING) + } + } + + @Test + fun `the same configuration on a different deployment registers cleanly`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val outcome = + authority.register( + WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01"), + fingerprint, + metadata, + ) + + assertThat(outcome).isInstanceOf(RegisterOutcome.Created::class.java) + } + + @Test + fun `registration survives reads without advancing version`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + repeat(3) { current() } + + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + + // ── Metadata mutation ─────────────────────────────────────────── + + @Test + fun `metadata update preserves identity and advances version`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + val newMetadata = WorkloadMetadata(owner = "Risk Platform Team", purpose = "Fraud review") + + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + newMetadata, + ) + + assertThat(outcome).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) + (outcome as MetadataUpdateOutcome.Applied).registration.let { updated -> + assertThat(updated.metadata).isEqualTo(newMetadata) + assertThat(updated.identity).isEqualTo(identity) + assertThat(updated.stateVersion).isEqualTo(WorkloadStateVersion(2)) + } + } + + @Test + fun `identical metadata update is unchanged and does not advance version`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + metadata, + ) + + assertThat(outcome).isInstanceOf(MetadataUpdateOutcome.Unchanged::class.java) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + + @Test + fun `stale metadata update is rejected without mutation`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadMetadata(owner = "Team A", purpose = "p"), + ) + // current version is now 2; a writer still holding version 1 must lose. + val staleOutcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadMetadata(owner = "Stale Team", purpose = "p"), + ) + + assertThat(staleOutcome).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + (staleOutcome as MetadataUpdateOutcome.Stale).let { + assertThat(it.currentVersion).isEqualTo(WorkloadStateVersion(2)) + assertThat(it.expectedVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(2)) + assertThat(current()?.metadata?.owner).isEqualTo("Team A") + } + + @Test + fun `metadata update on unknown registration is not found`() = + runBlocking { + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + metadata, + ) + + assertThat(outcome).isEqualTo(MetadataUpdateOutcome.NotFound) + } + + // ── Lifecycle transitions ─────────────────────────────────────── + + @Test + fun `ACTIVE to SUSPENDED to ACTIVE transitions advance version`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val suspendOutcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.SUSPENDED, + ) + assertThat(suspendOutcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + assertThat((suspendOutcome as LifecycleTransitionOutcome.Applied).registration.stateVersion) + .isEqualTo(WorkloadStateVersion(2)) + + val resumeOutcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(2), + WorkloadLifecycleState.ACTIVE, + ) + assertThat(resumeOutcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + assertThat((resumeOutcome as LifecycleTransitionOutcome.Applied).registration.stateVersion) + .isEqualTo(WorkloadStateVersion(3)) + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) + } + + @Test + fun `ACTIVE may retire directly and RETIRED is terminal`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val retire = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.RETIRED, + ) + assertThat(retire).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + + val resurrect = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + current()!!.stateVersion, + WorkloadLifecycleState.ACTIVE, + ) + assertThat(resurrect).isInstanceOf(LifecycleTransitionOutcome.InvalidTransition::class.java) + (resurrect as LifecycleTransitionOutcome.InvalidTransition).let { + assertThat(it.from).isEqualTo(WorkloadLifecycleState.RETIRED) + assertThat(it.to).isEqualTo(WorkloadLifecycleState.ACTIVE) + } + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.RETIRED) + } + + @Test + fun `SUSPENDED may retire and RETIRED remains terminal`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.SUSPENDED, + ) + + val retire = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + current()!!.stateVersion, + WorkloadLifecycleState.RETIRED, + ) + assertThat(retire).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + + val resurrect = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + current()!!.stateVersion, + WorkloadLifecycleState.ACTIVE, + ) + assertThat(resurrect).isInstanceOf(LifecycleTransitionOutcome.InvalidTransition::class.java) + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.RETIRED) + } + + @Test + fun `retired registration cannot be suspended either`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.RETIRED, + ) + + val outcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + current()!!.stateVersion, + WorkloadLifecycleState.SUSPENDED, + ) + + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.InvalidTransition::class.java) + } + + @Test + fun `same-state transition is unchanged and does not advance version`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + + val outcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.ACTIVE, + ) + + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.Unchanged::class.java) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + + @Test + fun `lifecycle transitions preserve identity`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.SUSPENDED, + ) + + assertThat(current()?.identity).isEqualTo(identity) + assertThat(current()?.configurationFingerprint).isEqualTo(fingerprint) + } + + @Test + fun `stale lifecycle transition loses and does not mutate`() = + runBlocking { + authority.register(identity, fingerprint, metadata) + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.SUSPENDED, + ) + + val stale = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.RETIRED, + ) + + assertThat(stale).isInstanceOf(LifecycleTransitionOutcome.Stale::class.java) + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.SUSPENDED) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(2)) + } + + // ── Version overflow boundary (rejected commands never advance) ─ + + /** + * Seeds the store at Long.MAX_VALUE — the last representable version. + * Authority commands must classify stale/no-op/invalid requests without + * ever calling next(); only a genuine mutation may attempt (and fail + * closed on) the overflow. + */ + private suspend fun seedAtMax(lifecycle: WorkloadLifecycleState): RegisteredWorkload { + val seeded = + WorkloadRegistrationFixtures.registration( + identity = identity, + configurationFingerprint = fingerprint, + metadata = metadata, + lifecycle = lifecycle, + stateVersion = WorkloadStateVersion(Long.MAX_VALUE), + ) + assertThat(store.create(seeded)).isInstanceOf(CreateResult.Created::class.java) + return seeded + } + + @Test + fun `metadata update at MAX version with identical metadata is unchanged and does not overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.ACTIVE) + + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(Long.MAX_VALUE), + metadata, + ) + + assertThat(outcome).isInstanceOf(MetadataUpdateOutcome.Unchanged::class.java) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + } + + @Test + fun `metadata update at MAX version with stale expected version is stale and does not overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.ACTIVE) + + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadRegistrationFixtures.metadata(owner = "Stale Team"), + ) + + assertThat(outcome).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + (outcome as MetadataUpdateOutcome.Stale).let { + assertThat(it.currentVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + assertThat(it.expectedVersion).isEqualTo(WorkloadStateVersion.INITIAL) + } + assertThat(current()?.metadata).isEqualTo(metadata) + } + + @Test + fun `metadata update at MAX version with a genuine change fails closed on overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.ACTIVE) + + val failure = + runCatching { + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(Long.MAX_VALUE), + WorkloadRegistrationFixtures.metadata(owner = "New Team"), + ) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure?.message).contains("overflow") + assertThat(current()?.metadata).isEqualTo(metadata) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + } + + @Test + fun `lifecycle transition at MAX version to the same state is unchanged and does not overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.ACTIVE) + + val outcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(Long.MAX_VALUE), + WorkloadLifecycleState.ACTIVE, + ) + + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.Unchanged::class.java) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + } + + @Test + fun `lifecycle transition at MAX version from RETIRED is invalid and does not overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.RETIRED) + + val outcome = + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(Long.MAX_VALUE), + WorkloadLifecycleState.ACTIVE, + ) + + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.InvalidTransition::class.java) + (outcome as LifecycleTransitionOutcome.InvalidTransition).let { + assertThat(it.from).isEqualTo(WorkloadLifecycleState.RETIRED) + assertThat(it.to).isEqualTo(WorkloadLifecycleState.ACTIVE) + } + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.RETIRED) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + } + + @Test + fun `lifecycle transition at MAX version with a genuine change fails closed on overflow`() = + runBlocking { + seedAtMax(WorkloadLifecycleState.ACTIVE) + + val failure = + runCatching { + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(Long.MAX_VALUE), + WorkloadLifecycleState.SUSPENDED, + ) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure?.message).contains("overflow") + assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) + assertThat(current()?.stateVersion).isEqualTo(WorkloadStateVersion(Long.MAX_VALUE)) + } + + // ── Concurrency ───────────────────────────────────────────────── + + @Test + fun `two concurrent metadata writers produce one winner and one stale loser`() = + runBlocking { + repeat(5) { iteration -> + val scope = + WorkloadRegistrationFixtures.identity( + deployment = "meta-race-$iteration", + ) + authority.register(scope, fingerprint, metadata) + + val outcomes = + runInParallel( + { + authority.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadMetadata(owner = "Writer A", purpose = "a"), + ) + }, + { + authority.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadMetadata(owner = "Writer B", purpose = "b"), + ) + }, + ) + + assertThat(outcomes.filterIsInstance()).hasSize(1) + val stale = outcomes.filterIsInstance().single() + // The loser must report the TRUTHFUL current version (2), not + // the version it read before the race (1) — 0.7.1e command + // preconditions will consume exactly this value. + assertThat(stale.expectedVersion).isEqualTo(WorkloadStateVersion.INITIAL) + assertThat(stale.currentVersion).isEqualTo(WorkloadStateVersion(2)) + val finalRecord = store.find(scope.workloadId, scope.environmentId, scope.deploymentId) + assertThat(finalRecord?.stateVersion).isEqualTo(stale.currentVersion) + assertThat(finalRecord?.metadata?.owner).isIn("Writer A", "Writer B") + } + } +} diff --git a/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationFixtures.kt b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationFixtures.kt new file mode 100644 index 000000000..a8e947970 --- /dev/null +++ b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationFixtures.kt @@ -0,0 +1,60 @@ +package dev.tramai.controlplane.testing + +import dev.tramai.controlplane.ConfigurationFingerprint +import dev.tramai.controlplane.RegisteredWorkload +import dev.tramai.controlplane.WorkloadLifecycleState +import dev.tramai.controlplane.WorkloadStateVersion +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata + +/** + * Deterministic fixtures for the workload-registration store contract. + * The TCK owns the identifiers — runners never do. + */ +object WorkloadRegistrationFixtures { + fun identity( + workload: String = "claims", + configurationId: String = "claims-prod", + configurationVersion: String = "17", + environment: String = "production", + deployment: String = "eu-west-amsterdam-01", + ): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workload), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environment), + deploymentId = DeploymentId(deployment), + ) + + fun fingerprint(value: String = "sha256:aaaa"): ConfigurationFingerprint = ConfigurationFingerprint(value) + + fun metadata( + owner: String = "Claims Team", + purpose: String = "Fraud review", + ): WorkloadMetadata = WorkloadMetadata(owner, purpose) + + fun registration( + identity: WorkloadDeploymentIdentity = identity(), + configurationFingerprint: ConfigurationFingerprint = fingerprint(), + metadata: WorkloadMetadata = metadata(), + lifecycle: WorkloadLifecycleState = WorkloadLifecycleState.ACTIVE, + stateVersion: WorkloadStateVersion = WorkloadStateVersion.INITIAL, + ): RegisteredWorkload = + RegisteredWorkload( + identity = identity, + configurationFingerprint = configurationFingerprint, + metadata = metadata, + lifecycle = lifecycle, + stateVersion = stateVersion, + ) +} diff --git a/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTck.kt b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTck.kt new file mode 100644 index 000000000..c03b916b6 --- /dev/null +++ b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTck.kt @@ -0,0 +1,443 @@ +package dev.tramai.controlplane.testing + +import dev.tramai.controlplane.CreateResult +import dev.tramai.controlplane.RegisteredWorkload +import dev.tramai.controlplane.RegistrationConflictReason +import dev.tramai.controlplane.WorkloadRegistrationStore +import dev.tramai.controlplane.WorkloadStateVersion +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Shared [WorkloadRegistrationStore] compatibility contract (0.7.1c). + * + * Every implementation — InMemory reference, JDBC durable — must satisfy + * exactly the same externally observable registration-storage semantics. The + * TCK tests the store contract, not implementation internals and not the + * authority state machine (which lives above the store and is covered by the + * authority test suite). + * + * Each test gets a FRESH store from [harness]. + * + * Suppressed rules: JUnit human-readable backtick test names are treated as + * production function names by Detekt because testFixtures sources are not on + * the ordinary test path, and the iteration literals of the race probes are + * intentional. The TramAI Detekt policy keeps one repository-wide + * configuration; these are narrow, documented class-level suppressions for a + * reusable TCK, not global config changes. + */ +@Suppress("FunctionNaming", "MagicNumber", "TooManyFunctions") +abstract class WorkloadRegistrationStoreTck { + abstract val harness: WorkloadRegistrationStoreTckHarness + + protected lateinit var store: WorkloadRegistrationStore + + @BeforeEach + fun setUp() { + store = harness.createStore() + } + + @AfterEach + fun tearDown() = runBlocking { harness.closeStore(store) } + + private suspend fun current(registration: RegisteredWorkload): RegisteredWorkload? = + store.find( + registration.identity.workloadId, + registration.identity.environmentId, + registration.identity.deploymentId, + ) + + // ── Creation / read ───────────────────────────────────────────── + + @Test + fun `fresh create round-trips through find`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + + val result = store.create(registration) + + assertThat(result).isEqualTo(CreateResult.Created(registration)) + assertThat(current(registration)).isEqualTo(registration) + } + + @Test + fun `find on unknown deployment scope returns null`() = + runBlocking { + val identity = WorkloadRegistrationFixtures.identity() + assertThat( + store.find(identity.workloadId, identity.environmentId, identity.deploymentId), + ).isNull() + } + + // ── Idempotency (register is not an upsert) ───────────────────── + + @Test + fun `identical declaration creates once then is idempotent`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + + val first = store.create(registration) + val second = store.create(registration) + + assertThat(first).isEqualTo(CreateResult.Created(registration)) + assertThat(second).isEqualTo(CreateResult.Idempotent(registration)) + } + + // ── Deployment scope exclusivity ──────────────────────────────── + + @Test + fun `same scope with different configuration is a conflicting registration`() = + runBlocking { + store.create(WorkloadRegistrationFixtures.registration()) + + val differentConfiguration = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(configurationVersion = "18"), + ) + + val result = store.create(differentConfiguration) + + assertThat(result).isInstanceOf(CreateResult.Conflicting::class.java) + result as CreateResult.Conflicting + assertThat(result.reason).isEqualTo(RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + + @Test + fun `same scope with different metadata is a conflicting registration`() = + runBlocking { + store.create(WorkloadRegistrationFixtures.registration()) + + val differentMetadata = + WorkloadRegistrationFixtures.registration( + metadata = WorkloadRegistrationFixtures.metadata(owner = "Risk Platform Team"), + ) + + val result = store.create(differentMetadata) + + assertThat(result).isInstanceOf(CreateResult.Conflicting::class.java) + result as CreateResult.Conflicting + assertThat(result.reason).isEqualTo(RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + + @Test + fun `distinct deployment ids within one environment stay distinct`() = + runBlocking { + val amsterdam = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(deployment = "eu-west-amsterdam-01"), + ) + val frankfurt = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01"), + ) + + assertThat(store.create(amsterdam)).isInstanceOf(CreateResult.Created::class.java) + assertThat(store.create(frankfurt)).isInstanceOf(CreateResult.Created::class.java) + } + + @Test + fun `same deployment id in distinct environments stays distinct`() = + runBlocking { + val production = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(environment = "production"), + ) + val staging = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(environment = "staging"), + ) + + assertThat(store.create(production)).isInstanceOf(CreateResult.Created::class.java) + assertThat(store.create(staging)).isInstanceOf(CreateResult.Created::class.java) + } + + // ── Configuration binding is fixed forever ────────────────────── + + @Test + fun `same configuration pair with different fingerprint is rejected from another deployment`() = + runBlocking { + val original = WorkloadRegistrationFixtures.registration() + store.create(original) + + // Same (configurationId, version), different fingerprint, DIFFERENT + // deployment scope: must still fail — the binding is global. + val rebindingAttempt = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01"), + configurationFingerprint = WorkloadRegistrationFixtures.fingerprint(value = "sha256:bbbb"), + ) + + val result = store.create(rebindingAttempt) + + assertThat(result).isInstanceOf(CreateResult.Conflicting::class.java) + result as CreateResult.Conflicting + assertThat(result.reason).isEqualTo(RegistrationConflictReason.CONFIGURATION_REBINDING) + } + + @Test + fun `same configuration pair with same fingerprint is allowed from another deployment`() = + runBlocking { + val original = WorkloadRegistrationFixtures.registration() + store.create(original) + + val sameConfiguration = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01"), + ) + + assertThat(store.create(sameConfiguration)).isInstanceOf(CreateResult.Created::class.java) + } + + // ── compareAndSet ─────────────────────────────────────────────── + + @Test + fun `compareAndSet applies when expected is current`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + val updated = registration.copy(stateVersion = WorkloadStateVersion(2)) + + assertThat(store.compareAndSet(registration, updated)).isTrue() + assertThat(current(registration)).isEqualTo(updated) + } + + @Test + fun `compareAndSet cannot change registration identity`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + val identityChange = + registration.copy( + identity = WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01"), + stateVersion = WorkloadStateVersion(2), + ) + + assertThat( + runCatching { store.compareAndSet(registration, identityChange) }.exceptionOrNull(), + ).isInstanceOf(IllegalArgumentException::class.java) + assertThat(current(registration)).isEqualTo(registration) + } + + @Test + fun `compareAndSet cannot change the configuration inside identity`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + val configurationChange = + registration.copy( + identity = WorkloadRegistrationFixtures.identity(configurationVersion = "18"), + stateVersion = WorkloadStateVersion(2), + ) + + assertThat( + runCatching { store.compareAndSet(registration, configurationChange) }.exceptionOrNull(), + ).isInstanceOf(IllegalArgumentException::class.java) + assertThat(current(registration)).isEqualTo(registration) + } + + @Test + fun `compareAndSet cannot change the configuration fingerprint`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + val fingerprintChange = + registration.copy( + configurationFingerprint = WorkloadRegistrationFixtures.fingerprint(value = "sha256:ffff"), + stateVersion = WorkloadStateVersion(2), + ) + + assertThat( + runCatching { store.compareAndSet(registration, fingerprintChange) }.exceptionOrNull(), + ).isInstanceOf(IllegalArgumentException::class.java) + // The CAS never applied, so the record — and its fingerprint — are untouched. + assertThat(current(registration)).isEqualTo(registration) + } + + @Test + fun `stale compareAndSet is rejected and state is untouched`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + val winner = registration.copy(stateVersion = WorkloadStateVersion(2)) + assertThat(store.compareAndSet(registration, winner)).isTrue() + + // A stale writer still holds the original version-1 snapshot. + val staleWriter = + registration.copy( + metadata = WorkloadRegistrationFixtures.metadata(owner = "Stale Team"), + ) + + assertThat(store.compareAndSet(registration, staleWriter)).isFalse() + assertThat(current(registration)).isEqualTo(winner) + } + + @Test + fun `compareAndSet token is deployment scope plus state version, not the whole mutable record`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + // Expected carries the same scope + version but STALE metadata: + // the CAS concurrency token is (deployment scope, stateVersion). + // Both implementations must agree — a full-record comparison in + // one store would let InMemory and JDBC drift apart. + val staleMetadataExpected = + registration.copy( + metadata = WorkloadRegistrationFixtures.metadata(owner = "Stale Owner"), + ) + val updated = + registration.copy( + metadata = WorkloadRegistrationFixtures.metadata(owner = "New Owner"), + stateVersion = WorkloadStateVersion(2), + ) + + assertThat(store.compareAndSet(staleMetadataExpected, updated)).isTrue() + assertThat(current(registration)).isEqualTo(updated) + } + + @Test + fun `compareAndSet rejects expected with a fabricated configuration for the scope`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + // Same scope + version, but the expected claims a DIFFERENT + // configuration than the stored authoritative record: the + // immutable witness must fail the CAS even though the version + // token matches. + val fabricatedConfiguration = + registration.copy( + identity = WorkloadRegistrationFixtures.identity(configurationVersion = "18"), + ) + val updated = + fabricatedConfiguration.copy(stateVersion = WorkloadStateVersion(2)) + + assertThat(store.compareAndSet(fabricatedConfiguration, updated)).isFalse() + assertThat(current(registration)).isEqualTo(registration) + } + + @Test + fun `compareAndSet rejects expected with a fabricated fingerprint for the scope`() = + runBlocking { + val registration = WorkloadRegistrationFixtures.registration() + store.create(registration) + + // Same scope + version + configuration, but the expected claims a + // DIFFERENT fingerprint: the immutable witness must fail the CAS. + val fabricatedFingerprint = + registration.copy( + configurationFingerprint = + WorkloadRegistrationFixtures.fingerprint(value = "sha256:ffff"), + ) + val updated = + fabricatedFingerprint.copy(stateVersion = WorkloadStateVersion(2)) + + assertThat(store.compareAndSet(fabricatedFingerprint, updated)).isFalse() + assertThat(current(registration)).isEqualTo(registration) + } + + // ── Concurrency ───────────────────────────────────────────────── + + @Test + fun `two concurrent creates for one scope yield exactly one authoritative outcome`() = + runBlocking { + repeat(RACE_ITERATIONS) { iteration -> + val registration = + WorkloadRegistrationFixtures.registration( + identity = + WorkloadRegistrationFixtures.identity( + deployment = "create-race-$iteration", + ), + ) + + val results = + runInParallel( + { store.create(registration) }, + { store.create(registration) }, + ) + + val created = results.filterIsInstance() + val idempotent = results.filterIsInstance() + assertThat(created.size + idempotent.size).isEqualTo(2) + assertThat(created.size).isEqualTo(1) + assertThat(current(registration)).isEqualTo(registration) + } + } + + @Test + fun `two concurrent CAS writers yield exactly one winner and final version 2`() = + runBlocking { + repeat(RACE_ITERATIONS) { iteration -> + val registration = + WorkloadRegistrationFixtures.registration( + identity = + WorkloadRegistrationFixtures.identity( + deployment = "cas-race-$iteration", + ), + ) + store.create(registration) + val winnerUpdate = + registration.copy( + metadata = WorkloadRegistrationFixtures.metadata(owner = "Winner"), + stateVersion = WorkloadStateVersion(2), + ) + val loserUpdate = + registration.copy( + metadata = WorkloadRegistrationFixtures.metadata(owner = "Loser"), + stateVersion = WorkloadStateVersion(2), + ) + + val results = + runInParallel( + { store.compareAndSet(registration, winnerUpdate) }, + { store.compareAndSet(registration, loserUpdate) }, + ) + + assertThat(results.filter { it }.size).isEqualTo(1) + assertThat(results.filter { !it }.size).isEqualTo(1) + assertThat(current(registration)?.stateVersion).isEqualTo(WorkloadStateVersion(2)) + } + } + + companion object { + /** How many times each concurrency race runs on fresh deployment scopes. */ + private const val RACE_ITERATIONS: Int = 5 + } +} + +/** + * Runs the contenders against each other on Dispatchers.Default, releasing + * them simultaneously once all are parked. The ready/release handshake is + * mandatory for a REAL race: `async { ... }` inside `runBlocking` inherits the + * single-threaded runBlocking event loop and would serialize the contenders, + * but `async(Dispatchers.Default)` genuinely runs them on the shared pool — + * the release gate only guarantees they START together. + */ +public suspend fun runInParallel(vararg contenders: suspend () -> T): List = + coroutineScope { + val ready = Channel(capacity = contenders.size) + val release = CompletableDeferred() + val results = + contenders.map { op -> + async(Dispatchers.Default) { + ready.send(Unit) + release.await() + op() + } + } + repeat(contenders.size) { ready.receive() } + release.complete(Unit) + results.map { it.await() } + } diff --git a/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTckHarness.kt b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTckHarness.kt new file mode 100644 index 000000000..745db3975 --- /dev/null +++ b/tramai-control-plane/src/testFixtures/kotlin/dev/tramai/controlplane/testing/WorkloadRegistrationStoreTckHarness.kt @@ -0,0 +1,16 @@ +package dev.tramai.controlplane.testing + +import dev.tramai.controlplane.WorkloadRegistrationStore + +/** + * Storage-technology hook for the [WorkloadRegistrationStore] contract. + * + * The runner owns everything implementation-specific: datasources, schema, + * store construction, cleanup. Every call to [createStore] must return a + * FRESH, isolated store. + */ +interface WorkloadRegistrationStoreTckHarness { + fun createStore(): WorkloadRegistrationStore + + suspend fun closeStore(store: WorkloadRegistrationStore) = Unit +} diff --git a/tramai-core/api/tramai-core.api b/tramai-core/api/tramai-core.api index 5e4c388bc..7f2da4677 100644 --- a/tramai-core/api/tramai-core.api +++ b/tramai-core/api/tramai-core.api @@ -1028,6 +1028,128 @@ public final class dev/tramai/core/exception/TramaiExceptionKt { public static final fun safeStructuredOutputFailure (Ldev/tramai/core/structured/StructuredOutputFailureCode;I)Ldev/tramai/core/exception/StructuredOutputException; } +public final class dev/tramai/core/identity/ConfigurationId { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/ConfigurationId; + public static synthetic fun copy$default (Ldev/tramai/core/identity/ConfigurationId;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/ConfigurationId; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/ConfigurationVersion { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/ConfigurationVersion; + public static synthetic fun copy$default (Ldev/tramai/core/identity/ConfigurationVersion;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/ConfigurationVersion; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/DeploymentId { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/DeploymentId; + public static synthetic fun copy$default (Ldev/tramai/core/identity/DeploymentId;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/DeploymentId; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/EnvironmentId { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/EnvironmentId; + public static synthetic fun copy$default (Ldev/tramai/core/identity/EnvironmentId;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/EnvironmentId; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/GovernedRunIdentity { + public fun (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/RunId;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun component2 ()Ldev/tramai/core/identity/RunId; + public final fun copy (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/RunId;)Ldev/tramai/core/identity/GovernedRunIdentity; + public static synthetic fun copy$default (Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/RunId;ILjava/lang/Object;)Ldev/tramai/core/identity/GovernedRunIdentity; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeployment ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun getRunId ()Ldev/tramai/core/identity/RunId; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/RunId { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/RunId; + public static synthetic fun copy$default (Ldev/tramai/core/identity/RunId;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/RunId; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/WorkloadConfigurationIdentity { + public fun (Ldev/tramai/core/identity/ConfigurationId;Ldev/tramai/core/identity/ConfigurationVersion;)V + public final fun component1 ()Ldev/tramai/core/identity/ConfigurationId; + public final fun component2 ()Ldev/tramai/core/identity/ConfigurationVersion; + public final fun copy (Ldev/tramai/core/identity/ConfigurationId;Ldev/tramai/core/identity/ConfigurationVersion;)Ldev/tramai/core/identity/WorkloadConfigurationIdentity; + public static synthetic fun copy$default (Ldev/tramai/core/identity/WorkloadConfigurationIdentity;Ldev/tramai/core/identity/ConfigurationId;Ldev/tramai/core/identity/ConfigurationVersion;ILjava/lang/Object;)Ldev/tramai/core/identity/WorkloadConfigurationIdentity; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ldev/tramai/core/identity/ConfigurationId; + public final fun getVersion ()Ldev/tramai/core/identity/ConfigurationVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/WorkloadDeploymentIdentity { + public fun (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/WorkloadConfigurationIdentity;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadId; + public final fun component2 ()Ldev/tramai/core/identity/WorkloadConfigurationIdentity; + public final fun component3 ()Ldev/tramai/core/identity/EnvironmentId; + public final fun component4 ()Ldev/tramai/core/identity/DeploymentId; + public final fun copy (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/WorkloadConfigurationIdentity;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;)Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public static synthetic fun copy$default (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/WorkloadConfigurationIdentity;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;ILjava/lang/Object;)Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfiguration ()Ldev/tramai/core/identity/WorkloadConfigurationIdentity; + public final fun getDeploymentId ()Ldev/tramai/core/identity/DeploymentId; + public final fun getEnvironmentId ()Ldev/tramai/core/identity/EnvironmentId; + public final fun getWorkloadId ()Ldev/tramai/core/identity/WorkloadId; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/WorkloadId { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Ldev/tramai/core/identity/WorkloadId; + public static synthetic fun copy$default (Ldev/tramai/core/identity/WorkloadId;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/WorkloadId; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/core/identity/WorkloadMetadata { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/core/identity/WorkloadMetadata; + public static synthetic fun copy$default (Ldev/tramai/core/identity/WorkloadMetadata;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/identity/WorkloadMetadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getOwner ()Ljava/lang/String; + public final fun getPurpose ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class dev/tramai/core/memory/ChatMemory { public abstract fun add (Ljava/lang/String;Ldev/tramai/core/model/Message;)V public abstract fun add (Ljava/lang/String;Ljava/util/List;)V diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationId.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationId.kt new file mode 100644 index 000000000..f520d8f9f --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationId.kt @@ -0,0 +1,19 @@ +package dev.tramai.core.identity + +/** + * Stable identity of a governed configuration family. + * + * Paired with [ConfigurationVersion] it identifies the exact governed + * configuration of a workload. The id is opaque: it may encode product/domain + * meaning (e.g. `customer-support-agent`, `fraud-review-policy`) but must not + * be parsed by consumers. + */ +data class ConfigurationId( + val value: String, +) { + init { + validateIdentity("ConfigurationId", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationVersion.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationVersion.kt new file mode 100644 index 000000000..88d21b0c9 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/ConfigurationVersion.kt @@ -0,0 +1,19 @@ +package dev.tramai.core.identity + +/** + * Version of one immutable governed-configuration revision. + * + * The version is opaque and must not be assumed to be SemVer. Legitimate + * values include datestamped revisions (`2026-09-09.3`), content digests + * (`sha256:abcd...`) and monotonic counters. Together with its + * [ConfigurationId] it names one exact governed configuration. + */ +data class ConfigurationVersion( + val value: String, +) { + init { + validateIdentity("ConfigurationVersion", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/DeploymentId.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/DeploymentId.kt new file mode 100644 index 000000000..0775b015f --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/DeploymentId.kt @@ -0,0 +1,21 @@ +package dev.tramai.core.identity + +/** + * Identity of one independently distinguishable deployment of a + * workload/configuration in an environment. + * + * Distinct deployments must never collapse into one identity, even when they + * share the same workload, configuration and environment. Example: two + * clusters running the same production configuration + * (`eu-west-amsterdam-01` vs `eu-central-frankfurt-01`) are different + * [WorkloadDeploymentIdentity] values. + */ +data class DeploymentId( + val value: String, +) { + init { + validateIdentity("DeploymentId", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/EnvironmentId.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/EnvironmentId.kt new file mode 100644 index 000000000..e7b8cce74 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/EnvironmentId.kt @@ -0,0 +1,19 @@ +package dev.tramai.core.identity + +/** + * Logical governance environment of a deployment. + * + * Examples: `dev`, `staging`, `production`, or a tenant-specific environment. + * An environment is NOT a deployment: two independent deployments of the same + * workload/configuration can both legitimately live in `production` and must + * remain distinguishable through [DeploymentId]. + */ +data class EnvironmentId( + val value: String, +) { + init { + validateIdentity("EnvironmentId", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunIdentity.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunIdentity.kt new file mode 100644 index 000000000..5ef2ed4f7 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunIdentity.kt @@ -0,0 +1,21 @@ +package dev.tramai.core.identity + +/** + * Identity of one authoritative execution of a governed workload deployment: + * + * ```text + * WorkloadDeploymentIdentity + RunId = GovernedRunIdentity + * ``` + * + * This is the canonical structure later candidates (approval, policy evidence, + * routing evidence, checkpoints, scheduler ticks, engine invocation, + * control-plane projection, reconstruction) reference instead of inventing + * their own identity tuple. It only represents identity: propagation through + * orchestration/engine/approval/evidence belongs to the run attribution + * candidate (Epic 0.7.1 candidate 0.7.1d), and no identifier is generated + * here. + */ +data class GovernedRunIdentity( + val deployment: WorkloadDeploymentIdentity, + val runId: RunId, +) diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/IdentityValidation.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/IdentityValidation.kt new file mode 100644 index 000000000..ed54e512b --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/IdentityValidation.kt @@ -0,0 +1,44 @@ +package dev.tramai.core.identity + +/** + * Maximum length of an identity value. Identity strings are opaque identifiers, + * not human-readable descriptions: bounded length protects persistence and + * projection surfaces without imposing a restrictive character grammar. + */ +internal const val ID_MAX_LENGTH: Int = 128 + +/** + * Maximum length of an [owner][WorkloadMetadata.owner] value. + */ +internal const val OWNER_MAX_LENGTH: Int = 256 + +/** + * Maximum length of a [purpose][WorkloadMetadata.purpose] value. + */ +internal const val PURPOSE_MAX_LENGTH: Int = 512 + +/** + * Fail-closed validation shared by every typed identity value. + * + * Rules are intentionally conservative: + * - non-blank; + * - no leading or trailing whitespace (no silent normalization on the way in); + * - bounded length; + * - no ISO control characters. + * + * Case is preserved and no character grammar is imposed: identifiers such as + * `payments`, `Payments`, `org.example.claims`, `urn:company:workload:claims`, + * `production-eu` and `deployment_2026_09` are all legal distinct values. + * Silent canonicalization (e.g. trimming, lowercasing, regex shaping) is + * deliberately absent: identity systems become dangerous when different inputs + * silently collapse to the same key. + */ +internal fun validateIdentity( + kind: String, + value: String, +) { + require(value.isNotBlank()) { "$kind must not be blank" } + require(value == value.trim()) { "$kind must not contain leading or trailing whitespace" } + require(value.length <= ID_MAX_LENGTH) { "$kind must not exceed $ID_MAX_LENGTH characters" } + require(value.none(Char::isISOControl)) { "$kind must not contain control characters" } +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/RunId.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/RunId.kt new file mode 100644 index 000000000..d9d90de01 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/RunId.kt @@ -0,0 +1,27 @@ +package dev.tramai.core.identity + +/** + * One immutable identifier for one authoritative execution of a governed + * workload deployment. + * + * Run semantics: + * - resume of the same authoritative run keeps the same [RunId]; + * - retry/restart within the same authoritative run keeps the same [RunId]; + * - approval-resume keeps the same [RunId]; + * - a genuinely new execution receives a new [RunId]. + * + * This type only REPRESENTS run identity; it never generates identifiers. + * Creation of the id at the supported execution boundary belongs to the run + * attribution candidate (Epic 0.7.1 candidate 0.7.1d). It is distinct from a + * diagnostic/cross-operation correlation id, which TramAI already models in + * `EngineExecutionIdentity.correlationId`. + */ +data class RunId( + val value: String, +) { + init { + validateIdentity("RunId", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadConfigurationIdentity.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadConfigurationIdentity.kt new file mode 100644 index 000000000..f0891a4c0 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadConfigurationIdentity.kt @@ -0,0 +1,21 @@ +package dev.tramai.core.identity + +/** + * Identity of one exact governed configuration: a stable configuration family + * ([ConfigurationId]) pinned to one immutable revision ([ConfigurationVersion]). + * + * Invariant: within an authority domain, the same `(configurationId, version)` + * pair must never refer to two different governed configurations. This type + * declares the invariant; collision/rebinding enforcement belongs to the + * registration/state authority (Epic 0.7.1 candidate 0.7.1c) because it + * requires an authoritative store. + * + * Note: a governed configuration is broader than a workflow definition. It may + * later include workflow definition, policy, classification profile, provider + * deployment configuration, trust-zone assignments and tool governance. Do not + * equate [ConfigurationVersion] with the workflow `definitionVersion`. + */ +data class WorkloadConfigurationIdentity( + val id: ConfigurationId, + val version: ConfigurationVersion, +) diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadDeploymentIdentity.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadDeploymentIdentity.kt new file mode 100644 index 000000000..d4e216804 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadDeploymentIdentity.kt @@ -0,0 +1,20 @@ +package dev.tramai.core.identity + +/** + * Identity of one governed workload/configuration as deployed in one place: + * which workload, which exact configuration, and where it runs. + * + * Distinct deployments must never collapse into one identity. Two deployments + * that share workload, configuration and environment but differ in + * [deploymentId] (e.g. `eu-west-amsterdam-01` and `eu-central-frankfurt-01`) + * are different identities. Owner/purpose metadata is deliberately NOT part of + * this type: ownership may change without changing workload identity. The + * authoritative association of metadata to identity belongs to the + * registration/state authority (Epic 0.7.1 candidate 0.7.1c). + */ +data class WorkloadDeploymentIdentity( + val workloadId: WorkloadId, + val configuration: WorkloadConfigurationIdentity, + val environmentId: EnvironmentId, + val deploymentId: DeploymentId, +) diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadId.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadId.kt new file mode 100644 index 000000000..95ed0cdbc --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadId.kt @@ -0,0 +1,27 @@ +package dev.tramai.core.identity + +/** + * Stable identity of one independently governed workload. + * + * A workload is the governed application/business capability that owns + * workflow executions (e.g. `payments-fraud-review`, `com.acme.claims-assistant`). + * The identifier is stable and case-preserving; it is not required to be a + * random UUID. Generation policy belongs to the registration authority + * (Epic 0.7.1 candidate 0.7.1c), not to this type. + * + * Plain JVM class, not an inline class: the identity vocabulary is part of the + * canonical public tramai-core contract, so every type in this package is + * constructible and readable from Java (`new WorkloadId("claims")`, + * `getValue()`). Do not convert these boundary types to `@JvmInline` value + * classes — that would make the canonical contract Kotlin-only (or dependent + * on experimental boxed exposure). + */ +data class WorkloadId( + val value: String, +) { + init { + validateIdentity("WorkloadId", value) + } + + override fun toString(): String = value +} diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadMetadata.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadMetadata.kt new file mode 100644 index 000000000..db640428a --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/WorkloadMetadata.kt @@ -0,0 +1,32 @@ +package dev.tramai.core.identity + +/** + * Bounded, safe operational metadata about a governed workload. + * + * [owner] and [purpose] describe responsibility and intent. They are NOT part + * of identity equality: ownership may change (e.g. from one team to another) + * without creating a different workload identity. The authoritative association + * between a [WorkloadDeploymentIdentity] and its metadata belongs to the + * registration/state authority (Epic 0.7.1 candidate 0.7.1c). + * + * This type is safe operational metadata only. It is NOT a place for prompts, + * credentials, PII, tool arguments, user content or secrets. A free-form + * `Map` is deliberately not provided: it would recreate the + * untyped attribute problem identified by the 0.7.1a audit. + */ +data class WorkloadMetadata( + val owner: String, + val purpose: String, +) { + init { + require(owner.isNotBlank()) { "owner must not be blank" } + require(owner == owner.trim()) { "owner must not contain leading or trailing whitespace" } + require(owner.length <= OWNER_MAX_LENGTH) { "owner must not exceed $OWNER_MAX_LENGTH characters" } + require(owner.none(Char::isISOControl)) { "owner must not contain control characters" } + + require(purpose.isNotBlank()) { "purpose must not be blank" } + require(purpose == purpose.trim()) { "purpose must not contain leading or trailing whitespace" } + require(purpose.length <= PURPOSE_MAX_LENGTH) { "purpose must not exceed $PURPOSE_MAX_LENGTH characters" } + require(purpose.none(Char::isISOControl)) { "purpose must not contain control characters" } + } +} diff --git a/tramai-core/src/test/java/dev/tramai/core/identity/JavaIdentityInteropTest.java b/tramai-core/src/test/java/dev/tramai/core/identity/JavaIdentityInteropTest.java new file mode 100644 index 000000000..966ba92e4 --- /dev/null +++ b/tramai-core/src/test/java/dev/tramai/core/identity/JavaIdentityInteropTest.java @@ -0,0 +1,61 @@ +package dev.tramai.core.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Java-visible proof for the 0.7.1b identity contract. + * + * The identity vocabulary is plain JVM classes by design: this test compiles + * and runs real Java source against the canonical public tramai-core identity + * surface. It fails if the types are ever converted back to @JvmInline value + * classes (which are not naturally constructible from Java) or if the + * composition/equality semantics regress. + */ +class JavaIdentityInteropTest { + + private WorkloadDeploymentIdentity deployment( + String workload, String version, String environment, String deployment) { + return new WorkloadDeploymentIdentity( + new WorkloadId(workload), + new WorkloadConfigurationIdentity( + new ConfigurationId(workload + "-prod"), + new ConfigurationVersion(version)), + new EnvironmentId(environment), + new DeploymentId(deployment)); + } + + @Test + void constructsIdentityVocabularyFromJava() { + WorkloadDeploymentIdentity amsterdam = + deployment("claims", "17", "production", "eu-west-amsterdam-01"); + GovernedRunIdentity run = new GovernedRunIdentity(amsterdam, new RunId("run-1")); + + assertEquals("claims", amsterdam.getWorkloadId().getValue()); + assertEquals("17", amsterdam.getConfiguration().getVersion().getValue()); + assertEquals("production", amsterdam.getEnvironmentId().getValue()); + assertEquals("eu-west-amsterdam-01", amsterdam.getDeploymentId().getValue()); + assertEquals("run-1", run.getRunId().getValue()); + assertEquals(amsterdam, run.getDeployment()); + } + + @Test + void sameEnvironmentDifferentDeploymentDoesNotCollapseFromJava() { + WorkloadDeploymentIdentity amsterdam = + deployment("claims", "17", "production", "eu-west-amsterdam-01"); + WorkloadDeploymentIdentity frankfurt = + deployment("claims", "17", "production", "eu-central-frankfurt-01"); + + assertNotEquals(amsterdam, frankfurt); + } + + @Test + void validationFailsClosedFromJava() { + assertThrows(IllegalArgumentException.class, () -> new WorkloadId(" ")); + assertThrows(IllegalArgumentException.class, () -> new RunId("run\nid")); + assertThrows(IllegalArgumentException.class, () -> new WorkloadMetadata(" ", "purpose")); + } +} diff --git a/tramai-core/src/test/kotlin/dev/tramai/core/identity/IdentityIdValidationTest.kt b/tramai-core/src/test/kotlin/dev/tramai/core/identity/IdentityIdValidationTest.kt new file mode 100644 index 000000000..f0641f5c3 --- /dev/null +++ b/tramai-core/src/test/kotlin/dev/tramai/core/identity/IdentityIdValidationTest.kt @@ -0,0 +1,112 @@ +package dev.tramai.core.identity + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import kotlin.test.Test + +/** + * Atomic id validation. Every typed id must fail closed on the same malformed + * inputs and accept opaque, case-preserving identifiers without grammar + * restrictions or silent normalization. + */ +class IdentityIdValidationTest { + private val overLimit = "a".repeat(129) + + private fun assertRejected( + construct: (String) -> Any, + vararg invalid: String, + ) { + for (raw in invalid) { + assertThatThrownBy { construct(raw) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must not") + } + } + + @Test + fun `blank id is rejected`() { + assertRejected({ WorkloadId(it) }, "", " ", "\t") + } + + @Test + fun `leading or trailing whitespace is rejected`() { + assertRejected({ WorkloadId(it) }, " workload", "workload ", " workload ") + } + + @Test + fun `control characters are rejected`() { + assertRejected({ WorkloadId(it) }, "work\nload", "work\u0000load", "work\rload") + } + + @Test + fun `over-limit id is rejected`() { + assertThatThrownBy { WorkloadId(overLimit) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("128") + } + + @Test + fun `every atomic id type validates its own value`() { + val constructs = + listOf<(String) -> Any>( + { WorkloadId(it) }, + { ConfigurationId(it) }, + { ConfigurationVersion(it) }, + { EnvironmentId(it) }, + { DeploymentId(it) }, + { RunId(it) }, + ) + for (construct in constructs) { + assertThatThrownBy { construct(" ") } + .isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { construct(overLimit) } + .isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { construct("bad\u0000id") } + .isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `opaque identifiers without restricted grammar are accepted`() { + val accepted = + listOf( + "claims", + "claims-v2", + "org.example.claims", + "production/eu", + "deployment_01", + "urn:company:workload:claims", + "customer-support/v3", + ) + for (raw in accepted) { + assertThat(WorkloadId(raw).value).isEqualTo(raw) + assertThat(RunId(raw).value).isEqualTo(raw) + } + } + + @Test + fun `boundary length id is accepted and over-limit rejected`() { + val atLimit = "a".repeat(128) + assertThat(WorkloadId(atLimit).value).isEqualTo(atLimit) + assertRejected({ WorkloadId(it) }, overLimit) + } + + @Test + fun `case is preserved and not normalized`() { + val upper = WorkloadId("Payments") + val lower = WorkloadId("payments") + + assertThat(upper.value).isEqualTo("Payments") + assertThat(lower.value).isEqualTo("payments") + assertThat(upper).isNotEqualTo(lower) + } + + @Test + fun `equal values produce equal ids and same hash`() { + val a = WorkloadId("claims") + val b = WorkloadId("claims") + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } +} diff --git a/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadIdentityCompositionTest.kt b/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadIdentityCompositionTest.kt new file mode 100644 index 000000000..38023c7d8 --- /dev/null +++ b/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadIdentityCompositionTest.kt @@ -0,0 +1,99 @@ +package dev.tramai.core.identity + +import org.assertj.core.api.Assertions.assertThat +import kotlin.test.Test + +/** + * Composition and discrimination semantics of the identity vocabulary. + * + * The critical adversarial proof: two deployments of the same workload and + * configuration in the SAME environment must not collapse into one identity. + */ +class WorkloadIdentityCompositionTest { + private fun deployment( + workload: String = "claims", + configId: String = "claims-prod", + configVersion: String = "17", + environment: String = "production", + deployment: String, + ): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workload), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configId), + version = ConfigurationVersion(configVersion), + ), + environmentId = EnvironmentId(environment), + deploymentId = DeploymentId(deployment), + ) + + @Test + fun `deployments in the same environment do not collapse`() { + val amsterdam = deployment(deployment = "eu-west-amsterdam-01") + val frankfurt = deployment(deployment = "eu-central-frankfurt-01") + + assertThat(amsterdam).isNotEqualTo(frankfurt) + } + + @Test + fun `deployments in different environments do not collapse`() { + val production = deployment(environment = "production", deployment = "ams-01") + val staging = deployment(environment = "staging", deployment = "ams-01") + + assertThat(production).isNotEqualTo(staging) + } + + @Test + fun `different workloads do not collapse`() { + val claims = deployment(workload = "claims", deployment = "ams-01") + val fraud = deployment(workload = "fraud-review", deployment = "ams-01") + + assertThat(claims).isNotEqualTo(fraud) + } + + @Test + fun `configuration version discriminates`() { + val v17 = deployment(configVersion = "17", deployment = "ams-01") + val v18 = deployment(configVersion = "18", deployment = "ams-01") + + assertThat(v17).isNotEqualTo(v18) + } + + @Test + fun `configuration id discriminates`() { + val prod = deployment(configId = "claims-prod", deployment = "ams-01") + val canary = deployment(configId = "claims-canary", deployment = "ams-01") + + assertThat(prod).isNotEqualTo(canary) + } + + @Test + fun `identical fields produce one identity`() { + val a = deployment(deployment = "ams-01") + val b = deployment(deployment = "ams-01") + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `run identity discriminates runs within one deployment`() { + val deployment = deployment(deployment = "ams-01") + val runA = GovernedRunIdentity(deployment = deployment, runId = RunId("run-a")) + val runB = GovernedRunIdentity(deployment = deployment, runId = RunId("run-b")) + val sameAsA = GovernedRunIdentity(deployment = deployment, runId = RunId("run-a")) + + assertThat(runA).isNotEqualTo(runB) + assertThat(runA).isEqualTo(sameAsA) + } + + @Test + fun `run identity carries the deployment identity unchanged`() { + val deployment = deployment(deployment = "ams-01") + val run = GovernedRunIdentity(deployment = deployment, runId = RunId("run-a")) + + assertThat(run.deployment).isEqualTo(deployment) + assertThat(run.runId.value).isEqualTo("run-a") + } +} diff --git a/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadMetadataTest.kt b/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadMetadataTest.kt new file mode 100644 index 000000000..9725a8452 --- /dev/null +++ b/tramai-core/src/test/kotlin/dev/tramai/core/identity/WorkloadMetadataTest.kt @@ -0,0 +1,86 @@ +package dev.tramai.core.identity + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import kotlin.test.Test + +/** + * Metadata validation plus the structural proof that owner/purpose metadata is + * separate from identity equality: identity composition types must not embed a + * [WorkloadMetadata] field, so ownership changes can never alter identity. + */ +class WorkloadMetadataTest { + @Test + fun `valid metadata constructs`() { + val metadata = WorkloadMetadata(owner = "Payments Team", purpose = "Fraud review") + + assertThat(metadata.owner).isEqualTo("Payments Team") + assertThat(metadata.purpose).isEqualTo("Fraud review") + } + + @Test + fun `blank owner or purpose is rejected`() { + assertThatThrownBy { WorkloadMetadata(owner = " ", purpose = "Fraud review") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("owner") + assertThatThrownBy { WorkloadMetadata(owner = "Payments Team", purpose = " ") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("purpose") + } + + @Test + fun `control characters are rejected in metadata`() { + assertThatThrownBy { WorkloadMetadata(owner = "Payments\u0000Team", purpose = "Fraud review") } + .isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { WorkloadMetadata(owner = "Payments Team", purpose = "Fraud\nreview") } + .isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `over-limit owner or purpose is rejected`() { + assertThatThrownBy { WorkloadMetadata(owner = "a".repeat(257), purpose = "Fraud review") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("256") + assertThatThrownBy { WorkloadMetadata(owner = "Payments Team", purpose = "a".repeat(513)) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("512") + } + + @Test + fun `identity composition types do not embed metadata`() { + // Plain JVM classes expose their real parameter types — no value-class + // erasure. The guard asserts the exact constructor shape and the + // absence of WorkloadMetadata: ownership can never alter identity. + val deploymentParams = + WorkloadDeploymentIdentity::class.java.constructors + .single() + .parameterTypes + .toList() + assertThat(deploymentParams).containsExactly( + WorkloadId::class.java, + WorkloadConfigurationIdentity::class.java, + EnvironmentId::class.java, + DeploymentId::class.java, + ) + + val runParams = + GovernedRunIdentity::class.java.constructors + .single() + .parameterTypes + .toList() + assertThat(runParams).containsExactly( + WorkloadDeploymentIdentity::class.java, + RunId::class.java, + ) + + val configurationParams = + WorkloadConfigurationIdentity::class.java.constructors + .single() + .parameterTypes + .toList() + assertThat(configurationParams).containsExactly( + ConfigurationId::class.java, + ConfigurationVersion::class.java, + ) + } +} diff --git a/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api b/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api index 8a6d9cf62..e651227ab 100644 --- a/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api +++ b/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api @@ -120,3 +120,10 @@ public final class dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore : de public final class dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore$Companion { } +public final class dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore : dev/tramai/controlplane/WorkloadRegistrationStore { + public fun (Ljavax/sql/DataSource;)V + public fun compareAndSet (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun create (Ldev/tramai/controlplane/RegisteredWorkload;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun find (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/tramai-persistence-jdbc/build.gradle.kts b/tramai-persistence-jdbc/build.gradle.kts index bf667e5ea..abaa00b36 100644 --- a/tramai-persistence-jdbc/build.gradle.kts +++ b/tramai-persistence-jdbc/build.gradle.kts @@ -11,11 +11,13 @@ dependencies { api(project(":tramai-core")) api(project(":tramai-engine")) api(project(":tramai-security")) + api(project(":tramai-control-plane")) implementation(libs.jackson.databind) implementation(libs.jackson.module.kotlin) implementation(libs.jackson.datatype.jsr310) testImplementation(testFixtures(project(":tramai-testing"))) + testImplementation(testFixtures(project(":tramai-control-plane"))) testImplementation(platform(libs.testcontainers.bom)) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation(libs.testcontainers.postgresql) diff --git a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore.kt b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore.kt new file mode 100644 index 000000000..f3cc1ca2c --- /dev/null +++ b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStore.kt @@ -0,0 +1,360 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.controlplane.ConfigurationFingerprint +import dev.tramai.controlplane.CreateResult +import dev.tramai.controlplane.RegisteredWorkload +import dev.tramai.controlplane.RegistrationConflictReason +import dev.tramai.controlplane.WorkloadLifecycleState +import dev.tramai.controlplane.WorkloadRegistrationStore +import dev.tramai.controlplane.WorkloadStateVersion +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata +import java.sql.Connection +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException +import javax.sql.DataSource + +/** + * JDBC-backed [WorkloadRegistrationStore] (Epic 0.7.1c) over the + * `tramai_workload_registration` and `tramai_configuration_revision` tables + * (migration V8). + * + * ## Atomicity + * [create] runs inside an explicit transaction: the configuration-revision + * binding is established/verified and the registration inserted in one commit. + * A Postgres unique violation aborts the transaction, so classification of an + * existing registration happens on a fresh connection after rollback — never + * inside the aborted transaction. + * + * ## Database-level authority + * The primary keys themselves enforce one registration per deployment scope + * and one immutable fingerprint per `(configuration_id, configuration_version)` + * — two JDBC store instances pointing at the same database cannot race around + * the contract. + * + * The store never implements authority rules; it only stores what + * [dev.tramai.controlplane.WorkloadRegistrationAuthority] decides. + * + * TooManyFunctions: the class carries the persistence-SPI surface plus small + * per-query resource/row helpers; splitting them into a separate helper class + * would spread one storage concern across files. + */ +@Suppress("TooManyFunctions") +class JdbcWorkloadRegistrationStore( + private val dataSource: DataSource, +) : WorkloadRegistrationStore { + override suspend fun find( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): RegisteredWorkload? = + withSafeJdbc({ "Database operation failed while reading a workload registration" }) { + readScope(workloadId, environmentId, deploymentId) + } + + private fun readScope( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): RegisteredWorkload? = + queryRegistration(SELECT_REGISTRATION_WHERE_SCOPE) { statement -> + var index = 1 + statement.setString(index++, workloadId.value) + statement.setString(index++, environmentId.value) + statement.setString(index, deploymentId.value) + } + + override suspend fun create(registration: RegisteredWorkload): CreateResult = + withSafeJdbc( + { + "Database operation failed while registering workload " + + "${registration.identity.workloadId.value}/" + + "${registration.identity.environmentId.value}/${registration.identity.deploymentId.value}" + }, + ) { + val connection = dataSource.connection + try { + connection.autoCommit = false + ensureConfigurationBinding(connection, registration)?.let { return@withSafeJdbc it } + insertRegistration(connection, registration) + connection.commit() + CreateResult.Created(registration) + } catch (e: SQLException) { + connection.rollbackQuietly() + if (isUniqueViolation(e)) { + // Scope already registered — classify on a fresh connection. + classifyExisting(registration) + } else { + throw e + } + } finally { + connection.closeQuietly() + } + } + + /** + * Establishes the global configuration binding (insert-if-absent) and + * verifies it cannot be rebound. Returns a [CreateResult.Conflicting] + * when the pair is already bound to a different fingerprint; null when the + * binding matches and registration may proceed. + */ + private fun ensureConfigurationBinding( + conn: Connection, + registration: RegisteredWorkload, + ): CreateResult? { + val configurationId = registration.identity.configuration.id.value + val configurationVersion = registration.identity.configuration.version.value + val requestedFingerprint = registration.configurationFingerprint.value + + conn.prepareStatement(INSERT_CONFIGURATION_REVISION).use { statement -> + var index = 1 + statement.setString(index++, configurationId) + statement.setString(index++, configurationVersion) + statement.setString(index, requestedFingerprint) + statement.executeUpdate() + } + + val boundFingerprint = + conn.prepareStatement(SELECT_CONFIGURATION_FINGERPRINT).use { statement -> + var index = 1 + statement.setString(index++, configurationId) + statement.setString(index, configurationVersion) + statement.executeQuery().use { rs -> + if (rs.next()) rs.getString(1) else requestedFingerprint + } + } + if (boundFingerprint != requestedFingerprint) { + conn.rollbackQuietly() + return CreateResult.Conflicting( + existing = boundRegistrationFor(configurationId, configurationVersion), + reason = RegistrationConflictReason.CONFIGURATION_REBINDING, + ) + } + return null + } + + private fun insertRegistration( + conn: Connection, + registration: RegisteredWorkload, + ) { + conn.prepareStatement(INSERT_WORKLOAD_REGISTRATION).use { statement -> + var index = 1 + statement.setString(index++, registration.identity.workloadId.value) + statement.setString(index++, registration.identity.environmentId.value) + statement.setString(index++, registration.identity.deploymentId.value) + statement.setString(index++, registration.identity.configuration.id.value) + statement.setString(index++, registration.identity.configuration.version.value) + statement.setString(index++, registration.metadata.owner) + statement.setString(index++, registration.metadata.purpose) + statement.setString(index++, registration.lifecycle.name) + statement.setLong(index, registration.stateVersion.value) + statement.executeUpdate() + } + } + + /** Runs on a fresh connection (the failed transaction is aborted). */ + private fun classifyExisting(registration: RegisteredWorkload): CreateResult { + val scope = registration.identity + val existing = + readScope(scope.workloadId, scope.environmentId, scope.deploymentId) + ?: error("Unique violation reported but no registration exists for the scope") + return if (existing.isSameDeclaration(registration)) { + CreateResult.Idempotent(existing) + } else { + CreateResult.Conflicting(existing, RegistrationConflictReason.CONFLICTING_REGISTRATION) + } + } + + private fun boundRegistrationFor( + configurationId: String, + configurationVersion: String, + ): RegisteredWorkload { + val registration = + queryRegistration(SELECT_REGISTRATION_WHERE_CONFIGURATION) { statement -> + var index = 1 + statement.setString(index++, configurationId) + statement.setString(index, configurationVersion) + } + return checkNotNull(registration) { + "Configuration binding exists without an owning registration" + } + } + + override suspend fun compareAndSet( + expected: RegisteredWorkload, + updated: RegisteredWorkload, + ): Boolean { + require(updated.identity == expected.identity) { + "compareAndSet must not change registration identity" + } + require(updated.configurationFingerprint == expected.configurationFingerprint) { + "compareAndSet must not change configuration fingerprint" + } + return withSafeJdbc( + { "Database operation failed while updating workload registration " + expected.identity.scopeKey() }, + ) { + dataSource.connection.use { conn -> + conn.prepareStatement(UPDATE_WORKLOAD_REGISTRATION).use { statement -> + var index = 1 + statement.setString(index++, updated.metadata.owner) + statement.setString(index++, updated.metadata.purpose) + statement.setString(index++, updated.lifecycle.name) + statement.setLong(index++, updated.stateVersion.value) + statement.setString(index++, expected.identity.workloadId.value) + statement.setString(index++, expected.identity.environmentId.value) + statement.setString(index++, expected.identity.deploymentId.value) + statement.setString(index++, expected.identity.configuration.id.value) + statement.setString(index++, expected.identity.configuration.version.value) + statement.setLong(index++, expected.stateVersion.value) + statement.setString(index, expected.configurationFingerprint.value) + statement.executeUpdate() == 1 + } + } + } + } + + /** + * Executes a registration SELECT with the given parameter binder and maps + * the first row, or null when no row matches. Resource handling is + * sequential try/finally so nesting stays flat. + */ + private fun queryRegistration( + selectSql: String, + bind: (PreparedStatement) -> Unit, + ): RegisteredWorkload? { + val connection = dataSource.connection + connection.use { conn -> + return queryOnConnection(conn, selectSql, bind) + } + } + + private fun queryOnConnection( + conn: Connection, + selectSql: String, + bind: (PreparedStatement) -> Unit, + ): RegisteredWorkload? = + conn.prepareStatement(selectSql).use { statement -> + bind(statement) + statement.executeQuery().use { rs -> + if (rs.next()) rs.toRegisteredWorkload() else null + } + } + + private fun WorkloadDeploymentIdentity.scopeKey(): String { + val workload = workloadId.value + val environment = environmentId.value + val deployment = deploymentId.value + return "$workload/$environment/$deployment" + } + + private fun ResultSet.toRegisteredWorkload(): RegisteredWorkload = + RegisteredWorkload( + identity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(getString("workload_id")), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(getString("configuration_id")), + version = ConfigurationVersion(getString("configuration_version")), + ), + environmentId = EnvironmentId(getString("environment_id")), + deploymentId = DeploymentId(getString("deployment_id")), + ), + configurationFingerprint = ConfigurationFingerprint(getString("fingerprint")), + metadata = + WorkloadMetadata( + owner = getString("owner"), + purpose = getString("purpose"), + ), + lifecycle = WorkloadLifecycleState.valueOf(getString("lifecycle_state")), + stateVersion = WorkloadStateVersion(getLong("state_version")), + ) + + private fun Connection.rollbackQuietly() { + runCatching { rollback() } + } + + private fun Connection.closeQuietly() { + runCatching { close() } + } + + private fun isUniqueViolation(e: SQLException): Boolean = e.sqlState == "23505" + + private fun RegisteredWorkload.isSameDeclaration(other: RegisteredWorkload): Boolean = + identity == other.identity && + configurationFingerprint == other.configurationFingerprint && + metadata == other.metadata +} + +private const val INSERT_CONFIGURATION_REVISION = + """ + INSERT INTO tramai_configuration_revision (configuration_id, configuration_version, fingerprint) + VALUES (?, ?, ?) + ON CONFLICT (configuration_id, configuration_version) DO NOTHING + """ + +private const val SELECT_CONFIGURATION_FINGERPRINT = + """ + SELECT fingerprint FROM tramai_configuration_revision + WHERE configuration_id = ? AND configuration_version = ? + """ + +private const val INSERT_WORKLOAD_REGISTRATION = + """ + INSERT INTO tramai_workload_registration ( + workload_id, environment_id, deployment_id, + configuration_id, configuration_version, + owner, purpose, lifecycle_state, state_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + +private const val UPDATE_WORKLOAD_REGISTRATION = + """ + UPDATE tramai_workload_registration + SET owner = ?, purpose = ?, lifecycle_state = ?, state_version = ? + WHERE workload_id = ? AND environment_id = ? AND deployment_id = ? + AND configuration_id = ? AND configuration_version = ? + AND state_version = ? + AND EXISTS ( + SELECT 1 + FROM tramai_configuration_revision cr + WHERE cr.configuration_id = tramai_workload_registration.configuration_id + AND cr.configuration_version = tramai_workload_registration.configuration_version + AND cr.fingerprint = ? + ) + """ + +private const val SELECT_REGISTRATION_WHERE_SCOPE = + """ + SELECT r.workload_id, r.environment_id, r.deployment_id, + r.configuration_id, r.configuration_version, + cr.fingerprint, + r.owner, r.purpose, r.lifecycle_state, r.state_version + FROM tramai_workload_registration r + JOIN tramai_configuration_revision cr + ON cr.configuration_id = r.configuration_id + AND cr.configuration_version = r.configuration_version + WHERE r.workload_id = ? AND r.environment_id = ? AND r.deployment_id = ? + """ + +private const val SELECT_REGISTRATION_WHERE_CONFIGURATION = + """ + SELECT r.workload_id, r.environment_id, r.deployment_id, + r.configuration_id, r.configuration_version, + cr.fingerprint, + r.owner, r.purpose, r.lifecycle_state, r.state_version + FROM tramai_workload_registration r + JOIN tramai_configuration_revision cr + ON cr.configuration_id = r.configuration_id + AND cr.configuration_version = r.configuration_version + WHERE r.configuration_id = ? AND r.configuration_version = ? + ORDER BY r.workload_id, r.environment_id, r.deployment_id + LIMIT 1 + """ diff --git a/tramai-persistence-jdbc/src/main/resources/tramai/persistence/jdbc/postgres/V8__control_plane_workload_registration.sql b/tramai-persistence-jdbc/src/main/resources/tramai/persistence/jdbc/postgres/V8__control_plane_workload_registration.sql new file mode 100644 index 000000000..a4ff43aab --- /dev/null +++ b/tramai-persistence-jdbc/src/main/resources/tramai/persistence/jdbc/postgres/V8__control_plane_workload_registration.sql @@ -0,0 +1,65 @@ +-- Control-plane workload registration (Epic 0.7.1c) +-- +-- Two tables: +-- tramai_configuration_revision — global authority over +-- (configuration_id, configuration_version) -> fingerprint. A pair can +-- never be rebound to a different fingerprint, from any deployment. +-- tramai_workload_registration — one authoritative registration per +-- deployment scope (workload_id, environment_id, deployment_id), bound +-- to the exact governed configuration revision. + +-- Column bounds mirror the Kotlin identity/metadata contracts (tramai-core +-- identity validation): a hand-written or corrupted row cannot enter a state +-- whose reconstruction into validated types would fail on read. The database +-- defends the bounds; richer grammar (blank, whitespace, control characters) +-- stays owned by the typed values. + +CREATE TABLE IF NOT EXISTS tramai_configuration_revision ( + configuration_id TEXT NOT NULL, + configuration_version TEXT NOT NULL, + fingerprint TEXT NOT NULL, + PRIMARY KEY (configuration_id, configuration_version), + CONSTRAINT ck_configuration_revision_fingerprint CHECK ( + length(fingerprint) BETWEEN 1 AND 128 + ), + CONSTRAINT ck_configuration_revision_identity_lengths CHECK ( + length(configuration_id) BETWEEN 1 AND 128 + AND length(configuration_version) BETWEEN 1 AND 128 + ) +); + +CREATE TABLE IF NOT EXISTS tramai_workload_registration ( + workload_id TEXT NOT NULL, + environment_id TEXT NOT NULL, + deployment_id TEXT NOT NULL, + configuration_id TEXT NOT NULL, + configuration_version TEXT NOT NULL, + owner TEXT NOT NULL, + purpose TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + state_version BIGINT NOT NULL, + PRIMARY KEY (workload_id, environment_id, deployment_id), + CONSTRAINT fk_workload_registration_configuration + FOREIGN KEY (configuration_id, configuration_version) + REFERENCES tramai_configuration_revision (configuration_id, configuration_version), + CONSTRAINT ck_workload_registration_lifecycle CHECK ( + lifecycle_state IN ('ACTIVE', 'SUSPENDED', 'RETIRED') + ), + CONSTRAINT ck_workload_registration_state_version CHECK ( + state_version >= 1 + ), + CONSTRAINT ck_workload_registration_identity_lengths CHECK ( + length(workload_id) BETWEEN 1 AND 128 + AND length(environment_id) BETWEEN 1 AND 128 + AND length(deployment_id) BETWEEN 1 AND 128 + AND length(configuration_id) BETWEEN 1 AND 128 + AND length(configuration_version) BETWEEN 1 AND 128 + ), + CONSTRAINT ck_workload_registration_metadata_lengths CHECK ( + length(owner) BETWEEN 1 AND 256 + AND length(purpose) BETWEEN 1 AND 512 + ) +); + +CREATE INDEX IF NOT EXISTS idx_workload_registration_configuration + ON tramai_workload_registration (configuration_id, configuration_version); diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTckTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTckTest.kt new file mode 100644 index 000000000..4aaee0352 --- /dev/null +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTckTest.kt @@ -0,0 +1,84 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.controlplane.WorkloadRegistrationStore +import dev.tramai.controlplane.testing.WorkloadRegistrationStoreTck +import dev.tramai.controlplane.testing.WorkloadRegistrationStoreTckHarness +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import javax.sql.DataSource + +/** + * The JDBC durable implementation must satisfy the shared + * [WorkloadRegistrationStore] contract (Epic 0.7.1c). The runner owns the + * datasource + schema; storage technology never contaminates the contract. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcWorkloadRegistrationStoreTckTest : WorkloadRegistrationStoreTck() { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("control_plane_tck") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + executeSchema(dataSource) + } + + @AfterAll + fun tearDownAll() { + runCatching { postgres.stop() } + } + + override val harness = + object : WorkloadRegistrationStoreTckHarness { + override fun createStore(): WorkloadRegistrationStore { + // Fresh isolated storage per case. + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute("DELETE FROM tramai_workload_registration") + stmt.execute("DELETE FROM tramai_configuration_revision") + } + } + return JdbcWorkloadRegistrationStore(dataSource) + } + } + + companion object { + internal fun executeSchema(dataSource: DataSource) { + dataSource.connection.use { conn -> + val migrations = + listOf( + "V1__sovereign_persistence.sql", + "V2__approval_continuations.sql", + "V3__audit_events_hardening.sql", + "V4__audit_outbox_hardening.sql", + "V5__worker_leases_hardening.sql", + "V6__approval_resume_credential_custody.sql", + "V7__approval_continuations_resume_retry.sql", + "V8__control_plane_workload_registration.sql", + ) + for (migration in migrations) { + val sql = + JdbcWorkloadRegistrationStoreTckTest::class.java.classLoader + .getResource("tramai/persistence/jdbc/postgres/$migration") + ?.readText() + ?: error("Schema SQL resource not found: $migration") + conn.createStatement().use { stmt -> stmt.execute(sql) } + } + } + } + } +} diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTest.kt new file mode 100644 index 000000000..483225d43 --- /dev/null +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStoreTest.kt @@ -0,0 +1,326 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.controlplane.ConfigurationFingerprint +import dev.tramai.controlplane.CreateResult +import dev.tramai.controlplane.RegisteredWorkload +import dev.tramai.controlplane.RegistrationConflictReason +import dev.tramai.controlplane.WorkloadLifecycleState +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import dev.tramai.controlplane.WorkloadRegistrationStore +import dev.tramai.controlplane.WorkloadStateVersion +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import dev.tramai.controlplane.testing.runInParallel +import dev.tramai.core.identity.WorkloadMetadata +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.sql.SQLException +import javax.sql.DataSource +import kotlin.test.assertFailsWith + +/** + * JDBC-specific proofs the shared TCK cannot show: durability across store + * instances (restart), configuration-rebinding enforcement between two + * independent JDBC stores on the same database, DB-level atomic create races + * across store instances, and the migration-level bounds that stop + * out-of-contract rows from entering the authoritative tables. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcWorkloadRegistrationStoreTest { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + + private fun newStore(): WorkloadRegistrationStore = JdbcWorkloadRegistrationStore(dataSource) + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("control_plane_test") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + JdbcWorkloadRegistrationStoreTckTest.executeSchema(dataSource) + } + + @AfterAll + fun tearDownAll() { + runCatching { postgres.stop() } + } + + private fun wipe() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute("DELETE FROM tramai_workload_registration") + stmt.execute("DELETE FROM tramai_configuration_revision") + } + } + } + + /** + * Proves the migration defends the same bounds the typed identity and + * metadata values defend: a row that cannot be reconstructed into valid + * Kotlin types must not be insertable in the first place. + */ + @Test + fun `database rejects rows outside the bounded identity and metadata contracts`() = + runBlocking { + wipe() + + // Exactly at the contract bound: accepted, and readable back through the + // typed mapper — the database bound is never stricter than the contract. + val atBound = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(workload = "w".repeat(ID_BOUND)), + metadata = + WorkloadRegistrationFixtures.metadata( + owner = "o".repeat(OWNER_BOUND), + purpose = "p".repeat(PURPOSE_BOUND), + ), + ) + assertThat(newStore().create(atBound)).isInstanceOf(CreateResult.Created::class.java) + wipe() + + // One character over a bound: the database itself refuses the row. + assertCheckViolation("configuration_id") { + insertRawConfigurationRevision("c".repeat(ID_BOUND + 1), REVISION_VERSION) + } + assertCheckViolation("configuration_version") { + insertRawConfigurationRevision(REVISION_ID, "v".repeat(ID_BOUND + 1)) + } + for (column in IDENTITY_COLUMNS) { + assertCheckViolation(column) { + insertRawRegistration(mapOf(column to "x".repeat(ID_BOUND + 1))) + } + } + assertCheckViolation("owner") { + insertRawRegistration(mapOf("owner" to "o".repeat(OWNER_BOUND + 1))) + } + assertCheckViolation("purpose") { + insertRawRegistration(mapOf("purpose" to "p".repeat(PURPOSE_BOUND + 1))) + } + } + + private fun insertRawConfigurationRevision( + configurationId: String, + configurationVersion: String, + ) { + dataSource.connection.use { conn -> + conn.prepareStatement(INSERT_RAW_CONFIGURATION_REVISION).use { statement -> + statement.setString(1, configurationId) + statement.setString(2, configurationVersion) + statement.setString(3, VALID_FINGERPRINT) + statement.executeUpdate() + } + } + } + + /** + * Inserts a registration row straight into SQL, bypassing every typed value. + * The parent revision row is always valid, so only the overridden column can + * violate a bound. + */ + private fun insertRawRegistration(overrides: Map) { + insertRawConfigurationRevision(REVISION_ID, REVISION_VERSION) + val values = VALID_COLUMNS + overrides + val columns = values.keys.joinToString(", ") + val placeholders = values.keys.joinToString(", ") { "?" } + dataSource.connection.use { conn -> + conn + .prepareStatement( + "INSERT INTO tramai_workload_registration ($columns, lifecycle_state, state_version) " + + "VALUES ($placeholders, '$ACTIVE_STATE', 1)", + ).use { statement -> + values.values.forEachIndexed { index, value -> statement.setString(index + 1, value) } + statement.executeUpdate() + } + } + } + + private fun assertCheckViolation( + column: String, + block: () -> Unit, + ) { + val failure = + assertFailsWith("$column outside its contract bound must be rejected") { block() } + assertThat(failure.sqlState) + .describedAs("$column must fail its CHECK constraint, not some other error") + .isEqualTo(CHECK_VIOLATION) + } + + @Test + fun `registration state survives store restart`() = + runBlocking { + wipe() + val identity = WorkloadRegistrationFixtures.identity() + val registration = WorkloadRegistrationFixtures.registration(identity = identity) + assertThat(newStore().create(registration)).isInstanceOf(CreateResult.Created::class.java) + + // A brand-new store instance over the same database is a restart. + val restarted = newStore() + assertThat( + restarted.find(identity.workloadId, identity.environmentId, identity.deploymentId), + ).isEqualTo(registration) + + // Authority rules survive the restart too. + val authority = WorkloadRegistrationAuthority(newStore()) + val outcome = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadMetadata(owner = "Restart Team", purpose = "Fraud review"), + ) + assertThat(outcome).isInstanceOf(dev.tramai.controlplane.MetadataUpdateOutcome.Applied::class.java) + } + + @Test + fun `configuration rebinding is rejected across independent JDBC stores`() = + runBlocking { + wipe() + val first = WorkloadRegistrationFixtures.identity(deployment = "eu-west-amsterdam-01") + val second = WorkloadRegistrationFixtures.identity(deployment = "eu-central-frankfurt-01") + assertThat( + newStore().create(WorkloadRegistrationFixtures.registration(identity = first)), + ).isInstanceOf(CreateResult.Created::class.java) + + // A DIFFERENT store instance attempts the same (config, version) with a + // different fingerprint: database-level authority rejects it. + val result = + newStore().create( + WorkloadRegistrationFixtures.registration( + identity = second, + configurationFingerprint = ConfigurationFingerprint("sha256:ffff"), + ), + ) + + assertThat(result).isInstanceOf(CreateResult.Conflicting::class.java) + result as CreateResult.Conflicting + assertThat(result.reason).isEqualTo(RegistrationConflictReason.CONFIGURATION_REBINDING) + } + + @Test + fun `concurrent creates across two store instances yield one authoritative outcome`() = + runBlocking { + repeat(5) { iteration -> + wipe() + val identity = WorkloadRegistrationFixtures.identity(deployment = "race-$iteration") + val registration = WorkloadRegistrationFixtures.registration(identity = identity) + + val results = + runInParallel( + { newStore().create(registration) }, + { newStore().create(registration) }, + ) + + val created = results.filterIsInstance() + val idempotent = results.filterIsInstance() + assertThat(created.size).isEqualTo(1) + assertThat(created.size + idempotent.size).isEqualTo(2) + } + } + + @Test + fun `registration is never silently replaced across store instances`() = + runBlocking { + wipe() + val identity = WorkloadRegistrationFixtures.identity() + val original = WorkloadRegistrationFixtures.registration(identity = identity) + newStore().create(original) + + // A second store tries to claim the same scope with a different + // configuration version: must conflict, never overwrite. + val conflicting = + WorkloadRegistrationFixtures.registration( + identity = WorkloadRegistrationFixtures.identity(configurationVersion = "99"), + ) + val result = newStore().create(conflicting) + + assertThat(result).isInstanceOf(CreateResult.Conflicting::class.java) + result as CreateResult.Conflicting + assertThat(result.reason).isEqualTo(RegistrationConflictReason.CONFLICTING_REGISTRATION) + assertThat( + newStore().find(identity.workloadId, identity.environmentId, identity.deploymentId), + ).isEqualTo(original) + } + + @Test + fun `retired registration remains terminal after restart`() = + runBlocking { + wipe() + val identity = WorkloadRegistrationFixtures.identity() + newStore().create(WorkloadRegistrationFixtures.registration(identity = identity)) + val authority = WorkloadRegistrationAuthority(newStore()) + authority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.RETIRED, + ) + + val resurrectedAuthority = WorkloadRegistrationAuthority(newStore()) + val restarted = + resurrectedAuthority.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion(2), + WorkloadLifecycleState.ACTIVE, + ) + + assertThat(restarted) + .isInstanceOf(dev.tramai.controlplane.LifecycleTransitionOutcome.InvalidTransition::class.java) + val record = newStore().find(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(record?.lifecycle).isEqualTo(WorkloadLifecycleState.RETIRED) + assertThat(record?.stateVersion).isEqualTo(WorkloadStateVersion(2)) + } + + /** Bounds the migration and the typed contracts must agree on. */ + private companion object { + const val ID_BOUND = 128 + const val OWNER_BOUND = 256 + const val PURPOSE_BOUND = 512 + const val REVISION_ID = "claims-prod" + const val REVISION_VERSION = "17" + const val VALID_FINGERPRINT = "sha256:raw" + const val ACTIVE_STATE = "ACTIVE" + const val CHECK_VIOLATION = "23514" + const val INSERT_RAW_CONFIGURATION_REVISION = + "INSERT INTO tramai_configuration_revision " + + "(configuration_id, configuration_version, fingerprint) VALUES (?, ?, ?) " + + "ON CONFLICT (configuration_id, configuration_version) DO NOTHING" + + val IDENTITY_COLUMNS = + listOf( + "workload_id", + "environment_id", + "deployment_id", + "configuration_id", + "configuration_version", + ) + + val VALID_COLUMNS = + mapOf( + "workload_id" to "claims", + "environment_id" to "production", + "deployment_id" to "eu-west-amsterdam-01", + "configuration_id" to REVISION_ID, + "configuration_version" to REVISION_VERSION, + "owner" to "Claims Team", + "purpose" to "Fraud review", + ) + } +} From 4f5ec22951ff7a1c186b6e03c9ef38421ec80f89 Mon Sep 17 00:00:00 2001 From: Giona Granchelli Date: Sun, 13 Sep 2026 17:57:25 +0200 Subject: [PATCH 2/3] build(quality): scope catalog description parity to the frozen oracle module set (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D5 required the catalog description of every published module to equal the frozen pre-B8 oracle entry, which is null for a module introduced after the catalog migration — a new published module could never satisfy it. Parity is now asserted for every pre-B8 module (still present, still published, still byte-identical), with a new D6 proving an edited pre-B8 description is still rejected. The consumer fixture also copies a fixed subset of tramai-core; any example importing a package outside that subset cannot compile in the fixture. The identity package is now copied too. Co-authored-by: TramAI Test --- .../quality/ModuleCatalogMutationTest.kt | 61 ++++++++++++++++--- .../ResidualQualityVerifierTasksTest.kt | 1 + 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/ModuleCatalogMutationTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/ModuleCatalogMutationTest.kt index 55cc722d3..7915af5bc 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/ModuleCatalogMutationTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/ModuleCatalogMutationTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -455,16 +456,56 @@ class ModuleCatalogMutationTest { val catalog = ModuleCatalog.fromRootDir(tempDir).parse() assertEquals(emptySet(), codes(catalog), "catalog must parse clean, got ${codes(catalog)}") + assertLegacyDescriptionParity(catalog) + } + + /** + * The parity oracle is frozen: it records the descriptions the removed + * `projectDescription()` compatibility function produced for the modules that + * existed at migration time. Parity is therefore defined for those modules — + * a module introduced later has no pre-B8 POM description to stay + * byte-identical to, but every pre-B8 module must still be published with an + * unchanged description. + */ + private fun assertLegacyDescriptionParity(catalog: ModuleCatalog.CatalogResult) { val legacy = LegacyPublicationDescriptions.byModule() - catalog.modules.values - .filter { it.publishability == ModulePublishability.PUBLISHED } - .forEach { entry -> - val moduleName = entry.path.removePrefix(":") - assertEquals( - legacy[moduleName], - entry.description, - "catalog description for $moduleName must exactly match the pre-B8 policy", - ) - } + val published = + catalog.modules.values + .filter { it.publishability == ModulePublishability.PUBLISHED } + .associateBy { it.path.removePrefix(":") } + + assertEquals( + emptySet(), + legacy.keys - published.keys, + "every pre-B8 published module must still be present and published in the catalog", + ) + for ((moduleName, legacyDescription) in legacy) { + assertEquals( + legacyDescription, + published.getValue(moduleName).description, + "catalog description for $moduleName must exactly match the pre-B8 policy", + ) + } + } + + @Test + fun `D6 edited description of a pre-B8 module is rejected by the parity oracle`() { + fixtureDir() + val edited = + realCatalogText().replace( + "Core annotations, request models, provider registry, and exception types for Tramai.", + "Core annotations, request models, provider registry, and utility types for Tramai.", + ) + assertNotEquals(realCatalogText(), edited, "mutation must change content") + writeCatalog(edited) + + val catalog = ModuleCatalog.fromRootDir(tempDir).parse() + assertEquals(emptySet(), codes(catalog), "catalog must still parse clean") + + val failure = assertFailsWith { assertLegacyDescriptionParity(catalog) } + assertTrue( + failure.message?.contains("tramai-core") == true, + "parity failure must name the edited module, got ${failure.message}", + ) } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt index 0561910bb..e7da8f78c 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt @@ -239,6 +239,7 @@ class ResidualQualityVerifierTasksTest { dir, srcRel, "tramai-core/src/main/kotlin/dev/tramai/core/annotations", + "tramai-core/src/main/kotlin/dev/tramai/core/identity", "tramai-core/src/main/kotlin/dev/tramai/core/model/Tool.kt", "tramai-core/src/main/kotlin/dev/tramai/core/model/ToolResult.kt", "tramai-core/src/main/kotlin/dev/tramai/core/model/ContentPart.kt", From 9ccbd0d3c6d2b0c0f30277724ef9a33ef240a22a Mon Sep 17 00:00:00 2001 From: Giona Granchelli Date: Thu, 1 Oct 2026 12:30:22 +0200 Subject: [PATCH 3/3] docs(0.7): align roadmap with cross-runtime governance (#468) Align the 0.7/0.8 roadmap with framework-independent governance authority, XR1 Spring AI proof, enforcement provenance, and deferred 0.8 adapter/DX ownership. --- docs/LONG-TERM-ROADMAP-0.7-0.10.md | 11 +- docs/ROADMAP-0.7.0-RELEASE-CUT.md | 79 ++++++- ...AP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md | 35 ++- docs/design/ECOSYSTEM-GOVERNANCE-STRATEGY.md | 214 ++++++++---------- ....7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md | 92 ++++++++ .../0.7.0/EPIC-0.7.2-POLICY-TRUST-ZONES.md | 11 +- .../0.7.0/EPIC-0.7.3-AUTHORIZED-SELECTION.md | 13 +- .../0.7.0/EPIC-0.7.4-EVIDENCE-PROJECTION.md | 16 +- .../EPIC-0.7.5-TIMELINE-RECONSTRUCTION.md | 5 +- .../0.7.0/EPIC-0.7.6-AUTH-RUNTIME-CONTROL.md | 6 +- .../EPIC-0.7.7-SUSPENDED-RUN-CANCELLATION.md | 11 +- .../0.7.0/EPIC-0.7.8-DASHBOARD-INTEGRATION.md | 6 +- docs/roadmap/0.7.0/README.md | 9 +- 13 files changed, 368 insertions(+), 140 deletions(-) create mode 100644 docs/roadmap/0.7.0/CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md diff --git a/docs/LONG-TERM-ROADMAP-0.7-0.10.md b/docs/LONG-TERM-ROADMAP-0.7-0.10.md index 79b99bc8b..fea4b2a99 100644 --- a/docs/LONG-TERM-ROADMAP-0.7-0.10.md +++ b/docs/LONG-TERM-ROADMAP-0.7-0.10.md @@ -48,6 +48,8 @@ Authorized provider/model candidates + stable reason paths ↓ Policy-constrained selection and execution ↓ +Reference external-runtime authority proof + ↓ Authoritative typed governance evidence ↓ Control-plane projection/query API @@ -86,6 +88,7 @@ Unless an implementation finding makes one necessary for P0 correctness or secur - key rotation/KMS breadth; - Docker/Helm product packaging; - broad Spring adoption sugar; +- broad framework-adapter matrix beyond the single 0.7 reference authority proof; - broad compliance-framework mapping. Those concerns remain valuable architectural inputs, but they belong to later release themes. @@ -122,7 +125,9 @@ See [`ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md`](ROADMAP-0.8.0-GOVERNANCE - governance contract testing; - developer-local governance debugger; - deterministic findings and richer incident analysis; -- approval-lifetime and safe-replacement refinement where still needed. +- approval-lifetime and safe-replacement refinement where still needed; +- cross-runtime governance adapter SDK/TCK and additional framework integrations after the 0.7 reference proof; +- runtime capability provenance and effect/reversibility semantics (`READ_ONLY`, `REVERSIBLE`, `COMPENSATABLE`, `IRREVERSIBLE`, `UNKNOWN`) where justified. ## Core invariants @@ -279,6 +284,10 @@ Examples: | Learning traces | audit/telemetry must not silently become training-data collection | | Adaptive optimization | authorization, viability, and selection must remain distinct stages | | Helm/reference deployment | Dashboard/control plane must remain headless/API-first and not own policy authority | +| Cross-runtime governance adapters | 0.7 workload identity, decisions, approval, and evidence must not depend on TramAI owning the agent runtime | +| Governance simulation/testing | 0.7 deterministic policy/authorization evaluation must remain side-effect-free and reusable rather than embedding execution side effects into decision meaning | +| Effect-aware governance/compensation | 0.7 cancellation and evidence must distinguish fencing future execution from reversing or compensating external effects that already occurred | +| Enforcement provenance | 0.7 evidence/query contracts must preserve the difference between authoritative enforcement, partial instrumentation, observation, and imported evidence | Core rule: diff --git a/docs/ROADMAP-0.7.0-RELEASE-CUT.md b/docs/ROADMAP-0.7.0-RELEASE-CUT.md index a7f1b4068..262732f37 100644 --- a/docs/ROADMAP-0.7.0-RELEASE-CUT.md +++ b/docs/ROADMAP-0.7.0-RELEASE-CUT.md @@ -247,6 +247,37 @@ P0 behavior is protected by deterministic tests, compatibility/TCK coverage wher - reconstruction side effects; - sensitive payload exposure. +### Reference external-runtime authority proof + +0.7.0 must include one narrow proof that TramAI can authoritatively govern an execution boundary initiated by a **non-TramAI-authored JVM AI workload** without introducing a second policy model. + +The reference target is **Spring AI**. This is an architecture proof, not a productionized adapter programme. + +The proof must demonstrate, at the declared governed boundary: + +```text +external runtime intent + ↓ +TramAI workload/run identity + ↓ +authoritative TramAI governance decision + ↓ +ALLOW / DENY / REQUIRE_APPROVAL + ↓ +execution or governed suspension +``` + +Required properties: + +- `DENY` prevents execution at the declared boundary; +- `REQUIRE_APPROVAL` cannot execute before authoritative approval; +- workload/configuration/run and decision correlation survive the external-runtime boundary; +- an authoritative integration does not silently fail open when TramAI evaluation is unavailable; +- the external runtime does not gain a second policy/authorization model; +- evidence identifies the runtime/integration boundary and does not overstate TramAI's enforcement strength. + +The first integration contract may remain experimental. The stable commitment is the governance meaning, not a framework-shaped adapter API. + --- # 4. Explicitly not part of 0.7.0 @@ -266,7 +297,9 @@ The following do **not** block 0.7.0 unless implementation proves that a narrow - dynamic tool-obligation lifecycle refinement; - broader metadata-classification ergonomics; - richer deterministic findings/incident analysis; -- approval lifetime/replacement redesign beyond existing safety requirements. +- approval lifetime/replacement redesign beyond existing safety requirements; +- broad Spring AI / Koog / LangChain4j / Embabel adapter matrix beyond the single reference external-runtime authority proof; +- productionized framework-adapter SDK/TCK ergonomics beyond what the reference proof requires. See [`ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md`](ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md). @@ -344,6 +377,46 @@ The canonical/authoritative execution input remains distinct from provider-speci Existing model-output/tool-result DLP remains valid; 0.7 must preserve compatibility or provide an explicit migration path if implementation changes its public contracts. +## J. Framework-independent governance authority + +A governed workload is not synonymous with a TramAI-authored workflow. + +Core workload identity, policy, decision, approval, and evidence semantics must remain usable when execution intent originates in another JVM AI runtime. Framework/runtime identity is metadata about the execution source, not the source of governance authority. + +```text +governed workload != TramAI-authored workflow +``` + +## K. Deterministic, side-effect-free governance decisions + +Where a governance decision is defined as deterministic, equivalent authoritative facts under the same policy/configuration must produce the same pre-execution decision. + +```text +same authoritative facts + same policy/configuration += same governance decision +``` + +The evaluator that determines policy/authorization meaning must not itself invoke providers, execute tools, create external side effects, or depend on Dashboard behavior. Public simulation remains 0.8 scope; 0.7 preserves the semantic boundary that makes simulation possible without a second engine. + +## L. Enforcement and evidence provenance + +Observation is not enforcement. + +Evidence/query contracts must preserve enough source/authority information to distinguish a TramAI-enforced decision from an instrumented, observed, or imported fact. Exact public enum names are an implementation decision. + +```text +observed != enforced +telemetry != proof of prevention +``` + +An external runtime or client must not be able to upgrade its own claimed enforcement strength merely by supplying metadata. + +## M. Stable semantics, experimental integrations + +0.7 should stabilize governance meaning before stabilizing framework-specific adapter APIs. + +Stable commitments should center on workload identity, policy/decision semantics, structured reasons, authority/provenance, and evidence correlation. Initial external-runtime integration APIs may remain experimental until at least one reference adapter has proved the boundary. + --- # 6. Critical dependency graph @@ -404,6 +477,7 @@ Classification/policy work may define provider-input release obligations before 3. Policy-constrained selection/fallback. 4. Provider-specific release/minimization integration immediately before invocation, including recomputation on provider-changing fallback/retry. 5. Decision/configuration identity/digest where required for evidence. +6. **Reference external-runtime authority proof using Spring AI**, after the 0.7.2/0.7.3 decision semantics exist and before 0.7.4 evidence/projection contracts are considered frozen. ## Wave C — evidence and control plane @@ -454,6 +528,9 @@ into the provider invocation ↓ Authoritative decision/evidence emitted ↓ +Reference external-runtime boundary proves the same governance authority +without TramAI owning the agent runtime + ↓ Control plane projects the run safely ↓ Operator can inspect the semantic timeline diff --git a/docs/ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md b/docs/ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md index f729641bb..6a1b7bb02 100644 --- a/docs/ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md +++ b/docs/ROADMAP-0.8.0-GOVERNANCE-DX-AND-INTELLIGENCE.md @@ -109,6 +109,34 @@ Existing 0.7 safety defects remain P0 defects if discovered; broader lifecycle p Detailed design: [`design/APPROVAL-VALIDITY-REPLACEMENT-AND-LIFECYCLE.md`](design/APPROVAL-VALIDITY-REPLACEMENT-AND-LIFECYCLE.md). +### 2.8 Cross-runtime governance DX and adapter compatibility + +- turn the 0.7 XR1 reference proof into a reusable adapter SDK/SPI only after the semantic boundary has been proven; +- define an adapter compatibility/TCK profile for declared enforcement modes; +- add additional runtime adapters based on adoption value, with candidates including Koog, LangChain4j, Embabel, ADK, MCP execution boundaries, and custom JVM runtimes; +- preserve the rule that adapters depend inward on stable governance semantics rather than leaking framework-specific agent concepts into core; +- support developer-local inspection of runtime/framework identity, boundary coverage, adapter health, and known bypass limitations; +- keep adapter APIs experimental until native plus external paths demonstrate that the contract generalizes. + +Candidate TCK assertions include: `DENY` prevents execution at the declared authoritative boundary, `REQUIRE_APPROVAL` cannot execute early, identity/correlation is preserved, stale decisions cannot authorize a new action, fail-open/fail-closed behavior is explicit, and evidence does not overstate enforcement strength. + +### 2.9 Runtime capability provenance and effect semantics + +- model where executable capabilities originate, including native tools, MCP tools, remote agents, models/providers, external APIs, and network destinations; +- keep capability registration/discovery/exposure distinct from permission to execute; +- introduce effect/reversibility metadata where justified: + +```text +READ_ONLY +REVERSIBLE +COMPENSATABLE +IRREVERSIBLE +UNKNOWN +``` + +- allow effect semantics to influence approval, risk findings, evidence, and incident response without pretending compensation is transactional rollback; +- keep a full saga/compensation engine out of the release unless concrete use cases justify it separately. + --- ## 3. Dependencies on 0.7 @@ -123,7 +151,9 @@ Detailed design: [`design/APPROVAL-VALIDITY-REPLACEMENT-AND-LIFECYCLE.md`](desig - control-plane projection/query; - authorized runtime control; - authoritative persisted suspended-run cancellation and future-resume fencing; -- forensic reconstruction without side effects. +- forensic reconstruction without side effects; +- one reference external-runtime authority proof showing that governance semantics do not require TramAI to own the agent runtime; +- evidence authority/provenance semantics that distinguish authoritative enforcement from instrumented, observed, or imported facts. 0.8 may improve authoring and analysis around those contracts but must not redefine their authority model casually. @@ -137,6 +167,7 @@ Detailed design: [`design/APPROVAL-VALIDITY-REPLACEMENT-AND-LIFECYCLE.md`](desig - governed raw-content learning capture, dataset export, FinOps breadth, or adaptive optimization owned by 0.10; - a generic business-rules engine; - a general ontology/knowledge-graph platform; +- a promise to productize every JVM framework adapter in one release; - a second workflow executor; - a second policy engine; - a visual workflow builder as a release blocker; @@ -161,6 +192,8 @@ execute through the same authoritative TramAI runtime ↓ inspect structured reasons/debug information ↓ +govern at least one non-TramAI-authored JVM workload through the same semantic authority + ↓ reconstruct/replay deterministic governance meaning where evidence permits ``` diff --git a/docs/design/ECOSYSTEM-GOVERNANCE-STRATEGY.md b/docs/design/ECOSYSTEM-GOVERNANCE-STRATEGY.md index 62f2fa69d..ed304bc33 100644 --- a/docs/design/ECOSYSTEM-GOVERNANCE-STRATEGY.md +++ b/docs/design/ECOSYSTEM-GOVERNANCE-STRATEGY.md @@ -1,8 +1,8 @@ -# TramAI 0.7.0 — Ecosystem Governance, Simulation, and Contract-Testing Addendum +# TramAI — Ecosystem Governance, Cross-Runtime Authority, Simulation, and Contract-Testing Strategy -> **Status:** Normative companion to `docs/ROADMAP-0.7.0.md` -> **Target:** TramAI 0.7.0 architecture and sequencing -> **Scope:** Strategic/architectural amendments derived from the 2026 JVM AI framework landscape +> **Status:** Cross-release design strategy; release ownership is defined by the authoritative 0.7/0.8 roadmaps +> **Target:** 0.7 architecture constraints + one reference external-runtime authority proof; 0.8 simulation/testing/debugger/adapter DX; later compensation execution where justified +> **Scope:** Strategic/architectural guidance derived from the 2026 JVM AI framework landscape > **Primary decision:** TramAI must remain valuable even when AI workloads are authored with another JVM framework. > **Positioning consequence:** **Build AI workloads with the framework that fits. Govern their execution with TramAI.** @@ -31,16 +31,14 @@ The strategic implication is not to abandon TramAI's runtime. It is to define a > TramAI's durable differentiation is **governance authority**, not generic agent authoring. -A company should not need to rewrite a Koog, Spring AI, LangChain4j, ADK, Genkit, or custom JVM workload merely to gain TramAI governance. +A company should not need to rewrite a Koog, Spring AI, LangChain4j, Embabel, Google ADK Java, or custom JVM workload merely to gain TramAI governance. -This addendum therefore strengthens four areas of the 0.7.0 roadmap: +This strategy has four release-owned consequences: -1. **cross-runtime governance boundary**; -2. **policy simulation / decision preview**; -3. **governance contract testing**; -4. **developer-local governance UX**. - -It also records a later architectural opportunity around **tool reversibility and compensation semantics**. +1. **0.7:** preserve a framework-independent governance boundary and prove it once against a non-TramAI JVM runtime; +2. **0.7:** preserve explicit enforcement/evidence provenance so observation cannot masquerade as prevention; +3. **0.8:** productize policy simulation, governance contract testing, local governance debugging, and adapter compatibility/TCK work over the same authoritative evaluator; +4. **0.8/later:** introduce capability provenance and tool effect/reversibility semantics, with automated compensation execution deferred until concrete use cases justify it. --- @@ -194,23 +192,20 @@ The local developer workflow is therefore a governance control surface, not cosm --- -## 4. Must-ship boundary amendments - -The six existing P0 capabilities in the main roadmap remain authoritative. - -Add the following architectural requirement to the P0 boundary: - -7. **Cross-runtime governance contract** defining how non-TramAI-authored JVM workloads can be identified, evaluated, observed, and—where integration strength permits—constrained without duplicating the policy engine. +## 4. Release ownership and must-ship boundary -Add the following P0/P1 capability: +The authoritative release roadmaps decide what blocks each release. This design note does not expand them implicitly. -8. **Policy Simulation / Decision Preview** using the authoritative policy/selection evaluator with all external side effects disabled. +For **0.7**, the must-ship consequences are: -Add the following P1 capability: +1. core workload/decision/evidence semantics do not require TramAI to own the agent runtime; +2. deterministic policy/authorization meaning remains side-effect-free so later simulation/testing can reuse it; +3. evidence preserves source/enforcement provenance and does not upgrade observation into proof of prevention; +4. one **reference external-runtime authority proof** (XR1, using Spring AI) demonstrates `ALLOW`, `DENY`, and `REQUIRE_APPROVAL` across a non-TramAI execution boundary without a second policy model. -9. **Governance Contract Testing** enabling deterministic CI assertions over classification, provider/model eligibility, tool approval, fallback, and other governance decisions. +For **0.8**, productize the broader DX layer described below: policy simulation/decision preview, governance contract testing, local governance debugger, adapter SDK/TCK and additional runtime integrations, plus capability/effect semantics where justified. -These additions do not require shipping production-grade adapters for every JVM framework in 0.7.0. The P0 requirement is the architecture and contract that prevents TramAI from becoming impossible to integrate later. +0.7 does **not** require production-grade adapters for every JVM framework, a public simulation API, a governance-testing SDK, or debugger UX. --- @@ -379,20 +374,21 @@ Therefore: 7. Replayed decision IDs must not accidentally authorize a different action. 8. Evidence must distinguish adapter-reported facts from TramAI-observed/enforced facts. -### 0.2.6 Initial adapter targets +### 0.2.6 Reference proof and later adapter targets -0.7.0 does **not** require all of these to ship. +0.7 XR1 uses **Spring AI** as the single reference external-runtime authority proof. It is intentionally narrow and may use an experimental integration surface. -Candidates for post-contract adapters: +0.8 candidates after XR1 include: -- Spring AI; - Koog; - LangChain4j; +- Embabel; +- Google ADK Java; - plain HTTP/custom JVM model clients; - MCP tool execution boundaries; -- generic OpenAI-compatible provider call boundary. +- generic OpenAI-compatible provider call boundaries. -The first production adapter should be selected by adoption value and ability to provide meaningful enforcement, not brand visibility. +Broader production adapters should be selected by adoption value and ability to provide meaningful enforcement, not brand visibility. ### 0.2.7 Acceptance criteria @@ -411,7 +407,7 @@ The first production adapter should be selected by adoption value and ability to ## Epic 1.2: Authoritative policy simulation -**Priority:** P0/P1 +**Priority:** 0.8 — Governance DX & Intelligence **Goal:** Allow developers/operators to ask "what would TramAI decide?" without executing a provider call, tool, workflow action, approval side effect, or network request. ### 1.2.1 Core invariant @@ -598,7 +594,7 @@ Diff mode may be P1 if basic simulation is P0/P1. ## Epic 5.2: Local governance debugger -**Priority:** P1, with P0 architectural compatibility +**Priority:** 0.8 — Governance DX & Intelligence; 0.7 preserves the structured decision/provenance inputs it needs **Goal:** Make TramAI governance understandable during development before a workload reaches an operational control plane. ### 5.2.1 Principle @@ -680,7 +676,7 @@ Rules: ## Epic D.1: Deterministic governance test kit -**Priority:** P1 +**Priority:** 0.8 — Governance DX & Intelligence **Goal:** Let teams prove governance invariants in CI without network calls, real models, or real tool side effects. ### D.1.1 Why this differs from generic AI testing @@ -840,7 +836,7 @@ SARIF is exploratory and should not block the initial test kit. ## Epic E.1: Reversibility metadata -**Priority:** P2 / later than the 0.7.0 core +**Priority:** 0.8 candidate for effect metadata; automated compensation execution is later/optional **Goal:** Allow governance decisions to account for whether a tool's external effect can be reversed or compensated. ### E.1.1 Motivation @@ -905,11 +901,11 @@ A "compensatable" action must never be presented as equivalent to a transaction Compensation can fail and may require separate authorization. -### E.1.5 0.7.0 decision +### E.1.5 Release ownership -Do not block 0.7.0 on compensation execution. +0.7 establishes only the honesty boundary: cancellation/fencing future execution must not be represented as compensation of already-produced external effects. -However, avoid tool-governance APIs that make future effect/reversibility metadata impossible to add cleanly. +0.8 may add effect/reversibility metadata where useful. A general compensation/saga execution engine remains later/optional and must never pretend compensatable is equivalent to transactional rollback. --- @@ -1107,21 +1103,11 @@ TramAI can provide the deterministic result without claiming that passing CI equ ## 8. Reference workflow amendment -Extend the primary 0.7.0 reference workflow with a pre-execution simulation and an external-runtime variant. +0.7 proves the native governed loop and one external-runtime authority boundary. 0.8 adds pre-execution simulation, contract testing, and debugger workflows over the same semantics. -### 8.1 Native flow +### 8.1 Native 0.7 flow ```text -Developer changes provider/tool policy - ↓ -Governance contract tests run - ↓ -Policy simulation shows candidate routes - ↓ -No unexpected governance regression - ↓ -Deploy - ↓ Sensitive document enters ↓ Classification @@ -1137,6 +1123,22 @@ Execution Semantic trace + evidence ``` +### 8.1a 0.8 developer preflight extension + +```text +Developer changes provider/tool policy + ↓ +Governance contract tests run + ↓ +Policy simulation shows candidate routes + ↓ +No unexpected governance regression + ↓ +Deploy + ↓ +execute through the same authoritative semantics +``` + ### 8.2 External-runtime flow ```text @@ -1163,73 +1165,46 @@ Evidence records include enforcement provenance ### 8.3 Demonstration acceptance criteria -At least one 0.7.x demonstration should eventually prove that TramAI governance is conceptually independent from agent authoring. +0.7 XR1 is the required reference proof that TramAI governance is conceptually independent from agent authoring. -The demo does not require every external framework. One adapter is enough to validate the boundary. +The proof does not require every external framework. One Spring AI integration boundary is enough to validate the architecture; broader adapter productization moves to 0.8. --- ## 9. Execution-order amendments -The main roadmap's dependency ordering remains valid, with these insertions. - -### Milestone A — Architecture contract - -Existing: - -1. control-plane authority/read-model/command ADR; -2. control-plane module/API scaffold; -3. safe projection rules. - -Add: - -4. **External-runtime governance boundary ADR/SPI**; -5. **enforcement-mode and evidence-provenance model**. +The authoritative 0.7 execution board remains the source of sequencing truth. -These should land before public adapter APIs are committed. +### 0.7 — preserve and prove authority -### Milestone B — Selection and workload identity - -After candidate eligibility and workload identity: - -6. **Policy simulation core using authoritative selection/policy evaluator**; -7. stable decision/reason normalization suitable for execution, simulation, and tests. - -### Milestone C — Projection layer - -Add projection support for: - -- enforcement mode; -- external runtime identity; -- simulation records/summaries where persisted; -- evidence provenance. +1. Finish 0.7.1 identity/authority without cross-runtime scope churn. +2. In 0.7.2, keep policy/authorization evaluation deterministic and side-effect-free. +3. In 0.7.3, establish reusable decision identity/reason semantics rather than provider-selection-only representations. +4. Run **XR1 — Spring AI external-runtime authority proof** after 0.7.3 semantics exist and before 0.7.4 evidence/projection contracts are frozen. +5. In 0.7.4, finalize runtime/integration identity plus enforcement/evidence provenance informed by XR1. +6. In 0.7.5–0.7.8, preserve provenance through reconstruction, control, cancellation, and Dashboard explanation without adding a second policy engine. -### Milestone D — Dashboard 2.0 +### 0.8 — productize governance DX -Add: +After 0.7 proves the authority boundary: -- local governance debugger affordances; -- "why" views based on structured decisions; -- simulation/decision-preview UI; -- explicit governance coverage/enforcement-mode display. +- policy simulation / decision preview and diff; +- governance contract-testing module; +- local governance debugger; +- adapter SDK/SPI plus compatibility TCK; +- additional adapters based on adoption value; +- capability provenance and registration/discovery/exposure semantics; +- tool effect/reversibility metadata; +- compensation execution only if concrete scenarios justify it. -### Milestone F — Expansion +### Deferred beyond the immediate 0.8 core -Add: - -- governance contract testing module; -- first external-runtime adapter proof; -- simulation diff mode if not already delivered. - -### Deferred / post-0.7.0 - -- broad framework adapter matrix; -- tool compensation execution; -- external-runtime discovery across the enterprise; -- remote agent marketplace/inventory connectors. +- universal adapter coverage for every AI framework; +- enterprise-wide external-runtime discovery; +- remote agent marketplace/inventory connectors; +- generic distributed transaction/saga engine. --- - ## 10. Proposed module boundaries Names are provisional. @@ -1350,47 +1325,50 @@ Add to Section 9 of the main roadmap. - UI/evidence language never upgrades OBSERVED into ENFORCED. - External adapter identity is authenticated/registered according to the declared threat model for authoritative mode. -### 13.2 Simulation gates +### 13.2 0.8 simulation gates - Simulation performs zero external side effects. - Simulation and execution share the authoritative evaluator. - Simulation results are type-distinct from executable authorization/evidence of enforcement. - Deterministic parity tests are green. -### 13.3 Governance-test gates +### 13.3 0.8 governance-test gates -If the testing module ships in 0.7.0: +When the testing module ships: - it runs offline; - it cannot invoke real tools/providers by default; - failures expose structured reason paths; - production evaluator code is exercised directly. -### 13.4 Developer UX gate +### 13.4 0.8 developer UX gate -For the reference workflow, a developer can answer "why was this denied/approved/routed?" from structured local/control-plane output without reverse-engineering logs or frontend policy code. +A developer can answer "why was this denied/approved/routed?" from structured local/control-plane output without reverse-engineering logs or frontend policy code. --- ## 14. Success-metric amendments -Add product/architecture metrics: +Split metrics by release ownership: + +### 0.7 metrics + +| Metric | Target | +|---|---:| +| XR1 denied operations executed at the declared authoritative boundary | 0 | +| XR1 approval-required operations executed before approval | 0 | +| External-reference evidence records that preserve enforcement provenance | 100% | +| OBSERVED/INSTRUMENTED facts presented as AUTHORITATIVE | 0 | +| Generic framework-specific concepts leaked into core governance semantics | 0 known intentional violations | + +### 0.8 metrics | Metric | Target | |---|---:| | Simulation/runtime deterministic decision parity | 100% for declared deterministic fixtures | | Simulation external side effects | 0 | -| External workloads with explicit enforcement mode | 100% | -| Evidence records that preserve enforcement provenance | 100% | | Governance test assertions using production evaluator | 100% | -| OBSERVED integrations presented as AUTHORITATIVE | 0 | -| Generic framework-specific concepts leaked into core governance model | 0 known intentional violations | - -Longer-term adoption metric: - -> At least one non-TramAI-authored JVM workload can participate in TramAI governance without rewriting its agent/workflow into a TramAI-native abstraction. - -This may be a 0.7.x proof rather than a hard 0.7.0 GA gate if implementation scope becomes excessive. +| Supported external adapters with explicit enforcement mode | 100% | --- @@ -1438,7 +1416,7 @@ The framework landscape suggests the following product discipline. ## 17. Architecture decision summary -After this amendment, the 0.7.0 architecture should satisfy the following statement: +After the 0.7/0.8 split recorded by the authoritative roadmaps, the architecture should satisfy the following statement: > TramAI may execute a workload itself, or it may govern an execution boundary owned by another JVM AI runtime. In either case, authoritative TramAI governance semantics remain typed, deterministic, explainable, headless, testable, and evidence-producing. The control plane displays those semantics without becoming a second policy engine. Where TramAI cannot guarantee enforcement, it says so explicitly. diff --git a/docs/roadmap/0.7.0/CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md b/docs/roadmap/0.7.0/CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md new file mode 100644 index 000000000..a03208dfe --- /dev/null +++ b/docs/roadmap/0.7.0/CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md @@ -0,0 +1,92 @@ +# Checkpoint 0.7-XR1 — External Runtime Authority Proof + +**Status:** ⚪ Planned +**Placement:** after 0.7.3 decision semantics; before 0.7.4 evidence/projection contracts are frozen +**Reference runtime:** Spring AI +**Change class:** architecture/release proof + +## Executive decision + +Prove that TramAI can authoritatively govern a declared execution boundary initiated by a **non-TramAI-authored JVM AI workload** without requiring the workload to be rewritten as a TramAI workflow and without introducing a second policy model. + +This is a narrow architecture proof. It is not a productionized framework-adapter programme. + +## Required flow + +```text +Spring AI workload + ↓ +tool/action intent + ↓ +TramAI workload + configuration + run correlation + ↓ +authoritative TramAI governance evaluator + ↓ +ALLOW / DENY / REQUIRE_APPROVAL + ↓ +execute / reject / governed suspension + ↓ +correlated outcome + evidence +``` + +The exact Spring AI integration mechanism is implementation-specific and may remain experimental. + +## Core invariants + +```text +external runtime != governance authority +governed workload != TramAI-authored workflow +DENY => no execution at the declared boundary +REQUIRE_APPROVAL => no execution before authoritative approval +adapter failure in authoritative mode => no silent fail-open +observed != enforced +projection/query state cannot authorize execution +``` + +## Required proof + +1. **ALLOW** — an allowed action executes exactly through the declared governed boundary and the outcome correlates to the TramAI decision. +2. **DENY** — a denied action does not execute. +3. **REQUIRE_APPROVAL** — the action cannot execute before approval; after valid approval the continuation preserves workload/run/decision identity. +4. **Identity continuity** — workload, configuration/version, run, policy/decision and correlation identity survive the external-runtime boundary. +5. **Failure semantics** — loss/unavailability of the governance call does not silently become permission where the integration declares authoritative enforcement. +6. **No second policy model** — Spring-specific code translates execution intent but does not recreate TramAI authorization or approval semantics. +7. **Evidence provenance** — emitted evidence identifies the external runtime/integration and represents only the enforcement strength TramAI can prove. +8. **No projection bypass** — the adapter cannot authorize by writing directly to control-plane read models, evidence stores or UI state. + +## Enforcement-strength requirement + +The reference proof must be **authoritative at the specific declared boundary**: TramAI's denial must be able to prevent that operation. + +0.7 may introduce an internal/preview representation capable of distinguishing concepts equivalent to: + +```text +AUTHORITATIVE +INSTRUMENTED +OBSERVED +IMPORTED +``` + +Exact names and public API stability are deferred to the 0.7.4 evidence/projection work. + +The integration must never imply that governing one Spring AI boundary proves every possible execution path in the host application is governed. + +## Non-goals + +- production-quality Spring Boot starter or five-minute adoption flow; +- broad Spring AI feature coverage; +- Koog, LangChain4j, Embabel, ADK or other adapter implementation; +- stable public adapter SPI; +- automatic framework discovery; +- policy simulation/decision preview; +- governance contract-testing SDK; +- Dashboard-specific policy behavior; +- MCP/A2A productization. + +Those belong to later roadmap work unless a narrow seam is required to complete this proof safely. + +## Exit + +XR1 is complete when a non-TramAI-authored Spring AI workload demonstrates the same authoritative TramAI governance meaning used by native execution, and the findings are sufficient for 0.7.4 to finalize evidence authority/provenance without assuming TramAI owns the agent runtime. + +If the proof requires a second policy model or reveals that core workload/decision semantics are inherently TramAI-workflow-specific, stop and report the architectural mismatch before 0.7.4 contracts are frozen. diff --git a/docs/roadmap/0.7.0/EPIC-0.7.2-POLICY-TRUST-ZONES.md b/docs/roadmap/0.7.0/EPIC-0.7.2-POLICY-TRUST-ZONES.md index 560286f1a..1abc1d5d8 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.2-POLICY-TRUST-ZONES.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.2-POLICY-TRUST-ZONES.md @@ -6,7 +6,7 @@ ## Executive decision -Resolve classification, concrete provider-deployment trust topology, effective policy, and the provider-bound data-release decision before a model invocation can cross a trust boundary. Authorization answers whether a provider may be used; the provider-input release boundary separately answers what exact representation of the governed input that provider may receive. +Resolve classification, concrete provider-deployment trust topology, effective policy, and the provider-bound data-release decision before a model invocation can cross a trust boundary. Authorization answers whether a provider may be used; the provider-input release boundary separately answers what exact representation of the governed input that provider may receive. The governance decision path must remain deterministic and side-effect-free so later execution, external-runtime integration, simulation, and testing can reuse one meaning rather than invent parallel policy engines. ## Scope @@ -24,6 +24,7 @@ Resolve classification, concrete provider-deployment trust topology, effective p - fail closed before invocation when required input minimization/inspection cannot be completed; - emit safe typed evidence for provider-input release/minimization without persisting raw matched sensitive values; - stable safe reason paths for denial/constraint decisions; +- a side-effect-free deterministic evaluation boundary for classification/policy/release meaning; - typed evidence sufficient for downstream projection/reconstruction. ## Non-goals @@ -52,6 +53,8 @@ providerBoundInput = projection(canonicalInput, selectedDeployment, effectivePol providerBoundInput transformation never mutates canonicalInput fallback to a different deployment => derive a new providerBoundInput required minimization/inspection failure => no provider invocation +same authoritative facts + same effective policy => same governance decision +policy evaluation itself performs no provider/tool/network side effects safe evidence never contains raw matched sensitive values ``` @@ -65,7 +68,7 @@ The provider-input release decision is distinct from provider authorization. A p | 0.7.2b | Provider deployment + named zone contract | Introduce/normalize deployment identity and named-zone/category association | | 0.7.2c | Classification-before-exposure integration | Move/guard orchestration ordering so required classification precedes eligibility/exposure | | 0.7.2d | Provider-input data-release/minimization contract | Define canonical input vs provider-bound projection, typed release outcomes, fail-closed transformation semantics, provider-specific recomputation, and safe evidence | -| 0.7.2e | Restrictive policy composition | Deterministic org/environment/workload intersection with no widening path; provider-input release obligations derive from the same effective policy authority | +| 0.7.2e | Restrictive policy composition | Deterministic org/environment/workload intersection with no widening path; provider-input release obligations derive from the same effective policy authority; evaluation remains side-effect-free | | 0.7.2f | Missing/unknown/failure semantics | Fail closed where classification/topology or required provider-input minimization/inspection is unavailable | | 0.7.2g | Reason/evidence model | Stable safe reasons for classification/trust/policy/data-release outcomes without raw sensitive values | | 0.7.2h | Adversarial/TCK/mutation proof | Prove ordering, no downgrade, no brand trust, no widening, no pre-release invocation, no cross-provider projection reuse, and no fail-open sanitizer path | @@ -84,10 +87,12 @@ The provider-input release decision is distinct from provider authorization. A p - Lower scope cannot authorize something denied above it. - Missing required classification/topology cannot silently become permissive. - Decisions expose safe stable reasons usable by 0.7.3/0.7.4. +- Equivalent authoritative facts under the same policy/configuration produce the same deterministic decision for the supported profile. +- Decision evaluation can be exercised with provider/tool/network traps and produces no external side effects. ## Adversarial proof -Reject implementations that classify after exposure, downgrade an explicit strong class, infer EU/local trust from vendor name, union policies instead of intersecting them, treat unknown as allow, invoke the provider before the release/minimization decision, mutate canonical input in place, reuse an OpenAI-authorized/minimized projection after fallback to a different provider deployment, expose raw matched values in evidence, or pass raw input through when required DLP/minimization fails. +Reject implementations that classify after exposure, downgrade an explicit strong class, infer EU/local trust from vendor name, union policies instead of intersecting them, treat unknown as allow, invoke the provider before the release/minimization decision, mutate canonical input in place, reuse an OpenAI-authorized/minimized projection after fallback to a different provider deployment, expose raw matched values in evidence, pass raw input through when required DLP/minimization fails, or make policy evaluation itself depend on an external provider/tool/network side effect. ## Mutation expectations diff --git a/docs/roadmap/0.7.0/EPIC-0.7.3-AUTHORIZED-SELECTION.md b/docs/roadmap/0.7.0/EPIC-0.7.3-AUTHORIZED-SELECTION.md index eb58d34e8..d4feed375 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.3-AUTHORIZED-SELECTION.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.3-AUTHORIZED-SELECTION.md @@ -32,6 +32,7 @@ optimization signals can rank but cannot authorize - selection/non-selection reason paths; - constrained fallback/retry; - decision/configuration identity/digest where required for evidence; +- reusable governance-decision identity/envelope semantics that bind workload/run/policy/authority/reasons without becoming provider-selection-specific; - safe historical decision evidence. ## Non-goals @@ -39,7 +40,8 @@ optimization signals can rank but cannot authorize - rich adaptive routing; - machine-learned routing; - FinOps optimization; -- broad cost/quality strategy productization. +- broad cost/quality strategy productization; +- productionized external-runtime adapter SDKs or a broad framework-integration matrix. ## Tasks @@ -50,7 +52,7 @@ optimization signals can rank but cannot authorize | 0.7.3c | Authorized-set derivation | Compute governance-authorized candidates from 0.7.2 contracts | | 0.7.3d | Viability stage | Apply runtime constraints only after authorization | | 0.7.3e | Selection/fallback fencing | Ensure selection/retry/fallback cannot escape viable authorized set | -| 0.7.3f | Decision identity/evidence | Persist enough context to explain historical selection safely | +| 0.7.3f | Decision identity/evidence | Persist enough workload/run/policy/decision/authority context to explain historical selection safely and to support the XR1 external-runtime authority proof without inventing a second decision model | | 0.7.3g | Adversarial/mutation/provider proof | Prove ineligible routes never become selected via fallback/preference | | 0.7.3h | Integration/docs | Final API/architecture docs and Epic acceptance | @@ -60,6 +62,13 @@ optimization signals can rank but cannot authorize - Policy-ineligible candidates cannot become selected through retry/fallback/preference. - Rejected/non-selected candidates expose structured safe reasons. - Historical evidence identifies the relevant workload/config/policy/routing context. +- Decision identity and structured reasons are not coupled to TramAI owning the workflow runtime or to provider selection as the only future decision family. + +## Post-Epic release checkpoint + +After 0.7.3 decision semantics are available, run [XR1 — External Runtime Authority Proof](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) using Spring AI as the reference non-TramAI runtime. XR1 must complete before 0.7.4 evidence/projection contracts are considered frozen. + +XR1 is a release architecture proof, not an expansion of this Epic into framework-adapter productization. ## Adversarial proof diff --git a/docs/roadmap/0.7.0/EPIC-0.7.4-EVIDENCE-PROJECTION.md b/docs/roadmap/0.7.0/EPIC-0.7.4-EVIDENCE-PROJECTION.md index c8da5729d..fd6b69c43 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.4-EVIDENCE-PROJECTION.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.4-EVIDENCE-PROJECTION.md @@ -2,7 +2,7 @@ **Branch:** `epic/0.7.4-evidence-projection` **Status:** ⚪ Planned -**Dependencies:** 0.7.1 HARD; 0.7.2/0.7.3 SOFT +**Dependencies:** 0.7.1 HARD; 0.7.2/0.7.3 SOFT; XR1 required before evidence/projection contracts are frozen ## Executive decision @@ -11,7 +11,7 @@ Project authoritative runtime truth into a stable operational read model without ## Architecture ```text -runtime authority → typed evidence → projection/read model → query API → clients +runtime authority / governed external boundary → typed evidence → projection/read model → query API → clients ``` OTEL and best-effort telemetry may mirror facts but remain non-authoritative. @@ -20,6 +20,8 @@ OTEL and best-effort telemetry may mirror facts but remain non-authoritative. - typed governance evidence/events; - workload/run/config/classification/policy/routing/approval/control correlation; +- runtime/framework/integration identity where relevant to evidence interpretation; +- explicit evidence source, enforcement strength, and provenance sufficient to distinguish enforced, instrumented, observed, and imported facts; - idempotent projection application; - ordering/version handling; - explicit projection lag/consistency/source authority; @@ -40,11 +42,11 @@ OTEL and best-effort telemetry may mirror facts but remain non-authoritative. | ID | Candidate | Required result | |---|---|---| | 0.7.4a | Evidence baseline audit | Inventory authoritative events/stores, audit evidence and telemetry gaps | -| 0.7.4b | Governance evidence schema | Typed/versioned evidence families with identity and source authority | +| 0.7.4b | Governance evidence schema | Typed/versioned evidence families with identity, source authority, runtime/integration context where relevant, and explicit enforcement/evidence provenance | | 0.7.4c | Projection contract | Idempotency, ordering/version, lag and rebuild semantics | | 0.7.4d | Materialized read model | Durable/queryable operational projection without mutation authority | | 0.7.4e | Query API | Stable headless workload/run/decision/control-state query contracts | -| 0.7.4f | Safe exposure/OTEL mapping | Protected-payload defaults and non-authoritative telemetry distinction | +| 0.7.4f | Safe exposure/OTEL mapping | Protected-payload defaults; observed/instrumented/imported evidence remains distinguishable from authoritative enforcement; OTEL never upgrades telemetry into proof of prevention | | 0.7.4g | Adversarial/rebuild/mutation proof | Duplicate/reorder/stale/missing-source and privacy discriminators | | 0.7.4h | Integration/docs | Evidence schema/query docs and Epic acceptance | @@ -55,6 +57,8 @@ projection cannot create or widen authority same authoritative event applied twice => same projection as once projection lag is never presented as stronger authority than source missing evidence != inferred success +observed telemetry != proof of enforcement +integration/client metadata cannot self-upgrade enforcement strength protected payloads are excluded by default ``` @@ -65,7 +69,9 @@ protected payloads are excluded by default - Duplicate/reordered evidence obeys explicit semantics. - Projection lag/source authority is observable. - Query/telemetry defaults do not leak protected payloads. +- The XR1 Spring AI reference path can be projected without pretending TramAI authored the workload. +- Clients can distinguish a TramAI-enforced boundary from instrumented, observed, or imported evidence. ## Adversarial proof -Reject projection-side policy decisions, duplicate evidence changing final state incorrectly, stale event overwriting newer version, unavailable source reported as PASS/current, and payload leakage through generic DTO/telemetry. +Reject projection-side policy decisions, duplicate evidence changing final state incorrectly, stale event overwriting newer version, unavailable source reported as PASS/current, payload leakage through generic DTO/telemetry, observed telemetry represented as authoritative prevention, client-supplied enforcement strength accepted without authoritative validation, and external-runtime evidence projected as though TramAI authored or governed boundaries it did not control. diff --git a/docs/roadmap/0.7.0/EPIC-0.7.5-TIMELINE-RECONSTRUCTION.md b/docs/roadmap/0.7.0/EPIC-0.7.5-TIMELINE-RECONSTRUCTION.md index 8020ddc5b..158ab6d2c 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.5-TIMELINE-RECONSTRUCTION.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.5-TIMELINE-RECONSTRUCTION.md @@ -15,6 +15,7 @@ reconstruction != re-execution missing historical evidence remains missing current configuration is never silently substituted for historical configuration best-effort telemetry remains distinguishable from authoritative evidence +reconstruction preserves historical enforcement/evidence provenance rather than upgrading it ``` ## Scope @@ -22,6 +23,7 @@ best-effort telemetry remains distinguishable from authoritative evidence - semantic run event model; - deterministic ordering/grouping rules; - authoritative vs best-effort/missing markers; +- preservation of evidence source, enforcement strength, and runtime/integration provenance from 0.7.4; - reconstruction input contract and completeness model; - historical configuration/policy/topology identity resolution; - side-effect-free reconstruction API/result; @@ -38,7 +40,7 @@ best-effort telemetry remains distinguishable from authoritative evidence | ID | Candidate | Required result | |---|---|---| | 0.7.5a | Timeline/reconstruction audit | Characterize available evidence and missing historical identities | -| 0.7.5b | Semantic timeline contract | Typed governance transitions and authority/source markers | +| 0.7.5b | Semantic timeline contract | Typed governance transitions plus authority/source/enforcement-provenance markers that remain truthful for native and external-runtime evidence | | 0.7.5c | Timeline projection/query | Deterministic semantic ordering from 0.7.4 evidence | | 0.7.5d | Reconstruction contract | Inputs, completeness states, historical identity requirements | | 0.7.5e | Side-effect-free reconstructor | Produce historical governed narrative/state without external invocation | @@ -52,6 +54,7 @@ best-effort telemetry remains distinguishable from authoritative evidence - Missing evidence is visible. - Reconstruction causes zero provider/tool/approval/network/workflow side effects. - Historical configuration is used when available; absence is explicit. +- A reconstructed external-runtime event retains whether it was authoritatively enforced, instrumented, observed, or imported according to the evidence available at execution time. ## Adversarial proof diff --git a/docs/roadmap/0.7.0/EPIC-0.7.6-AUTH-RUNTIME-CONTROL.md b/docs/roadmap/0.7.0/EPIC-0.7.6-AUTH-RUNTIME-CONTROL.md index 8fd99d6e8..4542abfae 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.6-AUTH-RUNTIME-CONTROL.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.6-AUTH-RUNTIME-CONTROL.md @@ -12,7 +12,7 @@ Expose privileged control-plane commands only through an identity-provider-neutr - generic OIDC/Spring Security integration boundary; - authenticated control-plane principal; -- capability-based server-side authorization; +- operator/control-plane capability-based server-side authorization, explicitly distinct from runtime capabilities such as tools, MCP operations, remote agents, models, or network destinations; - command/target/version/precondition contracts; - typed lifecycle/control commands for supported P0 operations; - actor/reason/previous/resulting-state/evidence correlation; @@ -31,7 +31,7 @@ Expose privileged control-plane commands only through an identity-provider-neutr | ID | Candidate | Required result | |---|---|---| | 0.7.6a | Security/control baseline audit | Map current auth hooks, lifecycle APIs, stores and unsafe direct mutations | -| 0.7.6b | Principal + capability contract | IdP-neutral authenticated identity and server-side capability model | +| 0.7.6b | Principal + operator capability contract | IdP-neutral authenticated identity and server-side operator/control-plane capability model; terminology must not collapse operator authority with runtime capability governance | | 0.7.6c | OIDC/Spring boundary | Generic adapter/integration seam without vendor authority semantics in core | | 0.7.6d | Typed control commands | Target identity, expected version/precondition, reason and supported lifecycle operations | | 0.7.6e | Runtime authorization path | Authenticate → authorize → validate → mutate authority; no bypass store path | @@ -46,6 +46,7 @@ UI visibility != authorization privileged mutation requires authenticated actor + server-side authorization stale command cannot silently overwrite newer authoritative state dashboard/client cannot directly mutate authoritative persistence +operator capability != runtime capability ``` ## Acceptance criteria @@ -54,6 +55,7 @@ dashboard/client cannot directly mutate authoritative persistence - Authorization is enforced server-side and is IdP-neutral at the governance boundary. - Successful controls are attributable and reconstructable. - Direct-store/client bypasses are absent from supported control paths. +- Operator authorization vocabulary is unambiguous and does not become the canonical representation for runtime tool/MCP/model capabilities. ## Adversarial proof diff --git a/docs/roadmap/0.7.0/EPIC-0.7.7-SUSPENDED-RUN-CANCELLATION.md b/docs/roadmap/0.7.0/EPIC-0.7.7-SUSPENDED-RUN-CANCELLATION.md index acc8d7791..9f8849db2 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.7-SUSPENDED-RUN-CANCELLATION.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.7-SUSPENDED-RUN-CANCELLATION.md @@ -12,6 +12,7 @@ Make cancellation of a persisted suspended run an authoritative terminal lifecyc ```text cancelled(run) => no subsequent authoritative execution(run) +cancelled(run) != already-produced external effects compensated ``` Once cancellation wins, no model, tool, approval continuation, network action, or workflow side effect may resume that run. @@ -26,13 +27,16 @@ Once cancellation wins, no model, tool, approval continuation, network action, o - restart survival; - idempotent repeated cancellation; - terminal evidence/timeline/reconstruction support; -- cleanup behavior that cannot erase authoritative cancellation semantics. +- cleanup behavior that cannot erase authoritative cancellation semantics; +- evidence semantics that distinguish fencing future execution from compensating or reversing side effects that already occurred. ## Non-goals - redesigning all approval lifetime/replacement semantics; - generic distributed workflow engine rewrite; -- application-emulated cancellation flags. +- application-emulated cancellation flags; +- generic saga/compensation engine; +- claiming that terminal cancellation automatically reverses an email, payment, deployment, external API mutation, or other already-completed side effect. ## Tasks @@ -55,6 +59,7 @@ Once cancellation wins, no model, tool, approval continuation, network action, o - Repeated cancellation is idempotent. - Late approval cannot authorize continuation. - Cancellation remains visible to timeline/reconstruction. +- Timeline/evidence never represents cancellation itself as proof that external side effects completed before cancellation were reversed or compensated. ## Adversarial proof @@ -68,7 +73,7 @@ Required scenarios: 6. cleanup/checkpoint removal after cancellation; 7. stale worker attempts continuation after cancellation. -Any model/tool/network/workflow side effect after authoritative cancellation is a release blocker. +Any model/tool/network/workflow side effect after authoritative cancellation is a release blocker. A run cancelled after an earlier external side effect must also remain reconstructable without implying that cancellation compensated that already-completed effect. ## Mutation expectations diff --git a/docs/roadmap/0.7.0/EPIC-0.7.8-DASHBOARD-INTEGRATION.md b/docs/roadmap/0.7.0/EPIC-0.7.8-DASHBOARD-INTEGRATION.md index 73e51a271..96412d05c 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.8-DASHBOARD-INTEGRATION.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.8-DASHBOARD-INTEGRATION.md @@ -12,6 +12,7 @@ Deliver Dashboard 2.0 as a first-party, replaceable client of the headless contr - workload inventory/detail; - identity/version/owner/purpose/environment/lifecycle posture; +- runtime/framework identity plus integration/enforcement provenance where relevant, without implying that TramAI authored external workloads; - effective classification/policy/trust posture; - provider/model authorization/selection reasons; - semantic run timeline; @@ -35,7 +36,7 @@ Deliver Dashboard 2.0 as a first-party, replaceable client of the headless contr |---|---|---| | 0.7.8a | Dashboard baseline/API inventory | Remove/avoid hidden authority assumptions; map required headless APIs | | 0.7.8b | Control-plane client layer | Typed client models for workload/run/decision/timeline/control/reconstruction | -| 0.7.8c | Workload/detail surface | P0 governance posture and recent operational state | +| 0.7.8c | Workload/detail surface | P0 governance posture, recent operational state, runtime/framework identity, and truthful integration/enforcement provenance | | 0.7.8d | Semantic run/timeline surface | Render authoritative/best-effort/missing distinctions | | 0.7.8e | Approval/control surface | Only server-authorized actions; stale/denied states handled safely | | 0.7.8f | Reconstruction/evidence surface | Expose availability/completeness without re-execution | @@ -56,6 +57,7 @@ protected payloads remain hidden by default - Full P0 loop is usable headlessly and through Dashboard 2.0. - UI displays structured reasons/evidence from control-plane contracts rather than recreating decisions. +- The XR1 reference external workload can display its runtime separately from TramAI governance authority and cannot be visually upgraded from observed/instrumented evidence to authoritative enforcement. - Authorized controls flow through typed server APIs. - Reference sensitive-input scenario proves classification → policy → authorized route → evidence → observation/control → reconstruction. - Persisted suspended-run scenario proves authoritative cancellation and no-reactivation. @@ -63,4 +65,4 @@ protected payloads remain hidden by default ## Adversarial proof -Reject hidden frontend authorization, direct persistence access, reconstructed policy logic in TypeScript/UI, stale controls without server rejection, protected-payload default exposure, and reconstruction implemented as rerun/retry. +Reject hidden frontend authorization, direct persistence access, reconstructed policy logic in TypeScript/UI, stale controls without server rejection, protected-payload default exposure, reconstruction implemented as rerun/retry, or UI language that represents observed/instrumented external-runtime evidence as authoritative TramAI enforcement. diff --git a/docs/roadmap/0.7.0/README.md b/docs/roadmap/0.7.0/README.md index b864d38f4..5b60e73d6 100644 --- a/docs/roadmap/0.7.0/README.md +++ b/docs/roadmap/0.7.0/README.md @@ -63,6 +63,7 @@ Status values: `⚪ Planned` · `🟡 Active` · `🟣 Review` · `✅ Complete` ### Wave B — Authoritative decisions - 0.7.3 Authorized provider/model selection +- [XR1 External-runtime authority proof](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) — Spring AI reference integration; required before 0.7.4 evidence/projection contracts are frozen ### Wave C — Evidence & observability - 0.7.4 Evidence/projection/query API @@ -87,7 +88,11 @@ providerBoundInput = projection(canonicalInput, selectedDeployment, effectivePol provider-specific transformation never mutates canonicalInput provider-changing fallback => recompute providerBoundInput required minimization/inspection failure => no provider invocation +governed workload != TramAI-authored workflow +same authoritative facts + same policy/configuration => same governance decision +observed != enforced cancelled(run) => no subsequent authoritative execution(run) +cancelled(run) != already-produced external effects compensated reconstruction != re-execution Dashboard != policy authority ``` @@ -101,7 +106,8 @@ Dashboard != policy authority 3. [`QUALITY-GATES.md`](QUALITY-GATES.md) is green at the exact release head; 4. the reference control-plane scenario, provider-input release/minimization scenario, and persisted-cancellation scenario pass end to end; 5. no deferred 0.8/0.9/0.10 scope has leaked into the release without an explicit release-cut change; -6. `release/0.7.0 → master` is a certification/promotion PR, not a first-time integration event. +6. `release/0.7.0 → master` is a certification/promotion PR, not a first-time integration event; +7. the [XR1 external-runtime authority proof](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) proves that one non-TramAI-authored JVM workload can cross an authoritative TramAI governance boundary without a second policy model or an overstated enforcement claim. ## Execution documents @@ -109,4 +115,5 @@ Dashboard != policy authority - [`DEPENDENCIES.md`](DEPENDENCIES.md) - [`QUALITY-GATES.md`](QUALITY-GATES.md) - [`SPEC-TEMPLATE.md`](SPEC-TEMPLATE.md) +- [`CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md`](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) - Epic specs linked in the board above.