Author SHA1 Message Date
Hare 5c19bfbad0 feat: integrate typed worker failure presentation 2026-11-06 12:28:50 +09:00
Hare dab8c019cb fix(protocol): refresh generated failure JSON schemas 2026-11-06 12:17:31 +09:00
Hare a65875fd66 fix(web): validate corrected startup HTTP and align generated schemas 2026-11-06 12:14:20 +09:00
Hare 9454f153fe fix(web): align startup consumer with corrected T812 Rust boundary 2026-11-06 12:01:17 +09:00
Hare 4664f225b3 fix(startup): retain postconnection creation facts and safe observations 2026-11-06 11:57:30 +09:00
Hare 29d41d883c fix(test): connect captured startup HTTP to the Web contract 2026-11-06 11:20:13 +09:00
Hare bf796f8db2 feat(web): present typed startup causes and gate uncertain launches 2026-11-06 11:06:10 +09:00
Hare 590a270757 build(web): consume T812 public startup contracts and HTTP fixtures 2026-11-06 11:06:10 +09:00
Hare 8d39122294 fix(api): enable typed creation error hooks on generated Backend clients 2026-11-06 10:35:14 +09:00
Hare ba560ad226 fix(startup): preserve typed creation outcomes across Backend boundaries 2026-11-06 10:29:32 +09:00
Hare 838b6cd3c3 fix(web): validate problem references and distinguish read failures 2026-11-06 07:41:47 +09:00
Hare 8c71ec719a feat(web): add bounded shared failure reception and presentation 2026-11-06 07:27:21 +09:00
Hare 4de62650da fix(standalone): retain exact consolidation admission through recovery
Own accepted never-dispatched consolidation reservations across actual capacity and typed source storage failures. Subscribe before capacity checks, revalidate original binding/attempt/input/grant/retirement and all prior cleanup, join successful resource owners, preserve one deadline and do not redispatch admitted executions. Distinguish settled/admission-required/retained outcomes so Reserved storage recovery transfers the original admission.

Persist pre-dispatch refusal using existing rejection evidence with a v8 guard-only migration, retaining immutable v7 text and old Job cutover semantics. Actual migration rollback/retry/reopen/fresh-schema and execution-negative controls; no operating migration or inferred cleanup.

Validation: old-source D1 red2/D2 red2; focused11 and nonempty finalizer integration; parent normal standalone159+11+9(ignored1)+9/doc0; downstream connected7; root/doc/fmt/names/diff. Child long-temp SUN_LEN failures retained; same-source full parent package passes under prior short isolated temp root. 1230 actual source hashes unchanged during validation. Fresh whole-current/all-seven-AC formal review pending; no integration or Ticket conclusion.
2026-11-06 02:31:16 +09:00
Hare c0b0afe082 fix(jobs): retain domain recovery and establish lifecycle durability once
Keep the original standalone consolidation storage recovery owner beyond fast settlement, with typed temporary causes, capped backoff and current binding/attempt/input/grant/cleanup checks. Permanent constraints and authority loss retain diagnostics without blind execution retries.

Separate successful lifecycle durability establishment from ordinary canonical lock acquisition. Pin and recheck ancestors and scoped lock identity before/after every OS lock wait; never cache DB identity or authorization. Retry all sync steps after failure, reestablish for each new manager, and retain full persistence for configured Host symlinks. Add bounded manager-local diagnostics and actual replacement/failure controls.

Validation: Feature-storage ordinary46/test-support50 and docs; standalone ordinary147+11+9(1ignored)+9; Subject196; Runtime628+20+20; exact-current connected/cleanup15; root check/fmt/names/diff. Server uses package-only opt0/debug0/codegen256, not normal-profile performance proof. Keep historical Journal phase-unknown failure separate; initial fixture failure and later admission-order correction are retained in scratch evidence. Fresh whole-source/all-seven-AC formal review remains pending; no integration, operating DB migration or case closure.
2026-11-06 01:29:47 +09:00
Hare 45c2ade158 fix(job): retain source-aware cleanup storage recovery
Preserve typed removal storage causes across initial/resumed canonical cleanup and retain exact Feature operation ownership after settlement. Keep public removal response adaptation at the outer boundary. Exercise actual Feature lock and catalog capacity outages beyond budget, original-child recovery, stale-child and permanent-constraint negatives.

Focused cleanup 7 passed; affected removal/fence boundaries 10 passed, 1 real Journal recording wait failed at first-body before checkpoint. G1 latency cause remains unresolved; no whole-suite/all-AC success or approval claimed. Root check, format, state names, diff and source hash checks passed. No operating migration, integration or close.
2026-11-06 00:19:29 +09:00
Hare a5ff591311 fix(journal): own accepted reservations before Host enqueue
Keep the same durable Journal attempt owned even when the eager Host lane rejects admission. A finite-lane barrier reproduces the missing owner before the fix and verifies autonomous dispatch with one original input/attempt after capacity returns.

Use a catalog commit fault before checkpoint publication for the independent completion/cleanup regression; pin/unpin changes canonical removal authority and is not a retryable storage fault. Preserve canonical child identity and the separate staleness negative.

Validated affected seven-case owner boundaries, root check, format, state names and diff. Incomplete/unapproved checkpoint: historical Journal recording wait non-convergence remains unexplained; these fixes are not proof of its cause. Fresh exact-source formal review remains blocked by unavailable typed review-request/capability-update tools and the recorded Flow verifier failure. No integration or operating-data migration.
2026-11-05 22:12:44 +09:00
Hare 58a25cf6af test(server): retain bounded Journal convergence diagnostics
Distinguish first and next body waits and retain bounded tool-result, exact attempt/run, Runtime and recovery-owner observations without changing assertions or the 30-second limit.

Diagnostic checkpoint only. Instrumented original 44-case reproduction passed once, but the earlier 43/44 failure remains unexplained; no root-cause or full-AC approval claim.
2026-11-05 06:43:26 +09:00
Hare 7a7589ae6e fix(job): reconcile retained owners and diagnose cleanup admission failures
Own all startup recovery pages fairly, consume original scoped removal history, and distinguish ordinary Worker failure from retained cleanup obligations.

Persist exact-owner Host admission preflight diagnostics without inventing execution fences, and retain original Stop identity across uncertain acknowledgements.

Checkpoint only: Runtime normal package passes; standalone selected target closure passes after fixture corrections; Server owner closure is 43/44 with a retained real-Journal convergence failure. Not approved or integrated.
2026-11-05 06:14:08 +09:00
Hare 0b002cf34f test(server): seed unstarted restore heads without synthetic shutdown
Use reserved launch snapshot and real Host registration for the pending first head. Run blocking Stop outside the test reactor, retain exact callee termination and joined-task diagnostics, and keep restore attribution/queue invariants.

Unapproved incomplete checkpoint: Server package-only profile build succeeds; latest broad focus 30/32, then Journal release succeeds after reactor fix while existing-attribution teardown still times out waiting for real Shutdown ACK. No termination is fabricated and assertions remain. Root check/fmt/diff/names pass; ordinary Server profile build and whole-source review remain outstanding.
2026-11-05 04:11:08 +09:00
Hare 6383b20501 fix(runtime): reconcile cold Stop owners before Subject cleanup
Retain exact canonical termination for pending Stop recovery, separate controller join from factory cleanup, and confirm removed originals only from an existing Subject receipt. Preserve cancellation and missing-proof refusal boundaries. Bind HTTP/WS fixtures to real scoped Controller ownership.

Checkpoint validation: normal Runtime lib build and 38 focused cases passed; normal affected suite previously 619/627, not rerun as all-green. Root check, format, names and diff pass. Migration focused runner 24 pass. Incomplete and unapproved: ordinary removal-before-Subject history consumer, one connected Server restored-controller teardown, final affected suites and fresh whole-source review remain.
2026-11-05 04:11:08 +09:00
Hare 0c53ff0282 merge: reconcile integrated configuration adoption with Job recovery 2026-11-05 02:02:04 +09:00
Hare f25cd9ee9a fix(api): generate configuration adoption imports from canonical type owners 2026-11-05 01:58:24 +09:00
Hare e6db25497d test(server): join prepared restore owners with operation-bound Stop 2026-11-05 01:58:24 +09:00
Hare 50c5c80527 test(standalone): bind retry and migration fixtures to retained contracts 2026-11-05 01:58:24 +09:00
Hare 0f86465929 fix(runtime): consume original Stop receipts for worker deletion 2026-11-05 01:58:24 +09:00
Hare 70889c7c1c feat: integrate atomic configuration and policy adoption 2026-11-05 01:40:38 +09:00
Hare 7181287dfc merge: reconcile integrated Session cause fixes into Job recovery 2026-11-05 01:09:59 +09:00
Hare 21e5fb11de fix: return committed report receipts without waiting for delivery 2026-11-05 01:01:27 +09:00
Hare 0d1840249b fix: fence original host writers and retain canonical job cleanup children 2026-11-05 00:57:32 +09:00
Hare cfffc80c36 fix: retain original controller termination and exact submission evidence 2026-11-05 00:57:32 +09:00
Hare 27b401e025 fix: reconcile typed domain storage faults within the original job deadline
Keep Feature storage causes through Journal, Subject authorization and consolidation boundaries. Reconcile the exact operation and receipt with capped backoff until the original accepted attempt deadline, without treating another database write as domain recovery. Retain the original deadline when claiming dispatch; do not fabricate execution termination.

Validated: real independent Journal DB writer lock beyond initial settlement, both absent and existing generation, same Job/attempt/input and one submit after release; deadline failure retains cause and input. Two new tests pass, adjacent Server 13, domain 4, standalone 20+13, root check/fmt/names/diff pass and 608 source hashes match. Initial 300s build timeout is retained; second build passes. Unfinished/unapproved checkpoint: R1 lost original Host/Controller evidence and R6/final suites/review remain open.
2026-11-04 22:37:09 +09:00
Hare 50a828a008 fix: retain exact standalone result recovery owners across capacity and job errors
Recover captured Job/attempt identities independently; wait for actual task return and cleanup before freeing execution capacity. Preserve per-job rejection and wait causes without dropping subsequent reservations. Add one-time additive v7 admission-rejection evidence, never infer execution drain from Failed or an absent map.

Validated: parent Jobs 20 tests and store/recovery/migration 13 tests on the current dependency snapshot; root check, format, names and diff pass. This is an unfinished, unapproved T-841 checkpoint; R1/R6 and final whole-contract validation/review remain outstanding.
2026-11-04 22:37:09 +09:00
Hare 5851e6d7f2 fix: advance cleanup recovery past already owned pages
Use immutable Job/attempt keyset pagination rather than rereading a mutable first page and stopping on deduplication. Selection stays read-only; exact cleanup claim, execution fences and canonical removal are unchanged.

The real Server regression fails at 64/66 owners before the fix. After the fix it holds all 66 before claim, deduplicates a concurrent sweep, and drains every canonical cleanup without new input. Keep exact Worker/run/original failure and no-resubmit assertions. The initial 60s drain bound diagnosed 42 complete/24 live, so the bounded stress timeout is now 3s per Worker. Regression passes; 12 cleanup/store cases, root cargo check, full format and diff checks pass. Remaining T841 major findings/full-contract review are still open.
2026-11-04 21:44:02 +09:00
T840 Coder ddfea7660e Preserve typed Session refusal at pending release fence 2026-11-04 21:36:35 +09:00
Hare 9f948151b6 fix: keep prepared dirty consolidation owned until terminal receipt
Address R2 from T841 MR 01a1a65b-0d99-7631-b994-a83888bf4c2b: a current-fingerprint dirty surface without terminal fields is unfinished work, not a definitive recovery failure. Keep stale/inconsistent evidence rejection. Add an actual owner-tick barrier test between prepare and terminal publication and verify same Job/attempt/input/run completion without second submission.

Domain regression fails before fix and four domain cases pass after fix. Server integration: 1 pass; root cargo check, full format and diff checks pass. Other major review findings and whole-Ticket validation remain open; this is not a reviewed completion.
2026-11-04 21:07:53 +09:00
Hare 8613f2df7d fix: preserve uncertain config effects and exercise owner adoption fixtures
Select identifier-free startup summaries from T-840 1d8c205b, without adopting that whole branch or its review status. Keep physical identity guards, migration rollback assertions and old-writer fencing intact.
2026-11-04 20:48:18 +09:00
Hare 6c67390567 fix: retain Session recovery failures across real trust rejection 2026-11-04 20:39:16 +09:00
Hare cf2c39f808 feat: checkpoint owned Job recovery and one-time upgrade failure
Incomplete, unapproved T841 checkpoint. Separate normal same-attempt
reconciliation from the version-upgrade conversion of legacy unfinished
Jobs. Preserve exact allocation/submission identity, immutable input,
independent Controller/Host fences and cleanup ownership; add
same-transaction preallocation rejection evidence for historical consumers.
Journal reconciliation uses persisted operations/checkpoints, never live
input reselection.

Validated root cargo check, 34 focused Server/store/Host tests, 24 upgrade
tests, historical erasure over 100 Jobs, formatting, state-contract names
and diff checks. Full acceptance remains unverified: Executing cleanup
restart without retained Host/removal evidence, frozen-source race,
remaining Journal/NonJournal failures and full crate/generated validation.
Not a completed reviewed handoff or target integration.
2026-11-04 20:38:33 +09:00
Hare c6756bba2e chore: retain pinned configuration and Session prerequisites
Preserve the already-applied dependency tree from T838 source 9f8aad79a3, including its T840 prerequisites. This is dependency attribution for the incomplete T841 checkpoint, not adoption of later dependency changes, inherited approval, integration, or a target-branch update.
2026-11-04 20:33:09 +09:00
Hare eb64644c9b fix: validate repository access within owner adoption transaction 2026-11-04 20:05:09 +09:00
Hare e6c95894c5 fix: validate owner adoption clients publication and storage lifecycle 2026-11-04 19:34:58 +09:00
Hare 9fadd1bcaf fix: retain local Session binding causes and factory sources safely 2026-11-04 19:06:41 +09:00
Hare 1d8c205b2e fix: keep attested Session operation actionable in safe startup summaries 2026-11-04 18:06:13 +09:00
Hare 10e16bb77d fix: retain Session domain in factory binding regression fixtures 2026-11-04 17:35:35 +09:00
Hare f8262bbab8 fix: assert typed registration causes separately from attribution lifecycle 2026-11-04 17:21:22 +09:00
Hare 4bc5c231d2 test: retain real Session denial alongside metadata save failure 2026-11-04 17:09:47 +09:00
Hare 1ff50d9b54 fix: synchronize permission contract with typed error attachment 2026-11-04 16:59:32 +09:00
Hare 9f8aad79a3 feat: connect owner configuration adoption and guarded policy cutover
Integration checkpoint for T-838, not a completed reviewed handoff. Includes published T-840 startup dependencies. Root check, API artifacts and focused HTTP/cutover tests executed; Unicode renderer roundtrip still fails on the separately owned Decodal 0.4.0 issue (T-830). Web validation/visual review, broad suites and independent review remain outstanding.
2026-11-04 16:54:54 +09:00
Hare 075ea4e440 fix: initialize real Session store in local registration fixture 2026-11-04 16:24:00 +09:00
Hare 6d43025a30 Merge commit '6f5b165831a7f4ce63758c0f63027e56c5028cd8' into work/t838-source-policy-adoption 2026-11-04 16:20:34 +09:00
Hare 6f5b165831 test: cover authorization judgments transfer privacy and failure investigation 2026-11-04 16:11:33 +09:00
Hare 25e8f7ae57 Merge commit '7873f4359ab62c83107591afd1a8d9f1311bd26b' into work/t838-source-policy-adoption 2026-11-04 15:54:35 +09:00
Hare f814e808ec wip: retain atomic configuration adoption core before startup dependency integration
Local checkpoint only. HTTP wiring, focused migration retest, generated artifacts and review remain incomplete; not a reviewed or validated handoff.
2026-11-04 15:54:35 +09:00
Hare 7873f4359a feat: transfer typed startup causes over Runtime HTTP with real registration fixture 2026-11-04 15:50:30 +09:00
Hare acbaaaa1d6 feat: retain typed session registration causes across host startup 2026-11-04 15:29:35 +09:00
Hare c4a34cf0cc feat: retain typed session registration causes across host startup 2026-11-04 15:25:24 +09:00
Hare c63a750943 feat: integrate reviewed storage identity with rebuild foundations 2026-11-04 14:01:45 +09:00
Hare 273aa44244 fix: preserve restore provenance and cached catalog identity
Bind completed restore staging to its exact source and request, retain adopted legacy-live loss recovery, and lease the managed catalog through cached Drive transactions. Add interrupted restore, legacy recovery, and production consumer regressions.
2026-11-04 13:50:00 +09:00
Hare a8b9afc35c feat: integrate typed startup causes and Session persistence 2026-11-04 13:14:59 +09:00
Hare 80e7334513 feat: integrate Feature permission declarations with content history 2026-11-04 13:13:48 +09:00
Hare 21fe1a7a31 fix: bind startup cause persistence to confirmed Session transitions 2026-11-04 12:18:37 +09:00
Hare dfa04107ab feat: bind FeatureStorage identity and restore to persistent owners 2026-11-04 12:11:35 +09:00
Hare d1cd2855e8 Preserve explicit permission exclusions through disabled Feature declarations 2026-11-04 11:47:45 +09:00
Hare 9e404b0021 Verify typed startup failures across factory adapter and protocol 2026-11-04 11:47:05 +09:00
Hare 326616d07e Preserve startup causes through Runtime rollback and Session metadata CAS 2026-11-04 11:31:12 +09:00
Hare e166117c60 Exercise permission catalog and resolution management DTO roundtrips 2026-11-04 11:28:44 +09:00
Hare a7f5ecb56a Bind Feature declarations to Host handles and publish permission contract fixtures
T836: registered Feature callback/namespace registrar, explicit children/catalog resolution, frozen child and Job key tests, payload-free text/storage/API/Web wire contract and generated OpenAPI/TypeScript. Real LocalWorkdirSession checks preserve path, recursion and symlink denial independently of allowed keys. No policy adoption/migration, real-operation authorization or Worker defaults.

Validated: feature-permissions 22 tests and 3 doctests; worker declaration/provider filter 4; server-api permission filter 2; typed Deno Web 2; exact generated artifacts and format/diff/naming. Full root check/changed-crate closure and independent MR verdict remain to be run before final reviewed handoff. Pinned dependency format is exact rustfmt output, including formatter optional punctuation/closure braces, not a claim of bytewise non-whitespace token identity.
2026-11-04 11:19:17 +09:00
Hare 9f09159e2f Validate scoped content adoption, native WASM parity, and Server rollback 2026-11-04 11:11:22 +09:00
Hare 267f4d6842 Complete the pinned parity example module closure
Adopt only tests/common/mod.rs from exact T835 checkpoint 29cb24a878. The already-adopted 223aa125 parity example references this missing module, preventing root cargo fmt. No in-progress product fixes or full test suites are incorporated.
2026-11-04 11:04:33 +09:00
Hare 3078d0cee9 chore: format the pinned content history foundation
Only rustfmt normalization of exact T835 dependency snapshot 223aa125de. Diff reviewed: whitespace and optional trailing-comma changes only; string literal sequences unchanged. No semantic fixes, manifests or lock changes adopted from the in-progress branch.
2026-11-04 11:04:33 +09:00
Hare 9d8175f52e Add projected startup cause and secondary failure contracts 2026-11-04 11:01:56 +09:00
Hare 2998f53de7 Establish payload-free permission contracts and coordinated dependency checkpoint
T836 declaration/resolver leaf; no policy adoption or real-operation authorization. Includes T843 confirmed error-contract dependency edges and T835 Server test dependency edges from exact 29cb24a878. T835 foundation source 223aa125de was adopted separately. External lock pins unchanged. Initial resolver12/wire8 passed before catalog snapshot freshness refinement; post-refinement Rust validation pending build-slot handoff.
2026-11-04 10:50:47 +09:00
Hare 29cb24a878 Exercise canonical fixtures, scoped persistence, and Server transaction contracts 2026-11-04 10:47:53 +09:00
Hare c0559fa251 Introduce canonical content history and scoped transaction foundation 2026-11-04 10:44:09 +09:00
Hare 223aa125de Introduce canonical content history and scoped transaction foundation 2026-11-04 10:41:44 +09:00
311 changed files with 74208 additions and 6406 deletions
Generated
+57
View File
@@ -199,6 +199,7 @@ dependencies = [
"api-macros-impl", "api-macros-impl",
"axum", "axum",
"bytes", "bytes",
"error-contract",
"futures", "futures",
"oas3", "oas3",
"reqwest", "reqwest",
@@ -837,6 +838,35 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "content-history"
version = "0.1.0"
dependencies = [
"sha2 0.11.0",
"thiserror 2.0.18",
]
[[package]]
name = "content-history-adapters"
version = "0.1.0"
dependencies = [
"config-source",
"content-history",
"thiserror 2.0.18",
]
[[package]]
name = "content-history-store"
version = "0.1.0"
dependencies = [
"config-source",
"content-history",
"content-history-adapters",
"rusqlite",
"tempfile",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.10.0"
@@ -1594,6 +1624,18 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "feature-permissions"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
"serde_json",
"thiserror 2.0.18",
"toml",
"ts-rs",
]
[[package]] [[package]]
name = "feature-storage" name = "feature-storage"
version = "0.1.0" version = "0.1.0"
@@ -4284,12 +4326,15 @@ name = "runtime-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"api-macros", "api-macros",
"error-contract",
"manifest", "manifest",
"plugin-plan", "plugin-plan",
"protocol", "protocol",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"session-store", "session-store",
"sha2 0.11.0",
"tokio", "tokio",
"workdir", "workdir",
] ]
@@ -4723,6 +4768,8 @@ name = "server-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"api-macros", "api-macros",
"error-contract",
"feature-permissions",
"memory", "memory",
"merge-request", "merge-request",
"plugin-plan", "plugin-plan",
@@ -4768,6 +4815,7 @@ dependencies = [
"agen", "agen",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"error-contract",
"filetime", "filetime",
"fs4", "fs4",
"futures", "futures",
@@ -4997,6 +5045,7 @@ dependencies = [
"agen", "agen",
"async-trait", "async-trait",
"client", "client",
"error-contract",
"feature-storage", "feature-storage",
"fs4", "fs4",
"futures", "futures",
@@ -7272,6 +7321,8 @@ dependencies = [
"client", "client",
"config-source", "config-source",
"dotenv", "dotenv",
"error-contract",
"feature-permissions",
"flow", "flow",
"fs-operation", "fs-operation",
"fs4", "fs4",
@@ -7328,6 +7379,7 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"decodal", "decodal",
"error-contract",
"flow", "flow",
"fs4", "fs4",
"futures", "futures",
@@ -7530,6 +7582,11 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"config-source", "config-source",
"content-history",
"content-history-adapters",
"content-history-store",
"error-contract",
"feature-permissions",
"feature-storage", "feature-storage",
"flate2", "flate2",
"flow", "flow",
+12
View File
@@ -31,10 +31,14 @@ members = [
"crates/builtin-source", "crates/builtin-source",
"crates/config-source", "crates/config-source",
"crates/config-source-wasm", "crates/config-source-wasm",
"crates/content-history",
"crates/content-history-store",
"crates/content-history-adapters",
"crates/workdir", "crates/workdir",
"crates/tui", "crates/tui",
"crates/memory", "crates/memory",
"crates/feature-storage", "crates/feature-storage",
"crates/feature-permissions",
"crates/workspace-drive", "crates/workspace-drive",
"crates/subjektiv", "crates/subjektiv",
"crates/ticket", "crates/ticket",
@@ -74,10 +78,14 @@ default-members = [
"crates/builtin-source", "crates/builtin-source",
"crates/config-source", "crates/config-source",
"crates/config-source-wasm", "crates/config-source-wasm",
"crates/content-history",
"crates/content-history-store",
"crates/content-history-adapters",
"crates/workdir", "crates/workdir",
"crates/tui", "crates/tui",
"crates/memory", "crates/memory",
"crates/feature-storage", "crates/feature-storage",
"crates/feature-permissions",
"crates/workspace-drive", "crates/workspace-drive",
"crates/subjektiv", "crates/subjektiv",
"crates/ticket", "crates/ticket",
@@ -118,6 +126,7 @@ mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" } lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
feature-storage = { path = "crates/feature-storage" } feature-storage = { path = "crates/feature-storage" }
feature-permissions = { path = "crates/feature-permissions" }
workspace-drive = { path = "crates/workspace-drive" } workspace-drive = { path = "crates/workspace-drive" }
subjektiv = { path = "crates/subjektiv" } subjektiv = { path = "crates/subjektiv" }
merge-request = { path = "crates/merge-request" } merge-request = { path = "crates/merge-request" }
@@ -140,6 +149,9 @@ secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }
builtin-source = { path = "crates/builtin-source" } builtin-source = { path = "crates/builtin-source" }
config-source = { path = "crates/config-source" } config-source = { path = "crates/config-source" }
content-history = { path = "crates/content-history" }
content-history-store = { path = "crates/content-history-store" }
content-history-adapters = { path = "crates/content-history-adapters" }
error-contract = { path = "crates/error-contract" } error-contract = { path = "crates/error-contract" }
fs-operation = { path = "crates/fs-operation" } fs-operation = { path = "crates/fs-operation" }
workdir = { path = "crates/workdir" } workdir = { path = "crates/workdir" }
+181 -32
View File
@@ -53,6 +53,7 @@ enum Method {
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
struct AdapterConfig { struct AdapterConfig {
reqwest: bool, reqwest: bool,
client_error_hook: bool,
axum: bool, axum: bool,
openapi: bool, openapi: bool,
} }
@@ -64,9 +65,10 @@ impl Parse for AdapterConfig {
for adapter in adapters { for adapter in adapters {
match adapter.to_string().as_str() { match adapter.to_string().as_str() {
"reqwest" if !config.reqwest => config.reqwest = true, "reqwest" if !config.reqwest => config.reqwest = true,
"client_error_hook" if !config.client_error_hook => config.client_error_hook = true,
"axum" if !config.axum => config.axum = true, "axum" if !config.axum => config.axum = true,
"openapi" if !config.openapi => config.openapi = true, "openapi" if !config.openapi => config.openapi = true,
"reqwest" | "axum" | "openapi" => { "reqwest" | "axum" | "openapi" | "client_error_hook" => {
return Err(syn::Error::new(adapter.span(), "duplicate API adapter")); return Err(syn::Error::new(adapter.span(), "duplicate API adapter"));
} }
_ => { _ => {
@@ -77,6 +79,12 @@ impl Parse for AdapterConfig {
} }
} }
} }
if config.client_error_hook && !config.reqwest {
return Err(syn::Error::new(
Span::call_site(),
"`client_error_hook` requires the `reqwest` adapter",
));
}
Ok(config) Ok(config)
} }
} }
@@ -121,6 +129,8 @@ struct RouteArgs {
responses: Vec<DeclaredResponse>, responses: Vec<DeclaredResponse>,
error_status: Option<LitInt>, error_status: Option<LitInt>,
additional_error_statuses: Vec<LitInt>, additional_error_statuses: Vec<LitInt>,
additional_error_content_types: Option<Vec<LitStr>>,
client_error_hook: Option<syn::LitBool>,
bearer_auth: Option<syn::LitBool>, bearer_auth: Option<syn::LitBool>,
browser_auth: Option<syn::LitBool>, browser_auth: Option<syn::LitBool>,
normalize_body_errors: Option<syn::LitBool>, normalize_body_errors: Option<syn::LitBool>,
@@ -310,6 +320,8 @@ impl Parse for RouteArgs {
responses: Vec::new(), responses: Vec::new(),
error_status: None, error_status: None,
additional_error_statuses: Vec::new(), additional_error_statuses: Vec::new(),
additional_error_content_types: None,
client_error_hook: None,
bearer_auth: None, bearer_auth: None,
browser_auth: None, browser_auth: None,
normalize_body_errors: None, normalize_body_errors: None,
@@ -379,6 +391,25 @@ impl Parse for RouteArgs {
} }
} }
} }
"additional_error_content_types" => {
let content;
syn::bracketed!(content in input);
let values = Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?
.into_iter()
.collect();
set_once(
&mut result.additional_error_content_types,
values,
&key,
"additional_error_content_types",
)?;
}
"client_error_hook" => set_once(
&mut result.client_error_hook,
input.parse::<syn::LitBool>()?,
&key,
"client_error_hook",
)?,
"bearer_auth" => set_once( "bearer_auth" => set_once(
&mut result.bearer_auth, &mut result.bearer_auth,
input.parse::<syn::LitBool>()?, input.parse::<syn::LitBool>()?,
@@ -406,7 +437,7 @@ impl Parse for RouteArgs {
_ => { _ => {
return Err(syn::Error::new( return Err(syn::Error::new(
key.span(), key.span(),
"unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, bearer_auth, browser_auth, normalize_body_errors, or openapi", "unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, additional_error_content_types, client_error_hook, bearer_auth, browser_auth, normalize_body_errors, or openapi",
)); ));
} }
} }
@@ -502,6 +533,8 @@ struct Operation {
alternate_status: Option<u16>, alternate_status: Option<u16>,
error_status: Option<u16>, error_status: Option<u16>,
additional_error_statuses: Vec<u16>, additional_error_statuses: Vec<u16>,
additional_error_content_types: Vec<String>,
client_error_hook: bool,
bearer_auth: bool, bearer_auth: bool,
browser_auth: bool, browser_auth: bool,
normalize_body_errors: bool, normalize_body_errors: bool,
@@ -546,7 +579,7 @@ fn normalize_api(
for mut trait_item in std::mem::take(&mut item.items) { for mut trait_item in std::mem::take(&mut item.items) {
let operation = match &mut trait_item { let operation = match &mut trait_item {
TraitItem::Fn(method) => { TraitItem::Fn(method) => {
let operation = normalize_operation(method)?; let operation = normalize_operation(method, adapters)?;
retained_items.push(trait_item); retained_items.push(trait_item);
operation operation
} }
@@ -778,6 +811,8 @@ fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::R
alternate_status: None, alternate_status: None,
error_status: None, error_status: None,
additional_error_statuses: Vec::new(), additional_error_statuses: Vec::new(),
additional_error_content_types: Vec::new(),
client_error_hook: false,
bearer_auth: false, bearer_auth: false,
browser_auth: false, browser_auth: false,
normalize_body_errors: false, normalize_body_errors: false,
@@ -796,7 +831,10 @@ fn validate_frame_type(ty: &Type) -> syn::Result<()> {
} }
} }
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> { fn normalize_operation(
method: &mut TraitItemFn,
adapters: AdapterConfig,
) -> syn::Result<Operation> {
validate_signature(method)?; validate_signature(method)?;
let mut route = None; let mut route = None;
@@ -845,6 +883,53 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
validate_named_body_type(ty, "error response")?; validate_named_body_type(ty, "error response")?;
} }
let client_error_hook = route
.client_error_hook
.as_ref()
.is_some_and(|value| value.value);
if client_error_hook {
let option = route.client_error_hook.as_ref().expect("enabled hook");
if !adapters.reqwest {
return Err(syn::Error::new_spanned(
option,
"client_error_hook requires the `reqwest` adapter",
));
}
if error_body.is_none() {
return Err(syn::Error::new_spanned(
option,
"client_error_hook requires a public error response type",
));
}
}
let mut additional_error_content_types = Vec::new();
if let Some(content_types) = &route.additional_error_content_types {
if error_body.is_none() {
return Err(syn::Error::new_spanned(
&route.path,
"additional_error_content_types require a public error response type",
));
}
if content_types.is_empty() {
return Err(syn::Error::new_spanned(
&route.path,
"additional_error_content_types must not be empty",
));
}
for content_type in content_types {
let value = validate_error_content_type(content_type)?;
if value == "application/json" || additional_error_content_types.contains(&value) {
return Err(syn::Error::new_spanned(
content_type,
"additional error content types must be distinct from each other and application/json",
));
}
additional_error_content_types.push(value);
}
additional_error_content_types.sort();
}
let declared_responses = !route.responses.is_empty(); let declared_responses = !route.responses.is_empty();
if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) { if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
@@ -1196,6 +1281,8 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
alternate_status, alternate_status,
error_status, error_status,
additional_error_statuses, additional_error_statuses,
additional_error_content_types,
client_error_hook,
bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value), bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value),
browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value), browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value),
normalize_body_errors: route normalize_body_errors: route
@@ -1206,6 +1293,43 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
}) })
} }
// Route media declarations are concrete type/subtype tokens, not media ranges or parameters.
fn validate_error_content_type(literal: &LitStr) -> syn::Result<String> {
let value = literal.value().to_ascii_lowercase();
let valid_token = |token: &str| {
!token.is_empty()
&& token.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
};
if !value
.split_once('/')
.is_some_and(|(kind, subtype)| valid_token(kind) && valid_token(subtype))
{
return Err(syn::Error::new_spanned(
literal,
"error content type must be a concrete type/subtype without parameters",
));
}
Ok(value)
}
fn validate_signature(method: &TraitItemFn) -> syn::Result<()> { fn validate_signature(method: &TraitItemFn) -> syn::Result<()> {
let sig = &method.sig; let sig = &method.sig;
if sig.asyncness.is_none() { if sig.asyncness.is_none() {
@@ -1815,17 +1939,27 @@ fn reqwest_adapter_tokens(
} }
} }
}; };
let public_error = match operation.error_body.as_ref() { let public_error_decode = if api.adapters.client_error_hook || operation.client_error_hook {
Some(error) => quote! { quote! {
if (400..=599).contains(&__actual) { <#error_type as #api_crate::reqwest::HttpClientError>::decode_error(&__response)
let __status = __response.status(); .map_err(#api_crate::reqwest::ClientError::failure)?
let __error = __response }
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError) } else {
quote! {
__response
.decode_json::<#error_type>(#api_crate::reqwest::DecodeKind::PublicError)
.map_err(|_| #api_crate::reqwest::ClientError::failure( .map_err(|_| #api_crate::reqwest::ClientError::failure(
#api_crate::reqwest::ClientFailure::ErrorResponseDecode { #api_crate::reqwest::ClientFailure::ErrorResponseDecode {
status: __status.as_u16(), status: __status.as_u16(),
}, },
))?; ))?
}
};
let public_error = match operation.error_body.as_ref() {
Some(_) => quote! {
if (400..=599).contains(&__actual) {
let __status = __response.status();
let __error = #public_error_decode;
return ::core::result::Result::Err( return ::core::result::Result::Err(
#api_crate::reqwest::ClientError::public(__status, __error), #api_crate::reqwest::ClientError::public(__status, __error),
); );
@@ -2098,7 +2232,7 @@ fn axum_adapter_tokens(
status, status,
rejection.body_text(), rejection.body_text(),
); );
return #api_crate::axum::error_response( return #api_crate::axum::http_error_response(
#api_crate::axum::status(status), #api_crate::axum::status(status),
error, error,
); );
@@ -2132,7 +2266,7 @@ fn axum_adapter_tokens(
status, status,
rejection.body_text(), rejection.body_text(),
); );
return #api_crate::axum::error_response( return #api_crate::axum::http_error_response(
#api_crate::axum::status(status), #api_crate::axum::status(status),
error, error,
); );
@@ -2218,7 +2352,7 @@ fn axum_adapter_tokens(
let error = match operation.error_status { let error = match operation.error_status {
Some(_) => quote!({ Some(_) => quote!({
let status = #api_crate::HttpError::status_code(&error); let status = #api_crate::HttpError::status_code(&error);
#api_crate::axum::error_response(#api_crate::axum::status(status), error) #api_crate::axum::http_error_response(#api_crate::axum::status(status), error)
}), }),
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }), None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
}; };
@@ -2319,7 +2453,10 @@ fn openapi_adapter_tokens(
let path = &operation.path; let path = &operation.path;
let operation_id = &operation.operation_id; let operation_id = &operation.operation_id;
let parameters = operation.parameters.iter().filter_map(|parameter| { let parameters = operation.parameters.iter().filter_map(|parameter| {
if matches!(parameter.location, Location::Body | Location::Binary | Location::Extension) { if matches!(
parameter.location,
Location::Body | Location::Binary | Location::Extension
) {
return None; return None;
} }
let ty = &parameter.ty; let ty = &parameter.ty;
@@ -2334,7 +2471,10 @@ fn openapi_adapter_tokens(
operation.parameter::<#ty>(#name, #location)?; operation.parameter::<#ty>(#name, #location)?;
}) })
}); });
let request = operation.request_body.as_ref().map(|body| match body.wire_kind { let request = operation
.request_body
.as_ref()
.map(|body| match body.wire_kind {
RequestWireKind::Json => { RequestWireKind::Json => {
let ty = &body.ty; let ty = &body.ty;
quote! { quote! {
@@ -2345,7 +2485,8 @@ fn openapi_adapter_tokens(
operation.binary_request_body()?; operation.binary_request_body()?;
}, },
}); });
let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| { let success_responses = operation.success_responses.iter().enumerate().map(
|(index, response)| {
let status = response.status; let status = response.status;
let description = if index == 0 { let description = if index == 0 {
"Successful response" "Successful response"
@@ -2374,25 +2515,24 @@ fn openapi_adapter_tokens(
#body #body
#(#headers)* #(#headers)*
} }
}); },
let error = operation );
.error_status let errors = operation
.zip(operation.error_body.as_ref())
.map(|(status, ty)| {
quote! {
operation.response::<#ty>(#status, "application/json", "Error response")?;
}
});
let additional_errors = operation
.error_body .error_body
.as_ref() .as_ref()
.map(|ty| { .map(|ty| {
let additional_content_types = &operation.additional_error_content_types;
operation operation
.additional_error_statuses .error_status
.iter() .into_iter()
.chain(operation.additional_error_statuses.iter().copied())
.map(|status| { .map(|status| {
quote! { quote! {
operation.response::<#ty>(#status, "application/json", "Error response")?; operation.response_content_types::<#ty>(
#status,
&["application/json", #(#additional_content_types),*],
"Error response",
)?;
} }
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -2413,8 +2553,7 @@ fn openapi_adapter_tokens(
#browser_auth #browser_auth
#request #request
#(#success_responses)* #(#success_responses)*
#error #(#errors)*
#(#additional_errors)*
operation.finish()?; operation.finish()?;
} }
} }
@@ -2660,6 +2799,13 @@ fn metadata_tokens(
} else { } else {
quote!(#api_crate::WireKind::Empty) quote!(#api_crate::WireKind::Empty)
}; };
let content_types = if response.body.as_ref().is_some_and(is_binary_body_type) {
quote!(&["application/octet-stream"])
} else if response.body.is_some() {
quote!(&["application/json"])
} else {
quote!(&[])
};
let headers = response.headers.iter().map(|header| { let headers = response.headers.iter().map(|header| {
let wire_name = &header.wire_name; let wire_name = &header.wire_name;
let ty = &header.ty; let ty = &header.ty;
@@ -2674,6 +2820,7 @@ fn metadata_tokens(
#api_crate::ResponseMetadata { #api_crate::ResponseMetadata {
status: #status, status: #status,
body: #api_crate::BodyMetadata { wire_kind: #wire_kind }, body: #api_crate::BodyMetadata { wire_kind: #wire_kind },
content_types: #content_types,
headers: &[#(#headers),*], headers: &[#(#headers),*],
} }
} }
@@ -2683,10 +2830,12 @@ fn metadata_tokens(
.into_iter() .into_iter()
.chain(operation.additional_error_statuses.iter().copied()); .chain(operation.additional_error_statuses.iter().copied());
let error_responses = error_statuses.map(|status| { let error_responses = error_statuses.map(|status| {
let additional_content_types = &operation.additional_error_content_types;
quote! { quote! {
#api_crate::ResponseMetadata { #api_crate::ResponseMetadata {
status: #status, status: #status,
body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json }, body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json },
content_types: &["application/json", #(#additional_content_types),*],
headers: &[], headers: &[],
} }
} }
+2
View File
@@ -7,6 +7,7 @@ publish = false
[features] [features]
default = [] default = []
error-contract = ["dep:error-contract"]
axum = ["dep:axum", "dep:serde"] axum = ["dep:axum", "dep:serde"]
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"] openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
reqwest = [ reqwest = [
@@ -20,6 +21,7 @@ reqwest = [
[dependencies] [dependencies]
api-macros-impl = { path = "../api-macros-impl" } api-macros-impl = { path = "../api-macros-impl" }
error-contract = { path = "../error-contract", optional = true }
bytes.workspace = true bytes.workspace = true
axum = { workspace = true, optional = true } axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true } futures = { workspace = true, optional = true }
+2
View File
@@ -6,4 +6,6 @@ Defines transport-independent API metadata from public traits. The same declarat
- [`api`] — API declaration and validation - [`api`] — API declaration and validation
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts - [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
- [`HttpError`] — opt-in public error encoding, with unchanged JSON defaults
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features - `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features
- `http` — projected error-contract response conversion and bounded reception, enabled by `error-contract` without enabling a framework
+28
View File
@@ -96,6 +96,34 @@ pub fn error_response<T: Serialize + Send + Sync + 'static>(
response response
} }
/// Apply the optional public encoding hook, retaining the original server-local error.
///
/// Invalid hook metadata fails closed with an empty 500 response and no typed extension.
/// A hook failure must not expose a potentially private legacy serialization.
pub fn http_error_response<T: crate::HttpError + Serialize + Send + Sync + 'static>(
status: StatusCode,
error: T,
) -> Response {
let Some(encoded) = error.custom_response() else {
return error_response(status, error);
};
let Ok(status) = StatusCode::from_u16(encoded.status) else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let Ok(content_type) = axum::http::HeaderValue::from_str(encoded.content_type) else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
if !(status.is_client_error() || status.is_server_error()) || content_type.is_empty() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
let mut response = (status, encoded.body.into_bytes()).into_response();
response
.headers_mut()
.insert(axum::http::header::CONTENT_TYPE, content_type);
response.extensions_mut().insert(std::sync::Arc::new(error));
response
}
/// Emit an exact binary response with the contract status and octet-stream content type. /// Emit an exact binary response with the contract status and octet-stream content type.
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response { pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
( (
+56
View File
@@ -0,0 +1,56 @@
//! Shared HTTP error-contract adapter, independent of Axum and Reqwest.
//!
//! Only Browser-projected failures can be encoded here. Receiving a valid failure does not
//! grant permission to forward it: domains must reproject with current authorization.
pub use error_contract::problem::{ApiBoundary, ProblemResponse};
use error_contract::{
Browser, Budget, ContractError, DecodeFailure, ProjectedFailure, WireDetails,
};
/// Encode a projected failure or a static, non-domain 500 fallback under the same budget.
///
/// `safe_instance` must be selected by the caller's disclosure policy (for example a local
/// opaque occurrence URI), never copied from a request URI containing credentials or queries.
/// URI syntax validation is not disclosure authorization. If even the static fallback exceeds
/// the budget, return an error instead of truncating JSON or silently exceeding the limit.
pub fn problem_response<D: WireDetails>(
projected: &ProjectedFailure<Browser, D>,
boundary: &impl ApiBoundary<D>,
safe_instance: &str,
budget: Budget,
) -> Result<ProblemResponse, ContractError> {
error_contract::problem::to_problem_details(projected, boundary, safe_instance, budget)
.or_else(|_| error_contract::problem::problem_fallback(budget))
}
impl From<ProblemResponse> for crate::HttpErrorResponse {
fn from(response: ProblemResponse) -> Self {
Self {
status: response.status,
content_type: response.content_type,
body: response.body().to_vec().into(),
}
}
}
/// Normalize a bounded HTTP error using actual transport metadata, not a body-claimed status.
///
/// Unsupported schema/reason, corrupt input, and `Corrupt(TooLarge)` remain distinct. None of
/// these results, including a known cause, establish remote execution or retry permission.
/// Streaming callers must also bound collection before calling this byte-slice adapter.
pub fn decode_problem_response<D: WireDetails>(
input: &[u8],
actual_status: u16,
content_type: &str,
boundary: &impl ApiBoundary<D>,
budget: Budget,
) -> DecodeFailure<D> {
error_contract::problem::decode_problem_details(
input,
actual_status,
content_type,
boundary,
budget,
)
}
+32
View File
@@ -14,6 +14,16 @@ extern crate self as api_macros;
/// leave authentication, authorization, body limits, and timeouts to the enclosing application. /// leave authentication, authorization, body limits, and timeouts to the enclosing application.
/// Declarations without adapters do not depend on an HTTP framework. /// Declarations without adapters do not depend on an HTTP framework.
/// ///
/// `#[api(reqwest, client_error_hook)]` delegates error decoding to the error type's
/// `reqwest::HttpClientError` implementation. A mixed-error API can instead set
/// `client_error_hook = true` on individual HTTP operations. Either flag enables the hook;
/// a method-level `false` does not override a trait-wide opt-in. Without opt-in, client-only
/// errors need only serde deserialization; server traits and codecs are not imposed on them.
///
/// `additional_error_content_types = ["application/problem+json"]` on an HTTP operation
/// declares extra representations for every declared error status, alongside legacy JSON.
/// Each representation uses the error type's schema; the producer still owns its encoding.
///
/// This declaration checks that a Rust trait and typed operation markers can be defined together. /// This declaration checks that a Rust trait and typed operation markers can be defined together.
/// ///
/// ``` /// ```
@@ -71,6 +81,8 @@ pub use api_macros_impl::api;
#[cfg(feature = "axum")] #[cfg(feature = "axum")]
pub mod axum; pub mod axum;
#[cfg(feature = "error-contract")]
pub mod http;
#[cfg(feature = "openapi")] #[cfg(feature = "openapi")]
pub mod openapi; pub mod openapi;
#[cfg(feature = "reqwest")] #[cfg(feature = "reqwest")]
@@ -213,6 +225,8 @@ pub struct ResponseHeaderMetadata {
pub struct ResponseMetadata { pub struct ResponseMetadata {
pub status: u16, pub status: u16,
pub body: BodyMetadata, pub body: BodyMetadata,
/// Declared media types, empty for bodyless responses. JSON remains the default error media.
pub content_types: &'static [&'static str],
pub headers: &'static [ResponseHeaderMetadata], pub headers: &'static [ResponseHeaderMetadata],
} }
@@ -272,6 +286,24 @@ pub trait WebSocketOperation {
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers. /// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
pub trait HttpError { pub trait HttpError {
fn status_code(&self) -> u16; fn status_code(&self) -> u16;
/// Opt in to an already encoded public response instead of the legacy JSON representation.
/// The adapter retains the original error as a server-local `Arc<Self>` extension.
/// Returning `None` preserves the existing JSON/status behavior exactly.
fn custom_response(&self) -> Option<HttpErrorResponse> {
None
}
}
/// An explicitly encoded HTTP error, independent of any server or client framework.
///
/// Producers own disclosure and byte limits. Adapters reject non-error statuses and invalid
/// content types rather than substituting the legacy representation, which may not be public.
#[derive(Debug)]
pub struct HttpErrorResponse {
pub status: u16,
pub content_type: &'static str,
pub body: BinaryBody,
} }
/// Successful JSON response that selects one of an operation's declared success statuses. /// Successful JSON response that selects one of an operation's declared success statuses.
+46 -6
View File
@@ -39,6 +39,19 @@ pub trait OpenApiSchema: JsonSchema {
fn openapi_schema_name() -> Cow<'static, str> { fn openapi_schema_name() -> Cow<'static, str> {
Self::schema_name() Self::schema_name()
} }
/// Select the exact wire schema for an explicitly declared response media type.
/// Custom error adapters may have distinct JSON and Problem Details bodies;
/// this avoids claiming an ambiguous untagged body union for either medium.
fn response_schema(
builder: &mut OpenApiBuilder,
_content_type: &str,
) -> Result<Value, OpenApiError>
where
Self: Sized,
{
builder.schema_ref::<Self>()
}
} }
macro_rules! impl_openapi_schema { macro_rules! impl_openapi_schema {
@@ -256,7 +269,8 @@ impl OpenApiBuilder {
Ok(OpenApiDocument(document)) Ok(OpenApiDocument(document))
} }
fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> { /// Register an explicitly asserted wire schema, retaining collision and bounds checks.
pub fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
let name = T::openapi_schema_name().into_owned(); let name = T::openapi_schema_name().into_owned();
validate_component_name(&name)?; validate_component_name(&name)?;
@@ -411,13 +425,39 @@ impl OpenApiOperation<'_> {
content_type: &'static str, content_type: &'static str,
description: &'static str, description: &'static str,
) -> Result<(), OpenApiError> { ) -> Result<(), OpenApiError> {
let schema = self.parent.schema_ref::<T>()?; self.response_content_types::<T>(status, &[content_type], description)
}
/// Declare one response schema under several explicit representations, atomically.
/// This does not change conflict detection for repeated status declarations.
pub fn response_content_types<T: OpenApiSchema>(
&mut self,
status: u16,
content_types: &[&str],
description: &'static str,
) -> Result<(), OpenApiError> {
let mut content = Map::new();
for content_type in content_types {
let schema = T::response_schema(self.parent, content_type)?;
if content
.insert((*content_type).to_owned(), json!({ "schema": schema }))
.is_some()
{
return Err(OpenApiError::InvalidContract(format!(
"duplicate response content type for `{}`",
self.operation_id
)));
}
}
if content.is_empty() {
return Err(OpenApiError::InvalidContract(format!(
"response content types are empty for `{}`",
self.operation_id
)));
}
self.insert_response( self.insert_response(
status, status,
json!({ json!({ "description": description, "content": content }),
"description": description,
"content": { content_type: { "schema": schema } },
}),
) )
} }
+85 -7
View File
@@ -482,6 +482,17 @@ pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody {
EncodedBody::binary(value) EncodedBody::binary(value)
} }
/// Explicit decoder for APIs declared with `#[api(reqwest, client_error_hook)]` or an
/// operation-level `client_error_hook = true` option.
///
/// There is deliberately no blanket `Deserialize` or `HttpError` bound: legacy client-only
/// errors keep serde decoding without implementing this trait. Opted-in decoders receive the
/// actual status/headers and bytes collected under the client's configured limit. Returning
/// a failure does not imply anything about remote execution or authorize a new request.
pub trait HttpClientError: Sized {
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure>;
}
/// Response received under the configured byte limit. Its content is always redacted in Debug. /// Response received under the configured byte limit. Its content is always redacted in Debug.
pub struct ReceivedResponse { pub struct ReceivedResponse {
status: StatusCode, status: StatusCode,
@@ -494,6 +505,48 @@ impl ReceivedResponse {
self.status self.status
} }
/// Borrow bytes already collected under the configured response limit.
pub fn body(&self) -> &[u8] {
&self.bytes
}
/// Require exactly one valid Content-Type header; ambiguous metadata must not select a codec.
pub fn content_type(&self) -> Result<&str, ClientFailure> {
let name = "content-type";
let mut values = self.headers.get_all(name).iter();
let value = values
.next()
.ok_or(ClientFailure::MissingResponseHeader { name })?;
if values.next().is_some() {
return Err(ClientFailure::DuplicateResponseHeader { name });
}
value
.to_str()
.map_err(|_| ClientFailure::InvalidResponseHeader { name })
}
/// Decode without promoting received data to a public projection or execution result.
/// Transport collection and contract budgets are enforced independently.
#[cfg(feature = "error-contract")]
pub fn decode_problem<D: error_contract::WireDetails>(
&self,
boundary: &impl crate::http::ApiBoundary<D>,
budget: error_contract::Budget,
) -> error_contract::DecodeFailure<D> {
match self.content_type() {
Ok(content_type) => crate::http::decode_problem_response(
&self.bytes,
self.status.as_u16(),
content_type,
boundary,
budget,
),
Err(_) => {
error_contract::DecodeFailure::Corrupt(error_contract::ContractError::Invalid)
}
}
}
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure> pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
where where
T: std::str::FromStr, T: std::str::FromStr,
@@ -555,13 +608,32 @@ pub enum ClientFailure {
Connect, Connect,
Transport, Transport,
Timeout, Timeout,
ResponseTooLarge { limit: usize }, ResponseTooLarge {
UnexpectedStatus { expected: u16, actual: u16 }, limit: usize,
ErrorResponseDecode { status: u16 }, },
Decode { kind: DecodeKind }, UnexpectedStatus {
MissingResponseHeader { name: &'static str }, expected: u16,
DuplicateResponseHeader { name: &'static str }, actual: u16,
InvalidResponseHeader { name: &'static str }, },
ErrorResponseDecode {
status: u16,
},
/// A bounded, structurally valid error used an unsupported schema or reason.
UnsupportedErrorResponse {
status: u16,
},
Decode {
kind: DecodeKind,
},
MissingResponseHeader {
name: &'static str,
},
DuplicateResponseHeader {
name: &'static str,
},
InvalidResponseHeader {
name: &'static str,
},
UnexpectedBody, UnexpectedBody,
} }
@@ -588,6 +660,12 @@ impl fmt::Display for ClientFailure {
"expected HTTP status {expected}, received {actual}" "expected HTTP status {expected}, received {actual}"
) )
} }
Self::UnsupportedErrorResponse { status } => {
write!(
formatter,
"HTTP {status} error response contract was unsupported"
)
}
Self::ErrorResponseDecode { status } => { Self::ErrorResponseDecode { status } => {
write!(formatter, "HTTP {status} error response JSON was malformed") write!(formatter, "HTTP {status} error response JSON was malformed")
} }
+463
View File
@@ -0,0 +1,463 @@
//! Connect generated Axum responses to generated Reqwest opt-in decoding over isolated loopback.
//! Domain disclosure is a fixture; HTTP encoding, bounded reads and metadata checks are production.
#![cfg(all(feature = "axum", feature = "reqwest", feature = "error-contract"))]
use std::{sync::Arc, time::Duration};
use api_macros::{
HttpErrorResponse, api,
reqwest::{ClientError, ClientFailure, HttpClientError, ReceivedResponse},
};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
response::IntoResponse,
routing::get,
};
use error_contract::{
Browser, Budget, Category, ContractError, DecodeFailure, Disclosure, FailureCause,
FailureFacts, FailureIdentity, ProjectFor, ProjectedFailure, WireDetails, project_failure,
};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
#[derive(Serialize, Deserialize)]
pub struct Payload {
value: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Denied {
required_operation: String,
}
impl FailureCause for Denied {
fn identity(&self) -> FailureIdentity {
FailureIdentity {
domain: "subject",
reason: "operation_denied",
category: Category::Authorization,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
if self.required_operation == "session.record" {
Ok(())
} else {
Err(ContractError::InvalidDetails)
}
}
}
impl WireDetails for Denied {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "subject" && reason == "operation_denied" && version == 1
}
}
impl ProjectFor<Browser> for Denied {
type Authorization = ();
type Details = Self;
fn disclose(&self, _: &FailureFacts<Self>, _: &()) -> Result<Disclosure<Self>, ContractError> {
Ok(Disclosure::new(
self.clone(),
"Subject cannot record a session.",
))
}
}
struct Boundary;
impl api_macros::http::ApiBoundary<Denied> for Boundary {
fn classify(&self, _: &Denied) -> error_contract::problem::ProblemClass {
error_contract::problem::ProblemClass::authorization_denied()
}
}
fn budget() -> Budget {
Budget::new(4096, 4096)
}
fn projected() -> ProjectedFailure<Browser, Denied> {
project_failure(
&FailureFacts::new(Denied {
required_operation: "session.record".into(),
})
.unwrap(),
&(),
)
.unwrap()
}
fn encoded() -> HttpErrorResponse {
api_macros::http::problem_response(&projected(), &Boundary, "urn:yoi:failure:local-1", budget())
.unwrap()
.into()
}
// Not Clone: the original must be retained without duplicating a domain error.
#[derive(Serialize)]
pub struct ServerError {
legacy: &'static str,
#[serde(skip)]
mode: u8,
}
impl api_macros::HttpError for ServerError {
fn status_code(&self) -> u16 {
422
}
fn custom_response(&self) -> Option<HttpErrorResponse> {
match self.mode {
0 => Some(encoded()),
1 => None,
mode => Some(HttpErrorResponse {
status: if mode == 2 { 200 } else { 403 },
content_type: if mode == 3 {
"bad\ncontent-type"
} else {
"application/problem+json"
},
body: b"private-hook-body".to_vec().into(),
}),
}
}
}
impl api_macros::HttpRequestError for ServerError {
fn from_request_rejection(_: u16, _: String) -> Self {
Self {
legacy: "never serialize",
mode: 0,
}
}
}
#[api(axum)]
pub trait HookServer {
#[get("/problem", status = 200, error_status = 422)]
async fn problem(&self) -> Result<Payload, ServerError>;
#[post("/reject", error_status = 422, normalize_body_errors = true)]
async fn reject(&self, #[body] input: Payload) -> Result<Payload, ServerError>;
#[post("/reject-binary", error_status = 422, normalize_body_errors = true)]
async fn reject_binary(
&self,
#[binary] input: api_macros::BinaryBody,
) -> Result<Payload, ServerError>;
}
pub struct Service;
impl HookServer for Service {
async fn problem(&self) -> Result<Payload, ServerError> {
Err(ServerError {
legacy: "never serialize",
mode: 0,
})
}
async fn reject(&self, input: Payload) -> Result<Payload, ServerError> {
Ok(input)
}
async fn reject_binary(&self, _input: api_macros::BinaryBody) -> Result<Payload, ServerError> {
Ok(Payload { value: "ok".into() })
}
}
// Neither Deserialize nor HttpError: client hooks impose no server or serde error bounds.
pub struct DecodedError {
operation: String,
}
impl HttpClientError for DecodedError {
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure> {
let status = response.status().as_u16();
match response.decode_problem::<Denied>(&Boundary, budget()) {
DecodeFailure::Known(failure) => Ok(Self {
operation: failure.facts().cause().required_operation.clone(),
}),
DecodeFailure::Unsupported(_) => {
Err(ClientFailure::UnsupportedErrorResponse { status })
}
DecodeFailure::Corrupt(ContractError::TooLarge) => {
Err(ClientFailure::ResponseTooLarge {
limit: budget().bytes(),
})
}
DecodeFailure::Corrupt(_) => Err(ClientFailure::ErrorResponseDecode { status }),
}
}
}
#[api(reqwest)]
pub trait HookClient {
#[get("/problem", status = 200, error_status = 422, client_error_hook = true)]
async fn problem(&self) -> Result<Payload, DecodedError>;
#[get(
"/wire/{mode}",
status = 200,
error_status = 422,
client_error_hook = true
)]
async fn wire(&self, mode: String) -> Result<Payload, DecodedError>;
#[get("/legacy", error_status = 422)]
async fn legacy(&self) -> Result<Payload, LegacyClientError>;
}
// A false method flag cannot disable a trait-wide opt-in; the selection is logical OR.
#[api(reqwest, client_error_hook)]
pub trait TraitHookClient {
#[get("/problem", client_error_hook = false)]
async fn problem(&self) -> Result<Payload, DecodedError>;
}
// Client-only legacy errors must still compile and decode without either hook trait.
#[derive(Deserialize)]
pub struct LegacyClientError {
legacy: String,
}
#[api(reqwest)]
pub trait LegacyClient {
#[get("/legacy", error_status = 422)]
async fn legacy(&self) -> Result<Payload, LegacyClientError>;
}
#[tokio::test]
async fn generated_server_hook_overrides_wire_encoding_and_retains_arc_for_errors_and_rejections() {
let app = HookServerAxum::router(Service).layer(axum::extract::DefaultBodyLimit::max(32));
for request in [
Request::builder()
.uri("/problem")
.body(Body::empty())
.unwrap(),
Request::builder()
.method("POST")
.uri("/reject")
.header("content-type", "application/json")
.body(Body::from("{"))
.unwrap(),
Request::builder()
.method("POST")
.uri("/reject-binary")
.body(Body::from(vec![0; 64]))
.unwrap(),
] {
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers()["content-type"],
"application/problem+json"
);
assert_eq!(
response
.extensions()
.get::<Arc<ServerError>>()
.unwrap()
.mode,
0
);
let bytes = to_bytes(response.into_body(), 4096).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["status"], 403);
assert_eq!(
value["yoi_failure"]["details"]["required_operation"],
"session.record"
);
assert!(!String::from_utf8_lossy(&bytes).contains("never serialize"));
}
}
#[tokio::test]
async fn absent_hook_preserves_json_and_invalid_hook_never_falls_back_to_legacy() {
let legacy = api_macros::axum::http_error_response(
StatusCode::UNPROCESSABLE_ENTITY,
ServerError {
legacy: "legacy-value",
mode: 1,
},
);
assert_eq!(legacy.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(legacy.headers()["content-type"], "application/json");
assert!(legacy.extensions().get::<Arc<ServerError>>().is_some());
let body = to_bytes(legacy.into_body(), 4096).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["legacy"],
"legacy-value"
);
for mode in [2, 3] {
let response = api_macros::axum::http_error_response(
StatusCode::UNPROCESSABLE_ENTITY,
ServerError {
legacy: "private",
mode,
},
);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(response.extensions().get::<Arc<ServerError>>().is_none());
assert!(
to_bytes(response.into_body(), 4096)
.await
.unwrap()
.is_empty()
);
}
}
#[test]
fn problem_conversion_falls_back_without_exceeding_byte_or_depth_budgets() {
for (instance, budget) in [
("invalid URI", budget()),
("", Budget::new(256, 256)),
("", budget().with_depth(1, 1)),
] {
let response =
api_macros::http::problem_response(&projected(), &Boundary, instance, budget).unwrap();
assert_eq!(response.status, 500);
assert_eq!(
response.body(),
error_contract::problem::STATIC_PROBLEM_FALLBACK
);
assert!(response.body().len() <= budget.bytes());
}
assert!(matches!(
api_macros::http::problem_response(&projected(), &Boundary, "", Budget::new(1, 1)),
Err(ContractError::TooLarge)
));
}
async fn wire(axum::extract::Path(mode): axum::extract::Path<String>) -> axum::response::Response {
let mut value: serde_json::Value = serde_json::from_slice(encoded().body.as_ref()).unwrap();
let mut status = StatusCode::FORBIDDEN;
let mut content_type = "application/problem+json; charset=utf-8";
match mode.as_str() {
"unknown" => value["yoi_failure"]["reason"] = "future_reason".into(),
"version" => value["yoi_failure"]["version"] = 99.into(),
"status" => status = StatusCode::CONFLICT,
"content-type" => content_type = "application/json",
"corrupt" => {
return (
status,
[("content-type", content_type)],
"{private-raw-body",
)
.into_response();
}
"large" => {
return (status, [("content-type", content_type)], "x".repeat(8192)).into_response();
}
_ => {}
}
let mut response = (
status,
[("content-type", content_type)],
serde_json::to_vec(&value).unwrap(),
)
.into_response();
if mode == "duplicate" {
response
.headers_mut()
.append("content-type", "application/json".parse().unwrap());
}
if mode == "missing" {
response.headers_mut().remove("content-type");
}
response
}
struct ServerTask(tokio::task::JoinHandle<()>);
impl Drop for ServerTask {
fn drop(&mut self) {
self.0.abort();
}
}
async fn serve() -> (String, ServerTask) {
let app = HookServerAxum::router(Service)
.route("/wire/{mode}", get(wire))
.route(
"/legacy",
get(|| async {
api_macros::axum::http_error_response(
StatusCode::UNPROCESSABLE_ENTITY,
ServerError {
legacy: "legacy-value",
mode: 1,
},
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let task = ServerTask(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
}));
(url, task)
}
#[tokio::test]
async fn generated_client_hook_uses_actual_metadata_and_preserves_unknown_corrupt_and_size_failures()
{
let (url, _server) = serve().await;
let client = HookClientClient::builder(&url)
.unwrap()
.request_timeout(Duration::from_secs(5))
.response_body_limit(16384)
.build()
.unwrap();
match client.problem().await {
Err(ClientError::Public { status, error }) => {
assert_eq!(status.as_u16(), 403);
assert_eq!(error.operation, "session.record");
}
_ => panic!("expected a typed problem"),
}
for mode in ["unknown", "version"] {
assert!(matches!(
client.wire(mode.into()).await,
Err(ClientError::Failure(
ClientFailure::UnsupportedErrorResponse { status: 403 }
))
));
}
for (mode, expected) in [
("status", 409),
("content-type", 403),
("corrupt", 403),
("duplicate", 403),
("missing", 403),
] {
let result = client.wire(mode.into()).await;
assert!(
matches!(result, Err(ClientError::Failure(ClientFailure::ErrorResponseDecode { status })) if status == expected),
"mode={mode}"
);
}
// Contract budget and streaming transport budget are independent, bounded failures.
assert!(matches!(
client.wire("large".into()).await,
Err(ClientError::Failure(ClientFailure::ResponseTooLarge {
limit: 4096
}))
));
let small_client = HookClientClient::builder(&url)
.unwrap()
.request_timeout(Duration::from_secs(5))
.response_body_limit(128)
.build()
.unwrap();
assert!(matches!(
small_client.problem().await,
Err(ClientError::Failure(ClientFailure::ResponseTooLarge {
limit: 128
}))
));
match client.legacy().await {
Err(ClientError::Public { status, error }) => {
assert_eq!(status.as_u16(), 422);
assert_eq!(error.legacy, "legacy-value");
}
_ => panic!("unrelated error on the same trait must keep serde behavior"),
}
let trait_client = TraitHookClientClient::builder(&url)
.unwrap()
.request_timeout(Duration::from_secs(5))
.build()
.unwrap();
assert!(
matches!(trait_client.problem().await, Err(ClientError::Public { error, .. }) if error.operation == "session.record")
);
let legacy = LegacyClientClient::builder(&url)
.unwrap()
.request_timeout(Duration::from_secs(5))
.build()
.unwrap();
match legacy.legacy().await {
Err(ClientError::Public { status, error }) => {
assert_eq!(status.as_u16(), 422);
assert_eq!(error.legacy, "legacy-value");
}
_ => panic!("legacy client-only error must keep serde behavior"),
}
}
+152 -1
View File
@@ -3,7 +3,7 @@
use api_macros::{ use api_macros::{
BinaryBody, api, BinaryBody, api,
openapi::{OpenApiInfo, OpenApiSchema}, openapi::{OpenApiBuilder, OpenApiInfo, OpenApiSchema},
}; };
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -465,3 +465,154 @@ fn file_export_writes_the_canonical_document() {
std::fs::remove_file(path).expect("remove export"); std::fs::remove_file(path).expect("remove export");
assert_eq!(written, document.to_json().expect("canonical JSON")); assert_eq!(written, document.to_json().expect("canonical JSON"));
} }
#[derive(Deserialize, Serialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum ProblemOrLegacy {
Legacy { code: String },
Problem { status: u16, detail: String },
}
impl JsonSchema for ProblemOrLegacy {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ProblemOrLegacy".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
// The owner explicitly attests disjoint closed object shapes instead of admitting anyOf.
schemars::json_schema!({ "oneOf": [
{ "type": "object", "required": ["code"], "additionalProperties": false,
"properties": { "code": { "type": "string" } } },
{ "type": "object", "required": ["status", "detail"], "additionalProperties": false,
"properties": { "status": { "type": "integer", "format": "uint16" }, "detail": { "type": "string" } } }
] })
}
}
impl OpenApiSchema for ProblemOrLegacy {}
#[api(openapi)]
pub trait ErrorMediaApi {
#[post(
"/create",
error_status = 403,
additional_error_statuses = [409, 504],
additional_error_content_types = ["Application/Problem+JSON"]
)]
async fn create(&self) -> Result<Widget, ProblemOrLegacy>;
#[get("/legacy", error_status = 422)]
async fn legacy(&self) -> Result<Widget, PublicError>;
}
#[test]
fn additional_error_media_share_the_declared_schema_for_every_error_status_only() {
use api_macros::{ApiContract, Operation};
let document = error_media_api_openapi(OpenApiInfo {
title: "Error media",
version: "1",
source_digest: "fixture:error-media",
})
.unwrap();
let value = document.as_value();
let responses = &value["paths"]["/create"]["post"]["responses"];
for status in ["403", "409", "504"] {
let content = responses[status]["content"].as_object().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(
content["application/json"]["schema"],
json!({ "$ref": "#/components/schemas/ProblemOrLegacy" })
);
assert_eq!(
content["application/problem+json"],
content["application/json"]
);
}
assert_eq!(responses["200"]["content"].as_object().unwrap().len(), 1);
assert!(
responses["200"]["content"]
.get("application/problem+json")
.is_none()
);
assert_eq!(
value["paths"]["/legacy"]["get"]["responses"]["422"]["content"]
.as_object()
.unwrap()
.len(),
1
);
assert!(value["components"]["schemas"]["ProblemOrLegacy"]["oneOf"].is_array());
let parsed = oas3::from_json(&document.to_json().unwrap()).unwrap();
assert_eq!(parsed.validate_version().unwrap().major, 3);
let operation = error_media_api_operations::Create::METADATA;
assert_eq!(
operation
.error_responses
.iter()
.map(|response| response.status)
.collect::<Vec<_>>(),
[403, 409, 504]
);
for response in operation.error_responses {
assert_eq!(
response.content_types,
["application/json", "application/problem+json"]
);
}
assert_eq!(
operation.success_responses[0].content_types,
["application/json"]
);
let legacy = ErrorMediaApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.path == "/legacy")
.unwrap();
assert_eq!(
legacy.error_responses[0].content_types,
["application/json"]
);
}
#[derive(JsonSchema)]
pub struct MediaSpecificError;
impl OpenApiSchema for MediaSpecificError {
fn response_schema(
builder: &mut OpenApiBuilder,
content_type: &str,
) -> Result<Value, api_macros::openapi::OpenApiError> {
match content_type {
"application/json" => builder.schema_ref::<PublicError>(),
"application/problem+json" => builder.schema_ref::<ProblemOrLegacy>(),
_ => unreachable!("only declared media are selected"),
}
}
}
#[api(openapi)]
pub trait MediaSpecificApi {
#[post("/media", error_status = 409, additional_error_content_types = ["application/problem+json"])]
async fn create(&self) -> Result<Widget, MediaSpecificError>;
}
#[test]
fn explicitly_different_error_representations_register_their_actual_schema_closures() {
let document = media_specific_api_openapi(OpenApiInfo {
title: "Media-specific",
version: "1",
source_digest: "fixture:media-specific",
})
.unwrap();
let value = document.as_value();
let content = &value["paths"]["/media"]["post"]["responses"]["409"]["content"];
assert_eq!(
content["application/json"]["schema"],
json!({"$ref":"#/components/schemas/PublicError"})
);
assert_eq!(
content["application/problem+json"]["schema"],
json!({"$ref":"#/components/schemas/ProblemOrLegacy"})
);
assert!(value["components"]["schemas"]["MediaSpecificError"].is_null());
oas3::from_json(&document.to_json().unwrap())
.unwrap()
.validate_version()
.unwrap();
}
@@ -0,0 +1,6 @@
use api_macros::api;
#[api(client_error_hook)]
pub trait Invalid {}
fn main() {}
@@ -0,0 +1,7 @@
error: `client_error_hook` requires the `reqwest` adapter
--> tests/ui/client_hook_requires_reqwest.rs:3:1
|
3 | #[api(client_error_hook)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -0,0 +1,47 @@
use api_macros::api;
pub struct Error;
#[api]
pub trait Wildcard {
#[get("/wildcard", additional_error_content_types = ["application/*"])]
async fn wildcard(&self) -> Result<(), Error>;
}
#[api]
pub trait DuplicateMedia {
#[get("/duplicate", additional_error_content_types = ["application/problem+json", "APPLICATION/PROBLEM+JSON"])]
async fn duplicate(&self) -> Result<(), Error>;
}
#[api]
pub trait DefaultMedia {
#[get("/default", additional_error_content_types = ["application/json"])]
async fn default_media(&self) -> Result<(), Error>;
}
#[api]
pub trait NoError {
#[get("/infallible", additional_error_content_types = ["application/problem+json"])]
async fn infallible(&self);
}
#[api]
pub trait EmptyMedia {
#[get("/empty", additional_error_content_types = [])]
async fn empty(&self) -> Result<(), Error>;
}
#[api]
pub trait DuplicateOption {
#[get("/duplicate-option", additional_error_content_types = [], additional_error_content_types = [])]
async fn duplicate_option(&self) -> Result<(), Error>;
}
#[api]
pub trait ParameterizedMedia {
#[get("/parameterized", additional_error_content_types = ["application/problem+json; charset=utf-8"])]
async fn parameterized(&self) -> Result<(), Error>;
}
fn main() {}
@@ -0,0 +1,41 @@
error: error content type must be a concrete type/subtype without parameters
--> tests/ui/invalid_error_media.rs:7:58
|
7 | #[get("/wildcard", additional_error_content_types = ["application/*"])]
| ^^^^^^^^^^^^^^^
error: additional error content types must be distinct from each other and application/json
--> tests/ui/invalid_error_media.rs:13:87
|
13 | #[get("/duplicate", additional_error_content_types = ["application/problem+json", "APPLICATION/PROBLEM+JSON"])]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
error: additional error content types must be distinct from each other and application/json
--> tests/ui/invalid_error_media.rs:19:57
|
19 | #[get("/default", additional_error_content_types = ["application/json"])]
| ^^^^^^^^^^^^^^^^^^
error: additional_error_content_types require a public error response type
--> tests/ui/invalid_error_media.rs:25:11
|
25 | #[get("/infallible", additional_error_content_types = ["application/problem+json"])]
| ^^^^^^^^^^^^^
error: additional_error_content_types must not be empty
--> tests/ui/invalid_error_media.rs:31:11
|
31 | #[get("/empty", additional_error_content_types = [])]
| ^^^^^^^^
error: duplicate `additional_error_content_types` route option
--> tests/ui/invalid_error_media.rs:37:69
|
37 | #[get("/duplicate-option", additional_error_content_types = [], additional_error_content_types = [])]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: error content type must be a concrete type/subtype without parameters
--> tests/ui/invalid_error_media.rs:43:63
|
43 | #[get("/parameterized", additional_error_content_types = ["application/problem+json; charset=utf-8"])]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,23 @@
use api_macros::api;
pub struct Error;
#[api]
pub trait NoReqwest {
#[get("/error", client_error_hook = true)]
async fn error(&self) -> Result<(), Error>;
}
#[api(reqwest)]
pub trait NoError {
#[post("/infallible", client_error_hook = true)]
async fn infallible(&self);
}
#[api(reqwest)]
pub trait DuplicateHook {
#[get("/duplicate", client_error_hook = true, client_error_hook = false)]
async fn duplicate(&self) -> Result<(), Error>;
}
fn main() {}
@@ -0,0 +1,17 @@
error: client_error_hook requires the `reqwest` adapter
--> tests/ui/operation_hook_invalid_combinations.rs:7:41
|
7 | #[get("/error", client_error_hook = true)]
| ^^^^
error: client_error_hook requires a public error response type
--> tests/ui/operation_hook_invalid_combinations.rs:13:47
|
13 | #[post("/infallible", client_error_hook = true)]
| ^^^^
error: duplicate `client_error_hook` route option
--> tests/ui/operation_hook_invalid_combinations.rs:19:51
|
19 | #[get("/duplicate", client_error_hook = true, client_error_hook = false)]
| ^^^^^^^^^^^^^^^^^
+12
View File
@@ -253,6 +253,7 @@ pub enum BackendRuntimeClientError {
Api(BackendApiClientError), Api(BackendApiClientError),
SessionApi(server_api::ServerApiClientError), SessionApi(server_api::ServerApiClientError),
ContractApi(server_api::client_support::ClientError<server_api::RepositoryApiError>), ContractApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
Http(reqwest::Error), Http(reqwest::Error),
Protocol(String), Protocol(String),
} }
@@ -264,6 +265,7 @@ impl fmt::Display for BackendRuntimeClientError {
Self::Api(error) => write!(f, "{error}"), Self::Api(error) => write!(f, "{error}"),
Self::SessionApi(error) => write!(f, "{error}"), Self::SessionApi(error) => write!(f, "{error}"),
Self::ContractApi(error) => write!(f, "{error}"), Self::ContractApi(error) => write!(f, "{error}"),
Self::WorkerCreateApi(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
Self::Protocol(message) => f.write_str(message), Self::Protocol(message) => f.write_str(message),
} }
@@ -1016,3 +1018,13 @@ mod tests {
assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err()); assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err());
} }
} }
impl From<server_api::client_support::ClientError<server_api::WorkerCreateApiError>>
for BackendRuntimeClientError
{
fn from(
error: server_api::client_support::ClientError<server_api::WorkerCreateApiError>,
) -> Self {
Self::WorkerCreateApi(error)
}
}
+30
View File
@@ -39,6 +39,7 @@ pub enum BackendWorkspaceClientError {
Api(BackendApiClientError), Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>), ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
RuntimeManagementApi( RuntimeManagementApi(
server_api::client_support::ClientError<server_api::RuntimeManagementApiError>, server_api::client_support::ClientError<server_api::RuntimeManagementApiError>,
), ),
@@ -51,6 +52,7 @@ impl fmt::Display for BackendWorkspaceClientError {
Self::Api(error) => write!(f, "{error}"), Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
Self::ServerApi(error) => write!(f, "{error}"), Self::ServerApi(error) => write!(f, "{error}"),
Self::WorkerCreateApi(error) => write!(f, "{error}"),
Self::RuntimeManagementApi(error) => write!(f, "{error}"), Self::RuntimeManagementApi(error) => write!(f, "{error}"),
} }
} }
@@ -454,3 +456,31 @@ mod tests {
assert_eq!(retry, request); assert_eq!(retry, request);
} }
} }
pub(crate) fn worker_create_client_error(
backend: &BackendApiClient,
error: server_api::client_support::ClientError<server_api::WorkerCreateApiError>,
) -> BackendWorkspaceClientError {
use server_api::client_support::ClientError;
// Retain typed startup facts even for an operator denial. A Subject execution
// condition is not a refusal of this client's credential or a login prompt.
match &error {
ClientError::Public {
status,
error: response,
} if response.failure().is_none() && *status == reqwest::StatusCode::UNAUTHORIZED => {
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized {
origin: backend.origin().clone(),
})
}
ClientError::Public {
status,
error: response,
} if response.failure().is_none() && *status == reqwest::StatusCode::FORBIDDEN => {
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden {
origin: backend.origin().clone(),
})
}
_ => BackendWorkspaceClientError::WorkerCreateApi(error),
}
}
+20 -3
View File
@@ -81,6 +81,23 @@ impl BackendWorkspaceProductClient {
server_api::client_support::ClientError<server_api::RepositoryApiError>, server_api::client_support::ClientError<server_api::RepositoryApiError>,
>, >,
>, >,
{
self.generated_normalized(operation, crate::backend_workspace::server_client_error)
}
fn generated_normalized<T, E, F, Fut>(
&self,
operation: F,
normalize: impl FnOnce(
&BackendApiClient,
server_api::client_support::ClientError<E>,
) -> BackendWorkspaceClientError,
) -> Result<T, BackendWorkspaceClientError>
where
F: FnOnce(
server_api::ServerApiClient<crate::backend_workspace::ServerBearerAuthorizer>,
) -> Fut,
Fut: std::future::Future<Output = Result<T, server_api::client_support::ClientError<E>>>,
{ {
let client = crate::backend_workspace::server_api_client(&self.api)?; let client = crate::backend_workspace::server_api_client(&self.api)?;
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
@@ -89,7 +106,7 @@ impl BackendWorkspaceProductClient {
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
runtime runtime
.block_on(operation(client)) .block_on(operation(client))
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error)) .map_err(|error| normalize(&self.api, error))
} }
pub fn read_ticket_link_summary( pub fn read_ticket_link_summary(
@@ -584,9 +601,9 @@ impl BackendWorkspaceProductClient {
execution_grant: None, execution_grant: None,
}; };
let workspace_id = self.workspace_id.clone(); let workspace_id = self.workspace_id.clone();
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move { let response: BrowserCreateWorkerResponse = self.generated_normalized(move |client| async move {
client.workspace_worker_create(workspace_id, request).await client.workspace_worker_create(workspace_id, request).await
})?; }, crate::backend_workspace::worker_create_client_error)?;
Ok(format!( Ok(format!(
"Started Intake Worker {}/{} for Ticket {ticket_id}", "Started Intake Worker {}/{} for Ticket {ticket_id}",
response.runtime_id, response.worker_id response.runtime_id, response.worker_id
@@ -0,0 +1,11 @@
[package]
name = "content-history-adapters"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
content-history.workspace = true
config-source.workspace = true
thiserror.workspace = true
@@ -0,0 +1,9 @@
# content-history-adapters
Contract examples mapping configuration sources and Ticket compound documents onto the independent content-history model. These adapters prepare immutable candidates and restore complete content; they do not save, authorize, evaluate, adopt, or migrate business data.
- [`settings`] preserves virtual paths, full UTF-8 sources, and explicit entry metadata.
- [`ticket`] separates document content and original authorship from business state and edit history.
- [`Snapshot`] supplies a candidate root and objects for caller-owned store preparation. A successful preparation is not business adoption; the caller must validate and atomically adopt source, projection, and history head in its own transaction.
Readers must be scoped to an authorized object/root by the store. Knowing a hash is not authorization. Missing or erased content must fail restoration rather than be reconstructed from another scope. Retention, explicit erasure, expected-history-head CAS, and new authenticated editor history on restoration belong to the store and caller, not these examples.
@@ -0,0 +1,39 @@
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
use content_history_adapters::{settings, ticket};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let sources = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("nested/main.dcdl")?,
ConfigContentType::Decodal,
"// preserve this comment\r\n{}\r\n",
)?])?;
let config = settings::prepare(&sources)?;
assert_eq!(
settings::restore(config.root, |id| config.get(id))?,
sources
);
let document = ticket::Document {
ticket_id: "T-example".into(),
title: "History contract".into(),
initial_author: "user:original".into(),
initial_body: "Full text\r\n".into(),
posts: vec![ticket::Post {
id: "stable-post-id".into(),
author: "user:other".into(),
body: "Reply\n".into(),
}],
};
let candidate = ticket::prepare(&document)?;
assert_eq!(
ticket::restore(candidate.root, "T-example", |id| candidate.get(id))?,
document
);
// Pass candidate.root and candidate.objects to store preparation in a caller-owned transaction.
// Do not interpret successful preparation as business adoption or change an authoritative head.
println!(
"config candidate: {}; Ticket candidate: {}",
config.root, candidate.root
);
Ok(())
}
+159
View File
@@ -0,0 +1,159 @@
#![doc = include_str!("../README.md")]
use std::collections::BTreeMap;
use content_history::{BlobId, ContentId, Entry, Object, ObjectId, Tree, TreeId};
pub mod settings;
pub mod ticket;
/// Complete immutable candidate for store preparation, not a saved or adopted version.
#[derive(Debug, Clone)]
pub struct Snapshot {
pub root: TreeId,
pub objects: BTreeMap<ObjectId, Object>,
}
impl Snapshot {
/// In-memory reader for examples. Production readers must enforce authorization and erasure.
pub fn get(&self, id: ObjectId) -> Result<Object, Error> {
self.objects.get(&id).cloned().ok_or(Error::Missing(id))
}
}
/// Restoration fails closed: no partial document is returned on any read or contract error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Model(#[from] content_history::Error),
#[error(transparent)]
Config(#[from] config_source::ConfigTreeError),
#[error("missing object: {0:?}")]
Missing(ObjectId),
#[error("reader returned an object not matching requested identity: {0:?}")]
HashMismatch(ObjectId),
#[error("invalid adapter structure: {0}")]
Structure(&'static str),
#[error("unsupported adapter format")]
Format,
#[error("content is not UTF-8")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("file/directory conflict: {0}")]
PathConflict(String),
#[error("Ticket identity does not match the restoration target")]
TicketIdentity,
#[error("authorized reader failed: {0}")]
Read(String),
}
#[derive(Default)]
struct Builder {
objects: BTreeMap<ObjectId, Object>,
}
impl Builder {
fn blob(&mut self, bytes: impl AsRef<[u8]>) -> ContentId {
let object = Object::Blob(bytes.as_ref().to_vec());
let ObjectId::Blob(id) = object.id() else {
unreachable!()
};
self.objects.insert(object.id(), object);
ContentId::Blob(id)
}
fn tree(&mut self, tree: Tree) -> TreeId {
let object = Object::Tree(tree);
let ObjectId::Tree(id) = object.id() else {
unreachable!()
};
self.objects.insert(object.id(), object);
id
}
fn map(&mut self, entries: Vec<Entry>) -> Result<TreeId, Error> {
Ok(self.tree(Tree::map(entries)?))
}
fn finish(self, root: TreeId) -> Snapshot {
Snapshot {
root,
objects: self.objects,
}
}
}
fn entry(key: impl Into<String>, target: ContentId) -> Entry {
Entry {
key: key.into(),
target,
}
}
fn read_object(
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
id: ObjectId,
) -> Result<Object, Error> {
let object = read(id)?;
if object.id() != id {
return Err(Error::HashMismatch(id));
}
Ok(object)
}
fn read_tree(
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
id: TreeId,
ordered: bool,
) -> Result<Tree, Error> {
let Object::Tree(tree) = read_object(read, ObjectId::Tree(id))? else {
return Err(Error::Structure("expected tree"));
};
if tree.is_ordered() != ordered {
return Err(Error::Structure("incorrect tree ordering"));
}
Ok(tree)
}
fn tree_id(target: ContentId) -> Result<TreeId, Error> {
match target {
ContentId::Tree(id) => Ok(id),
_ => Err(Error::Structure("expected tree target")),
}
}
fn blob_id(target: ContentId) -> Result<BlobId, Error> {
match target {
ContentId::Blob(id) => Ok(id),
_ => Err(Error::Structure("expected blob target")),
}
}
fn text(
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
target: ContentId,
) -> Result<String, Error> {
let Object::Blob(bytes) = read_object(read, ObjectId::Blob(blob_id(target)?))? else {
return Err(Error::Structure("expected blob"));
};
Ok(String::from_utf8(bytes)?)
}
fn fields(tree: &Tree, expected: &[&str]) -> Result<(), Error> {
if tree.is_ordered()
|| tree.entries().len() != expected.len()
|| expected
.iter()
.any(|key| !tree.entries().iter().any(|e| e.key == *key))
{
return Err(Error::Structure("unexpected fields"));
}
Ok(())
}
fn field(tree: &Tree, key: &str) -> Result<ContentId, Error> {
tree.entries()
.iter()
.find(|e| e.key == key)
.map(|e| e.target)
.ok_or(Error::Structure("missing field"))
}
@@ -0,0 +1,201 @@
//! Configuration content is a real directory/name Merkle hierarchy with full-source blob leaves.
//!
//! The versioned root separates `files` from `types`: explicit content types are keyed by full
//! virtual path in `types`. A type change therefore changes the root without changing source bytes.
//! Types must not be guessed from suffixes: [`ConfigEntry`] permits explicit metadata independent
//! of a filename. Digests are reconstructed through config-source, never substituted by Merkle IDs.
//!
//! Preparation validates the original snapshot, then rejects file/directory prefix conflicts that
//! a flat snapshot can represent but a directory tree cannot. Restoration rejects empty non-root
//! directories, malformed paths, unknown types, surplus/missing metadata, invalid UTF-8 and limits.
//! Both directions use existing config-source validation; neither parses nor normalizes Decodal.
//! Evaluation, policy checks and transactional adoption remain the caller's responsibility.
use std::collections::BTreeMap;
use config_source::{
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, MAX_ENTRY_COUNT, MAX_PATH_BYTES,
MAX_TOTAL_BYTES, VirtualPath,
};
use content_history::{ContentId, Object, ObjectId, TreeId};
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
const FORMAT: &str = "config-tree/v1";
#[derive(Default)]
struct Directory {
children: BTreeMap<String, Node>,
}
enum Node {
File(ContentId),
Directory(Directory),
}
impl Directory {
fn insert(&mut self, parts: &[&str], target: ContentId, path: &str) -> Result<(), Error> {
let name = parts[0];
if parts.len() == 1 {
if self
.children
.insert(name.into(), Node::File(target))
.is_some()
{
return Err(Error::PathConflict(path.into()));
}
} else {
let child = self
.children
.entry(name.into())
.or_insert_with(|| Node::Directory(Directory::default()));
match child {
Node::Directory(directory) => directory.insert(&parts[1..], target, path)?,
Node::File(_) => return Err(Error::PathConflict(path.into())),
}
}
Ok(())
}
fn build(self, builder: &mut Builder) -> Result<TreeId, Error> {
let mut entries = Vec::new();
for (name, node) in self.children {
let target = match node {
Node::File(target) => target,
Node::Directory(directory) => ContentId::Tree(directory.build(builder)?),
};
entries.push(entry(name, target));
}
builder.map(entries)
}
}
/// Prepare objects only after validating all original snapshot metadata.
pub fn prepare(snapshot: &ConfigTreeSnapshot) -> Result<Snapshot, Error> {
snapshot.validate()?;
let mut builder = Builder::default();
let mut directory = Directory::default();
let mut types = Vec::new();
for (path, source) in &snapshot.entries {
directory.insert(
&path.as_str().split('/').collect::<Vec<_>>(),
builder.blob(&source.content),
path.as_str(),
)?;
let content_type = match source.content_type {
ConfigContentType::Decodal => "decodal",
ConfigContentType::Text => "text",
};
types.push(entry(path.as_str(), builder.blob(content_type)));
}
let files = directory.build(&mut builder)?;
let types = builder.map(types)?;
let format = builder.blob(FORMAT);
let root = builder.map(vec![
entry("format", format),
entry("files", ContentId::Tree(files)),
entry("types", ContentId::Tree(types)),
])?;
Ok(builder.finish(root))
}
/// Read through an authorized reader and reconstruct validated ConfigEntry metadata and digests.
/// The returned snapshot is a candidate, not a validated evaluation or an applied configuration.
pub fn restore(
root: TreeId,
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
) -> Result<ConfigTreeSnapshot, Error> {
let root = read_tree(&mut read, root, false)?;
fields(&root, &["format", "files", "types"])?;
if text(&mut read, field(&root, "format")?)? != FORMAT {
return Err(Error::Format);
}
let types = read_tree(&mut read, tree_id(field(&root, "types")?)?, false)?;
if types.entries().len() > MAX_ENTRY_COUNT {
return Err(Error::Structure("too many config types"));
}
let mut metadata = BTreeMap::new();
for item in types.entries() {
let path = VirtualPath::parse(&item.key)?;
let kind = match text(&mut read, item.target)?.as_str() {
"decodal" => ConfigContentType::Decodal,
"text" => ConfigContentType::Text,
_ => return Err(Error::Structure("unknown config content type")),
};
metadata.insert(path, kind);
}
let mut entries = Vec::new();
let mut total = 0;
restore_directory(
tree_id(field(&root, "files")?)?,
"",
&mut read,
&mut metadata,
&mut entries,
&mut total,
)?;
if !metadata.is_empty() {
return Err(Error::Structure("surplus config metadata"));
}
Ok(ConfigTreeSnapshot::from_entries(entries)?)
}
fn restore_directory(
id: TreeId,
prefix: &str,
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
metadata: &mut BTreeMap<VirtualPath, ConfigContentType>,
entries: &mut Vec<ConfigEntry>,
total: &mut usize,
) -> Result<(), Error> {
let directory = read_tree(read, id, false)?;
if !prefix.is_empty() && directory.entries().is_empty() {
return Err(Error::Structure("empty non-root config directory"));
}
// Limit directory fanout and path length even for malformed externally supplied trees.
if directory.entries().len() > MAX_ENTRY_COUNT {
return Err(Error::Structure("too many config children"));
}
for item in directory.entries() {
if item.key.contains('/') {
return Err(Error::Structure("config child must be one path component"));
}
let path = if prefix.is_empty() {
item.key.clone()
} else {
format!("{prefix}/{}", item.key)
};
if path.len() > MAX_PATH_BYTES {
return Err(Error::Structure("config path too long"));
}
let path = VirtualPath::parse(path)?;
match item.target {
ContentId::Tree(child) => {
// A valid directory must lead to an as-yet unrestored metadata entry. This also
// bounds traversal of adversarial shared subtrees by the existing entry/path limits.
let child_prefix = format!("{path}/");
if !metadata
.keys()
.any(|key| key.as_str().starts_with(&child_prefix))
{
return Err(Error::Structure("config directory has no file metadata"));
}
restore_directory(child, path.as_str(), read, metadata, entries, total)?
}
ContentId::Blob(_) => {
let kind = metadata
.remove(&path)
.ok_or(Error::Structure("missing config metadata"))?;
let source = text(read, item.target)?;
*total = total
.checked_add(source.len())
.ok_or(Error::Structure("config bytes overflow"))?;
if *total > MAX_TOTAL_BYTES || entries.len() >= MAX_ENTRY_COUNT {
return Err(Error::Structure("config snapshot limits exceeded"));
}
entries.push(ConfigEntry::new(path, kind, source)?);
}
}
}
Ok(())
}
@@ -0,0 +1,141 @@
//! Ticket compound-document contract, independent of the Ticket service and business events.
//!
//! The versioned root map includes `ticket-id`, `title`, `initial` and `posts`. `initial` is a map
//! of `author` and `body`; `posts` is an ordered sequence keyed by stable post IDs, each targeting
//! an `author`/`body` map. Order is never sorted for hash stability. All text is complete UTF-8,
//! including comments, Unicode and original line endings. Empty bodies and titles are preserved.
//!
//! Ticket identity deliberately participates in content: equal text on different Tickets yields
//! different roots. It is a business identity, not a content ID. Restoration requires the expected
//! identity and cannot retarget content to a different Ticket. Original authors participate in the
//! snapshot; authenticated history editors and edit times belong to a separate History record.
//!
//! These local types exclude state, assignment, relations, evidence and the existing content_digest.
//! Restoration returns only a document candidate; it cannot rewind business events or replace the
//! existing API's CAS. A caller restoring an old root must append a new history with current parent
//! and authenticated editor facts, and adopt content under its own business authorization/CAS.
use std::collections::BTreeSet;
use content_history::{ContentId, Object, ObjectId, Tree, TreeId};
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
const FORMAT: &str = "ticket-document/v1";
/// Content-only view. `initial_author` is the original author, not the latest history editor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document {
pub ticket_id: String,
pub title: String,
pub initial_body: String,
pub initial_author: String,
pub posts: Vec<Post>,
}
/// Stable business identity and original authorship survive edits and restoration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Post {
pub id: String,
pub author: String,
pub body: String,
}
impl Document {
fn validate(&self) -> Result<(), Error> {
if self.ticket_id.is_empty() {
return Err(Error::Structure("empty Ticket identity"));
}
let mut ids = BTreeSet::new();
for post in &self.posts {
if post.id.is_empty() || !ids.insert(&post.id) {
return Err(Error::Structure("empty or duplicate post identity"));
}
}
Ok(())
}
}
fn message(builder: &mut Builder, author: &str, body: &str) -> Result<TreeId, Error> {
let author = builder.blob(author);
let body = builder.blob(body);
builder.map(vec![entry("author", author), entry("body", body)])
}
/// Prepare a candidate with explicit root components and semantically ordered posts.
pub fn prepare(document: &Document) -> Result<Snapshot, Error> {
document.validate()?;
let mut builder = Builder::default();
let initial = message(
&mut builder,
&document.initial_author,
&document.initial_body,
)?;
let mut posts = Vec::new();
for post in &document.posts {
let content = message(&mut builder, &post.author, &post.body)?;
posts.push(entry(&post.id, ContentId::Tree(content)));
}
let posts = builder.tree(Tree::sequence(posts)?);
let format = builder.blob(FORMAT);
let identity = builder.blob(&document.ticket_id);
let title = builder.blob(&document.title);
let root = builder.map(vec![
entry("format", format),
entry("ticket-id", identity),
entry("title", title),
entry("initial", ContentId::Tree(initial)),
entry("posts", ContentId::Tree(posts)),
])?;
Ok(builder.finish(root))
}
/// Restore content only and verify the intended business identity before returning any document.
pub fn restore(
root: TreeId,
expected_ticket_id: &str,
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
) -> Result<Document, Error> {
let root = read_tree(&mut read, root, false)?;
fields(&root, &["format", "ticket-id", "title", "initial", "posts"])?;
if text(&mut read, field(&root, "format")?)? != FORMAT {
return Err(Error::Format);
}
let ticket_id = text(&mut read, field(&root, "ticket-id")?)?;
if ticket_id != expected_ticket_id {
return Err(Error::TicketIdentity);
}
let title = text(&mut read, field(&root, "title")?)?;
let (initial_author, initial_body) = restore_message(&mut read, field(&root, "initial")?)?;
let posts = read_tree(&mut read, tree_id(field(&root, "posts")?)?, true)?;
let mut restored = Vec::new();
for post in posts.entries() {
let (author, body) = restore_message(&mut read, post.target)?;
restored.push(Post {
id: post.key.clone(),
author,
body,
});
}
let document = Document {
ticket_id,
title,
initial_body,
initial_author,
posts: restored,
};
document.validate()?;
Ok(document)
}
fn restore_message(
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
target: ContentId,
) -> Result<(String, String), Error> {
let message = read_tree(read, tree_id(target)?, false)?;
fields(&message, &["author", "body"])?;
Ok((
text(read, field(&message, "author")?)?,
text(read, field(&message, "body")?)?,
))
}
@@ -0,0 +1,513 @@
use std::collections::BTreeMap;
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
use content_history::{ContentId, Entry, History, Object, ObjectId, Tree, TreeId};
use content_history_adapters::{Error, Snapshot, settings, ticket};
fn source(path: &str, kind: ConfigContentType, text: &str) -> ConfigEntry {
ConfigEntry::new(VirtualPath::parse(path).unwrap(), kind, text).unwrap()
}
fn config() -> ConfigTreeSnapshot {
ConfigTreeSnapshot::from_entries([
source(
"main.dcdl",
ConfigContentType::Decodal,
"// comment\r\n{ title: \"雪\" }\r\n",
),
// Explicit metadata is intentionally independent of the filename extension.
source(
"nested/prompt.dcdl",
ConfigContentType::Text,
"# Prompt\r\n<!-- keep -->\r\n\r\n",
),
source("nested/深い/empty.txt", ConfigContentType::Text, ""),
])
.unwrap()
}
fn document() -> ticket::Document {
ticket::Document {
ticket_id: "T-835".into(),
title: "設定の履歴".into(),
initial_author: "user:original".into(),
initial_body: "<!-- keep -->\r\nbody\r\n".into(),
posts: vec![
ticket::Post {
id: "post-20".into(),
author: "user:alice".into(),
body: "first\r\n".into(),
},
ticket::Post {
id: "post-03".into(),
author: "user:bob".into(),
body: "次\n".into(),
},
],
}
}
#[test]
fn version_one_fixed_root_and_full_source_fixtures() {
// Constants pin the adapter schema as well as the independent model's canonical encoding.
let config = settings::prepare(&config()).unwrap();
assert_eq!(
config.root.to_string(),
"cbd69a2f456e9c854d24c04725ac400de76d17101d3d1b21b19190d3f24284c9"
);
let ticket = ticket::prepare(&document()).unwrap();
assert_eq!(
ticket.root.to_string(),
"12dfd2e11581ea17e797c29b476a2b03ad6c0561cbccb611123f37be0771ff37"
);
let ContentId::Blob(source) = target(
tree(&config, child(&config, config.root, "files")),
"main.dcdl",
) else {
unreachable!()
};
assert_eq!(
source.to_string(),
"02e6cfe23152f0576d14a0806717168e544a7abb2309e64354f6bb4e62ee81bb"
);
assert_eq!(
config.get(ObjectId::Blob(source)).unwrap(),
Object::Blob("// comment\r\n{ title: \"雪\" }\r\n".as_bytes().to_vec())
);
}
#[test]
fn config_explicit_type_change_changes_only_metadata_branch() {
let original = config();
let old = settings::prepare(&original).unwrap();
let mut entries = original.entries.values().cloned().collect::<Vec<_>>();
entries[0].content_type = ConfigContentType::Text;
let candidate = ConfigTreeSnapshot::from_entries(entries).unwrap();
let new = settings::prepare(&candidate).unwrap();
assert_ne!(old.root, new.root);
assert_eq!(
child(&old, old.root, "files"),
child(&new, new.root, "files")
);
assert_eq!(
settings::restore(new.root, |id| new.get(id)).unwrap(),
candidate
);
}
#[test]
fn config_restore_rejects_missing_surplus_metadata_and_empty_directories() {
let original = settings::prepare(&config()).unwrap();
let mut invalid = original.clone();
let empty = Object::Tree(Tree::map(Vec::new()).unwrap());
let ObjectId::Tree(empty) = add(&mut invalid, empty) else {
unreachable!()
};
invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(empty));
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(empty));
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
// An empty non-root directory is not representable by ConfigTreeSnapshot.
let ObjectId::Tree(files) = add(
&mut invalid,
Object::Tree(
Tree::map(vec![Entry {
key: "empty".into(),
target: ContentId::Tree(empty),
}])
.unwrap(),
),
) else {
unreachable!()
};
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
}
#[test]
fn raw_reader_surfaces_unsupported_model_formats_without_partial_restore() {
let snapshot = ticket::prepare(&document()).unwrap();
let result = ticket::restore(snapshot.root, "T-835", |id| {
let mut bytes = snapshot.get(id)?.encode();
bytes[5] = 99;
Ok(Object::decode(&bytes)?)
});
assert!(matches!(result, Err(Error::Model(_))));
}
fn tree(snapshot: &Snapshot, id: TreeId) -> &Tree {
let Object::Tree(tree) = &snapshot.objects[&ObjectId::Tree(id)] else {
panic!("expected tree")
};
tree
}
fn target(tree: &Tree, key: &str) -> ContentId {
tree.entries().iter().find(|e| e.key == key).unwrap().target
}
fn child(snapshot: &Snapshot, id: TreeId, key: &str) -> TreeId {
let ContentId::Tree(id) = target(tree(snapshot, id), key) else {
panic!("expected tree child")
};
id
}
fn add(snapshot: &mut Snapshot, object: Object) -> ObjectId {
let id = object.id();
snapshot.objects.insert(id, object);
id
}
fn change_field(snapshot: &mut Snapshot, id: TreeId, key: &str, replacement: ContentId) -> TreeId {
let entries = tree(snapshot, id)
.entries()
.iter()
.map(|item| Entry {
key: item.key.clone(),
target: if item.key == key {
replacement
} else {
item.target
},
})
.collect();
let ObjectId::Tree(id) = add(snapshot, Object::Tree(Tree::map(entries).unwrap())) else {
unreachable!()
};
id
}
fn blob(snapshot: &mut Snapshot, bytes: &[u8]) -> ContentId {
let ObjectId::Blob(id) = add(snapshot, Object::Blob(bytes.to_vec())) else {
unreachable!()
};
ContentId::Blob(id)
}
#[test]
fn config_roundtrip_preserves_sources_paths_types_and_metadata() {
let original = config();
let snapshot = settings::prepare(&original).unwrap();
let restored = settings::restore(snapshot.root, |id| snapshot.get(id)).unwrap();
assert_eq!(original, restored);
// Directory tree uses actual names, not a flat list of full paths.
let files = child(&snapshot, snapshot.root, "files");
assert_eq!(
tree(&snapshot, files)
.entries()
.iter()
.map(|e| e.key.as_str())
.collect::<Vec<_>>(),
["main.dcdl", "nested"]
);
let nested = child(&snapshot, files, "nested");
assert!(matches!(
target(tree(&snapshot, nested), "prompt.dcdl"),
ContentId::Blob(_)
));
let deep = child(&snapshot, nested, "深い");
assert!(matches!(
target(tree(&snapshot, deep), "empty.txt"),
ContentId::Blob(_)
));
// Reopening canonical bytes does not reserialize domain sources or alter root identity.
let reopened = snapshot
.objects
.iter()
.map(|(id, object)| (*id, Object::decode(&object.encode()).unwrap()))
.collect::<BTreeMap<_, _>>();
assert_eq!(
settings::restore(snapshot.root, |id| reopened
.get(&id)
.cloned()
.ok_or(Error::Missing(id)))
.unwrap(),
original
);
let reverse =
ConfigTreeSnapshot::from_entries(original.entries.values().rev().cloned()).unwrap();
assert_eq!(snapshot.root, settings::prepare(&reverse).unwrap().root);
}
#[test]
fn config_edits_names_types_additions_and_deletions_change_root_share_unchanged_blobs() {
let original = config();
let old = settings::prepare(&original).unwrap();
for entries in [
vec![source("main.dcdl", ConfigContentType::Decodal, "changed\n")],
vec![source(
"renamed.dcdl",
ConfigContentType::Decodal,
"// comment\r\n{ title: \"雪\" }\r\n",
)],
vec![source(
"main.dcdl",
ConfigContentType::Text,
"// comment\r\n{ title: \"雪\" }\r\n",
)],
] {
assert_ne!(
old.root,
settings::prepare(&ConfigTreeSnapshot::from_entries(entries).unwrap())
.unwrap()
.root
);
}
let mut edited = original.entries.values().cloned().collect::<Vec<_>>();
edited[0] = source("main.dcdl", ConfigContentType::Decodal, "new\r\n");
edited.push(source("extra.txt", ConfigContentType::Text, "added"));
let new = settings::prepare(&ConfigTreeSnapshot::from_entries(edited).unwrap()).unwrap();
let files_old = child(&old, old.root, "files");
let files_new = child(&new, new.root, "files");
assert_eq!(
target(tree(&old, files_old), "nested"),
target(tree(&new, files_new), "nested")
);
let mut union = old.objects.clone();
union.extend(new.objects);
assert_eq!(
settings::restore(old.root, |id| union
.get(&id)
.cloned()
.ok_or(Error::Missing(id)))
.unwrap(),
original
);
let empty = settings::prepare(&ConfigTreeSnapshot::empty()).unwrap();
assert_ne!(old.root, empty.root);
assert_eq!(
settings::restore(empty.root, |id| empty.get(id)).unwrap(),
ConfigTreeSnapshot::empty()
);
}
#[test]
fn config_rejects_inconsistent_snapshot_and_file_directory_conflicts() {
let mut invalid = config();
invalid.digest.push('0');
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
let mut invalid = config();
invalid
.entries
.values_mut()
.next()
.unwrap()
.content
.push('!');
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
let mut invalid = config();
invalid.entries.values_mut().next().unwrap().path = VirtualPath::parse("other.dcdl").unwrap();
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
let prefix_conflict = ConfigTreeSnapshot::from_entries([
source("a", ConfigContentType::Text, "file"),
source("a/b", ConfigContentType::Text, "nested"),
])
.unwrap();
assert!(matches!(
settings::prepare(&prefix_conflict),
Err(Error::PathConflict(_))
));
}
#[test]
fn config_restore_rejects_invalid_utf8_metadata_paths_and_ordering() {
let original = settings::prepare(&config()).unwrap();
let mut invalid = original.clone();
let files = child(&invalid, invalid.root, "files");
let bad = blob(&mut invalid, &[0xff]);
let files = change_field(&mut invalid, files, "main.dcdl", bad);
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
assert!(matches!(
settings::restore(invalid.root, |id| invalid.get(id)),
Err(Error::Utf8(_))
));
let mut invalid = original.clone();
let types = child(&invalid, invalid.root, "types");
let bad = blob(&mut invalid, b"unknown");
let types = change_field(&mut invalid, types, "main.dcdl", bad);
invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(types));
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
for key in ["../escape", "a/b", "$builtin", ""] {
let mut invalid = original.clone();
let value = blob(&mut invalid, b"bad");
// Empty keys may already be rejected by the independent model.
let Ok(files) = Tree::map(vec![Entry {
key: key.into(),
target: value,
}]) else {
continue;
};
let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(files)) else {
unreachable!()
};
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
assert!(
settings::restore(invalid.root, |id| invalid.get(id)).is_err(),
"{key}"
);
}
let mut invalid = original.clone();
let files = child(&invalid, invalid.root, "files");
let ordered = Tree::sequence(tree(&invalid, files).entries().to_vec()).unwrap();
let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(ordered)) else {
unreachable!()
};
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
}
#[test]
fn ticket_roundtrip_explicit_structure_and_semantic_order() {
let original = document();
let snapshot = ticket::prepare(&original).unwrap();
assert_eq!(
ticket::restore(snapshot.root, "T-835", |id| snapshot.get(id)).unwrap(),
original
);
assert_eq!(
tree(&snapshot, snapshot.root)
.entries()
.iter()
.map(|e| e.key.as_str())
.collect::<Vec<_>>(),
["format", "initial", "posts", "ticket-id", "title"]
);
let posts = child(&snapshot, snapshot.root, "posts");
assert!(tree(&snapshot, posts).is_ordered());
assert_eq!(
tree(&snapshot, posts)
.entries()
.iter()
.map(|e| e.key.as_str())
.collect::<Vec<_>>(),
["post-20", "post-03"]
);
let mut swapped = original.clone();
swapped.posts.swap(0, 1);
assert_ne!(snapshot.root, ticket::prepare(&swapped).unwrap().root);
let mut other = original.clone();
other.ticket_id = "T-836".into();
assert_ne!(snapshot.root, ticket::prepare(&other).unwrap().root);
assert!(matches!(
ticket::restore(snapshot.root, "T-836", |id| snapshot.get(id)),
Err(Error::TicketIdentity)
));
let mut edited = original;
edited.posts[0].body = "edited".into();
let restored = ticket::restore(ticket::prepare(&edited).unwrap().root, "T-835", |id| {
ticket::prepare(&edited).unwrap().get(id)
})
.unwrap();
assert_eq!(restored.posts[0].id, "post-20");
assert_eq!(restored.posts[0].author, "user:alice");
}
#[test]
fn restore_old_document_leaves_separate_business_events_and_original_authors_untouched() {
#[derive(Debug, PartialEq)]
struct BusinessState {
state: &'static str,
assignment: &'static str,
events: Vec<&'static str>,
}
struct Ticket {
document: ticket::Document,
business: BusinessState,
}
let original = document();
let old = ticket::prepare(&original).unwrap();
let mut current = Ticket {
document: original.clone(),
business: BusinessState {
state: "closed",
assignment: "worker:new",
events: vec!["relation added", "evidence attached"],
},
};
current.document.title = "changed".into();
current.document =
ticket::restore(old.root, &current.document.ticket_id, |id| old.get(id)).unwrap();
assert_eq!(current.document, original);
assert_eq!(
current.business,
BusinessState {
state: "closed",
assignment: "worker:new",
events: vec!["relation added", "evidence attached"]
}
);
let first_edit = History {
root: old.root,
parent: None,
actor: "user:editor".into(),
timestamp_micros: 1,
};
let ObjectId::History(first_id) = Object::History(first_edit.clone()).id() else {
unreachable!()
};
let restore_edit = History {
root: old.root,
parent: Some(first_id),
actor: "user:restorer".into(),
timestamp_micros: 2,
};
assert_ne!(
Object::History(first_edit).id(),
Object::History(restore_edit).id()
);
assert_eq!(current.document.posts[0].author, "user:alice");
}
#[test]
fn readers_fail_closed_on_missing_corruption_wrong_kind_and_unknown_adapter_version() {
let snapshot = ticket::prepare(&document()).unwrap();
for id in snapshot.objects.keys() {
assert!(
ticket::restore(snapshot.root, "T-835", |requested| {
if requested == *id {
Err(Error::Missing(requested))
} else {
snapshot.get(requested)
}
})
.is_err()
);
}
assert!(matches!(
ticket::restore(snapshot.root, "T-835", |_| Ok(Object::Blob(
b"tampered".to_vec()
))),
Err(Error::HashMismatch(_))
));
let mut invalid = snapshot.clone();
let bad = blob(&mut invalid, b"ticket-document/v999");
invalid.root = change_field(&mut invalid, snapshot.root, "format", bad);
assert!(matches!(
ticket::restore(invalid.root, "T-835", |id| invalid.get(id)),
Err(Error::Format)
));
let bad = blob(&mut invalid, b"not a post sequence");
invalid.root = change_field(&mut invalid, snapshot.root, "posts", bad);
assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err());
let posts = child(&snapshot, snapshot.root, "posts");
let unordered = Tree::map(tree(&snapshot, posts).entries().to_vec()).unwrap();
let ObjectId::Tree(posts) = add(&mut invalid, Object::Tree(unordered)) else {
unreachable!()
};
invalid.root = change_field(&mut invalid, snapshot.root, "posts", ContentId::Tree(posts));
assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err());
}
#[test]
fn duplicate_business_post_ids_and_empty_ticket_ids_are_not_accepted() {
let mut invalid = document();
invalid.posts[1].id = invalid.posts[0].id.clone();
assert!(ticket::prepare(&invalid).is_err());
invalid = document();
invalid.ticket_id.clear();
assert!(ticket::prepare(&invalid).is_err());
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "content-history-store"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
content-history.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
content-history-adapters.workspace = true
config-source.workspace = true
+38
View File
@@ -0,0 +1,38 @@
# content-history-store
Persists immutable content and append-only editing history on the caller's SQLite transaction.
The pure canonical model belongs to `content-history`; this crate does not evaluate configuration,
edit Tickets, authenticate users, or replace their existing CAS contracts.
[`ScopedStore`] is a trusted Host adapter, not an untrusted SQL service. Its caller implements
[`Authority`] using existing current operation authorization for the exact [`ScopeKey`]. Every
operation rechecks authority before reading stored metadata. A hash is never a capability: even
preparation requires complete verified bytes, and public reads require reachability from an
adopted history in that same workspace and target. Physical deduplication does not grant access.
[`SCHEMA`] is the domain's schema contribution. The eventual adopting Server domain must install
it through its existing ordered migration and fresh-schema path, not through a second startup
migration runner. This foundation does not change the production configuration writer. The
adapter accepts the same `rusqlite::Transaction` used by the Server. The caller prepares complete
objects, validates one candidate snapshot, calls [`ScopedStore::adopt`] with the observed history
head, and updates source/projection/business records before committing that **same** transaction.
An error must roll back the caller's transaction; a successful adapter call alone is not successful
business adoption. Each individual mutating method also uses a savepoint, so catching its error
cannot retain its partial changes. SQLite writer acquisition and BUSY/retry policy remain caller-owned.
No automatic retries or external effects are hidden here.
Normal edits retain all adopted ancestors and their full snapshots; [`ScopedStore::collect_unadopted`]
only removes preparations not used by any adopted history. Object rows cannot be overwritten
through the adapter. A supplied hash/bytes mismatch, a differing existing row, a missing child,
wrong child kind or unsupported canonical format fails rather than publishing an incomplete head.
Stored bytes are verified on every read, not reserialized under a newer version.
[`ScopedStore::erase`] requires separate explicit authorization, tombstones the exact target
permanently and removes its ownership references. Old hashes cannot recreate that target's data.
Other targets' retained or prepared references keep shared physical objects alive. A newly created
business object needs a new target identity; erasure is not an edit or a rewind. This is logical
store erasure, not secure disk overwrite or revocation of already exported copies/backups. The
caller owns retention decisions, backup policy and authenticated editor/time facts. Restoring an
old content root is a new adoption with the **current** history parent, never resetting head to an
old history. Existing Ticket `content_digest`, Ticket/post identities and business events remain
separate contracts owned by their domain.
+308
View File
@@ -0,0 +1,308 @@
#![doc = include_str!("../README.md")]
use std::collections::BTreeMap;
use content_history::{History, HistoryId, Object, ObjectId, TreeId, verify};
use rusqlite::{OptionalExtension, Transaction, params};
/// New domain tables, deliberately not an alternate Server migration runner.
/// The adopting domain installs these through its ordered schema migration and fresh schema.
pub const SCHEMA: &str = include_str!("schema.sql");
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("content access denied")]
Denied,
#[error("content unavailable in authorized scope")]
Unavailable,
#[error("content scope was explicitly erased")]
Erased,
#[error("observed history head is stale")]
Conflict,
#[error("invalid content: {0}")]
Model(#[from] content_history::Error),
#[error("invalid stored history reference")]
InvalidReference,
#[error("immutable content differs from stored bytes")]
ImmutableMismatch,
#[error("sqlite content store: {0}")]
Sql(#[from] rusqlite::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
/// The business owner supplies exact identities, not path containment rules.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeKey {
pub workspace: String,
pub target: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Read,
Write,
Erase,
}
/// Existing caller authority is checked at each operation, before querying objects.
/// This is a trusted Host integration seam, not a grant constructor or authentication system.
pub trait Authority {
fn allows(&self, key: &ScopeKey, access: Access) -> bool;
}
/// An adapter borrows the caller's real transaction; it never commits business adoption.
/// Do not expose this type or its authority implementation directly to untrusted code.
pub struct ScopedStore<'a, 'conn> {
tx: &'a Transaction<'conn>,
key: ScopeKey,
authority: &'a dyn Authority,
}
impl<'a, 'conn> ScopedStore<'a, 'conn> {
pub fn new(tx: &'a Transaction<'conn>, key: ScopeKey, authority: &'a dyn Authority) -> Self {
Self { tx, key, authority }
}
fn authorize(&self, access: Access) -> Result<()> {
if self.key.workspace.is_empty()
|| self.key.target.is_empty()
|| !self.authority.allows(&self.key, access)
{
return Err(Error::Denied);
}
let erased: Option<bool> = self
.tx
.query_row(
"SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2",
params![self.key.workspace, self.key.target],
|r| r.get(0),
)
.optional()?;
if erased == Some(true) {
return Err(Error::Erased);
}
Ok(())
}
fn ensure_scope(&self) -> Result<()> {
self.tx.execute(
"INSERT OR IGNORE INTO content_scopes(workspace,target) VALUES(?1,?2)",
params![self.key.workspace, self.key.target],
)?;
Ok(())
}
pub fn head(&self) -> Result<Option<HistoryId>> {
self.authorize(Access::Read)?;
self.head_inner()
}
fn head_inner(&self) -> Result<Option<HistoryId>> {
let raw: Option<Vec<u8>> = self
.tx
.query_row(
"SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2",
params![self.key.workspace, self.key.target],
|r| r.get(0),
)
.optional()?
.flatten();
raw.map(|v| {
Ok(HistoryId::from_bytes(
v.try_into().map_err(|_| Error::InvalidReference)?,
))
})
.transpose()
}
/// Preparation requires the full canonical bytes, not possession of another scope's hash.
/// Prepared objects are not readable snapshots until adoption succeeds.
pub fn put(&self, claimed: ObjectId, bytes: &[u8]) -> Result<()> {
self.authorize(Access::Write)?;
verify(claimed, bytes)?;
self.atomic(|| self.put_inner(claimed, bytes))
}
fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> {
self.ensure_scope()?;
let (kind, hash) = parts(id);
self.tx.execute(
"INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)",
params![kind, hash, bytes],
)?;
let stored: Vec<u8> = self.tx.query_row(
"SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2",
params![kind, hash],
|r| r.get(0),
)?;
if stored != bytes {
return Err(Error::ImmutableMismatch);
}
self.tx.execute("INSERT OR IGNORE INTO content_scope_objects(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)",
params![self.key.workspace, self.key.target, kind, hash])?;
Ok(())
}
/// A hash read is confined to objects reachable from this target's adopted history.
/// Unknown and cross-target hashes have the same result, regardless of physical deduplication.
pub fn read(&self, id: ObjectId) -> Result<Object> {
self.authorize(Access::Read)?;
let (kind, hash) = parts(id);
let allowed: bool = self.tx.query_row(
"SELECT EXISTS(SELECT 1 FROM content_reachable WHERE workspace=?1 AND target=?2 AND kind=?3 AND hash=?4)",
params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0))?;
if !allowed {
return Err(Error::Unavailable);
}
self.load_prepared(id)
}
fn load_prepared(&self, id: ObjectId) -> Result<Object> {
let (kind, hash) = parts(id);
let bytes: Option<Vec<u8>> = self.tx.query_row(
"SELECT o.bytes FROM content_objects o JOIN content_scope_objects s ON s.kind=o.kind AND s.hash=o.hash WHERE s.workspace=?1 AND s.target=?2 AND s.kind=?3 AND s.hash=?4",
params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0)).optional()?;
Ok(verify(id, &bytes.ok_or(Error::Unavailable)?)?)
}
/// Restore uses a retained history, not a chain of text deltas. The returned graph includes
/// every full blob and structural node and fails on any missing or corrupt descendant.
pub fn snapshot(&self, history: HistoryId) -> Result<BTreeMap<ObjectId, Object>> {
let Object::History(record) = self.read(ObjectId::History(history))? else {
return Err(Error::InvalidReference);
};
self.walk([ObjectId::Tree(record.root)], true)
}
fn walk(
&self,
roots: impl IntoIterator<Item = ObjectId>,
public: bool,
) -> Result<BTreeMap<ObjectId, Object>> {
let mut pending: Vec<_> = roots.into_iter().collect();
let mut objects = BTreeMap::new();
while let Some(id) = pending.pop() {
if objects.contains_key(&id) {
continue;
}
let object = if public {
self.read(id)?
} else {
self.load_prepared(id)?
};
match &object {
Object::Blob(_) => {}
Object::Tree(tree) => {
pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target)))
}
Object::History(history) => {
pending.push(ObjectId::Tree(history.root));
if let Some(parent) = history.parent {
pending.push(ObjectId::History(parent));
}
}
}
objects.insert(id, object);
}
Ok(objects)
}
/// Authenticated actor and clock facts belong to the caller, not restored post authors.
/// Restoring an old root appends a history with the current parent; no rewind operation exists.
/// The private savepoint prevents even a caught error from publishing a partial adoption.
pub fn adopt(
&self,
root: TreeId,
expected: Option<HistoryId>,
actor: String,
timestamp_micros: i64,
) -> Result<HistoryId> {
self.authorize(Access::Write)?;
self.atomic(|| {
if self.head_inner()? != expected { return Err(Error::Conflict); }
let mut graph = self.walk([ObjectId::Tree(root)], false)?;
if let Some(parent) = expected {
// Validate all retained ancestors too; corruption cannot become a successful new head.
graph.extend(self.walk([ObjectId::History(parent)], false)?);
}
let history = Object::History(History { root, parent: expected, actor, timestamp_micros });
let ObjectId::History(id) = history.id() else { unreachable!() };
self.put_inner(history.id(), &history.encode())?;
graph.insert(history.id(), history);
for object in graph.keys() {
let (kind, hash) = parts(*object);
self.tx.execute("INSERT OR IGNORE INTO content_reachable(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)",
params![self.key.workspace, self.key.target, kind, hash])?;
}
let changed = self.tx.execute(
"UPDATE content_scopes SET head=?3 WHERE workspace=?1 AND target=?2 AND erased=0 AND head IS ?4",
params![self.key.workspace, self.key.target, id.as_bytes().as_slice(), expected.map(|h| h.as_bytes().to_vec())])?;
if changed != 1 { return Err(Error::Conflict); }
Ok(id)
})
}
/// Retains every adopted history. Only unadopted preparations are reclaimed.
/// Physical rows survive while *any* target has prepared or retained ownership.
pub fn collect_unadopted(&self) -> Result<()> {
self.authorize(Access::Write)?;
self.atomic(|| {
self.tx.execute("DELETE FROM content_scope_objects AS s WHERE workspace=?1 AND target=?2 AND NOT EXISTS(SELECT 1 FROM content_reachable r WHERE r.workspace=s.workspace AND r.target=s.target AND r.kind=s.kind AND r.hash=s.hash)",
params![self.key.workspace, self.key.target])?;
self.collect_physical()
})
}
/// Erasure is permanent for this exact business identity, including its old roots.
/// Shared bytes retained by other authorized targets are not destroyed. Backups and bytes
/// already exported by callers are outside this adapter's logical erasure guarantee.
pub fn erase(&self) -> Result<()> {
self.authorize(Access::Erase)?;
self.atomic(|| {
self.ensure_scope()?;
self.tx.execute(
"DELETE FROM content_reachable WHERE workspace=?1 AND target=?2",
params![self.key.workspace, self.key.target],
)?;
self.tx.execute(
"DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2",
params![self.key.workspace, self.key.target],
)?;
self.tx.execute(
"UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2",
params![self.key.workspace, self.key.target],
)?;
self.collect_physical()
})
}
fn collect_physical(&self) -> Result<()> {
self.tx.execute("DELETE FROM content_objects AS o WHERE NOT EXISTS(SELECT 1 FROM content_scope_objects s WHERE s.kind=o.kind AND s.hash=o.hash)", [])?;
Ok(())
}
fn atomic<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
self.tx
.execute_batch("SAVEPOINT content_history_operation")?;
match f() {
Ok(value) => {
self.tx.execute_batch("RELEASE content_history_operation")?;
Ok(value)
}
Err(error) => {
self.tx.execute_batch(
"ROLLBACK TO content_history_operation; RELEASE content_history_operation",
)?;
Err(error)
}
}
}
}
fn parts(id: ObjectId) -> (u8, Vec<u8>) {
match id {
ObjectId::Blob(h) => (1, h.as_bytes().to_vec()),
ObjectId::Tree(h) => (2, h.as_bytes().to_vec()),
ObjectId::History(h) => (3, h.as_bytes().to_vec()),
}
}
@@ -0,0 +1,32 @@
CREATE TABLE content_objects (
kind INTEGER NOT NULL CHECK(kind IN (1,2,3)),
hash BLOB NOT NULL CHECK(length(hash)=32),
bytes BLOB NOT NULL,
PRIMARY KEY(kind,hash)
) WITHOUT ROWID;
CREATE TABLE content_scopes (
workspace TEXT NOT NULL,
target TEXT NOT NULL,
head BLOB CHECK(head IS NULL OR length(head)=32),
erased INTEGER NOT NULL DEFAULT 0 CHECK(erased IN (0,1)),
PRIMARY KEY(workspace,target),
CHECK(erased=0 OR head IS NULL)
) WITHOUT ROWID;
CREATE TABLE content_scope_objects (
workspace TEXT NOT NULL,
target TEXT NOT NULL,
kind INTEGER NOT NULL,
hash BLOB NOT NULL,
PRIMARY KEY(workspace,target,kind,hash),
FOREIGN KEY(workspace,target) REFERENCES content_scopes(workspace,target),
FOREIGN KEY(kind,hash) REFERENCES content_objects(kind,hash)
) WITHOUT ROWID;
CREATE TABLE content_reachable (
workspace TEXT NOT NULL,
target TEXT NOT NULL,
kind INTEGER NOT NULL,
hash BLOB NOT NULL,
PRIMARY KEY(workspace,target,kind,hash),
FOREIGN KEY(workspace,target,kind,hash)
REFERENCES content_scope_objects(workspace,target,kind,hash)
) WITHOUT ROWID;
@@ -0,0 +1,467 @@
//! Real SQLite component contracts. Authority is an exact-key Host fixture; SQL, savepoints,
//! graph validation and persistence are production implementations. No live Server DB is used.
use content_history::{ContentId, Entry, HistoryId, Object, ObjectId, Tree, TreeId};
use content_history_store::{Access, Authority, Error, SCHEMA, ScopeKey, ScopedStore};
use rusqlite::{Connection, Transaction, params};
struct Host {
key: ScopeKey,
erase: bool,
}
impl Authority for Host {
fn allows(&self, key: &ScopeKey, access: Access) -> bool {
*key == self.key && (access != Access::Erase || self.erase)
}
}
fn key(workspace: &str, target: &str) -> ScopeKey {
ScopeKey {
workspace: workspace.into(),
target: target.into(),
}
}
fn host(target: &str) -> Host {
Host {
key: key("ws", target),
erase: true,
}
}
fn store<'a, 'c>(tx: &'a Transaction<'c>, h: &'a Host) -> ScopedStore<'a, 'c> {
ScopedStore::new(tx, h.key.clone(), h)
}
fn setup(conn: &mut Connection) {
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
let tx = conn.transaction().unwrap();
tx.execute_batch(SCHEMA).unwrap();
tx.execute_batch("CREATE TABLE business(source BLOB, projection TEXT, adopted BLOB)")
.unwrap();
tx.commit().unwrap();
}
fn candidate(s: &ScopedStore<'_, '_>, bytes: &[u8]) -> (TreeId, ObjectId) {
let blob = Object::Blob(bytes.to_vec());
let ObjectId::Blob(id) = blob.id() else {
unreachable!()
};
s.put(blob.id(), &blob.encode()).unwrap();
let tree = Object::Tree(
Tree::map(vec![Entry {
key: "source".into(),
target: ContentId::Blob(id),
}])
.unwrap(),
);
s.put(tree.id(), &tree.encode()).unwrap();
let ObjectId::Tree(root) = tree.id() else {
unreachable!()
};
(root, blob.id())
}
fn adopt(s: &ScopedStore<'_, '_>, root: TreeId, expected: Option<HistoryId>, t: i64) -> HistoryId {
s.adopt(root, expected, "authenticated editor".into(), t)
.unwrap()
}
#[test]
fn reopen_full_text_restore_aba_and_stale_head_preserve_current() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.sqlite");
let mut conn = Connection::open(&path).unwrap();
setup(&mut conn);
let h = host("config");
let (a, b, ha, hb, hc, blob_a) = {
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (a, blob_a) = candidate(&s, b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n");
assert!(matches!(s.read(blob_a), Err(Error::Unavailable)));
let ha = adopt(&s, a, None, 1);
let (b, _) = candidate(&s, b"other\n");
let hb = adopt(&s, b, Some(ha), 2);
let hc = adopt(&s, a, Some(hb), 3);
assert_ne!(ha, hc);
assert!(matches!(
s.adopt(b, Some(ha), "stale".into(), 4),
Err(Error::Conflict)
));
assert_eq!(s.head().unwrap(), Some(hc));
assert!(s.snapshot(ha).unwrap().contains_key(&blob_a));
tx.commit().unwrap();
(a, b, ha, hb, hc, blob_a)
};
drop(conn);
let mut reopened = Connection::open(path).unwrap();
let tx = reopened.transaction().unwrap();
let s = store(&tx, &h);
assert_ne!(a, b);
assert_ne!(ha, hb);
assert_eq!(s.head().unwrap(), Some(hc));
let old = s.snapshot(ha).unwrap();
let restored = s.snapshot(hc).unwrap();
assert_eq!(old, restored);
assert_eq!(
s.read(blob_a).unwrap(),
Object::Blob(b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n".to_vec())
);
}
#[test]
fn caller_rollback_keeps_source_projection_and_head_atomic() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let h = host("config");
let original = {
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"old");
let head = adopt(&s, root, None, 1);
tx.execute(
"INSERT INTO business VALUES(?1,'old projection',?2)",
params![b"old".as_slice(), head.as_bytes().as_slice()],
)
.unwrap();
tx.commit().unwrap();
head
};
{
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"new");
let head = adopt(&s, root, Some(original), 2);
tx.execute(
"UPDATE business SET source=?1,projection='new projection',adopted=?2",
params![b"new".as_slice(), head.as_bytes().as_slice()],
)
.unwrap();
tx.rollback().unwrap();
}
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
assert_eq!(s.head().unwrap(), Some(original));
let row: (Vec<u8>, String, Vec<u8>) = tx
.query_row("SELECT * FROM business", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap();
assert_eq!(
row,
(
b"old".to_vec(),
"old projection".into(),
original.as_bytes().to_vec()
)
);
}
#[test]
fn injected_history_or_head_failure_cannot_publish_partial_adoption_even_if_error_caught() {
for trigger in [
"CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects WHEN NEW.kind=3 BEGIN SELECT RAISE(ABORT,'history failure'); END",
"CREATE TEMP TRIGGER fail BEFORE UPDATE OF head ON content_scopes BEGIN SELECT RAISE(ABORT,'head failure'); END",
"CREATE TEMP TRIGGER fail BEFORE INSERT ON content_reachable BEGIN SELECT RAISE(ABORT,'reachability failure'); END",
] {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let h = host("config");
let original = {
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"old");
let head = adopt(&s, root, None, 1);
tx.commit().unwrap();
head
};
{
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (root, blob) = candidate(&s, b"new");
tx.execute_batch(trigger).unwrap();
assert!(matches!(
s.adopt(root, Some(original), "editor".into(), 2),
Err(Error::Sql(_))
));
assert_eq!(s.head().unwrap(), Some(original));
assert!(matches!(s.read(blob), Err(Error::Unavailable)));
tx.execute_batch("DROP TRIGGER fail").unwrap();
tx.commit().unwrap();
}
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
assert_eq!(s.head().unwrap(), Some(original));
let count: i64 = tx
.query_row(
"SELECT count(*) FROM content_objects WHERE kind=3",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}
#[test]
fn object_failure_and_missing_or_wrong_kind_children_do_not_publish_head() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let h = host("config");
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
tx.execute_batch("CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects BEGIN SELECT RAISE(ABORT,'object failure'); END").unwrap();
let blob = Object::Blob(b"not saved".to_vec());
assert!(matches!(
s.put(blob.id(), &blob.encode()),
Err(Error::Sql(_))
));
tx.execute_batch("DROP TRIGGER fail").unwrap();
assert_eq!(s.head().unwrap(), None);
let ObjectId::Blob(id) = blob.id() else {
unreachable!()
};
let tree = Object::Tree(
Tree::map(vec![Entry {
key: "missing".into(),
target: ContentId::Blob(id),
}])
.unwrap(),
);
s.put(tree.id(), &tree.encode()).unwrap();
let ObjectId::Tree(root) = tree.id() else {
unreachable!()
};
assert!(matches!(
s.adopt(root, None, "e".into(), 1),
Err(Error::Unavailable)
));
s.put(blob.id(), &blob.encode()).unwrap();
let tree = Object::Tree(
Tree::map(vec![Entry {
key: "wrong kind".into(),
target: ContentId::Tree(TreeId::from_bytes(*id.as_bytes())),
}])
.unwrap(),
);
s.put(tree.id(), &tree.encode()).unwrap();
let ObjectId::Tree(root) = tree.id() else {
unreachable!()
};
assert!(matches!(
s.adopt(root, None, "e".into(), 1),
Err(Error::Unavailable)
));
assert_eq!(s.head().unwrap(), None);
}
#[test]
fn scoped_hash_reads_and_adoption_cannot_cross_workspace_or_target() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let a = host("a");
let b = host("b");
let tx = conn.transaction().unwrap();
let sa = store(&tx, &a);
let (root, blob) = candidate(&sa, b"private");
let head = adopt(&sa, root, None, 1);
let sb = store(&tx, &b);
assert!(matches!(sb.read(blob), Err(Error::Unavailable)));
assert!(matches!(
sb.read(ObjectId::History(head)),
Err(Error::Unavailable)
));
assert!(matches!(
sb.adopt(root, None, "e".into(), 1),
Err(Error::Unavailable)
));
let denied = ScopedStore::new(&tx, key("another-workspace", "a"), &a);
assert!(matches!(denied.read(blob), Err(Error::Denied)));
assert!(matches!(denied.head(), Err(Error::Denied)));
let denied = ScopedStore::new(&tx, b.key.clone(), &a);
assert!(matches!(denied.read(blob), Err(Error::Denied)));
}
#[test]
fn explicit_erasure_never_revives_old_scope_and_preserves_shared_history() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let a = host("a");
let b = host("b");
let (root, blob, ha, hb) = {
let tx = conn.transaction().unwrap();
let sa = store(&tx, &a);
let sb = store(&tx, &b);
let (root, blob) = candidate(&sa, b"shared");
let ha = adopt(&sa, root, None, 1);
let (root_b, _) = candidate(&sb, b"shared");
assert_eq!(root, root_b);
let hb = adopt(&sb, root, None, 2);
let (old, _) = candidate(&sb, b"new");
adopt(&sb, old, Some(hb), 3);
sb.collect_unadopted().unwrap();
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
sa.erase().unwrap();
assert!(matches!(sa.read(blob), Err(Error::Erased)));
assert!(matches!(sa.snapshot(ha), Err(Error::Erased)));
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
tx.commit().unwrap();
(root, blob, ha, hb)
};
let tx = conn.transaction().unwrap();
let sa = store(&tx, &a);
let sb = store(&tx, &b);
assert!(matches!(
sa.adopt(root, Some(ha), "e".into(), 4),
Err(Error::Erased)
));
assert!(matches!(
sa.put(
Object::Blob(b"shared".to_vec()).id(),
&Object::Blob(b"shared".to_vec()).encode()
),
Err(Error::Erased)
));
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
sb.erase().unwrap();
assert_eq!(
tx.query_row("SELECT count(*) FROM content_objects", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn tampered_bytes_or_unsupported_format_fail_read_and_immutable_save() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let h = host("config");
let tx = conn.transaction().unwrap();
let s = store(&tx, &h);
let (root, blob) = candidate(&s, b"original");
let head = adopt(&s, root, None, 1);
let original = s.read(blob).unwrap();
assert!(matches!(
s.put(blob, &Object::Blob(b"different".to_vec()).encode()),
Err(Error::Model(_))
));
let ObjectId::Blob(id) = blob else {
unreachable!()
};
tx.execute(
"UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2",
params![
Object::Blob(b"tamper".to_vec()).encode(),
id.as_bytes().as_slice()
],
)
.unwrap();
assert!(matches!(s.snapshot(head), Err(Error::Model(_))));
assert!(matches!(
s.put(blob, &original.encode()),
Err(Error::ImmutableMismatch)
));
let mut bad = original.encode();
bad[4] = 255;
tx.execute(
"UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2",
params![bad, id.as_bytes().as_slice()],
)
.unwrap();
assert!(matches!(s.snapshot(head), Err(Error::Model(_))));
}
#[test]
fn independent_connections_reject_stale_observer_without_replacing_winner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("concurrent.sqlite");
let mut first = Connection::open(&path).unwrap();
setup(&mut first);
let mut second = Connection::open(&path).unwrap();
let h = host("config");
let initial = {
let tx = first.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"initial");
let head = adopt(&s, root, None, 1);
tx.commit().unwrap();
head
};
// Both clients observe the same head before either submits its edit.
for conn in [&mut first, &mut second] {
let tx = conn.transaction().unwrap();
assert_eq!(store(&tx, &h).head().unwrap(), Some(initial));
tx.commit().unwrap();
}
let winner = {
let tx = first.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"winner");
let winner = adopt(&s, root, Some(initial), 2);
tx.commit().unwrap();
winner
};
let tx = second.transaction().unwrap();
let s = store(&tx, &h);
let (root, _) = candidate(&s, b"loser");
assert!(matches!(
s.adopt(root, Some(initial), "loser".into(), 3),
Err(Error::Conflict)
));
assert_eq!(s.head().unwrap(), Some(winner));
tx.commit().unwrap();
}
#[test]
fn deduplication_and_collection_preserve_other_preparations_and_all_adopted_ancestors() {
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let a = host("a");
let b = host("b");
let tx = conn.transaction().unwrap();
let sa = store(&tx, &a);
let sb = store(&tx, &b);
let (root, blob) = candidate(&sa, b"shared");
let first = adopt(&sa, root, None, 1);
let count = || {
tx.query_row("SELECT count(*) FROM content_objects", [], |r| {
r.get::<_, i64>(0)
})
.unwrap()
};
assert_eq!(count(), 3);
candidate(&sa, b"shared");
candidate(&sb, b"shared");
assert_eq!(count(), 3);
let (new, _) = candidate(&sa, b"new");
adopt(&sa, new, Some(first), 2);
candidate(&sa, b"unadopted");
let before = count();
sa.collect_unadopted().unwrap();
assert_eq!(count(), before - 2);
assert!(sa.snapshot(first).unwrap().contains_key(&blob));
sa.erase().unwrap();
assert_eq!(count(), 2); // B's preparation survives A's erasure.
let b_head = adopt(&sb, root, None, 3);
assert!(sb.snapshot(b_head).unwrap().contains_key(&blob));
}
#[test]
fn revoked_authority_is_checked_again_on_existing_scoped_handle() {
use std::sync::atomic::{AtomicBool, Ordering};
struct Revocable(AtomicBool);
impl Authority for Revocable {
fn allows(&self, _: &ScopeKey, _: Access) -> bool {
self.0.load(Ordering::SeqCst)
}
}
let mut conn = Connection::open_in_memory().unwrap();
setup(&mut conn);
let authority = Revocable(AtomicBool::new(true));
let tx = conn.transaction().unwrap();
let s = ScopedStore::new(&tx, key("ws", "a"), &authority);
let (root, blob) = candidate(&s, b"protected");
let head = adopt(&s, root, None, 1);
authority.0.store(false, Ordering::SeqCst);
assert!(matches!(s.read(blob), Err(Error::Denied)));
assert!(matches!(s.snapshot(head), Err(Error::Denied)));
assert!(matches!(
s.adopt(root, Some(head), "e".into(), 2),
Err(Error::Denied)
));
assert!(matches!(s.erase(), Err(Error::Denied)));
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "content-history"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
sha2.workspace = true
thiserror.workspace = true
[[example]]
name = "wasm_parity"
crate-type = ["cdylib"]
+73
View File
@@ -0,0 +1,73 @@
# Content history
This crate owns immutable full-text content, typed identities and pure structural
operations. It does not own persistence, authorization, retention decisions,
transaction adoption, head compare-and-set, application events or text rendering.
[`Object`] and [`Tree`] define canonical content; [`History`] separates editing
identity from content identity. [`restore`], [`diff`] and [`replace_path`] operate
through a caller-scoped [`ObjectReader`].
## Canonical identity and compatibility
Version 1 hashes the entire canonical encoding using SHA-256, including object
kind and format version. Every object starts with `YCH\0`, a big-endian `u16`
version (`1`), and a kind byte (`1` blob, `2` tree, `3` history). Integer lengths
are unsigned big-endian `u64`; strings are UTF-8 prefixed by their byte length.
- A blob contains a length and the exact full bytes.
- A tree contains a mode byte (`0` map, `1` sequence), an entry count, then each
length-prefixed key, child kind byte (`1` blob, `2` tree), and 32 hash bytes.
- A history contains 32 root hash bytes, a parent flag (`0` absent, `1` present),
optional 32 parent hash bytes, a length-prefixed actor, and a signed big-endian
`i64` timestamp in microseconds.
Map keys are unique and sorted by exact UTF-8 byte order. Sequences also have
unique keys but preserve input order. Decoding never sorts a noncanonical map.
There is no Unicode, newline, whitespace, path or source normalization. Empty
keys and separator characters are opaque strings; adapters own path validation.
No extension fields or trailing bytes are accepted. Unsupported versions fail
explicitly: a future migration must retain old bytes and identities, not silently
reserialize old objects under a new format. [`verify`] checks kind and hash after
strict decoding. Typed IDs are identities, not evidence that an object exists.
## Store and adapter boundary
Readers must be bound to an authorized business target and retained root/history.
Knowing a hash must not grant access. They must refuse erased or revoked content
for that target even when shared physical bytes remain for another target. The
model cannot enforce this policy or safely implement global garbage collection.
Readers also own I/O buffer bounds; [`TraversalLimits`] bounds work after reading.
Full restoration validates every reachable object and returns no partial success.
A structural diff intentionally skips equal IDs and does not prove closure integrity.
Path replacement returns new ancestor objects without writing anything. The
caller must validate and save the complete closure, append a history with the
current head as its parent, and CAS-adopt the new history in the same caller-owned
transaction as business projections. A rollback or stale head must leave the old
head and content unchanged. Comparing only content roots would accept stale
A-to-B-to-A edits. Restoring an old root reuses content but creates a new history;
it never rewinds business events or adopts the old history identity.
Configuration adapters can map validated virtual path components to map entries
and exact source files to blobs. Ticket adapters can map stable document fields
to blobs and stable post IDs to a sequence; reordering posts changes content.
Ticket IDs, post IDs, existing content digests, content roots and history heads
remain distinct contracts. Neither adapter belongs in this pure model. Contract
fixtures exercise these shapes without claiming database or authorization coverage.
## Cross-target fixture
`tests/fixtures/v1.tsv` freezes bytes and hashes independently derived from the
format. The `wasm_parity` raw `cdylib` example exports `parity_len` (digest words)
and `parity_word(index)` (four digest bytes interpreted big-endian). It has no JS
or host-import dependency. A runner can compare its words with the fixture hashes
on native and `wasm32-unknown-unknown`; an out-of-range index returns `u32::MAX`.
Building a WASM module alone is not evidence of executed native/WASM parity.
The optional execution adapter uses only Deno's built-in WebAssembly runtime;
run from the workspace root after registering this crate and installing the target:
```text
cargo test -p content-history
cargo build -p content-history --example wasm_parity --target wasm32-unknown-unknown
deno run --allow-read crates/content-history/tests/wasm_parity.ts target/wasm32-unknown-unknown/debug/examples/wasm_parity.wasm
```
@@ -0,0 +1,13 @@
//! Raw WASM parity fixture. No allocator pointers, JavaScript packages or host imports.
#[path = "../tests/common/mod.rs"]
mod common;
#[unsafe(no_mangle)]
pub extern "C" fn parity_len() -> u32 {
(common::objects().len() * 8) as u32
}
#[unsafe(no_mangle)]
pub extern "C" fn parity_word(index: u32) -> u32 {
common::parity_word(index)
}
+13
View File
@@ -0,0 +1,13 @@
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod model;
mod traversal;
pub use model::{
BlobId, ContentId, Entry, Error, History, HistoryId, Object, ObjectId, Tree, TreeId, verify,
};
pub use traversal::{
Change, ObjectReader, RestoredContent, RestoredEntry, Rewrite, TraversalLimits, diff,
replace_path, restore,
};
+344
View File
@@ -0,0 +1,344 @@
use sha2::{Digest, Sha256};
use std::{collections::BTreeSet, fmt};
macro_rules! identifier {
($name:ident) => {
/// SHA-256 identity of canonical versioned bytes, not an access capability.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({self})", stringify!($name))
}
}
};
}
identifier!(BlobId);
identifier!(TreeId);
identifier!(HistoryId);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ContentId {
Blob(BlobId),
Tree(TreeId),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ObjectId {
Blob(BlobId),
Tree(TreeId),
History(HistoryId),
}
impl From<ContentId> for ObjectId {
fn from(id: ContentId) -> Self {
match id {
ContentId::Blob(id) => Self::Blob(id),
ContentId::Tree(id) => Self::Tree(id),
}
}
}
/// Failure is explicit: malformed or incomplete snapshots are never partial successes.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("invalid object magic")]
InvalidMagic,
#[error("unsupported format version {0}")]
UnsupportedVersion(u16),
#[error("invalid object tag {0}")]
InvalidTag(u8),
#[error("invalid child kind {0}")]
InvalidChildKind(u8),
#[error("invalid tree mode {0}")]
InvalidTreeMode(u8),
#[error("invalid parent flag {0}")]
InvalidParentFlag(u8),
#[error("truncated object")]
Truncated,
#[error("length does not fit remaining bytes or platform")]
InvalidLength,
#[error("invalid UTF-8")]
InvalidUtf8,
#[error("trailing bytes")]
TrailingBytes,
#[error("duplicate tree key {0:?}")]
DuplicateKey(String),
#[error("map entries are not in strict UTF-8 byte order")]
NonCanonicalMap,
#[error("object kind or hash does not match requested identity")]
HashMismatch,
#[error("missing object {0:?}")]
MissingObject(ObjectId),
#[error("reader failed: {0}")]
Read(String),
#[error("missing path key {0:?}")]
MissingKey(String),
#[error("path component is not a tree")]
NotTree,
#[error("replacement path must not be empty")]
EmptyPath,
#[error("traversal exceeds {0} limit")]
LimitExceeded(&'static str),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub key: String,
pub target: ContentId,
}
/// Maps sort by exact UTF-8 bytes; sequences retain semantic order. Both have unique keys.
/// Empty strings, slashes and Unicode are opaque keys, not normalized virtual paths.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tree {
ordered: bool,
entries: Vec<Entry>,
}
impl Tree {
pub fn map(mut entries: Vec<Entry>) -> Result<Self, Error> {
entries.sort_by(|a, b| a.key.cmp(&b.key));
Self::build(entries, false)
}
pub fn sequence(entries: Vec<Entry>) -> Result<Self, Error> {
Self::build(entries, true)
}
fn build(entries: Vec<Entry>, ordered: bool) -> Result<Self, Error> {
let mut keys = BTreeSet::new();
for entry in &entries {
if !keys.insert(&entry.key) {
return Err(Error::DuplicateKey(entry.key.clone()));
}
}
Ok(Self { ordered, entries })
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn is_ordered(&self) -> bool {
self.ordered
}
/// Replace in place, append new sequence keys, or delete an existing key.
/// Deleting a missing key is an error; this never mutates the old tree.
pub fn replace(&self, key: &str, replacement: Option<ContentId>) -> Result<Self, Error> {
let mut entries = self.entries.clone();
match (entries.iter().position(|e| e.key == key), replacement) {
(Some(index), Some(target)) => entries[index].target = target,
(Some(index), None) => {
entries.remove(index);
}
(None, Some(target)) => entries.push(Entry {
key: key.into(),
target,
}),
(None, None) => return Err(Error::MissingKey(key.into())),
}
if self.ordered {
Self::sequence(entries)
} else {
Self::map(entries)
}
}
}
/// Caller-authenticated editor/time facts. Restoring old content creates a new record
/// whose parent is the current head; this type does not implement adoption or head CAS.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct History {
pub root: TreeId,
pub parent: Option<HistoryId>,
pub actor: String,
pub timestamp_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Object {
Blob(Vec<u8>),
Tree(Tree),
History(History),
}
impl Object {
/// Canonical format v1. See the crate documentation for the compatibility boundary.
pub fn encode(&self) -> Vec<u8> {
let mut out = b"YCH\0\0\x01".to_vec();
match self {
Self::Blob(bytes) => {
out.push(1);
put_bytes(&mut out, bytes);
}
Self::Tree(tree) => {
out.push(2);
out.push(u8::from(tree.ordered));
put_u64(&mut out, tree.entries.len());
for entry in &tree.entries {
put_bytes(&mut out, entry.key.as_bytes());
match entry.target {
ContentId::Blob(id) => {
out.push(1);
out.extend_from_slice(id.as_bytes());
}
ContentId::Tree(id) => {
out.push(2);
out.extend_from_slice(id.as_bytes());
}
}
}
}
Self::History(history) => {
out.push(3);
out.extend_from_slice(history.root.as_bytes());
match history.parent {
Some(id) => {
out.push(1);
out.extend_from_slice(id.as_bytes());
}
None => out.push(0),
}
put_bytes(&mut out, history.actor.as_bytes());
out.extend_from_slice(&history.timestamp_micros.to_be_bytes());
}
}
out
}
/// Strict decoding rejects unknown versions, duplicate/unsorted maps and trailing bytes.
/// Lengths are checked against input before allocation; no input-driven capacity reservation.
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
let mut input = Input(bytes);
if input.take(4)? != b"YCH\0" {
return Err(Error::InvalidMagic);
}
let version = u16::from_be_bytes(input.array()?);
if version != 1 {
return Err(Error::UnsupportedVersion(version));
}
let object = match input.byte()? {
1 => Self::Blob(input.bytes()?.to_vec()),
2 => {
let ordered = match input.byte()? {
0 => false,
1 => true,
mode => return Err(Error::InvalidTreeMode(mode)),
};
let count = input.length()?;
// Each entry needs a u64 key length, a kind and 32 digest bytes.
if count > input.0.len() / 41 {
return Err(Error::InvalidLength);
}
let mut entries: Vec<Entry> = Vec::new();
for _ in 0..count {
let key = input.string()?;
if !ordered && entries.last().is_some_and(|last| last.key >= key) {
return Err(Error::NonCanonicalMap);
}
let kind = input.byte()?;
let target = match kind {
1 => ContentId::Blob(BlobId(input.array()?)),
2 => ContentId::Tree(TreeId(input.array()?)),
_ => return Err(Error::InvalidChildKind(kind)),
};
entries.push(Entry { key, target });
}
Self::Tree(Tree::build(entries, ordered)?)
}
3 => {
let root = TreeId(input.array()?);
let parent = match input.byte()? {
0 => None,
1 => Some(HistoryId(input.array()?)),
flag => return Err(Error::InvalidParentFlag(flag)),
};
let actor = input.string()?;
let timestamp_micros = i64::from_be_bytes(input.array()?);
Self::History(History {
root,
parent,
actor,
timestamp_micros,
})
}
tag => return Err(Error::InvalidTag(tag)),
};
if !input.0.is_empty() {
return Err(Error::TrailingBytes);
}
Ok(object)
}
pub fn id(&self) -> ObjectId {
let hash: [u8; 32] = Sha256::digest(self.encode()).into();
match self {
Self::Blob(_) => ObjectId::Blob(BlobId(hash)),
Self::Tree(_) => ObjectId::Tree(TreeId(hash)),
Self::History(_) => ObjectId::History(HistoryId(hash)),
}
}
}
/// Validate exact stored bytes and requested kind before returning any content.
/// Decoding is intentionally first, so unsupported formats remain diagnosable.
pub fn verify(id: ObjectId, bytes: &[u8]) -> Result<Object, Error> {
let object = Object::decode(bytes)?;
// Hash the stored preimage, not a reserialization under the current writer's format.
let hash: [u8; 32] = Sha256::digest(bytes).into();
let actual = match &object {
Object::Blob(_) => ObjectId::Blob(BlobId(hash)),
Object::Tree(_) => ObjectId::Tree(TreeId(hash)),
Object::History(_) => ObjectId::History(HistoryId(hash)),
};
if actual != id {
return Err(Error::HashMismatch);
}
Ok(object)
}
fn put_u64(out: &mut Vec<u8>, length: usize) {
out.extend_from_slice(&(length as u64).to_be_bytes());
}
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
put_u64(out, bytes.len());
out.extend_from_slice(bytes);
}
struct Input<'a>(&'a [u8]);
impl<'a> Input<'a> {
fn take(&mut self, count: usize) -> Result<&'a [u8], Error> {
if count > self.0.len() {
return Err(Error::Truncated);
}
let (head, tail) = self.0.split_at(count);
self.0 = tail;
Ok(head)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
Ok(self.take(N)?.try_into().expect("checked slice length"))
}
fn byte(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
fn length(&mut self) -> Result<usize, Error> {
usize::try_from(u64::from_be_bytes(self.array()?)).map_err(|_| Error::InvalidLength)
}
fn bytes(&mut self) -> Result<&'a [u8], Error> {
let length = self.length()?;
if length > self.0.len() {
return Err(Error::InvalidLength);
}
self.take(length)
}
fn string(&mut self) -> Result<String, Error> {
std::str::from_utf8(self.bytes()?)
.map(str::to_owned)
.map_err(|_| Error::InvalidUtf8)
}
}
+315
View File
@@ -0,0 +1,315 @@
use crate::{ContentId, Error, Object, ObjectId, Tree, TreeId, verify};
use std::collections::BTreeMap;
/// Reader already bound to an authorized target and retention policy by its caller.
/// Hash possession is not authority. Revoked/erased objects must fail here even if
/// the physical bytes are deduplicated with another target. Missing data is an error.
/// The reader owns I/O size limits; traversal checks bytes after each returned buffer.
pub trait ObjectReader {
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error>;
}
impl<F: FnMut(ObjectId) -> Result<Vec<u8>, Error>> ObjectReader for F {
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error> {
self(id)
}
}
/// Work budgets count all reads (including repeated shared subtrees) and encoded bytes.
/// Root depth is zero. Depth is additionally capped at 256 to bound Rust stack use.
#[derive(Clone, Copy, Debug)]
pub struct TraversalLimits {
pub max_depth: usize,
pub max_objects: usize,
pub max_bytes: usize,
}
impl Default for TraversalLimits {
fn default() -> Self {
Self {
max_depth: 128,
max_objects: 100_000,
max_bytes: 64 * 1024 * 1024,
}
}
}
struct Budget {
limits: TraversalLimits,
objects: usize,
bytes: usize,
}
impl Budget {
fn new(limits: TraversalLimits) -> Self {
Self {
limits,
objects: 0,
bytes: 0,
}
}
fn depth(&self, depth: usize) -> Result<(), Error> {
if depth > self.limits.max_depth.min(256) {
return Err(Error::LimitExceeded("depth"));
}
Ok(())
}
fn read<R: ObjectReader + ?Sized>(
&mut self,
reader: &mut R,
id: ObjectId,
depth: usize,
) -> Result<Object, Error> {
self.depth(depth)?;
if self.objects >= self.limits.max_objects {
return Err(Error::LimitExceeded("objects"));
}
self.objects += 1;
let bytes = reader.read(id)?;
self.bytes = self
.bytes
.checked_add(bytes.len())
.ok_or(Error::LimitExceeded("bytes"))?;
if self.bytes > self.limits.max_bytes {
return Err(Error::LimitExceeded("bytes"));
}
verify(id, &bytes)
}
fn tree<R: ObjectReader + ?Sized>(
&mut self,
reader: &mut R,
id: TreeId,
depth: usize,
) -> Result<Tree, Error> {
match self.read(reader, ObjectId::Tree(id), depth)? {
Object::Tree(tree) => Ok(tree),
_ => Err(Error::NotTree),
}
}
}
/// Fully materialized bytes and exact component order, not an applied business object.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RestoredContent {
Blob(Vec<u8>),
Tree {
ordered: bool,
entries: Vec<RestoredEntry>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestoredEntry {
pub key: String,
pub content: RestoredContent,
}
/// Restore a complete content snapshot without reading or replaying history/diffs.
/// On failure no partial result is returned. This does not restore state, assignments,
/// authorization, or other business events, nor adopt a historical head.
pub fn restore<R: ObjectReader + ?Sized>(
reader: &mut R,
root: TreeId,
limits: TraversalLimits,
) -> Result<RestoredContent, Error> {
restore_content(reader, ContentId::Tree(root), &mut Budget::new(limits), 0)
}
fn restore_content<R: ObjectReader + ?Sized>(
reader: &mut R,
id: ContentId,
budget: &mut Budget,
depth: usize,
) -> Result<RestoredContent, Error> {
match budget.read(reader, id.into(), depth)? {
Object::Blob(bytes) => Ok(RestoredContent::Blob(bytes)),
Object::Tree(tree) => {
let mut entries = Vec::new();
for entry in tree.entries() {
entries.push(RestoredEntry {
key: entry.key.clone(),
content: restore_content(reader, entry.target, budget, depth + 1)?,
});
}
Ok(RestoredContent::Tree {
ordered: tree.is_ordered(),
entries,
})
}
Object::History(_) => unreachable!("verification enforces requested kind"),
}
}
/// A replacement at an opaque key path. Empty path denotes the root itself.
/// Added/removed subtrees and tree mode/sequence layout changes are coarse replacements;
/// otherwise differences recurse by key. This is structural, not a text-line diff.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Change {
pub path: Vec<String>,
pub before: Option<ContentId>,
pub after: Option<ContentId>,
}
/// Skip identical subtree hashes without even reading them. This is not a closure
/// integrity check: unchanged or added/removed subtrees are intentionally not loaded.
/// Use [`restore`] when complete snapshot integrity is needed before adoption.
/// Order is deterministic: map keys sort by UTF-8; stable-layout sequences follow order.
pub fn diff<R: ObjectReader + ?Sized>(
reader: &mut R,
before: TreeId,
after: TreeId,
limits: TraversalLimits,
) -> Result<Vec<Change>, Error> {
let mut changes = Vec::new();
diff_content(
reader,
ContentId::Tree(before),
ContentId::Tree(after),
&mut Vec::new(),
&mut Budget::new(limits),
&mut changes,
)?;
Ok(changes)
}
fn diff_content<R: ObjectReader + ?Sized>(
reader: &mut R,
before: ContentId,
after: ContentId,
path: &mut Vec<String>,
budget: &mut Budget,
changes: &mut Vec<Change>,
) -> Result<(), Error> {
if before == after {
return Ok(());
}
budget.depth(path.len())?;
if let (ContentId::Tree(left), ContentId::Tree(right)) = (before, after) {
let left = budget.tree(reader, left, path.len())?;
let right = budget.tree(reader, right, path.len())?;
let layout_changed = left.is_ordered() != right.is_ordered()
|| (left.is_ordered()
&& !left
.entries()
.iter()
.map(|e| &e.key)
.eq(right.entries().iter().map(|e| &e.key)));
if !layout_changed {
let left_by_key: BTreeMap<_, _> = left
.entries()
.iter()
.map(|e| (e.key.as_str(), e.target))
.collect();
let right_by_key: BTreeMap<_, _> = right
.entries()
.iter()
.map(|e| (e.key.as_str(), e.target))
.collect();
let keys: Vec<&str> = if left.is_ordered() {
left.entries().iter().map(|e| e.key.as_str()).collect()
} else {
left_by_key
.keys()
.chain(right_by_key.keys())
.copied()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
};
for key in keys {
path.push(key.into());
let old = left_by_key.get(key).copied();
let new = right_by_key.get(key).copied();
match (old, new) {
(Some(old), Some(new)) => {
diff_content(reader, old, new, path, budget, changes)?
}
_ => changes.push(Change {
path: path.clone(),
before: old,
after: new,
}),
}
path.pop();
}
return Ok(());
}
}
changes.push(Change {
path: path.clone(),
before: Some(before),
after: Some(after),
});
Ok(())
}
/// New ancestor objects, leaf-first. The replacement object itself is caller-owned.
/// Nothing is saved or adopted; caller must persist a complete verified closure and
/// history before atomically CAS-adopting the new head in its transaction.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rewrite {
pub root: TreeId,
pub objects: Vec<Object>,
}
/// Copy-on-write replacement along exact keys (no slash splitting/normalization).
/// Intermediate keys must exist and refer to trees. The final key may be inserted,
/// replaced, or deleted. Sequence insertion appends; replacement preserves position.
/// Only ancestors are read; this does not validate the replacement object's closure.
pub fn replace_path<R: ObjectReader + ?Sized>(
reader: &mut R,
root: TreeId,
path: &[&str],
replacement: Option<ContentId>,
limits: TraversalLimits,
) -> Result<Rewrite, Error> {
if path.is_empty() {
return Err(Error::EmptyPath);
}
let mut budget = Budget::new(limits);
budget.depth(path.len())?;
let mut objects = Vec::new();
let root = rewrite(
reader,
root,
path,
replacement,
&mut budget,
0,
&mut objects,
)?;
Ok(Rewrite { root, objects })
}
fn rewrite<R: ObjectReader + ?Sized>(
reader: &mut R,
root: TreeId,
path: &[&str],
replacement: Option<ContentId>,
budget: &mut Budget,
depth: usize,
objects: &mut Vec<Object>,
) -> Result<TreeId, Error> {
let tree = budget.tree(reader, root, depth)?;
let target = if path.len() == 1 {
replacement
} else {
let entry = tree
.entries()
.iter()
.find(|e| e.key == path[0])
.ok_or_else(|| Error::MissingKey(path[0].into()))?;
let ContentId::Tree(child) = entry.target else {
return Err(Error::NotTree);
};
Some(ContentId::Tree(rewrite(
reader,
child,
&path[1..],
replacement,
budget,
depth + 1,
objects,
)?))
};
let object = Object::Tree(tree.replace(path[0], target)?);
let ObjectId::Tree(id) = object.id() else {
unreachable!("tree object")
};
if id != root {
objects.push(object);
}
Ok(id)
}
@@ -0,0 +1,77 @@
use content_history::{ContentId, Entry, History, Object, ObjectId, Tree};
pub fn objects() -> Vec<Object> {
let empty = Object::Blob(Vec::new());
let text = Object::Blob(b"a\r\n\xc3\xa9\0".to_vec());
let ObjectId::Blob(empty_id) = empty.id() else {
unreachable!()
};
let ObjectId::Blob(text_id) = text.id() else {
unreachable!()
};
let entries = vec![
Entry {
key: "é".into(),
target: ContentId::Blob(text_id),
},
Entry {
key: "z".into(),
target: ContentId::Blob(empty_id),
},
];
let map = Object::Tree(Tree::map(entries.clone()).unwrap());
let sequence = Object::Tree(Tree::sequence(entries).unwrap());
let ObjectId::Tree(root) = map.id() else {
unreachable!()
};
let first = Object::History(History {
root,
parent: None,
actor: "user:編集".into(),
timestamp_micros: -1,
});
let ObjectId::History(parent) = first.id() else {
unreachable!()
};
let returned = Object::History(History {
root,
parent: Some(parent),
actor: "user:編集".into(),
timestamp_micros: 0,
});
vec![
empty,
text,
Object::Tree(Tree::map(vec![]).unwrap()),
Object::Tree(Tree::sequence(vec![]).unwrap()),
map,
sequence,
first,
returned,
Object::Tree(
Tree::map(vec![Entry {
key: "nested".into(),
target: ContentId::Tree(root),
}])
.unwrap(),
),
]
}
pub fn digest_bytes(object: &Object) -> [u8; 32] {
match object.id() {
ObjectId::Blob(id) => *id.as_bytes(),
ObjectId::Tree(id) => *id.as_bytes(),
ObjectId::History(id) => *id.as_bytes(),
}
}
pub fn parity_word(index: u32) -> u32 {
let objects = objects();
let Some(object) = objects.get(index as usize / 8) else {
return u32::MAX;
};
let hash = digest_bytes(object);
let offset = (index as usize % 8) * 4;
u32::from_be_bytes(hash[offset..offset + 4].try_into().unwrap())
}
+630
View File
@@ -0,0 +1,630 @@
//! Pure model contract tests; the fixture store substitutes only scoped byte I/O.
//! No claims about database transactions, authorization, head CAS or business adoption.
mod common;
use content_history::*;
use std::collections::BTreeMap;
fn unhex(text: &str) -> Vec<u8> {
text.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
fn content(object: &Object) -> ContentId {
match object.id() {
ObjectId::Blob(id) => ContentId::Blob(id),
ObjectId::Tree(id) => ContentId::Tree(id),
_ => panic!("content required"),
}
}
fn entry(key: &str, target: ContentId) -> Entry {
Entry {
key: key.into(),
target,
}
}
fn map(entries: Vec<Entry>) -> Object {
Object::Tree(Tree::map(entries).unwrap())
}
fn tree_id(object: &Object) -> TreeId {
let ContentId::Tree(id) = content(object) else {
panic!("tree required")
};
id
}
#[test]
fn version_one_vectors_freeze_bytes_hashes_and_parity_words() {
let lines: Vec<_> = include_str!("fixtures/v1.tsv")
.lines()
.filter(|line| !line.starts_with('#'))
.collect();
let objects = common::objects();
assert_eq!(lines.len(), objects.len());
for (index, (line, object)) in lines.iter().zip(&objects).enumerate() {
let columns: Vec<_> = line.split('\t').collect();
let bytes = unhex(columns[1]);
let digest = unhex(columns[2]);
assert_eq!(object.encode(), bytes, "{} bytes", columns[0]);
assert_eq!(
common::digest_bytes(object).as_slice(),
digest,
"{} hash",
columns[0]
);
assert_eq!(Object::decode(&bytes).unwrap(), *object);
assert_eq!(verify(object.id(), &bytes).unwrap(), *object);
for word in 0..8 {
assert_eq!(
common::parity_word((index * 8 + word) as u32),
u32::from_be_bytes(digest[word * 4..word * 4 + 4].try_into().unwrap())
);
}
}
assert_eq!(common::parity_word((objects.len() * 8) as u32), u32::MAX);
}
#[test]
fn maps_canonicalize_enumeration_but_sequences_keys_and_exact_bytes_affect_identity() {
let blob = Object::Blob(b"x\r\n".to_vec());
let target = content(&blob);
let a = entry("a", target);
let b = entry("é", target);
assert_eq!(
map(vec![b.clone(), a.clone()]).id(),
map(vec![a.clone(), b.clone()]).id()
);
assert_ne!(
Object::Tree(Tree::sequence(vec![a.clone(), b.clone()]).unwrap()).id(),
Object::Tree(Tree::sequence(vec![b.clone(), a.clone()]).unwrap()).id()
);
assert_ne!(
map(vec![a.clone()]).id(),
map(vec![entry("b", target)]).id()
);
assert_ne!(blob.id(), Object::Blob(b"x\n".to_vec()).id());
assert_ne!(
Object::Blob("é".as_bytes().to_vec()).id(),
Object::Blob("e\u{301}".as_bytes().to_vec()).id()
);
let ContentId::Blob(hash) = target else {
unreachable!()
};
assert_ne!(
map(vec![a.clone()]).id(),
map(vec![entry(
"a",
ContentId::Tree(TreeId::from_bytes(*hash.as_bytes()))
)])
.id()
);
for result in [
Tree::map(vec![a.clone(), a.clone()]),
Tree::sequence(vec![a.clone(), a]),
] {
assert!(matches!(result, Err(Error::DuplicateKey(_))));
}
}
#[test]
fn decoder_rejects_all_truncations_and_trailing_bytes() {
for object in common::objects() {
let bytes = object.encode();
for end in 0..bytes.len() {
assert!(Object::decode(&bytes[..end]).is_err(), "prefix {end}");
}
let mut trailing = bytes;
trailing.push(0);
assert_eq!(Object::decode(&trailing), Err(Error::TrailingBytes));
}
}
#[test]
fn decoder_rejects_unknown_domains_and_malicious_lengths() {
let blob = Object::Blob(vec![]).encode();
let mut malformed = blob.clone();
malformed[0] = 0;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidMagic));
malformed = blob.clone();
malformed[5] = 2;
assert_eq!(
Object::decode(&malformed),
Err(Error::UnsupportedVersion(2))
);
malformed = blob.clone();
malformed[6] = 99;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidTag(99)));
malformed = blob;
malformed[7..15].fill(255);
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
let tree = map(vec![entry("a", target)]).encode();
malformed = tree.clone();
malformed[7] = 2;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidTreeMode(2)));
malformed = tree.clone();
malformed[8..16].fill(255);
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
malformed = tree.clone();
malformed[16..24].fill(255);
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
malformed = tree.clone();
malformed[24] = 255;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidUtf8));
malformed = tree;
malformed[25] = 3;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidChildKind(3)));
let history = common::objects()[6].encode();
malformed = history.clone();
malformed[39] = 2;
assert_eq!(Object::decode(&malformed), Err(Error::InvalidParentFlag(2)));
malformed = history;
malformed[40..48].fill(255);
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
}
#[test]
fn decoder_refuses_unsorted_maps_and_duplicate_sequences_instead_of_repairing_bytes() {
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
let mut sequence =
Object::Tree(Tree::sequence(vec![entry("b", target), entry("a", target)]).unwrap())
.encode();
sequence[7] = 0;
assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap));
sequence[7] = 1;
// First entry starts at 16, each single-byte-key entry occupies 42 bytes.
sequence[66] = b'b';
assert!(matches!(
Object::decode(&sequence),
Err(Error::DuplicateKey(_))
));
sequence[7] = 0;
assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap));
}
#[test]
fn integrity_rejects_modified_content_and_wrong_typed_identity() {
let object = Object::Blob(b"original".to_vec());
assert_eq!(
verify(object.id(), &Object::Blob(b"modified".to_vec()).encode()),
Err(Error::HashMismatch)
);
let ObjectId::Blob(id) = object.id() else {
unreachable!()
};
assert_eq!(
verify(
ObjectId::Tree(TreeId::from_bytes(*id.as_bytes())),
&object.encode()
),
Err(Error::HashMismatch)
);
assert_eq!(id.to_string().len(), 64);
assert!(
id.to_string()
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
);
}
#[derive(Default)]
struct Memory {
objects: BTreeMap<ObjectId, Vec<u8>>,
reads: Vec<ObjectId>,
}
impl Memory {
fn put(&mut self, object: Object) -> ContentId {
self.objects.insert(object.id(), object.encode());
content(&object)
}
fn root(&mut self, object: Object) -> TreeId {
let root = tree_id(&object);
self.put(object);
root
}
}
impl ObjectReader for Memory {
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error> {
self.reads.push(id);
self.objects
.get(&id)
.cloned()
.ok_or(Error::MissingObject(id))
}
}
fn restored_entry(key: &str, content: RestoredContent) -> RestoredEntry {
RestoredEntry {
key: key.into(),
content,
}
}
fn restored_map(entries: Vec<RestoredEntry>) -> RestoredContent {
RestoredContent::Tree {
ordered: false,
entries,
}
}
#[test]
fn configuration_and_ticket_shapes_restore_exact_bytes_and_business_key_order() {
let mut store = Memory::default();
let source = "# comment\r\nname = \"é\"\n".as_bytes();
let file = store.put(Object::Blob(source.to_vec()));
let config = store.put(map(vec![entry("settings.dcdl", file)]));
let title = store.put(Object::Blob(b"title".to_vec()));
let body = store.put(Object::Blob(b"initial body".to_vec()));
let posts = store.put(Object::Tree(
Tree::sequence(vec![entry("post-z", body), entry("post-a", title)]).unwrap(),
));
let ticket = store.put(map(vec![
entry("title", title),
entry("body", body),
entry("posts", posts),
]));
let root = store.root(map(vec![
entry("config", config),
entry("ticket-identity", ticket),
]));
let restored = restore(&mut store, root, TraversalLimits::default()).unwrap();
assert_eq!(
restored,
restored_map(vec![
restored_entry(
"config",
restored_map(vec![restored_entry(
"settings.dcdl",
RestoredContent::Blob(source.to_vec())
)])
),
restored_entry(
"ticket-identity",
restored_map(vec![
restored_entry("body", RestoredContent::Blob(b"initial body".to_vec())),
restored_entry(
"posts",
RestoredContent::Tree {
ordered: true,
entries: vec![
restored_entry(
"post-z",
RestoredContent::Blob(b"initial body".to_vec())
),
restored_entry("post-a", RestoredContent::Blob(b"title".to_vec()))
]
}
),
restored_entry("title", RestoredContent::Blob(b"title".to_vec())),
])
),
])
);
}
#[test]
fn copy_on_write_path_replacement_preserves_old_fulltext_and_shares_unchanged_objects() {
let mut store = Memory::default();
let old_blob = store.put(Object::Blob(b"old full text".to_vec()));
let new_blob = store.put(Object::Blob(b"new full text".to_vec()));
let child = store.put(map(vec![
entry("body", old_blob),
entry("unchanged", old_blob),
]));
let root = store.root(map(vec![entry("document", child)]));
let old_snapshot = restore(&mut store, root, TraversalLimits::default()).unwrap();
let rewrite = replace_path(
&mut store,
root,
&["document", "body"],
Some(new_blob),
TraversalLimits::default(),
)
.unwrap();
assert_eq!(rewrite.objects.len(), 2);
assert_ne!(rewrite.root, root);
for object in &rewrite.objects {
store.put(object.clone());
}
assert_eq!(
restore(&mut store, root, TraversalLimits::default()).unwrap(),
old_snapshot
);
assert_ne!(
restore(&mut store, rewrite.root, TraversalLimits::default()).unwrap(),
old_snapshot
);
assert_eq!(
diff(&mut store, root, rewrite.root, TraversalLimits::default()).unwrap(),
vec![Change {
path: vec!["document".into(), "body".into()],
before: Some(old_blob),
after: Some(new_blob)
}]
);
let unchanged = replace_path(
&mut store,
root,
&["document", "body"],
Some(old_blob),
TraversalLimits::default(),
)
.unwrap();
assert_eq!(unchanged.root, root);
assert!(unchanged.objects.is_empty());
let returned = replace_path(
&mut store,
rewrite.root,
&["document", "body"],
Some(old_blob),
TraversalLimits::default(),
)
.unwrap();
assert_eq!(returned.root, root);
let first = Object::History(History {
root,
parent: None,
actor: "editor".into(),
timestamp_micros: 0,
});
let ObjectId::History(parent) = first.id() else {
unreachable!()
};
let restored = Object::History(History {
root: returned.root,
parent: Some(parent),
actor: "editor".into(),
timestamp_micros: 1,
});
assert_ne!(first.id(), restored.id());
}
#[test]
fn structural_diff_skips_equal_subtrees_without_reading_them_and_reports_add_delete() {
let mut store = Memory::default();
let shared = ContentId::Tree(TreeId::from_bytes([42; 32])); // Intentionally unavailable.
let old = ContentId::Blob(BlobId::from_bytes([1; 32]));
let new = ContentId::Blob(BlobId::from_bytes([2; 32]));
let before = store.root(map(vec![
entry("shared", shared),
entry("removed", old),
entry("edit", old),
]));
let after = store.root(map(vec![
entry("shared", shared),
entry("added", new),
entry("edit", new),
]));
assert!(
diff(&mut store, before, before, TraversalLimits::default())
.unwrap()
.is_empty()
);
assert!(store.reads.is_empty());
let changes = diff(&mut store, before, after, TraversalLimits::default()).unwrap();
assert_eq!(
store.reads,
vec![ObjectId::Tree(before), ObjectId::Tree(after)]
);
assert_eq!(
changes,
vec![
Change {
path: vec!["added".into()],
before: None,
after: Some(new)
},
Change {
path: vec!["edit".into()],
before: Some(old),
after: Some(new)
},
Change {
path: vec!["removed".into()],
before: Some(old),
after: None
},
]
);
assert!(matches!(
restore(&mut store, before, TraversalLimits::default()),
Err(Error::MissingObject(_))
));
}
#[test]
fn sequence_order_and_tree_mode_changes_are_not_hidden_by_equal_children() {
let mut store = Memory::default();
let blob = ContentId::Blob(BlobId::from_bytes([1; 32]));
let entries = vec![entry("a", blob), entry("b", blob)];
let before = store.root(Object::Tree(Tree::sequence(entries.clone()).unwrap()));
let after = store.root(Object::Tree(
Tree::sequence(entries.iter().rev().cloned().collect()).unwrap(),
));
let mapped = store.root(map(entries));
for target in [after, mapped] {
assert_eq!(
diff(&mut store, before, target, TraversalLimits::default()).unwrap(),
vec![Change {
path: vec![],
before: Some(ContentId::Tree(before)),
after: Some(ContentId::Tree(target))
}]
);
}
}
#[test]
fn replacement_uses_opaque_keys_and_preserves_sequence_positions() {
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
let other = ContentId::Blob(BlobId::from_bytes([1; 32]));
let sequence = Tree::sequence(vec![entry("a/b", target), entry("", target)]).unwrap();
let replaced = sequence.replace("a/b", Some(other)).unwrap();
assert_eq!(
replaced.entries(),
&[entry("a/b", other), entry("", target)]
);
let appended = replaced.replace("new", Some(target)).unwrap();
assert_eq!(appended.entries().last().unwrap().key, "new");
assert_eq!(
appended.replace("", None).unwrap().entries(),
&[entry("a/b", other), entry("new", target)]
);
assert!(matches!(
sequence.replace("missing", None),
Err(Error::MissingKey(_))
));
let mut store = Memory::default();
let root = store.root(Object::Tree(sequence));
assert!(
replace_path(
&mut store,
root,
&["a/b"],
Some(other),
TraversalLimits::default()
)
.is_ok()
);
assert_eq!(
replace_path(&mut store, root, &[], None, TraversalLimits::default()),
Err(Error::EmptyPath)
);
assert_eq!(
replace_path(
&mut store,
root,
&["a/b", "nested"],
None,
TraversalLimits::default()
),
Err(Error::NotTree)
);
assert!(matches!(
replace_path(
&mut store,
root,
&["missing", "nested"],
None,
TraversalLimits::default()
),
Err(Error::MissingKey(_))
));
}
#[test]
fn restoration_rejects_corruption_and_resource_budget_exhaustion() {
let mut store = Memory::default();
let blob = store.put(Object::Blob(b"full bytes".to_vec()));
let root = store.root(map(vec![entry("leaf", blob)]));
for limits in [
TraversalLimits {
max_depth: 0,
..TraversalLimits::default()
},
TraversalLimits {
max_objects: 1,
..TraversalLimits::default()
},
TraversalLimits {
max_bytes: 1,
..TraversalLimits::default()
},
] {
assert!(matches!(
restore(&mut store, root, limits),
Err(Error::LimitExceeded(_))
));
}
store
.objects
.insert(blob.into(), Object::Blob(b"tampered".to_vec()).encode());
assert_eq!(
restore(&mut store, root, TraversalLimits::default()),
Err(Error::HashMismatch)
);
let mut denied = |_: ObjectId| Err(Error::Read("unavailable in authorized scope".into()));
assert!(matches!(
restore(&mut denied, root, TraversalLimits::default()),
Err(Error::Read(_))
));
}
#[test]
fn path_addition_and_deletion_reuse_fulltext_without_mutating_old_roots() {
let mut store = Memory::default();
let blob = store.put(Object::Blob(b"complete source".to_vec()));
let old = store.root(map(vec![entry("old", blob)]));
let added = replace_path(
&mut store,
old,
&["added"],
Some(blob),
TraversalLimits::default(),
)
.unwrap();
for object in added.objects {
store.put(object);
}
let deleted = replace_path(
&mut store,
added.root,
&["old"],
None,
TraversalLimits::default(),
)
.unwrap();
for object in deleted.objects {
store.put(object);
}
assert_eq!(
restore(&mut store, old, TraversalLimits::default()).unwrap(),
restored_map(vec![restored_entry(
"old",
RestoredContent::Blob(b"complete source".to_vec())
)])
);
assert_eq!(
restore(&mut store, deleted.root, TraversalLimits::default()).unwrap(),
restored_map(vec![restored_entry(
"added",
RestoredContent::Blob(b"complete source".to_vec())
)])
);
assert_eq!(
diff(&mut store, old, deleted.root, TraversalLimits::default()).unwrap(),
vec![
Change {
path: vec!["added".into()],
before: None,
after: Some(blob)
},
Change {
path: vec!["old".into()],
before: Some(blob),
after: None
},
]
);
}
#[test]
fn recursion_is_bounded_even_if_caller_requests_unlimited_depth() {
let mut store = Memory::default();
let mut root = store.root(map(vec![]));
for _ in 0..258 {
root = store.root(map(vec![entry("nested", ContentId::Tree(root))]));
}
let limits = TraversalLimits {
max_depth: usize::MAX,
..TraversalLimits::default()
};
assert_eq!(
restore(&mut store, root, limits),
Err(Error::LimitExceeded("depth"))
);
let path = vec!["nested"; 258];
assert_eq!(
replace_path(&mut store, root, &path, None, limits),
Err(Error::LimitExceeded("depth"))
);
}
+10
View File
@@ -0,0 +1,10 @@
# name canonical_hex sha256_hex
empty_blob 594348000001010000000000000000 46f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03
full_blob 594348000001010000000000000006610d0ac3a900 5baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4
empty_map 59434800000102000000000000000000 e90da7b51cdefa812a4dd9f5ef177e13f2ac53d59e693bad83533ef817d43671
empty_sequence 59434800000102010000000000000000 da589a35a84b290befc701f31a5c0916eadcf002590c406fb200951d659c2488
map 5943480000010200000000000000000200000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de030000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 4ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527
sequence 594348000001020100000000000000020000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f400000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 ba054669ee973912f8fb1e9ef99a0a9d0c674c58c3d46eaa5af1024491c8ce3f
history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52700000000000000000b757365723ae7b7a8e99b86ffffffffffffffff ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95
restored_history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52701ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95000000000000000b757365723ae7b7a8e99b860000000000000000 87bb274c7f99ae8aa653e0211eb26ee910d274420adcf8ec49bade8a46620187
nested_tree 5943480000010200000000000000000100000000000000066e6573746564024ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 a6a53aebc4f3d7e4da21ad3328b9f35858a339aea23f328750eada698c6248f2
1 # name canonical_hex sha256_hex
2 empty_blob 594348000001010000000000000000 46f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03
3 full_blob 594348000001010000000000000006610d0ac3a900 5baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4
4 empty_map 59434800000102000000000000000000 e90da7b51cdefa812a4dd9f5ef177e13f2ac53d59e693bad83533ef817d43671
5 empty_sequence 59434800000102010000000000000000 da589a35a84b290befc701f31a5c0916eadcf002590c406fb200951d659c2488
6 map 5943480000010200000000000000000200000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de030000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 4ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527
7 sequence 594348000001020100000000000000020000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f400000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 ba054669ee973912f8fb1e9ef99a0a9d0c674c58c3d46eaa5af1024491c8ce3f
8 history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52700000000000000000b757365723ae7b7a8e99b86ffffffffffffffff ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95
9 restored_history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52701ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95000000000000000b757365723ae7b7a8e99b860000000000000000 87bb274c7f99ae8aa653e0211eb26ee910d274420adcf8ec49bade8a46620187
10 nested_tree 5943480000010200000000000000000100000000000000066e6573746564024ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 a6a53aebc4f3d7e4da21ad3328b9f35858a339aea23f328750eada698c6248f2
@@ -0,0 +1,37 @@
// Optional execution adapter: only Deno's built-in WASM runtime, no JS packages.
// Native contracts.rs checks the same golden digests against the native model.
const wasmPath = Deno.args[0];
if (!wasmPath) {
throw new Error("usage: deno run --allow-read tests/wasm_parity.ts <wasm>");
}
const bytes = await Deno.readFile(wasmPath);
const module = await WebAssembly.compile(bytes);
if (WebAssembly.Module.imports(module).length !== 0) {
throw new Error("raw parity module must have no host imports");
}
const instance = await WebAssembly.instantiate(module, {});
const length = instance.exports.parity_len as () => number;
const word = instance.exports.parity_word as (index: number) => number;
const fixture = await Deno.readTextFile(
new URL("fixtures/v1.tsv", import.meta.url),
);
const expected = fixture.trim().split("\n").filter((line) =>
!line.startsWith("#")
)
.flatMap((line) =>
line.split("\t")[2].match(/.{8}/g)!.map((hex) => parseInt(hex, 16))
);
if (length() !== expected.length) {
throw new Error("fixture digest count differs");
}
for (let index = 0; index < expected.length; index++) {
if ((word(index) >>> 0) !== expected[index]) {
throw new Error(`WASM digest word mismatch at ${index}`);
}
}
if ((word(expected.length) >>> 0) !== 0xffffffff) {
throw new Error("out-of-range word did not return sentinel");
}
console.log(
`WASM canonical SHA256 parity: ${expected.length / 8} fixture objects`,
);
+8
View File
@@ -162,6 +162,14 @@ fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> {
raw.context.validate() raw.context.validate()
} }
/// Validate byte and lexical depth limits before parsing an enclosing JSON DTO.
///
/// This is a resource guard only: callers must still use their typed JSON parser
/// to validate grammar and schema. It grants no disclosure or encoding authority.
pub fn validate_json_input(input: &[u8], budget: Budget) -> Result<(), ContractError> {
check_json_bounds(input, budget)
}
/// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar. /// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar.
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> { pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
if input.len() > budget.bytes() { if input.len() > budget.bytes() {
+1
View File
@@ -107,6 +107,7 @@ pub struct CausalLink {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", schemars(rename = "FailureDiagnostic"))]
pub struct Diagnostic { pub struct Diagnostic {
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub code: String, pub code: String,
+4 -1
View File
@@ -10,7 +10,10 @@ pub mod schema;
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
pub mod test_support; pub mod test_support;
pub use codec::{Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure}; pub use codec::{
Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure,
validate_json_input,
};
pub use facts::*; pub use facts::*;
pub use projection::*; pub use projection::*;
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "feature-permissions"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
schemars.workspace = true
serde = { workspace = true, features = ["derive"] }
thiserror.workspace = true
ts-rs = "12.0.1"
[dev-dependencies]
serde_json.workspace = true
toml.workspace = true
+103
View File
@@ -0,0 +1,103 @@
# Feature permissions
This crate owns payload-free declarations, assignment DTOs, catalog projections,
explicit children resolution and operation-key delegation limits. It does not
adopt policy, migrate old grants, authorize a real operation, or choose Worker
permissions. See [`PermissionHost`] for the trusted registration boundary and
[`types`] for the shared text/storage/management/Web representation.
The Host authenticates a Workspace and a builtin or trusted Plugin identity
before registering it. It retains [`FeatureHandle`] and supplies only the bound
[`NamespaceHandle`] to that Feature's declaration/query adapter. Guest input is
[`NamespaceDeclaration`], never an owner string or a handle. Constructing another
Host with the same textual Workspace identity does not create interchangeable
handles or results. A namespace conveys ownership, not permission to execute.
Children are graph edges, not dotted-prefix wildcards. A key can have multiple
segments and explicit children; a shared set collects Feature contributions using
the same references. Installing a declaration validates its candidate graph
atomically. Duplicate definitions, unknown references and cycles reject the update
without changing the prior catalog. Duplicate assignment paths are deduplicated,
with distinct provenance paths retained in deterministic order. Invalid assignment
references deny the whole assignment with diagnostics. Disabled positive
contributions are omitted independently. Explicit exclusions still traverse disabled
Features' declared keys and set edges, so disabling a Plugin cannot revive another
Feature's excluded operation. Removing a Feature retires its handles and withdraws
only its declarations. Dangling references deny rather than recovering an old result.
Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the
current assignment before use. A resolution retains its actual catalog snapshot;
query rejects it unless that snapshot is still the Host's current catalog. There
is no independently ordered freshness counter. Unset,
explicit empty, exclusion and revocation never select a default. Exclusions expand
through the same children graph, then win over every grant path. No snapshot is a
policy store: consumers must read current policy and authenticated subject/Job
context at their actual operation boundary.
A [`LimitedGrant`] freezes the expanded keys actually passed at issuance. Later
set or children additions can extend a set-holding parent, not that grant. Child
execution intersects the fresh parent execution, fresh parent delegation ceiling
and frozen grant. A [`JobGrant`] is one further intersection bound to the Host's
Job identity; it never substitutes for Subject policy, attempt/writer/input/expiry
checks. Revoked grant objects cannot be made active again. Operation-key checks do
not replace provider path, recursive, symlink, network or domain constraints.
A text/storage/management assignment uses an explicit discriminant, for example:
```json
{
"grants": [
{"kind":"set", "value":"companion"},
{"kind":"namespace", "value":{"namespace":"ticket", "keys":["ticket.hoge.fuga"]}}
],
"exclusions": [],
"revoked": false
}
```
The same Serde types validate dynamic inputs; unknown fields (including payloads)
are rejected. [`typescript`] generates the Web contract. Management projections are
serializable observations, not credentials: deserialization cannot manufacture a
runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not
this DTO layer.
To add a Feature, the trusted loader registers the established identity and gives
its registrar a namespace handle. The declaration contains only operation keys
and explicit edges; no central domain list needs updating:
```rust
use feature_permissions::*;
let mut host = PermissionHost::new(WorkspaceId::parse("workspace-a")?);
// Host-only: a Plugin receives its bound registrar, not this registration API.
let feature = host.register_feature(FeatureIdentity::parse("plugin:example")?)?;
let namespace = host.bind_namespace(&feature, NamespaceId::parse("example")?)?;
let read = PermissionKey::parse("example.documents.read")?;
host.declare(&namespace, NamespaceDeclaration {
permissions: vec![PermissionDefinition {
key: read.clone(), description: "Read example documents".into(), children: vec![],
}],
sets: vec![SetContribution {
set: PermissionSetId::parse("companion")?,
children: vec![PermissionReference::Namespace(NamespacePermissions {
namespace: NamespaceId::parse("example")?, keys: vec![read.clone()],
})],
}],
})?;
let assignment = PermissionAssignment {
grants: vec![PermissionReference::Set(PermissionSetId::parse("companion")?)],
..Default::default()
};
let resolved = host.resolve(Some(&assignment));
assert!(host.allows(&namespace, &resolved, &read)?);
# Ok::<(), Box<dyn std::error::Error>>(())
```
Wire inputs cannot manufacture an owner handle or executable resolution:
```compile_fail
let _: feature_permissions::NamespaceHandle = serde_json::from_str("{}").unwrap();
```
```compile_fail
let _: feature_permissions::ResolvedPermissions = serde_json::from_str("{}").unwrap();
```
@@ -0,0 +1,3 @@
fn main() {
print!("{}", feature_permissions::typescript());
}
+10
View File
@@ -0,0 +1,10 @@
#![doc = include_str!("../README.md")]
pub mod types;
pub use types::*;
mod registry;
pub use registry::{
FeatureHandle, JobGrant, LimitedGrant, NamespaceHandle, PermissionError, PermissionHost,
ResolvedPermissions,
};
+688
View File
@@ -0,0 +1,688 @@
//! Registration is a trusted Host operation. Wire declarations cannot construct
//! handles. A handle is bound to one registry instance and one registration, not
//! merely to matching Workspace/Feature strings. Reinstallation retires old handles.
use crate::types::*;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PermissionError {
#[error("handle belongs to another Host or a retired registration")]
ForeignHandle,
#[error("Feature already registered: {0}")]
FeatureCollision(FeatureIdentity),
#[error("namespace already owned: {0}")]
NamespaceCollision(NamespaceId),
#[error("declaration does not belong to namespace: {0}")]
Ownership(PermissionKey),
#[error("duplicate declaration: {0}")]
Duplicate(String),
#[error("unknown key: {0}")]
UnknownKey(PermissionKey),
#[error("unknown set: {0}")]
UnknownSet(PermissionSetId),
#[error("cycle in permission graph: {0}")]
Cycle(String),
#[error("resolution is stale; resolve current policy again")]
StaleResolution,
#[error("requested delegation exceeds current ceiling: {0}")]
DelegationExceeded(PermissionKey),
#[error("limited grant has been revoked")]
RevokedGrant,
#[error("Job grant belongs to another Job")]
WrongJob,
}
#[derive(Clone, Debug)]
pub struct FeatureHandle {
host: Arc<()>,
registration: Arc<()>,
identity: FeatureIdentity,
}
#[derive(Clone, Debug)]
pub struct NamespaceHandle {
feature: FeatureHandle,
namespace: NamespaceId,
}
impl NamespaceHandle {
pub fn namespace(&self) -> &NamespaceId {
&self.namespace
}
/// Compare the actual registrations, never caller-supplied owner names.
pub fn is_owned_by(&self, feature: &FeatureHandle) -> bool {
Arc::ptr_eq(&self.feature.host, &feature.host)
&& Arc::ptr_eq(&self.feature.registration, &feature.registration)
}
}
#[derive(Clone, Debug)]
struct FeatureRecord {
registration: Arc<()>,
enabled: bool,
}
#[derive(Clone, Debug)]
struct NamespaceRecord {
owner: FeatureIdentity,
declaration: NamespaceDeclaration,
}
/// Not deserializable: a management projection cannot become an authorization
/// result. Results must be re-resolved after *any* registry change.
#[derive(Debug)]
pub struct ResolvedPermissions {
host: Arc<()>,
catalog: Arc<RegistryState>,
projection: PermissionResolution,
}
impl ResolvedPermissions {
pub fn projection(&self) -> &PermissionResolution {
&self.projection
}
}
/// Keys are expanded once at issuance and never expanded again. Thus later
/// children/set additions cannot enlarge an already issued child grant.
#[derive(Debug)]
pub struct LimitedGrant {
host: Arc<()>,
keys: BTreeSet<PermissionKey>,
registrations: BTreeMap<PermissionKey, Arc<()>>,
revoked: bool,
}
impl LimitedGrant {
pub fn keys(&self) -> &BTreeSet<PermissionKey> {
&self.keys
}
pub fn revoke(&mut self) {
self.revoked = true;
}
}
/// A Job's operation grant is an additional limit, not an alternative policy.
/// The caller supplies the authenticated Job identity at issuance and use; this
/// does not validate attempts, writers, deadlines or provider restrictions.
#[derive(Debug)]
pub struct JobGrant {
job: String,
limit: LimitedGrant,
}
impl JobGrant {
pub fn revoke(&mut self) {
self.limit.revoke();
}
}
pub struct PermissionHost {
identity: Arc<()>,
workspace: WorkspaceId,
catalog: Arc<RegistryState>,
}
#[derive(Clone, Debug, Default)]
struct RegistryState {
features: BTreeMap<FeatureIdentity, FeatureRecord>,
namespaces: BTreeMap<NamespaceId, NamespaceRecord>,
}
impl PermissionHost {
/// Constructed by the trusted Workspace Host, not from a Feature's input.
pub fn new(workspace: WorkspaceId) -> Self {
Self {
identity: Arc::new(()),
workspace,
catalog: Arc::new(RegistryState::default()),
}
}
/// Only the trusted loader calls this after establishing the actual builtin
/// or trusted Plugin identity. There is no Feature-facing raw-owner lookup.
pub fn register_feature(
&mut self,
identity: FeatureIdentity,
) -> Result<FeatureHandle, PermissionError> {
if self.catalog.features.contains_key(&identity) {
return Err(PermissionError::FeatureCollision(identity));
}
let registration = Arc::new(());
Arc::make_mut(&mut self.catalog).features.insert(
identity.clone(),
FeatureRecord {
registration: registration.clone(),
enabled: true,
},
);
Ok(FeatureHandle {
host: self.identity.clone(),
registration,
identity,
})
}
fn check_feature(&self, feature: &FeatureHandle) -> Result<(), PermissionError> {
if !Arc::ptr_eq(&self.identity, &feature.host)
|| !self
.catalog
.features
.get(&feature.identity)
.is_some_and(|r| Arc::ptr_eq(&r.registration, &feature.registration))
{
return Err(PermissionError::ForeignHandle);
}
Ok(())
}
fn check_namespace(&self, handle: &NamespaceHandle) -> Result<(), PermissionError> {
self.check_feature(&handle.feature)?;
if !self
.catalog
.namespaces
.get(&handle.namespace)
.is_some_and(|r| r.owner == handle.feature.identity)
{
return Err(PermissionError::ForeignHandle);
}
Ok(())
}
pub fn bind_namespace(
&mut self,
feature: &FeatureHandle,
namespace: NamespaceId,
) -> Result<NamespaceHandle, PermissionError> {
self.check_feature(feature)?;
if self.catalog.namespaces.contains_key(&namespace) {
return Err(PermissionError::NamespaceCollision(namespace));
}
Arc::make_mut(&mut self.catalog).namespaces.insert(
namespace.clone(),
NamespaceRecord {
owner: feature.identity.clone(),
declaration: NamespaceDeclaration {
permissions: vec![],
sets: vec![],
},
},
);
Ok(NamespaceHandle {
feature: feature.clone(),
namespace,
})
}
/// Replace atomically. All keys and set contributions must belong to this
/// namespace. References to existing other namespaces do not transfer ownership.
pub fn declare(
&mut self,
handle: &NamespaceHandle,
declaration: NamespaceDeclaration,
) -> Result<(), PermissionError> {
self.check_namespace(handle)?;
let mut keys = BTreeSet::new();
for definition in &declaration.permissions {
if definition.key.namespace() != handle.namespace {
return Err(PermissionError::Ownership(definition.key.clone()));
}
if !keys.insert(definition.key.clone()) {
return Err(PermissionError::Duplicate(definition.key.to_string()));
}
let mut children = BTreeSet::new();
for child in &definition.children {
if !children.insert(child) {
return Err(PermissionError::Duplicate(child.to_string()));
}
}
}
let mut sets = BTreeSet::new();
for contribution in &declaration.sets {
if !sets.insert(&contribution.set) {
return Err(PermissionError::Duplicate(contribution.set.to_string()));
}
for reference in &contribution.children {
if let PermissionReference::Namespace(ns) = reference {
if ns.namespace != handle.namespace {
return Err(PermissionError::ForeignHandle);
}
for key in &ns.keys {
if key.namespace() != ns.namespace {
return Err(PermissionError::Ownership(key.clone()));
}
}
}
}
}
let mut candidate = self.catalog.namespaces.clone();
candidate
.get_mut(&handle.namespace)
.expect("checked namespace")
.declaration = declaration;
validate_graph(&candidate)?;
Arc::make_mut(&mut self.catalog).namespaces = candidate;
Ok(())
}
pub fn set_enabled(
&mut self,
feature: &FeatureHandle,
enabled: bool,
) -> Result<(), PermissionError> {
self.check_feature(feature)?;
if self.catalog.features[&feature.identity].enabled != enabled {
Arc::make_mut(&mut self.catalog)
.features
.get_mut(&feature.identity)
.expect("checked feature")
.enabled = enabled;
}
Ok(())
}
/// Removal withdraws only this Feature's definitions/contributions. Dangling
/// references remain diagnostic and fail closed at resolution, never cached.
pub fn remove_feature(&mut self, feature: &FeatureHandle) -> Result<(), PermissionError> {
self.check_feature(feature)?;
Arc::make_mut(&mut self.catalog)
.features
.remove(&feature.identity);
Arc::make_mut(&mut self.catalog)
.namespaces
.retain(|_, r| r.owner != feature.identity);
Ok(())
}
pub fn catalog(&self) -> PermissionCatalog {
PermissionCatalog {
workspace: self.workspace.clone(),
namespaces: self
.catalog
.namespaces
.iter()
.map(|(ns, r)| NamespaceCatalog {
namespace: ns.clone(),
feature: r.owner.clone(),
enabled: self.catalog.features[&r.owner].enabled,
declaration: r.declaration.clone(),
})
.collect(),
}
}
pub fn resolve(&self, assignment: Option<&PermissionAssignment>) -> ResolvedPermissions {
let mut projection = PermissionResolution {
workspace: self.workspace.clone(),
allowed: BTreeMap::new(),
diagnostics: vec![],
};
match assignment {
None => projection.diagnostics.push(PermissionDiagnostic::Unset),
Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked),
Some(a) => {
let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
for reference in &a.grants {
expansion.reference(reference, vec![]);
}
let mut exclusions = Expansion::new(self, ExpansionPurpose::Exclusion);
for reference in &a.exclusions {
exclusions.reference(reference, vec![]);
}
for key in exclusions.allowed.keys() {
if expansion.allowed.remove(key).is_some() {
expansion
.diagnostics
.insert(PermissionDiagnostic::Excluded(key.clone()));
}
}
expansion.diagnostics.extend(exclusions.diagnostics);
// Invalid references, including malformed namespace/key pairings,
// invalidate the assignment instead of partially accepting a typo.
if !expansion.diagnostics.iter().any(|d| {
matches!(
d,
PermissionDiagnostic::UnknownKey(_)
| PermissionDiagnostic::UnknownSet(_)
| PermissionDiagnostic::UnknownNamespace(_)
)
}) {
projection.allowed = expansion
.allowed
.into_iter()
.map(|(key, origins)| (key, origins.into_iter().collect()))
.collect();
}
projection.diagnostics = expansion.diagnostics.into_iter().collect();
}
}
ResolvedPermissions {
host: self.identity.clone(),
catalog: self.catalog.clone(),
projection,
}
}
fn check_resolution(&self, resolved: &ResolvedPermissions) -> Result<(), PermissionError> {
if !Arc::ptr_eq(&self.identity, &resolved.host) {
return Err(PermissionError::ForeignHandle);
}
if !Arc::ptr_eq(&self.catalog, &resolved.catalog) {
return Err(PermissionError::StaleResolution);
}
Ok(())
}
/// A key permission is only one conjunct. The execution boundary must still
/// check path/provider/context/domain restrictions at the actual operation.
pub fn allows(
&self,
namespace: &NamespaceHandle,
resolved: &ResolvedPermissions,
key: &PermissionKey,
) -> Result<bool, PermissionError> {
self.check_namespace(namespace)?;
self.check_resolution(resolved)?;
if key.namespace() != namespace.namespace {
return Err(PermissionError::Ownership(key.clone()));
}
Ok(resolved.projection.allowed.contains_key(key))
}
pub fn issue_delegation(
&self,
ceiling: &ResolvedPermissions,
requested: &[PermissionKey],
) -> Result<LimitedGrant, PermissionError> {
self.check_resolution(ceiling)?;
let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
for key in requested {
expansion.key(key, vec![]);
}
for diagnostic in &expansion.diagnostics {
if let PermissionDiagnostic::UnknownKey(key) = diagnostic {
return Err(PermissionError::UnknownKey(key.clone()));
}
}
for key in expansion.allowed.keys() {
if !ceiling.projection.allowed.contains_key(key) {
return Err(PermissionError::DelegationExceeded(key.clone()));
}
}
Ok(LimitedGrant {
host: self.identity.clone(),
registrations: expansion
.allowed
.keys()
.map(|key| {
let owner = &self.catalog.namespaces[&key.namespace()].owner;
(
key.clone(),
self.catalog.features[owner].registration.clone(),
)
})
.collect(),
keys: expansion.allowed.into_keys().collect(),
revoked: false,
})
}
pub fn issue_job_grant(
&self,
job: String,
ceiling: &ResolvedPermissions,
requested: &[PermissionKey],
) -> Result<JobGrant, PermissionError> {
Ok(JobGrant {
job,
limit: self.issue_delegation(ceiling, requested)?,
})
}
fn grant_key_is_current(&self, grant: &LimitedGrant, key: &PermissionKey) -> bool {
let Some(record) = self.catalog.namespaces.get(&key.namespace()) else {
return false;
};
grant.registrations.get(key).is_some_and(|registration| {
let feature = &self.catalog.features[&record.owner];
feature.enabled && Arc::ptr_eq(registration, &feature.registration)
})
}
fn check_grant(&self, grant: &LimitedGrant) -> Result<(), PermissionError> {
if !Arc::ptr_eq(&self.identity, &grant.host) {
return Err(PermissionError::ForeignHandle);
}
if grant.revoked {
return Err(PermissionError::RevokedGrant);
}
Ok(())
}
/// Parent execution, parent delegation and actually passed keys all constrain
/// the child. This exact-set intersection performs no children expansion.
pub fn resolve_limited(
&self,
execution: &ResolvedPermissions,
delegation: &ResolvedPermissions,
grant: &LimitedGrant,
) -> Result<ResolvedPermissions, PermissionError> {
self.check_resolution(execution)?;
self.check_resolution(delegation)?;
self.check_grant(grant)?;
let mut projection = execution.projection.clone();
projection.allowed.retain(|key, _| {
grant.keys.contains(key)
&& self.grant_key_is_current(grant, key)
&& delegation.projection.allowed.contains_key(key)
});
Ok(ResolvedPermissions {
host: self.identity.clone(),
catalog: self.catalog.clone(),
projection,
})
}
pub fn resolve_job(
&self,
job: &str,
execution: &ResolvedPermissions,
delegation: &ResolvedPermissions,
child: &LimitedGrant,
grant: &JobGrant,
) -> Result<ResolvedPermissions, PermissionError> {
if grant.job != job {
return Err(PermissionError::WrongJob);
}
self.check_grant(&grant.limit)?;
let mut result = self.resolve_limited(execution, delegation, child)?;
result.projection.allowed.retain(|key, _| {
grant.limit.keys.contains(key) && self.grant_key_is_current(&grant.limit, key)
});
Ok(result)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Node {
Key(PermissionKey),
Set(PermissionSetId),
}
fn graph(
namespaces: &BTreeMap<NamespaceId, NamespaceRecord>,
) -> Result<BTreeMap<Node, Vec<Node>>, PermissionError> {
let mut nodes = BTreeMap::new();
for r in namespaces.values() {
for d in &r.declaration.permissions {
nodes.insert(
Node::Key(d.key.clone()),
d.children.iter().cloned().map(Node::Key).collect(),
);
}
for s in &r.declaration.sets {
let edges = nodes
.entry(Node::Set(s.set.clone()))
.or_insert_with(Vec::new);
for reference in &s.children {
match reference {
PermissionReference::Set(set) => edges.push(Node::Set(set.clone())),
PermissionReference::Namespace(ns) => {
for key in &ns.keys {
if key.namespace() != ns.namespace {
return Err(PermissionError::Ownership(key.clone()));
}
edges.push(Node::Key(key.clone()));
}
}
}
}
}
}
Ok(nodes)
}
fn validate_graph(
namespaces: &BTreeMap<NamespaceId, NamespaceRecord>,
) -> Result<(), PermissionError> {
fn visit(
node: &Node,
nodes: &BTreeMap<Node, Vec<Node>>,
active: &mut BTreeSet<Node>,
done: &mut BTreeSet<Node>,
) -> Result<(), PermissionError> {
if done.contains(node) {
return Ok(());
}
if !active.insert(node.clone()) {
return Err(PermissionError::Cycle(format!("{node:?}")));
}
let edges = nodes.get(node).ok_or_else(|| match node {
Node::Key(k) => PermissionError::UnknownKey(k.clone()),
Node::Set(s) => PermissionError::UnknownSet(s.clone()),
})?;
for child in edges {
visit(child, nodes, active, done)?;
}
active.remove(node);
done.insert(node.clone());
Ok(())
}
let nodes = graph(namespaces)?;
let mut done = BTreeSet::new();
for node in nodes.keys() {
visit(node, &nodes, &mut BTreeSet::new(), &mut done)?;
}
Ok(())
}
#[derive(PartialEq, Eq)]
enum ExpansionPurpose {
Grant,
// Disabling an owner withdraws positive contributions, not the meaning of
// an explicit deny. Its retained declaration still defines exclusion edges.
Exclusion,
}
struct Expansion<'a> {
host: &'a PermissionHost,
purpose: ExpansionPurpose,
allowed: BTreeMap<PermissionKey, BTreeSet<Vec<OriginStep>>>,
diagnostics: BTreeSet<PermissionDiagnostic>,
}
impl<'a> Expansion<'a> {
fn new(host: &'a PermissionHost, purpose: ExpansionPurpose) -> Self {
Self {
host,
purpose,
allowed: BTreeMap::new(),
diagnostics: BTreeSet::new(),
}
}
fn reference(&mut self, reference: &PermissionReference, path: Vec<OriginStep>) {
match reference {
PermissionReference::Namespace(ns) => {
if !self.host.catalog.namespaces.contains_key(&ns.namespace) {
self.diagnostics
.insert(PermissionDiagnostic::UnknownNamespace(ns.namespace.clone()));
return;
}
for key in &ns.keys {
if key.namespace() != ns.namespace {
self.diagnostics
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
} else {
self.key(key, path.clone());
}
}
}
PermissionReference::Set(set) => {
// Cycles cannot be installed; the path guard also makes this
// traversal robust to future graph-loading implementations.
if path.contains(&OriginStep::Set(set.clone())) {
return;
}
let mut found = false;
for record in self.host.catalog.namespaces.values() {
for contribution in &record.declaration.sets {
if &contribution.set != set {
continue;
}
found = true;
if !self.host.catalog.features[&record.owner].enabled {
self.diagnostics
.insert(PermissionDiagnostic::DisabledFeature(
record.owner.clone(),
));
if self.purpose == ExpansionPurpose::Grant {
continue;
}
}
let mut next = path.clone();
next.push(OriginStep::Set(set.clone()));
next.push(OriginStep::Feature(record.owner.clone()));
for child in &contribution.children {
self.reference(child, next.clone());
}
}
}
if !found {
self.diagnostics
.insert(PermissionDiagnostic::UnknownSet(set.clone()));
}
}
}
}
fn key(&mut self, key: &PermissionKey, mut path: Vec<OriginStep>) {
let Some(record) = self.host.catalog.namespaces.get(&key.namespace()) else {
self.diagnostics
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
return;
};
let Some(definition) = record
.declaration
.permissions
.iter()
.find(|d| &d.key == key)
else {
self.diagnostics
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
return;
};
if !self.host.catalog.features[&record.owner].enabled {
self.diagnostics
.insert(PermissionDiagnostic::DisabledFeature(record.owner.clone()));
if self.purpose == ExpansionPurpose::Grant {
return;
}
}
if path.contains(&OriginStep::Key(key.clone())) {
return;
}
path.push(OriginStep::Feature(record.owner.clone()));
path.push(OriginStep::Key(key.clone()));
self.allowed
.entry(key.clone())
.or_default()
.insert(path.clone());
for child in &definition.children {
self.key(child, path.clone());
}
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Wire values describe grants, never authority-bearing handles. Unknown fields
//! are rejected so old payload-bearing inputs cannot silently broaden a grant.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS;
macro_rules! identifier {
($name:ident, $validate:expr) => {
#[derive(
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, TS, schemars::JsonSchema,
)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn parse(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
if ($validate)(&value) {
Ok(Self(value))
} else {
Err(format!("invalid {}: {value:?}", stringify!($name)))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Self::parse(String::deserialize(d)?).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
};
}
fn segment(value: &str) -> bool {
!value.is_empty()
&& value.as_bytes()[0].is_ascii_lowercase()
&& value
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
fn identity(value: &str) -> bool {
!value.is_empty() && !value.chars().any(|c| c.is_control() || c.is_whitespace())
}
identifier!(WorkspaceId, identity);
identifier!(FeatureIdentity, identity);
identifier!(PermissionSetId, segment);
identifier!(NamespaceId, segment);
identifier!(PermissionKey, |value: &str| value.split('.').count() >= 2
&& value.split('.').all(segment));
impl PermissionKey {
pub fn namespace(&self) -> NamespaceId {
NamespaceId(self.0.split('.').next().expect("validated key").to_owned())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NamespacePermissions {
pub namespace: NamespaceId,
pub keys: Vec<PermissionKey>,
}
/// The discriminant, not an identifier's spelling, selects a reference kind.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(
tag = "kind",
content = "value",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PermissionReference {
Set(PermissionSetId),
Namespace(NamespacePermissions),
}
/// `None` at the consuming policy boundary means unset. An empty assignment is
/// explicit deny-all; this crate never chooses or reinstates a default.
#[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema,
)]
#[serde(deny_unknown_fields)]
pub struct PermissionAssignment {
pub grants: Vec<PermissionReference>,
pub exclusions: Vec<PermissionReference>,
pub revoked: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PermissionDefinition {
pub key: PermissionKey,
pub description: String,
pub children: Vec<PermissionKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetContribution {
pub set: PermissionSetId,
pub children: Vec<PermissionReference>,
}
/// This input deliberately has no owner field. The Host supplies the registered
/// identity/namespace handle independently of guest-controlled declaration data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NamespaceDeclaration {
pub permissions: Vec<PermissionDefinition>,
pub sets: Vec<SetContribution>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NamespaceCatalog {
pub namespace: NamespaceId,
pub feature: FeatureIdentity,
pub enabled: bool,
pub declaration: NamespaceDeclaration,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PermissionCatalog {
pub workspace: WorkspaceId,
pub namespaces: Vec<NamespaceCatalog>,
}
#[derive(
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema,
)]
#[serde(
tag = "kind",
content = "value",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum OriginStep {
Set(PermissionSetId),
Feature(FeatureIdentity),
Key(PermissionKey),
}
#[derive(
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema,
)]
#[serde(
tag = "kind",
content = "value",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PermissionDiagnostic {
Unset,
Revoked,
UnknownSet(PermissionSetId),
UnknownNamespace(NamespaceId),
UnknownKey(PermissionKey),
DisabledFeature(FeatureIdentity),
Excluded(PermissionKey),
}
/// A management projection, not an executable credential. Only the Host's opaque
/// resolution can be queried through a namespace handle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PermissionResolution {
pub workspace: WorkspaceId,
pub allowed: BTreeMap<PermissionKey, Vec<Vec<OriginStep>>>,
pub diagnostics: Vec<PermissionDiagnostic>,
}
/// The management boundary uses exactly the same assignment as text/storage.
/// Workspace comes from authenticated routing, not a repeated permission field.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ValidatePermissionsRequest {
pub assignment: PermissionAssignment,
}
pub fn typescript() -> String {
let config = ts_rs::Config::default();
let declarations = [
WorkspaceId::decl(&config),
FeatureIdentity::decl(&config),
PermissionSetId::decl(&config),
NamespaceId::decl(&config),
PermissionKey::decl(&config),
NamespacePermissions::decl(&config),
PermissionReference::decl(&config),
PermissionAssignment::decl(&config),
PermissionDefinition::decl(&config),
SetContribution::decl(&config),
NamespaceDeclaration::decl(&config),
NamespaceCatalog::decl(&config),
PermissionCatalog::decl(&config),
OriginStep::decl(&config),
PermissionDiagnostic::decl(&config),
PermissionResolution::decl(&config),
ValidatePermissionsRequest::decl(&config),
];
format!(
"// Generated by feature-permissions; do not edit.\n{}\n",
declarations
.iter()
.map(|d| format!("export {d}"))
.collect::<Vec<_>>()
.join("\n")
)
}
@@ -0,0 +1,22 @@
{
"grants": [
{ "kind": "set", "value": "ticket" },
{
"kind": "namespace",
"value": {
"namespace": "ticket",
"keys": ["ticket.hoge", "ticket.hoge.fuga.finish"]
}
}
],
"exclusions": [
{
"kind": "namespace",
"value": {
"namespace": "ticket",
"keys": ["ticket.hoge.unlinked"]
}
}
],
"revoked": false
}
@@ -0,0 +1,17 @@
revoked = false
[[grants]]
kind = "set"
value = "ticket"
[[grants]]
kind = "namespace"
[grants.value]
namespace = "ticket"
keys = ["ticket.hoge", "ticket.hoge.fuga.finish"]
[[exclusions]]
kind = "namespace"
[exclusions.value]
namespace = "ticket"
keys = ["ticket.hoge.unlinked"]
+45
View File
@@ -0,0 +1,45 @@
{
"workspace": "workspace-a",
"namespaces": [
{
"namespace": "ticket",
"feature": "builtin:ticket",
"enabled": true,
"declaration": {
"permissions": [
{
"key": "ticket.hoge",
"description": "Root operation with explicit children",
"children": ["ticket.hoge.fuga"]
},
{
"key": "ticket.hoge.fuga",
"description": "Intermediate operation with an explicit child",
"children": ["ticket.hoge.fuga.finish"]
},
{
"key": "ticket.hoge.fuga.finish",
"description": "Leaf operation",
"children": []
},
{
"key": "ticket.hoge.unlinked",
"description": "Same prefix without an inclusion edge",
"children": []
}
],
"sets": [
{
"set": "ticket",
"children": [
{
"kind": "namespace",
"value": { "namespace": "ticket", "keys": ["ticket.hoge"] }
}
]
}
]
}
}
]
}
+35
View File
@@ -0,0 +1,35 @@
workspace = "workspace-a"
[[namespaces]]
namespace = "ticket"
feature = "builtin:ticket"
enabled = true
[[namespaces.declaration.permissions]]
key = "ticket.hoge"
description = "Root operation with explicit children"
children = ["ticket.hoge.fuga"]
[[namespaces.declaration.permissions]]
key = "ticket.hoge.fuga"
description = "Intermediate operation with an explicit child"
children = ["ticket.hoge.fuga.finish"]
[[namespaces.declaration.permissions]]
key = "ticket.hoge.fuga.finish"
description = "Leaf operation"
children = []
[[namespaces.declaration.permissions]]
key = "ticket.hoge.unlinked"
description = "Same prefix without an inclusion edge"
children = []
[[namespaces.declaration.sets]]
set = "ticket"
[[namespaces.declaration.sets.children]]
kind = "namespace"
[namespaces.declaration.sets.children.value]
namespace = "ticket"
keys = ["ticket.hoge"]
@@ -0,0 +1,9 @@
//! The generated Web contract is an external artifact, not a second hand-written
//! list of domains. Normal crate tests reject drift from the declaration types.
#[test]
fn checked_in_web_contract_matches_the_payload_free_rust_types() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/feature-permissions.generated.ts");
let saved = std::fs::read_to_string(path).expect("run the generate_typescript example");
assert_eq!(saved, feature_permissions::typescript());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
//! Public JSON/TOML boundary tests using the production Serde DTOs and shared Web
//! fixtures. No resolver, Host, storage, or authentication boundary is replaced or
//! exercised here. Run with `cargo test -p feature-permissions --test wire_contract`.
use feature_permissions::{
NamespaceCatalog, NamespaceDeclaration, NamespacePermissions, PermissionAssignment,
PermissionCatalog, PermissionDefinition, PermissionReference, SetContribution,
ValidatePermissionsRequest,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use std::fmt::Debug;
const ASSIGNMENT_JSON: &str = include_str!("fixtures/assignment.json");
const ASSIGNMENT_TOML: &str = include_str!("fixtures/assignment.toml");
const CATALOG_JSON: &str = include_str!("fixtures/catalog.json");
const CATALOG_TOML: &str = include_str!("fixtures/catalog.toml");
fn paired_fixture<T>(json: &str, toml: &str) -> T
where
T: DeserializeOwned + Serialize + PartialEq + Debug,
{
let from_json: T = serde_json::from_str(json).expect("valid JSON fixture");
let from_toml: T = toml::from_str(toml).expect("valid TOML fixture");
assert_eq!(
from_json, from_toml,
"text formats must have the same meaning"
);
assert_eq!(
serde_json::to_value(&from_json).unwrap(),
serde_json::from_str::<Value>(json).unwrap(),
"serialization must not drop or synthesize wire fields"
);
assert_eq!(
serde_json::from_str::<T>(&serde_json::to_string(&from_toml).unwrap()).unwrap(),
from_json
);
assert_eq!(
toml::from_str::<T>(&toml::to_string(&from_json).unwrap()).unwrap(),
from_toml
);
from_json
}
fn reject_in_both_formats<T: DeserializeOwned>(rule: &str, input: Value) {
assert!(
serde_json::from_value::<T>(input.clone()).is_err(),
"JSON accepted {rule}: {input}"
);
let text = toml::to_string(&input).expect("rejection case must be representable as TOML");
assert!(
toml::from_str::<T>(&text).is_err(),
"TOML accepted {rule}: {text}"
);
}
fn reject_authority_and_payload_fields<T: DeserializeOwned>(input: Value) {
for (field, value) in [
("payload", json!({ "path": "/untrusted" })),
("owner", json!("other-feature")),
("workspace", json!("other-workspace")),
] {
let mut modified = input.clone();
modified
.as_object_mut()
.unwrap()
.insert(field.into(), value);
reject_in_both_formats::<T>(field, modified);
}
}
#[test]
fn assignment_text_formats_preserve_discriminants_and_multisegment_keys() {
let assignment: PermissionAssignment = paired_fixture(ASSIGNMENT_JSON, ASSIGNMENT_TOML);
// The same spelling is intentional: the tag, not a prefix, chooses the kind.
assert!(
matches!(&assignment.grants[0], PermissionReference::Set(id) if id.as_str() == "ticket")
);
let PermissionReference::Namespace(namespace) = &assignment.grants[1] else {
panic!("namespace reference lost its discriminant");
};
assert_eq!(namespace.namespace.as_str(), "ticket");
assert_eq!(
namespace
.keys
.iter()
.map(|key| key.as_str())
.collect::<Vec<_>>(),
["ticket.hoge", "ticket.hoge.fuga.finish"]
);
}
#[test]
fn namespace_catalog_roundtrip_preserves_only_explicit_multilevel_children() {
let catalog: PermissionCatalog = paired_fixture(CATALOG_JSON, CATALOG_TOML);
let namespace = &catalog.namespaces[0];
// Also roundtrip the standalone management DTO, not only its outer envelope.
let standalone: NamespaceCatalog =
serde_json::from_value(serde_json::to_value(namespace).unwrap()).unwrap();
assert_eq!(&standalone, namespace);
let permissions = &standalone.declaration.permissions;
let edges: Vec<_> = permissions
.iter()
.map(|permission| {
(
permission.key.as_str(),
permission
.children
.iter()
.map(|key| key.as_str())
.collect::<Vec<_>>(),
)
})
.collect();
assert_eq!(
edges,
[
("ticket.hoge", vec!["ticket.hoge.fuga"]),
("ticket.hoge.fuga", vec!["ticket.hoge.fuga.finish"]),
("ticket.hoge.fuga.finish", vec![]),
("ticket.hoge.unlinked", vec![]),
]
);
assert_eq!(
serde_json::to_value(&standalone.declaration.sets[0]).unwrap(),
json!({
"set": "ticket",
"children": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": ["ticket.hoge"]}}]
})
);
}
#[test]
fn explicit_empty_and_revocation_survive_without_defaults() {
for input in [
json!({"grants": [], "exclusions": [], "revoked": false}),
json!({"grants": [], "exclusions": [], "revoked": true}),
json!({
"grants": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": []}}],
"exclusions": [],
"revoked": false
}),
] {
paired_fixture::<PermissionAssignment>(
&serde_json::to_string(&input).unwrap(),
&toml::to_string(&input).unwrap(),
);
}
for missing in ["grants", "exclusions", "revoked"] {
let mut input = json!({"grants": [], "exclusions": [], "revoked": false});
input.as_object_mut().unwrap().remove(missing);
reject_in_both_formats::<PermissionAssignment>(missing, input);
}
}
#[test]
fn reference_kind_and_shape_must_match_in_json_and_toml() {
for (rule, input) in [
("unknown kind", json!({"kind": "all", "value": "ticket"})),
(
"case-sensitive kind",
json!({"kind": "Set", "value": "ticket"}),
),
("missing kind", json!({"value": "ticket"})),
("missing value", json!({"kind": "set"})),
(
"set with namespace shape",
json!({"kind": "set", "value": {"namespace": "ticket", "keys": []}}),
),
(
"namespace with set shape",
json!({"kind": "namespace", "value": "ticket"}),
),
(
"namespace missing keys",
json!({"kind": "namespace", "value": {"namespace": "ticket"}}),
),
(
"keys are not a scalar",
json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": "ticket.hoge"}}),
),
(
"keys carry no data",
json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": [{"key": "ticket.hoge", "payload": {}}]}}),
),
] {
reject_in_both_formats::<PermissionReference>(rule, input);
}
}
#[test]
fn external_identifiers_reject_invalid_segments_without_truncation() {
for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] {
reject_in_both_formats::<PermissionReference>(
"invalid set identifier",
json!({"kind": "set", "value": invalid}),
);
reject_in_both_formats::<NamespacePermissions>(
"invalid namespace identifier",
json!({"namespace": invalid, "keys": []}),
);
}
for invalid in [
"ticket",
"ticket..read",
".ticket.read",
"ticket.read.",
"ticket.Read",
"ticket.*",
"ticket.read path",
] {
reject_in_both_formats::<NamespacePermissions>(
"invalid assigned key",
json!({"namespace": "ticket", "keys": [invalid]}),
);
reject_in_both_formats::<PermissionDefinition>(
"invalid declared key",
json!({"key": invalid, "description": "", "children": []}),
);
reject_in_both_formats::<PermissionDefinition>(
"invalid child key",
json!({"key": "ticket.hoge", "description": "", "children": [invalid]}),
);
}
for (field, invalid) in [
("workspace", ""),
("workspace", "other workspace"),
("feature", ""),
("feature", "other\nfeature"),
] {
let mut catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap();
if field == "workspace" {
catalog["workspace"] = json!(invalid);
} else {
catalog["namespaces"][0]["feature"] = json!(invalid);
}
reject_in_both_formats::<PermissionCatalog>("invalid catalog identity", catalog);
}
}
#[test]
fn grant_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() {
let assignment: Value = serde_json::from_str(ASSIGNMENT_JSON).unwrap();
reject_authority_and_payload_fields::<PermissionAssignment>(assignment.clone());
for reference in assignment["grants"].as_array().unwrap() {
reject_authority_and_payload_fields::<PermissionReference>(reference.clone());
}
reject_authority_and_payload_fields::<NamespacePermissions>(
assignment["grants"][1]["value"].clone(),
);
let request = json!({"assignment": assignment});
let parsed: ValidatePermissionsRequest = serde_json::from_value(request.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), request);
reject_authority_and_payload_fields::<ValidatePermissionsRequest>(request.clone());
let mut nested = request;
nested["assignment"]["workspace"] = json!("other-workspace");
reject_in_both_formats::<ValidatePermissionsRequest>("nested workspace spoof", nested);
}
#[test]
fn assignment_fields_reject_untyped_grants_and_nonboolean_revocation() {
for (field, invalid) in [
("grants", json!("ticket.hoge")),
("grants", json!([{"ticket.hoge": true}])),
("exclusions", json!({})),
("revoked", json!("false")),
] {
let mut input = json!({"grants": [], "exclusions": [], "revoked": false});
input[field] = invalid;
reject_in_both_formats::<PermissionAssignment>(field, input);
}
}
#[test]
fn declaration_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() {
let catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap();
let declaration = &catalog["namespaces"][0]["declaration"];
reject_authority_and_payload_fields::<NamespaceDeclaration>(declaration.clone());
reject_authority_and_payload_fields::<PermissionDefinition>(
declaration["permissions"][0].clone(),
);
reject_authority_and_payload_fields::<SetContribution>(declaration["sets"][0].clone());
// A catalog reports authenticated identities; declaration inputs cannot supply them.
let mut input = declaration.clone();
input["feature"] = json!("other-feature");
reject_in_both_formats::<NamespaceDeclaration>("feature identity injection", input);
}
+62 -7
View File
@@ -1,11 +1,66 @@
# feature-storage # feature-storage
Manages the SQLite lifecycle for Features within a trusted Host. Owns physical SQLite identity and lifecycle for trusted Host repositories.
Does not own Feature domain schemas or authentication, or provide SQL access to Workers or models. Features own domain schema, authorization and transactions; storage identity is not a grant.
Workers and models do not receive SQL connections.
## Main entry points [`ScopedFeatureStorage::provision`] is fresh-only;
[`ScopedFeatureStorage::adopt_existing`] requires explicit Host authority and
persisted domain attribution. [`ScopedFeatureStorage::open_existing`] consumes
an expected Workspace/Feature/physical identity and never creates a missing DB.
The lazy [`ScopedFeatureStorage::open`] requests initial Host provisioning only
when no ownership record exists; an unbound existing file requires adoption,
and a missing previously bound file is an error, not a new incarnation.
- [`FeatureStorage`], [`ScopedFeatureStorage`]: registration, scope isolation, storage, and shutdown [`FeatureDatabase`] operations retain the verified connection and shared
- [`FeatureDatabase`]: connections and transactions cross-process lifecycle lease through consumer commit/rollback.
- [`FeatureMigration`]: Feature-owned schema changes [`DatabaseGuard`] extends the short lease over a writer-release decision without
- [`configure_connection`]: shared SQLite connection policy retaining a SQLite transaction. Long Job waits retain only [`DatabaseIdentity`];
resumption must reacquire storage and current domain authorization.
Lock order is scope lifecycle file, manager state, connection, SQLite transaction.
The Host must protect the root, owner catalog and stable lockfiles against native
code bypassing the owner. A successful same-root lock does not prove arbitrary
external writers, a previous Host, or a different filesystem participate.
Same-path replacement is checked by physical file identity as well as DB identity;
old receipt bytes alone cannot authorize a copied file. Restore records a new
physical lifecycle and retains original provenance. A durable erasure fence lives
outside the removed scope and cannot be cleared by restoring an older snapshot.
`restore` recovers missing scope files using the same owner's retained source
receipts, never by calling `delete` first. `restore_from` requires the Host to
select the source owner explicitly for a cross-root copy. Both check completed
snapshot provenance and the source erasure fence through publication; a fresh
root is not a way to forget that fence. Cross-root locks are acquired in canonical
root order, source shared and target exclusive; same-root recovery takes one
exclusive lock. A restore retires, but does not remove, the previous owner
receipt. Only the new physical identity is current, including after exact retry.
Before creating staging, the owner reserves the complete source-root, snapshot,
manifest and per-DB physical/request provenance. An unfinished restore cannot
be replaced by a different source or snapshot. Copying uses a separate file;
only a verified completed backup is renamed into reusable staging. Unowned
staging is diagnosed, not accepted as a completed copy. Identity/completion
response loss reconciles that exact request, including after directory publication.
Adopted legacy-live scopes can recover after stopped, complete scope-tree loss:
the old external record remains unchanged and its marker is reconstructed only
after completed owner/snapshot provenance checks. Missing markers in an existing
scope, legacy pending/deletion records and new-owner erasure fences still refuse
normal admission; this recovery path does not clear erasure.
A snapshot without matching source-owner evidence requires explicit, authorized
legacy adoption with domain attribution/shape validation instead of blind restore.
These checks cover owner-managed operations, not deliberate destruction of the
owner catalog/fences or arbitrary Host code inventing authority evidence.
Adoption identity and domain migrations share a SQLite transaction. A separate
owner receipt binds the stable request, target, authority evidence and inode.
If the DB commits but completion or its response is lost, the same request
reconciles the same physical DB. An unfinished reservation is not an adoption
receipt; a different target or request cannot reuse it. Failure after a fresh file
is created but before its reservation commits leaves an unowned file requiring
explicit Host reconciliation, never discovery-based issuance.
[`FeatureRegistration`] uses the existing ordered migration owner; a Feature can
validate its finite published receipt/shape families without rewriting original
migration identities or treating physical acceptance as domain-policy adoption.
[`FeatureStorage::open_host_catalog`] connects the Host catalog to the same
identity/lifecycle owner; [`configure_connection`] owns shared SQLite durability policy.
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
//! Retained physical metadata from published FeatureStorage writers. These are
//! provenance, not authority: adoption still needs a Host grant, attribution and
//! receipt/shape validation. Never rewrite their identity or provisioning receipt.
use super::*;
pub(super) fn identity(path: &Path, scope: &str, feature: &str) -> Result<Option<String>> {
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let exists = |name: &str| -> Result<bool> {
Ok(connection.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
[name],
|r| r.get(0),
)?)
};
if !exists("__yoi_feature_database_identity")? {
if exists("__yoi_feature_provisioning_receipt")? {
return Err(FeatureStorageError::Identity(
"legacy provisioning receipt has no physical identity".into(),
));
}
return Ok(None);
}
verify_shape(
&connection,
"__yoi_feature_database_identity",
"CREATE TABLE __yoi_feature_database_identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), format INTEGER NOT NULL CHECK(format=1), scope_id TEXT NOT NULL,feature_id TEXT NOT NULL,incarnation TEXT NOT NULL)",
)?;
let count: i64 = connection.query_row(
"SELECT COUNT(*) FROM __yoi_feature_database_identity",
[],
|r| r.get(0),
)?;
let (format,saved_scope,saved_feature,physical): (i64,String,String,String) = connection.query_row(
"SELECT format,scope_id,feature_id,incarnation FROM __yoi_feature_database_identity WHERE singleton=1",[],
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?;
let id = Uuid::parse_str(&physical)
.map_err(|_| FeatureStorageError::Identity("corrupt legacy physical identity".into()))?;
if count != 1
|| format != 1
|| scope != saved_scope
|| feature != saved_feature
|| id.get_version_num() != 7
|| id.to_string() != physical
{
return Err(FeatureStorageError::Identity(
"legacy physical attribution differs or is corrupt".into(),
));
}
if exists("__yoi_feature_provisioning_receipt")? {
verify_shape(
&connection,
"__yoi_feature_provisioning_receipt",
"CREATE TABLE __yoi_feature_provisioning_receipt (singleton INTEGER PRIMARY KEY CHECK(singleton=1),request_fingerprint TEXT NOT NULL)",
)?;
let (count,fingerprint): (i64,String) = connection.query_row(
"SELECT (SELECT COUNT(*) FROM __yoi_feature_provisioning_receipt),request_fingerprint FROM __yoi_feature_provisioning_receipt WHERE singleton=1",[],|r|Ok((r.get(0)?,r.get(1)?)))?;
if count != 1
|| fingerprint.len() != 71
|| !fingerprint.starts_with("sha256:")
|| !fingerprint.as_bytes()[7..]
.iter()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
{
return Err(FeatureStorageError::Identity(
"corrupt legacy provisioning receipt".into(),
));
}
}
Ok(Some(physical))
}
pub(super) fn fenced(root: &Path, scope: &str) -> Result<bool> {
fenced_inner(root, scope, false)
}
// Only restore may inspect a lost live scope; it must still match snapshot
// provenance against completed new-owner receipts before admitting any bytes.
pub(super) fn fenced_for_restore(root: &Path, scope: &str) -> Result<bool> {
fenced_inner(root, scope, true)
}
fn fenced_inner(root: &Path, scope: &str, recovering_loss: bool) -> Result<bool> {
if root
.join(".lifecycle")
.join(format!("{scope}.deleted"))
.try_exists()?
{
return Ok(true);
}
let record_path = root.join(".scope-locks").join(format!("{scope}.lock"));
let record = match fs::read_to_string(record_path) {
Ok(record) if !record.is_empty() => record,
Ok(_) => return Ok(false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
let fields: Vec<_> = record.trim_end_matches('\n').split(' ').collect();
if fields.len() != 3 || fields[0] != "v1" {
return Err(FeatureStorageError::Identity(
"corrupt legacy scope record".into(),
));
}
let id = Uuid::parse_str(fields[2])
.map_err(|_| FeatureStorageError::Identity("corrupt legacy scope identity".into()))?;
if record != format!("v1 {} {id}\n", fields[1]) {
return Err(FeatureStorageError::Identity(
"noncanonical legacy scope record".into(),
));
}
match fields[1] {
"deleted" | "deleting" => Ok(true),
"live" => {
let marker = fs::read_to_string(root.join(scope).join(".incarnation"));
if recovering_loss
&& !root.join(scope).try_exists()?
&& marker
.as_ref()
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
{
return Ok(false);
}
if marker? != format!("{id}\n") {
return Err(FeatureStorageError::Identity(
"legacy scope incarnation differs".into(),
));
}
Ok(false)
}
_ => Err(FeatureStorageError::Identity(
"unfinished legacy scope requires reconciliation by its owner".into(),
)),
}
}
/// Reconstruct only a previously retained live scope marker, never a deletion
/// record or a new identity. Called after matching completed snapshot provenance.
pub(super) fn restore_marker(root: &Path, scope: &str, temporary: &Path) -> Result<()> {
if fenced_for_restore(root, scope)? {
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
}
let record = match fs::read_to_string(root.join(".scope-locks").join(format!("{scope}.lock"))) {
Ok(record) => record,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
if let Some(id) = record
.strip_prefix("v1 live ")
.and_then(|s| s.strip_suffix('\n'))
{
let marker = temporary.join(".incarnation");
fs::write(&marker, format!("{id}\n"))?;
std::fs::File::open(marker)?.sync_all()?;
}
Ok(())
}
fn verify_shape(connection: &Connection, table: &str, expected: &str) -> Result<()> {
let sql: String = connection.query_row(
"SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
[table],
|r| r.get(0),
)?;
// These two frozen DDL definitions contain no SQL string literals. Whitespace
// and token case are immaterial; constraints and column programs are not.
let normalized = |s: &str| {
s.chars()
.filter(|c| !c.is_ascii_whitespace())
.collect::<String>()
.to_ascii_lowercase()
};
if normalized(&sql) != normalized(expected) {
return Err(FeatureStorageError::Identity(
"legacy physical metadata shape differs".into(),
));
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Durability establishment is local to a manager, not inferred from existence.
//! Retained descriptors pin the established ancestor chain and scoped lock inode;
//! each acquisition rechecks the configured/canonical paths before and after the
//! OS lock. A replacement is rejected for the lifetime of that proof. New
//! managers (including new processes) repeat all persistence steps. Failed
//! establishment publishes no proof. Configured Host symlinks remain supported,
//! but their mutable links require full establishment on every acquisition.
//!
//! This is only durability evidence. The caller must still verify the current
//! owner catalog, DB identity, fences and authorization under the returned lease.
//! As with the owner, advisory checks require a Host-protected filesystem; they
//! do not sandbox native writers racing after the final path check.
use super::*;
/// Instance-local durability evidence, shared only by a manager and its handles.
/// It never substitutes for the owner catalog, DB identity, fences or authority.
#[derive(Default)]
pub(crate) struct EstablishedLocks {
scopes: Mutex<HashMap<String, Arc<Proof>>>,
}
struct Directory {
path: PathBuf,
file: File,
symlink: bool,
}
struct Proof {
root: PathBuf,
canonical_root: PathBuf,
directories: Vec<Directory>,
lock_path: PathBuf,
lock: File,
reusable: bool,
}
fn conflict() -> FeatureStorageError {
FeatureStorageError::Identity("canonical lifecycle lock path was replaced or redirected".into())
}
fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.dev() == right.dev() && left.ino() == right.ino()
}
fn regular_lock(path: &Path) -> Result<fs::Metadata> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() {
return Err(conflict());
}
Ok(metadata)
}
impl Proof {
fn capture(root: &Path, scope: &str) -> Result<Self> {
let root = if root.is_absolute() {
root.to_path_buf()
} else {
std::env::current_dir()?.join(root)
};
let directory = root.join(".lifecycle");
// A configured Host root may contain symlinks. The lock namespace itself
// must not: following a replacement there could split the advisory lock.
if !fs::symlink_metadata(&directory)?.is_dir() {
return Err(conflict());
}
let canonical_root = fs::canonicalize(&root)?;
let resolved = fs::canonicalize(&directory)?;
if resolved != canonical_root.join(".lifecycle") {
return Err(conflict());
}
let mut directories = Vec::new();
let mut seen = HashSet::new();
for path in directory.ancestors().chain(resolved.ancestors()) {
if seen.insert(path.to_path_buf()) {
let symlink = fs::symlink_metadata(path)?.is_symlink();
let file = File::open(path)?;
if !file.metadata()?.is_dir() {
return Err(conflict());
}
directories.push(Directory {
path: path.into(),
file,
symlink,
});
}
}
let lock_path = directory.join(format!("{scope}.lock"));
match fs::symlink_metadata(&lock_path) {
Ok(_) => {
regular_lock(&lock_path)?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
let proof = Self {
root,
canonical_root,
reusable: directories.iter().all(|entry| !entry.symlink),
directories,
lock_path,
lock,
};
proof.check()?;
Ok(proof)
}
fn check(&self) -> Result<()> {
if fs::canonicalize(&self.root)? != self.canonical_root {
return Err(conflict());
}
for directory in &self.directories {
let link = fs::symlink_metadata(&directory.path)?;
if link.is_symlink() != directory.symlink
|| !same_file(&fs::metadata(&directory.path)?, &directory.file.metadata()?)
{
return Err(conflict());
}
}
if !same_file(&regular_lock(&self.lock_path)?, &self.lock.metadata()?)
|| fs::canonicalize(&self.lock_path)?
!= self
.canonical_root
.join(".lifecycle")
.join(self.lock_path.file_name().ok_or_else(conflict)?)
{
return Err(conflict());
}
Ok(())
}
fn open(&self) -> Result<File> {
self.check()?;
// A fresh open description is required for each flock lease. Cloning
// the pinned File would share one OS lock across independent operations.
let lock = OpenOptions::new()
.read(true)
.write(true)
.open(&self.lock_path)?;
if !same_file(&lock.metadata()?, &self.lock.metadata()?) {
return Err(conflict());
}
Ok(lock)
}
}
impl Lifecycle {
pub(super) fn lock_with_sync(
&self,
exclusive: bool,
sync: &mut impl FnMut(&Path) -> std::io::Result<()>,
) -> Result<File> {
#[cfg(feature = "test-support")]
self.observation.acquisition();
let proof = {
let mut scopes = self.established.scopes.lock().map_err(|_| {
FeatureStorageError::Operation("lifecycle establishment mutex poisoned".into())
})?;
let previous = scopes.get(&self.scope);
if let Some(proof) = previous {
// Replacements are rejected, not silently accepted as a new
// lock domain while an old manager/handle might still hold it.
proof.check()?;
}
if let Some(proof) = previous.filter(|proof| proof.reusable) {
proof.clone()
} else {
#[cfg(feature = "test-support")]
let started = self.observation.sync_start();
let result = (|| {
let directory = self.root.join(".lifecycle");
fs::create_dir_all(&directory)?;
let proof = Proof::capture(&self.root, &self.scope)?;
// Re-sync even existing ancestors: their presence is not
// evidence that an earlier attempt persisted their links.
durable_create_directory(&directory, sync)?;
proof.lock.sync_all()?;
sync(&directory)?;
proof.check()?;
Ok::<_, FeatureStorageError>(Arc::new(proof))
})();
#[cfg(feature = "test-support")]
self.observation.sync_finished(started, result.is_err());
let proof = result?;
// Publish only after every durability step and path recheck.
// Pinned descriptors prevent inode reuse from reviving proof.
scopes.insert(self.scope.clone(), proof.clone());
proof
}
};
let lock = proof.open()?;
#[cfg(feature = "test-support")]
let started = self.observation.lock_start();
let result = if exclusive {
lock.lock()
} else {
lock.lock_shared()
};
#[cfg(feature = "test-support")]
self.observation.lock_finished(started, result.is_err());
result?;
// Do not hold the instance mutex while waiting for another process.
// Check again after the OS wait, before the caller can touch its DB.
proof.check()?;
if !same_file(&lock.metadata()?, &proof.lock.metadata()?) {
return Err(conflict());
}
Ok(lock)
}
}
#[cfg(test)]
#[path = "lifecycle_lock_tests.rs"]
mod tests;
@@ -0,0 +1,255 @@
//! Component tests use the production manager, lifecycle locks and filesystem.
//! Only directory sync failure is injected; DB identity checks remain production.
use super::*;
use std::os::unix::fs::symlink;
fn sync_paths(lifecycle: &Lifecycle) -> Vec<PathBuf> {
let mut paths = Vec::new();
drop(
lifecycle
.lock_with_sync(false, &mut |path| {
paths.push(path.into());
sync_directory(path)
})
.unwrap(),
);
paths
}
#[test]
fn failed_establishment_retries_every_link_even_when_directories_and_lock_exist() {
let reference = tempfile::tempdir().unwrap();
let reference_manager = FeatureStorage::new(reference.path().join("nested/storage"));
let count = sync_paths(&reference_manager.lifecycle("scope")).len();
assert!(count > 2, "ancestor chain and final lock-directory sync");
for fail_at in 0..count {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("nested/storage");
let manager = FeatureStorage::new(&root);
let lifecycle = manager.lifecycle("scope");
let mut calls = 0;
let result = lifecycle.lock_with_sync(false, &mut |path| {
let index = calls;
calls += 1;
if index == fail_at {
Err(std::io::Error::other("injected directory sync failure"))
} else {
sync_directory(path)
}
});
assert!(
matches!(result, Err(FeatureStorageError::Io(_))),
"{fail_at}"
);
assert_eq!(calls, fail_at + 1);
assert!(root.join(".lifecycle/scope.lock").is_file());
assert!(lifecycle.established.scopes.lock().unwrap().is_empty());
let retried = sync_paths(&lifecycle);
assert_eq!(
retried.len(),
count,
"all durability steps retry at {fail_at}"
);
assert_eq!(
retried.first(),
retried.last(),
"lock directory persisted last"
);
drop(
lifecycle
.lock_with_sync(false, &mut |_| panic!("already established"))
.unwrap(),
);
}
}
#[test]
fn establishment_is_shared_with_handles_but_not_new_managers_or_scopes() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("storage");
let manager = FeatureStorage::new(&root);
let paths = sync_paths(&manager.lifecycle("scope"));
assert!(!paths.is_empty());
assert!(sync_paths(&manager.clone().lifecycle("scope")).is_empty());
assert_eq!(sync_paths(&manager.lifecycle("other")), paths);
let other = FeatureStorage::new(&root);
assert_eq!(
sync_paths(&other.lifecycle("scope")),
paths,
"existence is not proof"
);
assert_eq!(
sync_paths(&FeatureStorage::new(&root).lifecycle("scope")),
paths
);
}
fn open_database(root: &Path) -> (FeatureStorage, RegisteredFeature, FeatureDatabase) {
let manager = FeatureStorage::new(root);
let registration = manager
.register(FeatureRegistration::new("feature", &[]))
.unwrap();
let database = manager.scope("scope").unwrap().open(&registration).unwrap();
database
.transaction(|tx| {
tx.execute_batch(
"CREATE TABLE marker(value TEXT); INSERT INTO marker VALUES('persisted');",
)?;
Ok(())
})
.unwrap();
(manager, registration, database)
}
fn rejected(
manager: &FeatureStorage,
registration: &RegisteredFeature,
database: &FeatureDatabase,
) {
assert!(
database
.with_connection(|_| -> Result<()> { panic!("replaced lock cannot enter DB closure") })
.is_err()
);
assert!(database.guard(database.identity()).is_err());
assert!(manager.scope("scope").unwrap().open(registration).is_err());
}
#[test]
fn physical_root_directory_lock_and_ancestor_replacement_reject_retained_access() {
for replacement in ["root", "directory", "lock", "ancestor"] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("parent/storage");
let (manager, registration, database) = open_database(&root);
let path = match replacement {
"root" => root.clone(),
"directory" => root.join(".lifecycle"),
"lock" => root.join(".lifecycle/scope.lock"),
"ancestor" => root.parent().unwrap().to_path_buf(),
_ => unreachable!(),
};
let moved = temp.path().join("moved");
fs::rename(&path, &moved).unwrap();
if replacement == "lock" {
fs::write(&path, b"").unwrap();
} else {
fs::create_dir_all(&path).unwrap();
}
rejected(&manager, &registration, &database);
// Rejecting must not erase the old proof and accept the replacement on retry.
rejected(&manager, &registration, &database);
fs::remove_file(&path)
.or_else(|_| fs::remove_dir_all(&path))
.unwrap();
fs::rename(&moved, &path).unwrap();
let value: String = database
.with_connection(|connection| {
Ok(connection.query_row("SELECT value FROM marker", [], |r| r.get(0))?)
})
.unwrap();
assert_eq!(value, "persisted");
}
}
#[test]
fn symlink_replacement_cannot_redirect_or_split_an_established_lock() {
for replacement in ["root", "directory", "lock"] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("storage");
let (manager, registration, database) = open_database(&root);
let path = match replacement {
"root" => root.clone(),
"directory" => root.join(".lifecycle"),
"lock" => root.join(".lifecycle/scope.lock"),
_ => unreachable!(),
};
let moved = temp.path().join("moved");
fs::rename(&path, &moved).unwrap();
symlink(&moved, &path).unwrap();
// The target inode is still the original; following metadata alone is insufficient.
rejected(&manager, &registration, &database);
fs::remove_file(&path).unwrap();
fs::rename(&moved, &path).unwrap();
drop(database.guard(database.identity()).unwrap());
}
}
#[test]
fn initial_lock_namespace_symlinks_are_rejected_without_establishment_proof() {
for directory_link in [true, false] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("storage");
fs::create_dir(&root).unwrap();
let target = temp.path().join("target");
if directory_link {
fs::create_dir(&target).unwrap();
symlink(&target, root.join(".lifecycle")).unwrap();
} else {
fs::create_dir(root.join(".lifecycle")).unwrap();
fs::write(&target, b"").unwrap();
symlink(&target, root.join(".lifecycle/scope.lock")).unwrap();
}
let manager = FeatureStorage::new(&root);
let lifecycle = manager.lifecycle("scope");
assert!(matches!(
lifecycle.lock(false),
Err(FeatureStorageError::Identity(_))
));
assert!(lifecycle.established.scopes.lock().unwrap().is_empty());
}
}
#[test]
fn configured_host_symlinks_reestablish_and_reject_changed_canonical_root() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
fs::create_dir(&target).unwrap();
let root = temp.path().join("alias");
symlink(&target, &root).unwrap();
let manager = FeatureStorage::new(&root);
let lifecycle = manager.lifecycle("scope");
let paths = sync_paths(&lifecycle);
assert!(paths.contains(&target));
// Mutable symlink links are not pinned by ordinary directory descriptors;
// therefore no successful proof can suppress their durability steps.
assert_eq!(sync_paths(&lifecycle), paths);
let next = temp.path().join("next");
fs::create_dir(&next).unwrap();
fs::remove_file(&root).unwrap();
symlink(&next, &root).unwrap();
assert!(matches!(
lifecycle.lock(false),
Err(FeatureStorageError::Identity(_))
));
}
#[cfg(feature = "test-support")]
#[test]
fn replacement_during_os_lock_wait_is_rejected_before_database_access() {
use std::time::{Duration, Instant};
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("storage");
let (manager, registration, database) = open_database(&root);
let guard = database.guard(database.identity()).unwrap();
let attempts = manager.lifecycle_observation().lock_attempts;
let lifecycle = manager.lifecycle("scope");
let waiter = std::thread::spawn(move || lifecycle.lock(true));
let deadline = Instant::now() + Duration::from_secs(5);
while manager.lifecycle_observation().lock_attempts == attempts {
assert!(
Instant::now() < deadline,
"waiter did not reach OS lock call"
);
std::thread::yield_now();
}
let lock_path = root.join(".lifecycle/scope.lock");
fs::rename(&lock_path, root.join(".lifecycle/held.lock")).unwrap();
fs::write(&lock_path, b"").unwrap();
drop(guard);
assert!(matches!(
waiter.join().unwrap(),
Err(FeatureStorageError::Identity(_))
));
rejected(&manager, &registration, &database);
}
@@ -0,0 +1,114 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
/// Cumulative lifecycle observations for one storage manager and its clones/handles.
/// Counts attempts before starting I/O, including attempts that return errors.
/// `acquisitions` counts entries to lifecycle acquisition; `sync_attempts` counts
/// lifecycle durability establishment attempts (the full ancestor sync plus
/// lockfile and lock-directory persistence), and `lock_attempts` counts OS
/// shared/exclusive lock calls, excluding lockfile open failures. Once this
/// manager has established and rechecked the same physical paths, ordinary
/// acquisitions need no establishment attempt. A fresh manager establishes again.
///
/// Durations sum monotonic elapsed nanoseconds around those calls, including
/// errors and OS lock waits, but not lease holding time or database operations.
/// Overlapping threads contribute independently: aggregates are not wall time.
/// Fields saturate at u64::MAX and are independently sampled with relaxed loads;
/// a concurrent snapshot need not describe one atomic point in time. In-flight
/// calls contribute attempts before their duration or failure is recorded.
/// These diagnostics retain no database or authority and are not termination,
/// durability, or successful-work evidence; they never control storage I/O.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LifecycleObservation {
pub acquisitions: u64,
pub sync_attempts: u64,
pub sync_nanoseconds: u64,
pub failed_syncs: u64,
pub lock_attempts: u64,
pub lock_nanoseconds: u64,
pub failed_locks: u64,
}
impl LifecycleObservation {
/// Saturating differences between snapshots from the same manager.
pub fn since(self, before: Self) -> Self {
Self {
acquisitions: self.acquisitions.saturating_sub(before.acquisitions),
sync_attempts: self.sync_attempts.saturating_sub(before.sync_attempts),
sync_nanoseconds: self
.sync_nanoseconds
.saturating_sub(before.sync_nanoseconds),
failed_syncs: self.failed_syncs.saturating_sub(before.failed_syncs),
lock_attempts: self.lock_attempts.saturating_sub(before.lock_attempts),
lock_nanoseconds: self
.lock_nanoseconds
.saturating_sub(before.lock_nanoseconds),
failed_locks: self.failed_locks.saturating_sub(before.failed_locks),
}
}
}
#[derive(Debug, Default)]
pub(super) struct Counters {
acquisitions: AtomicU64,
sync_attempts: AtomicU64,
sync_nanoseconds: AtomicU64,
failed_syncs: AtomicU64,
lock_attempts: AtomicU64,
lock_nanoseconds: AtomicU64,
failed_locks: AtomicU64,
}
impl Counters {
pub(super) fn snapshot(&self) -> LifecycleObservation {
LifecycleObservation {
acquisitions: self.acquisitions.load(Ordering::Relaxed),
sync_attempts: self.sync_attempts.load(Ordering::Relaxed),
sync_nanoseconds: self.sync_nanoseconds.load(Ordering::Relaxed),
failed_syncs: self.failed_syncs.load(Ordering::Relaxed),
lock_attempts: self.lock_attempts.load(Ordering::Relaxed),
lock_nanoseconds: self.lock_nanoseconds.load(Ordering::Relaxed),
failed_locks: self.failed_locks.load(Ordering::Relaxed),
}
}
pub(super) fn acquisition(&self) {
add(&self.acquisitions, 1);
}
pub(super) fn sync_start(&self) -> Instant {
add(&self.sync_attempts, 1);
Instant::now()
}
pub(super) fn sync_finished(&self, started: Instant, failed: bool) {
let elapsed = started.elapsed();
add(&self.sync_nanoseconds, nanoseconds(elapsed));
if failed {
add(&self.failed_syncs, 1);
}
}
pub(super) fn lock_start(&self) -> Instant {
add(&self.lock_attempts, 1);
Instant::now()
}
pub(super) fn lock_finished(&self, started: Instant, failed: bool) {
let elapsed = started.elapsed();
add(&self.lock_nanoseconds, nanoseconds(elapsed));
if failed {
add(&self.failed_locks, 1);
}
}
}
fn nanoseconds(duration: std::time::Duration) -> u64 {
duration.as_nanos().min(u128::from(u64::MAX)) as u64
}
fn add(counter: &AtomicU64, amount: u64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
Some(value.saturating_add(amount))
});
}
+483
View File
@@ -0,0 +1,483 @@
//! Physical ownership is independent of domain authorization. The trusted Host
//! supplies adoption evidence and validates domain attribution inside the identity
//! transaction. Merely finding a file never grants adoption authority.
//!
//! Lock order is lifecycle file, manager state, connection, SQLite transaction.
//! The stable scope lock is shared through consumer commit/rollback and exclusive
//! during provisioning, adoption, deletion and restore, across managers/processes.
//! It is advisory: arbitrary native code bypassing this owner can rename files.
//! Such replacement is rejected on the next access using the file identity and
//! persisted physical identity. Hosts must protect the root and its lock/owner DB
//! against uncooperative writers; this is not a filesystem sandbox.
use super::*;
use rusqlite::OptionalExtension;
use std::fs::{File, OpenOptions};
use std::os::unix::fs::MetadataExt;
const IDENTITY_TABLE: &str = "__yoi_storage_identity";
#[path = "lifecycle_lock.rs"]
mod lifecycle_lock;
pub(super) use lifecycle_lock::EstablishedLocks;
/// Expected storage target, retained with authorization and rechecked on resume.
/// This is a physical identity, not an authorization credential or a Job lease.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatabaseIdentity {
pub scope: String,
pub feature: String,
pub physical: String,
}
#[derive(Clone, Debug)]
pub(super) struct Binding {
pub identity: DatabaseIdentity,
pub device: u64,
pub inode: u64,
pub request: String,
}
pub(super) struct Lifecycle {
pub root: PathBuf,
pub scope: String,
pub established: Arc<EstablishedLocks>,
#[cfg(feature = "test-support")]
pub observation: Arc<lifecycle_observation::Counters>,
}
impl Lifecycle {
pub fn lock(&self, exclusive: bool) -> Result<File> {
self.lock_with_sync(exclusive, &mut sync_directory)
}
fn catalog(&self, create: bool) -> Result<Connection> {
let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
| if create {
OpenFlags::SQLITE_OPEN_CREATE
} else {
OpenFlags::empty()
};
let connection = Connection::open_with_flags(self.root.join(".owner.sqlite"), flags)?;
configure_connection(&connection)?;
if create {
connection.execute_batch(
"
CREATE TABLE IF NOT EXISTS storage_requests (
request TEXT PRIMARY KEY, scope TEXT NOT NULL, feature TEXT NOT NULL,
physical TEXT NOT NULL, device INTEGER NOT NULL, inode INTEGER NOT NULL,
kind TEXT NOT NULL, evidence TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0,
current INTEGER NOT NULL DEFAULT 1);
CREATE UNIQUE INDEX IF NOT EXISTS storage_current_target ON storage_requests(scope,feature) WHERE current=1;
CREATE TABLE IF NOT EXISTS storage_deletions (scope TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS storage_restores (scope TEXT PRIMARY KEY, evidence TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0);
",
)?;
}
Ok(connection)
}
pub fn fenced(&self, scope: &str) -> Result<bool> {
if legacy_owner::fenced(&self.root, scope)? {
return Ok(true);
}
if !self.root.join(".owner.sqlite").try_exists()? {
return Ok(false);
}
Ok(self.catalog(false)?.query_row(
"SELECT EXISTS(SELECT 1 FROM storage_deletions WHERE scope=?1)",
[scope],
|r| r.get(0),
)?)
}
/// Reserve the entire restore before creating staging. An unfinished copy
/// cannot be adopted by another source/request, even if its files look valid.
pub fn reserve_restore(
&self,
scope: &str,
evidence: &str,
temporary: &Path,
destination: &Path,
) -> Result<()> {
let mut catalog = self.catalog(true)?;
let tx = catalog.transaction_with_behavior(TransactionBehavior::Immediate)?;
let previous: Option<(String, bool)> = tx
.query_row(
"SELECT evidence,published FROM storage_restores WHERE scope=?1",
[scope],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
match previous {
Some((saved, _)) if saved == evidence => {}
Some((_, false)) => {
return Err(FeatureStorageError::Identity(
"another restore is unfinished; reconcile its exact source/request".into(),
));
}
Some((_, true)) if destination.exists() || temporary.exists() => {
return Err(FeatureStorageError::Identity(
"another restore owns the existing destination/staging".into(),
));
}
None if destination.exists() => {
return Err(FeatureStorageError::DestinationExists(
destination.display().to_string(),
));
}
None if temporary.exists() => {
return Err(FeatureStorageError::Identity(
"unowned restore staging requires explicit reconciliation".into(),
));
}
_ => {
tx.execute("INSERT INTO storage_restores(scope,evidence,published) VALUES(?1,?2,0) ON CONFLICT(scope) DO UPDATE SET evidence=excluded.evidence,published=0", params![scope,evidence])?;
}
}
tx.commit()?;
Ok(())
}
pub fn publish_restore(&self, scope: &str, evidence: &str) -> Result<()> {
let changed = self.catalog(false)?.execute(
"UPDATE storage_restores SET published=1 WHERE scope=?1 AND evidence=?2",
params![scope, evidence],
)?;
if changed != 1 {
return Err(FeatureStorageError::Identity(
"restore request changed".into(),
));
}
Ok(())
}
pub fn fenced_for_restore(&self, scope: &str) -> Result<bool> {
if legacy_owner::fenced_for_restore(&self.root, scope)? {
return Ok(true);
}
if !self.root.join(".owner.sqlite").try_exists()? {
return Ok(false);
}
Ok(self.catalog(false)?.query_row(
"SELECT EXISTS(SELECT 1 FROM storage_deletions WHERE scope=?1)",
[scope],
|r| r.get(0),
)?)
}
pub fn delete(&self, scope: &str) -> Result<()> {
let directory = self.root.join(".lifecycle");
durable_create_directory(&directory, &mut sync_directory)?;
let fence = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(directory.join(format!("{scope}.deleted")))?;
fence.sync_all()?;
sync_directory(&directory)?;
self.catalog(true)?.execute(
"INSERT OR IGNORE INTO storage_deletions(scope) VALUES(?1)",
[scope],
)?;
Ok(())
}
pub fn lookup(&self, scope: &str, feature: &str) -> Result<Option<Binding>> {
if self.fenced(scope)? {
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
}
if !self.root.join(".owner.sqlite").try_exists()? {
return Ok(None);
}
let catalog = self.catalog(false)?;
Ok(catalog.query_row("SELECT physical,device,inode,request FROM storage_requests WHERE scope=?1 AND feature=?2 AND completed=1 AND current=1", params![scope,feature], |r| Ok(Binding {
identity: DatabaseIdentity { scope: scope.into(), feature: feature.into(), physical: r.get(0)? },
device: r.get(1)?, inode: r.get(2)?, request: r.get(3)?,
})).optional()?)
}
/// Called under exclusive lifecycle lock. The reservation precedes identity
/// persistence and survives response loss; exact retries reconcile the same
/// inode and UUID rather than manufacture past provisioning permission.
pub fn reserve(
&self,
scope: &str,
feature: &str,
path: &Path,
request: &str,
kind: &str,
evidence: &str,
) -> Result<Binding> {
if request.trim().is_empty() || evidence.trim().is_empty() {
return Err(FeatureStorageError::Identity(
"request and Host authority evidence are required".into(),
));
}
let fenced = if kind == "restore" {
self.fenced_for_restore(scope)?
} else {
self.fenced(scope)?
};
if fenced {
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
}
if kind != "provision" && !path.is_file() {
return Err(FeatureStorageError::Identity(
"expected existing database is missing".into(),
));
}
let mut catalog = self.catalog(true)?;
let tx = catalog.transaction_with_behavior(TransactionBehavior::Immediate)?;
let saved: Option<(String,String,String,u64,u64,String,String,bool)> = tx.query_row(
"SELECT scope,feature,physical,device,inode,kind,evidence,current FROM storage_requests WHERE request=?1", [request],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).optional()?;
if let Some((s, f, physical, device, inode, k, e, current)) = saved {
if s != scope || f != feature || k != kind || e != evidence || !current {
return Err(FeatureStorageError::Identity(
"request identity was reused for a different target or authority".into(),
));
}
let binding = Binding {
identity: DatabaseIdentity {
scope: s,
feature: f,
physical,
},
device,
inode,
request: request.into(),
};
binding.check_file(path)?;
return Ok(binding);
}
let busy: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND current=1)",
params![scope, feature],
|r| r.get(0),
)?;
if busy && kind != "restore" {
return Err(FeatureStorageError::Identity(
"target already has an ownership request; reconcile that request".into(),
));
}
if kind == "provision" {
durable_create_directory(
path.parent()
.ok_or_else(|| FeatureStorageError::Identity("missing parent".into()))?,
&mut sync_directory,
)?;
let file = OpenOptions::new().write(true).create_new(true).open(path)?;
file.sync_all()?;
sync_directory(path.parent().unwrap())?;
}
// A verified restore supersedes the active physical lifecycle, but keeps
// every old request/receipt as provenance. It cannot replay as current authority.
if kind == "restore" {
tx.execute(
"UPDATE storage_requests SET current=0 WHERE scope=?1 AND feature=?2 AND current=1",
params![scope, feature],
)?;
}
let metadata = fs::metadata(path)?;
if !metadata.is_file() {
return Err(FeatureStorageError::Identity(
"database is not a regular file".into(),
));
}
let binding = Binding {
identity: DatabaseIdentity {
scope: scope.into(),
feature: feature.into(),
physical: if kind == "adopt" {
legacy_owner::identity(path, scope, feature)?
.unwrap_or_else(|| Uuid::now_v7().to_string())
} else {
Uuid::now_v7().to_string()
},
},
device: metadata.dev(),
inode: metadata.ino(),
request: request.into(),
};
tx.execute("INSERT INTO storage_requests(request,scope,feature,physical,device,inode,kind,evidence) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![request,scope,feature,binding.identity.physical,binding.device,binding.inode,kind,evidence])?;
tx.commit()?;
Ok(binding)
}
/// Snapshot bytes are not authority. Match their saved identity to this
/// explicitly supplied source owner's completed provenance, including an old
/// lifecycle superseded by a prior restore. The caller holds its lifecycle lock.
pub fn verify_snapshot(
&self,
scope: &str,
feature: &str,
path: &Path,
) -> Result<(String, String)> {
if self.fenced_for_restore(scope)? {
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
}
if !self.root.join(".owner.sqlite").is_file() {
return Err(FeatureStorageError::Identity(
"snapshot source owner is missing".into(),
));
}
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let (s, f, physical, request): (String, String, String, String) = connection.query_row(
"SELECT scope,feature,physical,request FROM __yoi_storage_identity WHERE singleton=1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
Uuid::parse_str(&physical).map_err(|_| {
FeatureStorageError::Identity("invalid snapshot physical identity".into())
})?;
let known: bool = self.catalog(false)?.query_row(
"SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND physical=?3 AND request=?4 AND completed=1)",
params![scope, feature, physical, request], |r| r.get(0))?;
if s != scope || f != feature || !known {
return Err(FeatureStorageError::Identity(
"snapshot does not belong to the selected source owner".into(),
));
}
Ok((physical, request))
}
pub fn request_kind(&self, request: &str) -> Result<Option<String>> {
if !self.root.join(".owner.sqlite").try_exists()? {
return Ok(None);
}
Ok(self
.catalog(false)?
.query_row(
"SELECT kind FROM storage_requests WHERE request=?1 AND current=1",
[request],
|r| r.get(0),
)
.optional()?)
}
pub fn complete(&self, binding: &Binding) -> Result<()> {
let changed = self.catalog(false)?.execute("UPDATE storage_requests SET completed=1 WHERE request=?1 AND scope=?2 AND feature=?3 AND physical=?4 AND device=?5 AND inode=?6 AND current=1",params![binding.request,binding.identity.scope,binding.identity.feature,binding.identity.physical,binding.device,binding.inode])?;
if changed != 1 {
return Err(FeatureStorageError::Identity(
"ownership reservation changed".into(),
));
}
Ok(())
}
pub fn verify(&self, binding: &Binding, path: &Path, connection: &Connection) -> Result<()> {
let current = self
.lookup(&binding.identity.scope, &binding.identity.feature)?
.ok_or_else(|| FeatureStorageError::Identity("no completed ownership record".into()))?;
if current.identity != binding.identity
|| current.device != binding.device
|| current.inode != binding.inode
|| current.request != binding.request
{
return Err(FeatureStorageError::Identity(
"physical ownership changed".into(),
));
}
binding.check_file(path)?;
binding.check_database(connection)
}
}
impl Binding {
pub fn unknown(&self, error: FeatureStorageError) -> FeatureStorageError {
FeatureStorageError::OutcomeUnknown {
identity: self.identity.clone(),
detail: error.to_string(),
}
}
pub fn check_file(&self, path: &Path) -> Result<()> {
let metadata = fs::metadata(path)?;
if metadata.dev() != self.device || metadata.ino() != self.inode {
return Err(FeatureStorageError::Identity(
"database path was replaced; explicit authorized adoption is required".into(),
));
}
Ok(())
}
pub fn check_database(&self, connection: &Connection) -> Result<()> {
Uuid::parse_str(&self.identity.physical)
.map_err(|_| FeatureStorageError::Identity("invalid physical identity".into()))?;
let found: Option<(String, String, String, String)> = connection
.query_row(
&format!(
"SELECT scope,feature,physical,request FROM {IDENTITY_TABLE} WHERE singleton=1"
),
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()?;
if found
!= Some((
self.identity.scope.clone(),
self.identity.feature.clone(),
self.identity.physical.clone(),
self.request.clone(),
))
{
return Err(FeatureStorageError::Identity(
"database identity/receipt is missing or mismatched".into(),
));
}
Ok(())
}
/// Restore is a new physical lifecycle. Retain old provenance but never
/// return its identity as authority over the restored inode.
pub fn restore_identity(&self, connection: &mut Connection) -> Result<()> {
let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='__yoi_storage_identity')",
[],
|r| r.get(0),
)?;
if exists {
if self.check_database(&tx).is_ok() {
return Ok(());
}
let (scope, feature): (String, String) = tx.query_row(
"SELECT scope,feature FROM __yoi_storage_identity WHERE singleton=1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if scope != self.identity.scope || feature != self.identity.feature {
return Err(FeatureStorageError::Identity(
"snapshot belongs to another scope".into(),
));
}
tx.execute_batch("CREATE TABLE IF NOT EXISTS __yoi_storage_origins (scope TEXT NOT NULL, feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL, PRIMARY KEY(physical,request)); INSERT OR IGNORE INTO __yoi_storage_origins SELECT scope,feature,physical,request FROM __yoi_storage_identity; DELETE FROM __yoi_storage_identity;")?;
}
self.persist(&tx)?;
tx.commit()?;
Ok(())
}
pub fn persist(&self, tx: &Transaction<'_>) -> Result<()> {
tx.execute_batch(&format!(
"CREATE TABLE IF NOT EXISTS {IDENTITY_TABLE} (
singleton INTEGER PRIMARY KEY CHECK(singleton=1), scope TEXT NOT NULL,
feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL);"
))?;
let count: i64 =
tx.query_row(&format!("SELECT COUNT(*) FROM {IDENTITY_TABLE}"), [], |r| {
r.get(0)
})?;
if count == 0 {
tx.execute(
&format!("INSERT INTO {IDENTITY_TABLE} VALUES(1,?1,?2,?3,?4)"),
params![
self.identity.scope,
self.identity.feature,
self.identity.physical,
self.request
],
)?;
}
self.check_database(tx)
}
}
-2
View File
@@ -191,7 +191,6 @@ pub enum JobState {
Pending, Pending,
Completed, Completed,
Failed, Failed,
Unknown,
Cancelled, Cancelled,
} }
@@ -203,7 +202,6 @@ pub enum JobAttemptState {
Dispatched, Dispatched,
Completed, Completed,
Failed, Failed,
Unknown,
Cancelled, Cancelled,
} }
+3 -9
View File
@@ -39,7 +39,6 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
(JobState::Pending, "pending"), (JobState::Pending, "pending"),
(JobState::Completed, "completed"), (JobState::Completed, "completed"),
(JobState::Failed, "failed"), (JobState::Failed, "failed"),
(JobState::Unknown, "unknown"),
(JobState::Cancelled, "cancelled"), (JobState::Cancelled, "cancelled"),
] { ] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name)); assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
@@ -54,7 +53,6 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
(JobAttemptState::Dispatched, "dispatched"), (JobAttemptState::Dispatched, "dispatched"),
(JobAttemptState::Completed, "completed"), (JobAttemptState::Completed, "completed"),
(JobAttemptState::Failed, "failed"), (JobAttemptState::Failed, "failed"),
(JobAttemptState::Unknown, "unknown"),
(JobAttemptState::Cancelled, "cancelled"), (JobAttemptState::Cancelled, "cancelled"),
] { ] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name)); assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
@@ -63,6 +61,9 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
state state
); );
} }
// Previous uncertain lifecycle values belong only to versioned migration input.
assert!(serde_json::from_value::<JobState>(json!("unknown")).is_err());
assert!(serde_json::from_value::<JobAttemptState>(json!("unknown")).is_err());
assert!(serde_json::from_value::<JobState>(json!("running")).is_err()); assert!(serde_json::from_value::<JobState>(json!("running")).is_err());
assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err()); assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err());
} }
@@ -111,13 +112,6 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
Some("execution"), Some("execution"),
Some("known failure"), Some("known failure"),
), ),
(
JobState::Unknown,
JobAttemptState::Unknown,
None,
Some("interrupted"),
Some("requires reconciliation"),
),
( (
JobState::Cancelled, JobState::Cancelled,
JobAttemptState::Cancelled, JobAttemptState::Cancelled,
+2
View File
@@ -2,6 +2,8 @@
pub mod identity; pub mod identity;
pub mod invocation; pub mod invocation;
pub mod session_failure;
pub mod startup_failure;
#[cfg(feature = "stream")] #[cfg(feature = "stream")]
pub mod stream; pub mod stream;
pub mod subscription; pub mod subscription;
+662
View File
@@ -0,0 +1,662 @@
//! Typed session-registration failures, not lifecycle or retry authority.
//!
//! Causes contain only fixed enum values. Stored or transferred attachments are
//! observations, never permission to disclose: Browser/Tool projections require
//! the caller's current visibility decision and fail closed without it.
//!
//! # Projection boundary
//!
//! The same closed cause is mapped explicitly into the shared error-contract codec.
//! The transport boundary does not convert an internal Session denial into an HTTP
//! verdict about a Browser operator. Public consumers must resolve current viewer
//! authority before projecting; a saved attachment cannot carry that authority.
//!
//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool |
//! | --- | --- | --- |
//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose |
//! | Explanation | Fixed local template | Fixed local template, not received text |
//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references |
//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause |
//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority |
//!
//! A denied current visibility decision fails projection instead of substituting
//! an invented cause. Unknown codec versions and corrupt attachments stay distinct
//! from known causes. Missing legacy attachments do not certify legacy diagnostics.
//!
//! # Failure investigation
//!
//! A known denial identifies the operation and principal class, not an instruction
//! to grant rights. Check the current Subject execution policy and the authenticated
//! Runtime/Worker binding at the Session-record authorization boundary. Compare
//! existing Session attribution and saved Worker metadata before deciding whether
//! any state repair is needed. Operator authority and Subject execution policy are
//! different decisions; neither can be derived from an internal HTTP status alone.
//!
//! Transport and unknown outcomes do not establish non-execution. Reconcile the
//! existing Session attribution, Worker aggregate and Runtime operation result;
//! a lost response may coexist with a committed write. A conflict invalidates the
//! old metadata expectation. Do not retry the startup, create another Worker, or
//! enlarge permissions solely because a failure was displayed. Inspect primary,
//! secondary, persistence and resource facts independently: rollback failure must
//! not replace the refusal, and persistence uncertainty must not be called saved.
//!
//! The real authorization/factory fixture is owned by workspace-server's normal
//! `session_registration_connected` tests. Its signed Session callback revokes the
//! real policy at a synchronization point; a real retention pin forces independent
//! cleanup failure. Leaf transport/projection tests do not substitute for that
//! product connection, and the fixture claims neither a process crash nor restore.
use error_contract::{
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
InternalTransfer, OperationContext, ProjectFor, Storage, Tool, WireDetails, project_failure,
};
use serde::{Deserialize, Serialize};
use crate::startup_failure::StartupFailureAttachment;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionRegistrationReason {
PermissionDenied,
AttributionMissing,
BindingMismatch,
Transport,
OutcomeUnknown,
InvalidResponse,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionPrincipal {
Operator,
Subject,
Unknown,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionOperation {
#[serde(rename = "session.record")]
Record,
}
/// No account/session identifiers, paths, or free-form diagnostics are retained.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionRegistrationCause {
pub reason: SessionRegistrationReason,
pub principal: SessionPrincipal,
pub required_operation: SessionOperation,
}
impl SessionRegistrationCause {
pub const fn denied(principal: SessionPrincipal) -> Self {
Self::new(SessionRegistrationReason::PermissionDenied, principal)
}
pub const fn attribution_missing() -> Self {
Self::new(
SessionRegistrationReason::AttributionMissing,
SessionPrincipal::Subject,
)
}
pub const fn binding_mismatch() -> Self {
Self::new(
SessionRegistrationReason::BindingMismatch,
SessionPrincipal::Subject,
)
}
pub const fn transport() -> Self {
Self::new(
SessionRegistrationReason::Transport,
SessionPrincipal::Unknown,
)
}
pub const fn outcome_unknown() -> Self {
Self::new(
SessionRegistrationReason::OutcomeUnknown,
SessionPrincipal::Unknown,
)
}
pub const fn invalid_response() -> Self {
Self::new(
SessionRegistrationReason::InvalidResponse,
SessionPrincipal::Unknown,
)
}
const fn new(reason: SessionRegistrationReason, principal: SessionPrincipal) -> Self {
Self {
reason,
principal,
required_operation: SessionOperation::Record,
}
}
fn explanation(&self) -> &'static str {
match self.reason {
SessionRegistrationReason::PermissionDenied => {
"Session registration permission denied."
}
SessionRegistrationReason::AttributionMissing => {
"Session registration requires Subject attribution."
}
SessionRegistrationReason::BindingMismatch => {
"Session registration Subject binding does not match."
}
SessionRegistrationReason::Transport => "Session registration transport failed.",
SessionRegistrationReason::OutcomeUnknown => "Session registration outcome is unknown.",
SessionRegistrationReason::InvalidResponse => {
"Session registration returned an invalid response."
}
}
}
fn disclosure(&self, facts: &FailureFacts<Self>) -> Disclosure<Self> {
let mut disclosure = Disclosure::new(*self, self.explanation());
for context in &facts.context().operations {
if matches!(
(context.operation.as_str(), context.stage.as_str()),
("session.record", "authorization" | "registration")
| ("worker_startup", "binding" | "runtime")
) {
let mut context = context.clone();
context.operation_ref = None;
disclosure.context.operations.push(context);
}
}
disclosure
}
}
impl FailureCause for SessionRegistrationCause {
fn identity(&self) -> FailureIdentity {
let (reason, category) = match self.reason {
SessionRegistrationReason::PermissionDenied => {
("permission_denied", Category::Authorization)
}
SessionRegistrationReason::AttributionMissing => {
("attribution_missing", Category::Authorization)
}
SessionRegistrationReason::BindingMismatch => {
("binding_mismatch", Category::Authorization)
}
SessionRegistrationReason::Transport => ("transport", Category::Dependency),
SessionRegistrationReason::OutcomeUnknown => ("outcome_unknown", Category::Unknown),
SessionRegistrationReason::InvalidResponse => ("invalid_response", Category::Internal),
};
FailureIdentity {
domain: "session_registration",
reason,
category,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
// All fields are closed enums; arbitrary diagnostic data cannot enter.
Ok(())
}
}
impl WireDetails for SessionRegistrationCause {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "session_registration"
&& version == 1
&& matches!(
reason,
"permission_denied"
| "attribution_missing"
| "binding_mismatch"
| "transport"
| "outcome_unknown"
| "invalid_response"
)
}
}
impl std::fmt::Display for SessionRegistrationCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.explanation())
}
}
impl std::error::Error for SessionRegistrationCause {}
/// A current caller-supplied visibility decision, not a serialized capability.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SessionFailureVisibility {
pub can_view_cause: bool,
}
impl ProjectFor<InternalTransfer> for SessionRegistrationCause {
type Authorization = ();
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
_: &(),
) -> Result<Disclosure<Self>, ContractError> {
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Storage> for SessionRegistrationCause {
type Authorization = ();
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
_: &(),
) -> Result<Disclosure<Self>, ContractError> {
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Browser> for SessionRegistrationCause {
type Authorization = SessionFailureVisibility;
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &SessionFailureVisibility,
) -> Result<Disclosure<Self>, ContractError> {
if !authorization.can_view_cause {
// Redaction must not substitute a different reason/category identity.
return Err(ContractError::InvalidDetails);
}
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Tool> for SessionRegistrationCause {
type Authorization = SessionFailureVisibility;
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &SessionFailureVisibility,
) -> Result<Disclosure<Self>, ContractError> {
if !authorization.can_view_cause {
return Err(ContractError::InvalidDetails);
}
Ok(self.disclosure(facts))
}
}
/// Attach fixed registration/binding observations using internal-transfer policy.
/// This does not assert registration rollback, retry safety, or startup ownership.
pub fn session_registration_attachment(
cause: SessionRegistrationCause,
) -> Result<StartupFailureAttachment, ContractError> {
let mut facts = FailureFacts::new(cause)?;
facts.with_context(OperationContext::new("session.record", "registration")?)?;
facts.with_context(OperationContext::new("worker_startup", "binding")?)?;
let projected = project_failure::<InternalTransfer, _>(&facts, &())?;
StartupFailureAttachment::from_projected(&projected)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::startup_failure::StoredStartupFailureAttachment;
use error_contract::{
Audience, Budget, CausalLink, CorrelationRef, DecodeFailure, ProjectedFailure,
decode_failure, encode_projected_failure,
};
use serde_json::json;
fn budget() -> Budget {
Budget::new(
error_contract::MAX_FAILURE_BYTES,
error_contract::MAX_FAILURE_BYTES,
)
}
fn cases() -> [(
SessionRegistrationCause,
&'static str,
Category,
&'static str,
); 6] {
[
(
SessionRegistrationCause::denied(SessionPrincipal::Operator),
"permission_denied",
Category::Authorization,
"Session registration permission denied.",
),
(
SessionRegistrationCause::attribution_missing(),
"attribution_missing",
Category::Authorization,
"Session registration requires Subject attribution.",
),
(
SessionRegistrationCause::binding_mismatch(),
"binding_mismatch",
Category::Authorization,
"Session registration Subject binding does not match.",
),
(
SessionRegistrationCause::transport(),
"transport",
Category::Dependency,
"Session registration transport failed.",
),
(
SessionRegistrationCause::outcome_unknown(),
"outcome_unknown",
Category::Unknown,
"Session registration outcome is unknown.",
),
(
SessionRegistrationCause::invalid_response(),
"invalid_response",
Category::Internal,
"Session registration returned an invalid response.",
),
]
}
#[test]
fn constructors_serde_identity_and_fixed_explanations() {
for (cause, reason, category, explanation) in cases() {
let principal = match cause.reason {
SessionRegistrationReason::PermissionDenied => "operator",
SessionRegistrationReason::AttributionMissing
| SessionRegistrationReason::BindingMismatch => "subject",
_ => "unknown",
};
let value = serde_json::to_value(cause).unwrap();
assert_eq!(
value,
json!({
"reason": reason,
"principal": principal,
"required_operation": "session.record"
})
);
assert_eq!(
serde_json::from_value::<SessionRegistrationCause>(value).unwrap(),
cause
);
assert_eq!(cause.required_operation, SessionOperation::Record);
assert_eq!(
cause.identity(),
FailureIdentity {
domain: "session_registration",
reason,
category,
details_version: 1,
}
);
assert!(SessionRegistrationCause::supports(
"session_registration",
reason,
1
));
assert_eq!(cause.to_string(), explanation);
assert!(std::error::Error::source(&cause).is_none());
let facts = FailureFacts::new(cause).unwrap();
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
assert_eq!(projected.explanation(), explanation);
let encoded = encode_projected_failure(&projected, budget()).unwrap();
match decode_failure::<SessionRegistrationCause>(encoded.as_bytes(), budget()) {
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), &cause),
_ => panic!("expected known session-registration failure"),
}
}
for (principal, wire) in [
(SessionPrincipal::Operator, "operator"),
(SessionPrincipal::Subject, "subject"),
(SessionPrincipal::Unknown, "unknown"),
] {
let cause = SessionRegistrationCause::denied(principal);
assert_eq!(cause.principal, principal);
assert_eq!(
serde_json::to_value(cause).unwrap()["principal"],
json!(wire)
);
}
assert!(!SessionRegistrationCause::supports(
"worker_startup",
"transport",
1
));
assert!(!SessionRegistrationCause::supports(
"session_registration",
"future",
1
));
assert!(!SessionRegistrationCause::supports(
"session_registration",
"transport",
2
));
}
#[test]
fn serde_rejects_untyped_diagnostic_fields_and_unknown_enum_values() {
let valid = serde_json::to_value(SessionRegistrationCause::attribution_missing()).unwrap();
for (field, value) in [
("reason", json!("raw_error")),
("principal", json!("account-secret")),
("required_operation", json!("session.delete")),
("diagnostic", json!("/private/path TEST_ONLY_SECRET")),
] {
let mut changed = valid.clone();
changed[field] = value;
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
}
for field in ["reason", "principal", "required_operation"] {
let mut changed = valid.clone();
changed.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
}
}
#[test]
fn codec_distinguishes_unsupported_and_corrupt_and_replaces_received_prose() {
let facts = FailureFacts::new(SessionRegistrationCause::transport()).unwrap();
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
let encoded = encode_projected_failure(&projected, budget()).unwrap();
let original: serde_json::Value = serde_json::from_slice(encoded.as_bytes()).unwrap();
for (field, value) in [
("domain", json!("future_domain")),
("reason", json!("future_reason")),
("details_version", json!(2)),
] {
let mut changed = original.clone();
changed[field] = value;
assert!(matches!(
decode_failure::<SessionRegistrationCause>(
&serde_json::to_vec(&changed).unwrap(),
budget()
),
DecodeFailure::Unsupported(_)
));
}
for (field, value) in [
("reason", json!("permission_denied")),
("category", json!("authorization")),
] {
let mut changed = original.clone();
changed[field] = value;
assert!(matches!(
decode_failure::<SessionRegistrationCause>(
&serde_json::to_vec(&changed).unwrap(),
budget()
),
DecodeFailure::Corrupt(ContractError::InvalidDetails)
));
}
let mut changed = original;
changed["explanation"] = json!("TEST_ONLY_SECRET received prose");
changed["context"]["diagnostics"] = json!([
{"code": "TEST_ONLY_SECRET", "explanation": "TEST_ONLY_SECRET diagnostic"}
]);
let bytes = serde_json::to_vec(&changed).unwrap();
let DecodeFailure::Known(received) =
decode_failure::<SessionRegistrationCause>(&bytes, budget())
else {
panic!("expected known failure with untrusted supplementary fields");
};
let browser = project_failure::<Browser, _>(
received.facts(),
&SessionFailureVisibility {
can_view_cause: true,
},
)
.unwrap();
assert_eq!(
browser.explanation(),
"Session registration transport failed."
);
assert!(browser.context().diagnostics.is_empty());
let encoded = encode_projected_failure(&browser, budget()).unwrap();
assert!(
!std::str::from_utf8(encoded.as_bytes())
.unwrap()
.contains("TEST_ONLY_SECRET")
);
}
#[test]
fn attachment_storage_and_current_visibility_preserve_identity_without_granting_access() {
let allowed = SessionFailureVisibility {
can_view_cause: true,
};
let denied = SessionFailureVisibility::default();
for (cause, _, _, _) in cases() {
let attachment = session_registration_attachment(cause).unwrap();
match attachment.decode::<SessionRegistrationCause>() {
DecodeFailure::Known(received) => {
assert_eq!(received.facts().cause(), &cause);
assert_eq!(
received.facts().context().operations,
vec![
OperationContext::new("session.record", "registration").unwrap(),
OperationContext::new("worker_startup", "binding").unwrap(),
]
);
}
_ => panic!("expected typed startup attachment"),
}
let storage = attachment
.project_for::<Storage, SessionRegistrationCause>(&())
.unwrap();
let stored = StoredStartupFailureAttachment::from_projected(&storage).unwrap();
let loaded: StoredStartupFailureAttachment =
serde_json::from_str(&serde_json::to_string(&stored).unwrap()).unwrap();
let browser = loaded
.project_for::<Browser, SessionRegistrationCause>(&allowed)
.unwrap();
let tool = loaded
.project_for::<Tool, SessionRegistrationCause>(&allowed)
.unwrap();
assert_eq!(browser.details(), &cause);
assert_eq!(tool.details(), &cause);
assert_eq!(browser.identity(), cause.identity());
assert_eq!(tool.identity(), cause.identity());
assert!(matches!(
loaded.project_for::<Browser, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
assert!(matches!(
loaded.project_for::<Tool, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
// Even a previous public projection cannot serve as current permission.
let previously_public = StartupFailureAttachment::from_projected(&browser).unwrap();
assert!(matches!(
previously_public.project_for::<Browser, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
assert!(matches!(
previously_public.project_for::<Tool, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
}
}
fn assert_redacted<A: Audience>(projected: &ProjectedFailure<A, SessionRegistrationCause>) {
assert_eq!(
projected.context().operations,
vec![
OperationContext::new("session.record", "authorization").unwrap(),
OperationContext::new("session.record", "registration").unwrap(),
OperationContext::new("worker_startup", "binding").unwrap(),
OperationContext::new("worker_startup", "runtime").unwrap(),
]
);
assert!(projected.context().correlation.is_none());
assert!(projected.context().causes.is_empty());
assert!(projected.context().diagnostics.is_empty());
let encoded = encode_projected_failure(projected, budget()).unwrap();
let wire = std::str::from_utf8(encoded.as_bytes()).unwrap();
for secret in ["TEST_ONLY_SECRET", "/private/path", "raw_stage"] {
assert!(!wire.contains(secret));
}
}
#[test]
fn every_audience_allowlists_operation_stage_pairs_and_drops_references() {
let mut facts = FailureFacts::new(SessionRegistrationCause::binding_mismatch()).unwrap();
for (operation, stage) in [
("session.record", "authorization"),
("session.record", "registration"),
("worker_startup", "binding"),
("worker_startup", "runtime"),
("session.record", "binding"),
("worker_startup", "authorization"),
("/private/path", "registration"),
("session.record", "raw_stage"),
] {
let mut context = OperationContext::new(operation, stage).unwrap();
context.operation_ref = Some("TEST_ONLY_SECRET operation reference".into());
facts.with_context(context).unwrap();
}
facts
.set_correlation(CorrelationRef::new("TEST_ONLY_SECRET correlation").unwrap())
.unwrap();
facts
.with_causal_link(CausalLink {
domain: "TEST_ONLY_SECRET domain".into(),
reason: "TEST_ONLY_SECRET reason".into(),
correlation: CorrelationRef::new("TEST_ONLY_SECRET causal reference").unwrap(),
})
.unwrap();
let allowed = SessionFailureVisibility {
can_view_cause: true,
};
assert_redacted(&project_failure::<InternalTransfer, _>(&facts, &()).unwrap());
assert_redacted(&project_failure::<Storage, _>(&facts, &()).unwrap());
assert_redacted(&project_failure::<Browser, _>(&facts, &allowed).unwrap());
assert_redacted(&project_failure::<Tool, _>(&facts, &allowed).unwrap());
// Projection selects output without mutating the owner's facts.
assert_eq!(facts.context().operations.len(), 8);
assert!(
facts
.context()
.operations
.iter()
.all(|context| context.operation_ref.is_some())
);
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Startup diagnostics are observations, never lifecycle, ownership, or retry authority.
//!
//! Attachments contain a domain's explicitly projected error-contract envelope. Receivers
//! decode through the existing codec, and must project again for a different audience;
//! deserializing stored bytes never creates a disclosure capability. Unknown and corrupt
//! attachments remain readable alongside metadata instead of breaking its lifecycle fields.
use error_contract::{
Audience, Budget, ContractError, DecodeFailure, ProjectedFailure, WireDetails, decode_failure,
encode_projected_failure,
};
use serde::{Deserialize, Serialize};
/// A bounded, projected envelope at the Worker/Runtime or metadata boundary.
/// No constructor accepts Display text, arbitrary JSON fields, or an unprojected error.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct StartupFailureAttachment(String);
impl std::fmt::Debug for StartupFailureAttachment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StartupFailureAttachment")
.finish_non_exhaustive()
}
}
impl StartupFailureAttachment {
pub fn from_projected<A: Audience, D: WireDetails>(
failure: &ProjectedFailure<A, D>,
) -> Result<Self, ContractError> {
let encoded = encode_projected_failure(failure, Self::budget())?;
String::from_utf8(encoded.as_bytes().to_vec())
.map(Self)
.map_err(|_| ContractError::Encoding)
}
/// Missing attachments are represented by the enclosing Option, not a fabricated cause.
pub fn decode<D: WireDetails>(&self) -> DecodeFailure<D> {
decode_failure(self.0.as_bytes(), Self::budget())
}
/// Redisclosure requires a fresh domain/audience projection and current authorization.
/// Unsupported or corrupt stored bytes are never forwarded as public details.
pub fn project_for<A: Audience, D: WireDetails + error_contract::ProjectFor<A>>(
&self,
authorization: &D::Authorization,
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
match self.decode::<D>() {
DecodeFailure::Known(received) => {
error_contract::project_failure(received.facts(), authorization)
}
DecodeFailure::Unsupported(_) | DecodeFailure::Corrupt(_) => {
Err(ContractError::ProjectionUnavailable)
}
}
}
fn budget() -> Budget {
Budget::new(
error_contract::MAX_FAILURE_BYTES,
error_contract::MAX_FAILURE_BYTES,
)
}
}
/// Storage encoding cannot be constructed by relabeling an internal-transfer attachment.
/// A loaded value remains untrusted evidence; publication still requires `project_for`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct StoredStartupFailureAttachment(StartupFailureAttachment);
impl StoredStartupFailureAttachment {
pub fn from_projected<D: WireDetails>(
failure: &ProjectedFailure<error_contract::Storage, D>,
) -> Result<Self, ContractError> {
StartupFailureAttachment::from_projected(failure).map(Self)
}
pub fn decode<D: WireDetails>(&self) -> DecodeFailure<D> {
self.0.decode()
}
pub fn project_for<A: Audience, D: WireDetails + error_contract::ProjectFor<A>>(
&self,
authorization: &D::Authorization,
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
self.0.project_for::<A, D>(authorization)
}
}
/// Owner-selected startup stage; independent of resource cleanup disposition.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupStage {
Preflight,
Metadata,
Reconstruction,
Recovery,
Binding,
Feature,
ActivationEffects,
Controller,
}
/// Safe local Runtime cause when no more specific domain attachment was supplied.
/// The primary cause is selected where the failure occurs, not parsed from its diagnostic.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RuntimeStartupCause {
pub stage: StartupStage,
pub reason: RuntimeStartupReason,
}
/// Coarse local causes do not claim a domain authorization verdict.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum RuntimeStartupReason {
StartupFailed,
SegmentEmpty,
SystemPromptMissing,
MetadataPending,
MetadataMissing,
MetadataInactive,
ManifestMissing,
AllocationFailed,
SessionReadFailed,
MetadataReadFailed,
ProviderFailed,
AttributionFailed,
FeatureFailed,
}
impl error_contract::FailureCause for RuntimeStartupCause {
fn identity(&self) -> error_contract::FailureIdentity {
error_contract::FailureIdentity {
domain: "worker_startup",
reason: "failed",
category: error_contract::Category::Internal,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
Ok(())
}
}
impl WireDetails for RuntimeStartupCause {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "worker_startup" && reason == "failed" && version == 1
}
}
// This cause contains only an enum. Arbitrary provider messages, IO paths and credentials
// cannot enter its projection. Domain-specific causes must supply their own projector.
macro_rules! project_runtime {
($($audience:ty),*) => {$(
impl error_contract::ProjectFor<$audience> for RuntimeStartupCause {
type Authorization = ();
type Details = Self;
fn disclose(&self, facts: &error_contract::FailureFacts<Self>, _: &())
-> Result<error_contract::Disclosure<Self>, ContractError>
{
let mut disclosure = error_contract::Disclosure::new(self.clone(), "Worker startup failed.");
// Only fixed owner-selected operation/stage labels are published here.
for context in &facts.context().operations {
if context.operation == "worker_startup" && context.stage == "runtime" {
disclosure.context.operations.push(context.clone());
if let Some(last) = disclosure.context.operations.last_mut() {
last.operation_ref = None;
}
}
}
Ok(disclosure)
}
}
)*};
}
project_runtime!(
error_contract::InternalTransfer,
error_contract::Storage,
error_contract::Browser
);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupFailurePhase {
Cleanup,
RuntimeRecordDelete,
RuntimeSnapshotSave,
RuntimeRemovalPublish,
/// Retaining create reconciliation observations in the canonical Worker record.
RuntimeReconciliationRecordSave,
/// Publishing a retained record to Runtime subscribers; not a durable write.
RuntimeReconciliationPublish,
MetadataSave,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupCleanupFailure {
Ownership,
Workdir,
Artifacts,
Allocation,
Feature,
Unsupported,
}
/// A separate secondary fact, not a replacement primary cause or a causal-chain link.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupSecondaryFailure {
ProjectionUnavailable {
phase: StartupFailurePhase,
},
Cleanup {
failure: StartupCleanupFailure,
},
Persistence {
phase: StartupFailurePhase,
outcome: StartupPersistenceOutcome,
},
}
/// Persisted is evidence only for this attachment/record write, not execution completion.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupPersistenceOutcome {
Persisted,
NotSaved,
Conflict,
OutcomeUnknown,
}
/// Independent resource observations from the existing execution owner. These diagnostics
/// never promote a result to RolledBack or authorize replay; the backend disposition does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupResources {
None,
WorkerStartup,
Unproven,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupCleanup {
NotNeeded,
Required,
Confirmed,
Failed(StartupCleanupFailure),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct StartupResourceFacts {
pub resources: StartupResources,
pub cleanup: StartupCleanup,
pub durable_effects_possible: bool,
}
/// Path-free IO classification. Nonportable OS details remain at the resource owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum StartupIoKind {
NotFound,
PermissionDenied,
AlreadyExists,
InvalidInput,
InvalidData,
Interrupted,
TimedOut,
WouldBlock,
UnexpectedEof,
Other,
}
impl From<std::io::ErrorKind> for StartupIoKind {
fn from(kind: std::io::ErrorKind) -> Self {
use std::io::ErrorKind;
match kind {
ErrorKind::NotFound => Self::NotFound,
ErrorKind::PermissionDenied => Self::PermissionDenied,
ErrorKind::AlreadyExists => Self::AlreadyExists,
ErrorKind::InvalidInput => Self::InvalidInput,
ErrorKind::InvalidData => Self::InvalidData,
ErrorKind::Interrupted => Self::Interrupted,
ErrorKind::TimedOut => Self::TimedOut,
ErrorKind::WouldBlock => Self::WouldBlock,
ErrorKind::UnexpectedEof => Self::UnexpectedEof,
_ => Self::Other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use error_contract::{
Browser, FailureFacts, InternalTransfer, OperationContext, Storage, project_failure,
};
#[test]
fn startup_projection_preserves_allowlisted_facts_without_secret_context() {
let mut facts = FailureFacts::new(RuntimeStartupCause {
stage: StartupStage::Binding,
reason: RuntimeStartupReason::AttributionFailed,
})
.unwrap();
let mut context = OperationContext::new("worker_startup", "runtime").unwrap();
context.operation_ref = Some("TEST_ONLY_CREDENTIAL".into());
facts.with_context(context).unwrap();
facts
.with_context(OperationContext::new("/private/internal/path", "raw_http_body").unwrap())
.unwrap();
let transfer = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
let attachment = StartupFailureAttachment::from_projected(&transfer).unwrap();
let storage_projection = attachment
.project_for::<Storage, RuntimeStartupCause>(&())
.unwrap();
let stored = StoredStartupFailureAttachment::from_projected(&storage_projection).unwrap();
let browser = stored
.project_for::<Browser, RuntimeStartupCause>(&())
.unwrap();
assert_eq!(browser.details(), facts.cause());
assert_eq!(browser.context().operations.len(), 1);
assert_eq!(browser.context().operations[0].operation_ref, None);
let public =
encode_projected_failure(&browser, StartupFailureAttachment::budget()).unwrap();
let wire = std::str::from_utf8(public.as_bytes()).unwrap();
for secret in [
"TEST_ONLY_CREDENTIAL",
"/private/internal/path",
"raw_http_body",
] {
assert!(!wire.contains(secret));
assert!(!serde_json::to_string(&stored).unwrap().contains(secret));
}
assert!(matches!(
stored.decode::<RuntimeStartupCause>(),
DecodeFailure::Known(_)
));
}
#[test]
fn unsupported_and_corrupt_startup_attachments_cannot_be_redisclosed() {
let facts = FailureFacts::new(RuntimeStartupCause {
stage: StartupStage::Controller,
reason: RuntimeStartupReason::StartupFailed,
})
.unwrap();
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
let attachment = StartupFailureAttachment::from_projected(&projected).unwrap();
let future =
StartupFailureAttachment(attachment.0.replace("\"version\":1", "\"version\":999"));
assert!(matches!(
future.decode::<RuntimeStartupCause>(),
DecodeFailure::Unsupported(_)
));
assert!(
future
.project_for::<Browser, RuntimeStartupCause>(&())
.is_err()
);
let corrupt = StartupFailureAttachment("{invalid TEST_ONLY_SECRET".into());
assert!(matches!(
corrupt.decode::<RuntimeStartupCause>(),
DecodeFailure::Corrupt(_)
));
assert!(
corrupt
.project_for::<Browser, RuntimeStartupCause>(&())
.is_err()
);
assert!(!format!("{corrupt:?}").contains("TEST_ONLY_SECRET"));
}
}
+13
View File
@@ -64,6 +64,19 @@ pub fn generated_protocol_types() -> String {
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n", // Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n",
); );
push_decl::<crate::startup_failure::StartupFailureAttachment>(&cfg, &mut output);
push_decl::<crate::startup_failure::StoredStartupFailureAttachment>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupStage>(&cfg, &mut output);
push_decl::<crate::startup_failure::RuntimeStartupReason>(&cfg, &mut output);
push_decl::<crate::startup_failure::RuntimeStartupCause>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupFailurePhase>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupCleanupFailure>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupPersistenceOutcome>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupSecondaryFailure>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupResources>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupCleanup>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupResourceFacts>(&cfg, &mut output);
push_decl::<crate::startup_failure::StartupIoKind>(&cfg, &mut output);
push_decl::<AlertLevel>(&cfg, &mut output); push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertPosition>(&cfg, &mut output); push_decl::<AlertPosition>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output); push_decl::<AlertSource>(&cfg, &mut output);
+6 -2
View File
@@ -6,10 +6,14 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
api-macros = { workspace = true, features = ["reqwest", "axum"] } api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
manifest.workspace = true manifest.workspace = true
plugin-plan = { path = "../plugin-plan" } plugin-plan = { path = "../plugin-plan" }
protocol.workspace = true protocol = { workspace = true, features = ["json-schema"] }
error-contract = { workspace = true, features = ["json-schema"] }
schemars.workspace = true
sha2.workspace = true
serde_json.workspace = true
session-store.workspace = true session-store.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
workdir.workspace = true workdir.workspace = true
+1
View File
@@ -8,3 +8,4 @@ HTTP contract for Worker Runtime management. Owns REST DTOs and generated client
- [`WorkerRestoreRequest`] / [`WorkerRestoreCoordinationRequest`] — Restore intent and Backend preparation contracts - [`WorkerRestoreRequest`] / [`WorkerRestoreCoordinationRequest`] — Restore intent and Backend preparation contracts
- [`LogicalWorkdirAttachment`] — Logical Workdir attachments determined by Workspace - [`LogicalWorkdirAttachment`] — Logical Workdir attachments determined by Workspace
- [`BackendJobExecutionBinding`] — Job/attempt execution identity fixed by the Host - [`BackendJobExecutionBinding`] — Job/attempt execution identity fixed by the Host
- [`startup`] — projected startup Problem Details, independent operation observations and bounded receiver normalization
+95 -1
View File
@@ -1,5 +1,7 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
pub mod startup;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use api_macros::api; use api_macros::api;
@@ -10,6 +12,13 @@ use protocol::{CompletionEntry, CompletionKind, Segment, WorkerId, WorkerStateSn
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary}; use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary};
pub use protocol::startup_failure::{
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
StartupFailureAttachment, StartupFailurePhase, StartupIoKind, StartupPersistenceOutcome,
StartupResourceFacts, StartupResources, StartupSecondaryFailure, StartupStage,
StoredStartupFailureAttachment,
};
pub const RUNTIME_API_VERSION: &str = "v1"; pub const RUNTIME_API_VERSION: &str = "v1";
pub const RUNTIME_API_BASE_PATH: &str = "/v1"; pub const RUNTIME_API_BASE_PATH: &str = "/v1";
/// Runtime transport classification for a materialized Repository source. /// Runtime transport classification for a materialized Repository source.
@@ -96,12 +105,30 @@ pub struct RuntimeApiError {
pub error: RuntimeApiErrorDetail, pub error: RuntimeApiErrorDetail,
#[serde(skip)] #[serde(skip)]
status: u16, status: u16,
/// Local owner/normalized observations, never a second wire attachment.
#[serde(skip)]
create_failure: Option<startup::WorkerCreateFailure>,
}
/// Transient, allowlisted startup observations copied from the execution owner.
/// This is not an operation result, lifecycle record, or permission to retry.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerStartupFailure {
pub primary: StartupFailureAttachment,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secondary: Vec<StartupSecondaryFailure>,
pub resources: Option<StartupResourceFacts>,
pub persistence: Option<StartupPersistenceOutcome>,
pub io_kind: Option<StartupIoKind>,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RuntimeApiErrorDetail { pub struct RuntimeApiErrorDetail {
pub code: String, pub code: String,
pub message: String, pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup: Option<WorkerStartupFailure>,
} }
impl RuntimeApiError { impl RuntimeApiError {
@@ -110,20 +137,40 @@ impl RuntimeApiError {
error: RuntimeApiErrorDetail { error: RuntimeApiErrorDetail {
code: code.into(), code: code.into(),
message: message.into(), message: message.into(),
startup: None,
}, },
status, status,
create_failure: None,
} }
} }
pub fn status(&self) -> u16 { pub fn status(&self) -> u16 {
self.status self.status
} }
/// Retain owner-selected facts locally; the HTTP hook projects them afresh.
pub fn with_create_failure(mut self, failure: startup::WorkerCreateFailure) -> Self {
self.create_failure = Some(failure);
self
}
pub fn create_failure(&self) -> Option<&startup::WorkerCreateFailure> {
self.create_failure.as_ref()
}
pub fn into_create_failure(self) -> Option<startup::WorkerCreateFailure> {
self.create_failure
}
} }
impl api_macros::HttpError for RuntimeApiError { impl api_macros::HttpError for RuntimeApiError {
fn status_code(&self) -> u16 { fn status_code(&self) -> u16 {
self.status self.status
} }
fn custom_response(&self) -> Option<api_macros::HttpErrorResponse> {
startup::http::runtime_error_response(self)
}
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -1282,7 +1329,7 @@ pub struct SubjectJobRemovalPurgeResponse {
pub input_fingerprint: String, pub input_fingerprint: String,
} }
#[api(reqwest, axum)] #[api(reqwest, axum, client_error_hook)]
pub trait RuntimeApi { pub trait RuntimeApi {
#[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)] #[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)]
async fn purge_subject_job_removal( async fn purge_subject_job_removal(
@@ -1901,6 +1948,25 @@ mod tests {
} }
async fn get_worker(&self, worker_id: String) -> Result<WorkerResponse, RuntimeApiError> { async fn get_worker(&self, worker_id: String) -> Result<WorkerResponse, RuntimeApiError> {
if worker_id == "startup" {
use startup::*;
let failure = WorkerCreateFailure::from_cause(
StartupCause::Session(
protocol::session_failure::SessionRegistrationCause::denied(
protocol::session_failure::SessionPrincipal::Subject,
),
),
StartupOperationFacts {
acceptance: CreationAcceptance::Unknown,
execution: ExecutionOutcome::Unknown,
finalize: None,
backend_compensation: None,
correlation: None,
},
)
.unwrap();
return Err(test_error(500).with_create_failure(failure));
}
assert_eq!(worker_id, "missing worker"); assert_eq!(worker_id, "missing worker");
Err(test_error(404)) Err(test_error(404))
} }
@@ -2210,6 +2276,34 @@ mod tests {
assert!( assert!(
matches!(error, client_support::ClientError::Public { status, .. } if status.as_u16() == 404) matches!(error, client_support::ClientError::Public { status, .. } if status.as_u16() == 404)
); );
let error = client.get_worker("startup".to_string()).await.unwrap_err();
let client_support::ClientError::Public { status, error } = error else {
panic!("expected a normalized public startup failure");
};
assert_eq!(status.as_u16(), 403);
assert_eq!(error.status(), 403);
let failure = error
.create_failure()
.expect("hook retains non-HTTP observations");
assert_eq!(
failure.operation().acceptance,
startup::CreationAcceptance::Unknown
);
let browser = failure
.problem(
startup::StartupBoundary::Browser,
&startup::SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
assert_eq!(browser.status, 409);
assert!(
std::str::from_utf8(browser.body())
.unwrap()
.contains("session.record")
);
server.abort(); server.abort();
} }
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
//! Runtime API HTTP integration. No HTTP status is converted into execution facts.
use api_macros::reqwest::{ClientFailure, HttpClientError, ReceivedResponse};
use error_contract::{DecodeFailure, InternalTransfer};
use serde::Deserialize;
use super::*;
use crate::RuntimeApiError;
fn unknown_operation() -> StartupOperationFacts {
StartupOperationFacts {
acceptance: CreationAcceptance::Unknown,
execution: ExecutionOutcome::Unknown,
finalize: None,
backend_compensation: None,
correlation: None,
}
}
/// RuntimeManagementApi invokes this only after checking authenticated Runtime
/// scope. That scope permits the minimum public-safe cause schema, not arbitrary
/// internal-transfer details. Resolve the public projection afresh on each call;
/// no stored/received projection is relabeled as Browser authorization.
pub(crate) fn runtime_error_response(
error: &RuntimeApiError,
) -> Option<api_macros::HttpErrorResponse> {
let legacy;
let failure = match error.create_failure() {
Some(failure) => failure,
None => {
let startup = error.error.startup.as_ref()?;
legacy = WorkerCreateFailure::new(startup.clone(), unknown_operation());
match &legacy {
Ok(failure) => failure,
Err(_) => return Some(static_fallback()),
}
}
};
let visibility = SessionFailureVisibility {
can_view_cause: true,
};
Some(
match failure.problem(StartupBoundary::Runtime, &visibility, "") {
Ok(response) => api_macros::HttpErrorResponse {
status: response.status,
content_type: response.content_type,
body: response.into_body().into(),
},
// Never expose the legacy/raw attachment if projection cannot be made.
Err(_) => static_fallback(),
},
)
}
fn static_fallback() -> api_macros::HttpErrorResponse {
api_macros::HttpErrorResponse {
status: 500,
content_type: PROBLEM_CONTENT_TYPE,
body: error_contract::problem::STATIC_PROBLEM_FALLBACK
.to_vec()
.into(),
}
}
fn normalized_error(
status: u16,
failure: WorkerCreateFailure,
) -> Result<RuntimeApiError, ClientFailure> {
// Received text and legacy labels are not sources of local explanations.
// InternalTransfer is an internal observation policy, not Browser authority.
let projected = failure
.startup()
.primary
.project_for::<InternalTransfer, StartupCause>(&())
.map_err(|_| ClientFailure::ErrorResponseDecode { status })?;
let identity = projected.identity();
let mut error = RuntimeApiError::new(
status,
format!("{}.{}", identity.domain, identity.reason),
projected.explanation(),
);
error.error.startup = Some(failure.startup().clone());
Ok(error.with_create_failure(failure))
}
fn local_observation(
status: u16,
reason: StartupBoundaryReason,
) -> Result<RuntimeApiError, ClientFailure> {
let failure = WorkerCreateFailure::from_cause(
StartupCause::Boundary(StartupBoundaryCause { reason }),
unknown_operation(),
)
.map_err(|_| ClientFailure::ErrorResponseDecode { status })?;
normalized_error(status, failure)
}
// A deliberately narrow legacy schema. Bounded typed parsing avoids arbitrary JSON
// allocation; startup attachment strings remain opaque until domain codec validation.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyError {
error: LegacyDetail,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyDetail {
code: String,
message: String,
startup: Option<WorkerStartupFailure>,
}
impl HttpClientError for RuntimeApiError {
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure> {
let status = response.status().as_u16();
if !(400..=599).contains(&status) {
return Err(ClientFailure::ErrorResponseDecode { status });
}
// The generated client has already bounded collection; enforce this
// smaller contract limit independently before any schema allocation.
if response.body().len() > MAX_STARTUP_PROBLEM_BYTES {
return local_observation(status, StartupBoundaryReason::TooLarge);
}
let content_type = match response.content_type() {
Ok(content_type) => content_type,
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
};
let media_type = content_type.split(';').next().unwrap_or("").trim();
if media_type.eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE) {
return match decode_startup_problem(
response.body(),
status,
content_type,
StartupBoundary::Runtime,
) {
DecodeStartupProblem::Known(failure) => normalized_error(status, failure),
DecodeStartupProblem::UnsupportedVersion => {
local_observation(status, StartupBoundaryReason::UnsupportedVersion)
}
DecodeStartupProblem::UnsupportedReason => {
local_observation(status, StartupBoundaryReason::UnsupportedReason)
}
DecodeStartupProblem::Invalid => {
local_observation(status, StartupBoundaryReason::InvalidResponse)
}
DecodeStartupProblem::TooLarge => {
local_observation(status, StartupBoundaryReason::TooLarge)
}
};
}
if !media_type.eq_ignore_ascii_case("application/json") {
return local_observation(status, StartupBoundaryReason::InvalidResponse);
}
// The typed legacy parser has fixed nesting and rejects unknown fields.
// Its total allocation/opaque attachment size is capped by the byte limit.
let legacy = match serde_json::from_slice::<LegacyError>(response.body()) {
Ok(legacy) => legacy.error,
Err(_) => return local_observation(status, StartupBoundaryReason::Decode),
};
if error_contract::validate_identifier(&legacy.code).is_err()
|| error_contract::validate_explanation(&legacy.message).is_err()
{
return local_observation(status, StartupBoundaryReason::InvalidResponse);
}
let Some(mut startup) = legacy.startup else {
// Preserve ordinary legacy errors exactly, including their actual status.
return Ok(RuntimeApiError::new(status, legacy.code, legacy.message));
};
let projected = match startup.primary.decode::<StartupCause>() {
DecodeFailure::Known(received) => match error_contract::project_failure::<
InternalTransfer,
_,
>(received.facts(), &())
{
Ok(projected) => projected,
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
},
DecodeFailure::Corrupt(error_contract::ContractError::TooLarge) => {
return local_observation(status, StartupBoundaryReason::TooLarge);
}
// The legacy attachment codec intentionally exposes no unsupported
// metadata. Do not guess a reason/version from that opaque evidence.
_ => return local_observation(status, StartupBoundaryReason::InvalidResponse),
};
startup.primary = match StartupFailureAttachment::from_projected(&projected) {
Ok(primary) => primary,
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
};
match WorkerCreateFailure::new(startup, unknown_operation()) {
Ok(failure) => normalized_error(status, failure),
Err(_) => local_observation(status, StartupBoundaryReason::InvalidResponse),
}
}
}
+162
View File
@@ -0,0 +1,162 @@
//! HTTP-independent snapshot of a Backend coordinator failure. This is a codec
//! for the existing reservation result, not a separate ledger or retry authority.
use super::*;
use crate::StoredStartupFailureAttachment;
use error_contract::{Storage, validate_json_input};
const STORAGE_VERSION: u16 = 1;
// Private DTO: only the concrete failure encoder may construct a stored snapshot.
// In particular no arbitrary error/source, HTTP response or private identity fits here.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredWorkerCreateFailure {
version: u16,
primary: StoredStartupFailureAttachment,
facts: StartupProblemFacts,
}
// Inspect versions from original input before attempting to understand future facts.
// IgnoredAny still checks JSON grammar without allocating arbitrary extension trees.
#[derive(Deserialize)]
struct StorageMetadata {
version: u16,
#[serde(rename = "primary")]
_primary: serde::de::IgnoredAny,
facts: ExtensionMetadata,
}
impl ProjectFor<Storage> for StartupBoundaryCause {
type Authorization = ();
type Details = Self;
fn disclose(&self, _: &FailureFacts<Self>, _: &()) -> Result<Disclosure<Self>, ContractError> {
// The boundary domain stores only its local enum, never received context.
Ok(Disclosure::new(*self, boundary_explanation(self)))
}
}
impl ProjectFor<Storage> for StartupCause {
type Authorization = ();
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
_: &(),
) -> Result<Disclosure<Self>, ContractError> {
match self {
Self::Session(cause) => {
let concrete = domain_facts(*cause, facts)?;
let disclosed = <SessionRegistrationCause as ProjectFor<Storage>>::disclose(
cause,
&concrete,
&(),
)?;
Ok(wrap(disclosed, Self::Session, session_explanation(cause)))
}
Self::Runtime(cause) => {
let concrete = domain_facts(cause.clone(), facts)?;
let disclosed =
<RuntimeStartupCause as ProjectFor<Storage>>::disclose(cause, &concrete, &())?;
Ok(wrap(
disclosed,
Self::Runtime,
runtime_explanation(cause).into(),
))
}
Self::Boundary(cause) => {
let concrete = domain_facts(*cause, facts)?;
let disclosed =
<StartupBoundaryCause as ProjectFor<Storage>>::disclose(cause, &concrete, &())?;
Ok(wrap(
disclosed,
Self::Boundary,
boundary_explanation(cause).into(),
))
}
}
}
}
impl WorkerCreateFailure {
/// Encode a bounded version-1 storage snapshot through each concrete domain's
/// Storage allowlist. Never encodes or parses an HTTP body. The caller selects
/// the Backend persistence marker (including prospective Persisted for an
/// atomic reservation write); encoding alone never proves persistence.
pub fn encode_storage(&self) -> Result<String, ContractError> {
let facts = self.public_facts();
facts.validate()?;
let projected = self
.startup
.primary
.project_for::<Storage, StartupCause>(&())?;
let dto = StoredWorkerCreateFailure {
version: STORAGE_VERSION,
primary: StoredStartupFailureAttachment::from_projected(&projected)?,
facts,
};
let encoded = serde_json::to_string(&dto).map_err(|_| ContractError::Encoding)?;
// Includes escaped attachment bytes and the entire enclosing snapshot.
validate_json_input(encoded.as_bytes(), budget())?;
Ok(encoded)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DecodeStoredWorkerCreateFailure {
Known(WorkerCreateFailure),
Unsupported,
Corrupt(ContractError),
}
/// Read a bounded storage snapshot without an HTTP classification or boundary.
/// Unsupported/corrupt evidence never becomes a fabricated primary cause, and a
/// Known result still needs current visibility for any later public projection.
/// Missing legacy columns are represented by the store's Option, not this codec.
pub fn decode_stored_worker_create_failure(input: &str) -> DecodeStoredWorkerCreateFailure {
match decode_checked(input) {
Ok(value) => value,
Err(error) => DecodeStoredWorkerCreateFailure::Corrupt(error),
}
}
fn decode_checked(input: &str) -> Result<DecodeStoredWorkerCreateFailure, ContractError> {
use DecodeStoredWorkerCreateFailure::*;
// Run the shared lexical guard over the whole input before any JSON parsing.
validate_json_input(input.as_bytes(), budget())?;
let metadata: StorageMetadata =
serde_json::from_str(input).map_err(|_| ContractError::Invalid)?;
if metadata.version == 0 || metadata.facts.version == 0 {
return Err(ContractError::Invalid);
}
if metadata.version != STORAGE_VERSION || metadata.facts.version != STARTUP_FACTS_VERSION {
return Ok(Unsupported);
}
// Original-byte typed decoding rejects duplicates rather than collapsing a Value.
let dto: StoredWorkerCreateFailure =
serde_json::from_str(input).map_err(|_| ContractError::InvalidDetails)?;
dto.facts.validate()?;
let received = match dto.primary.decode::<StartupCause>() {
DecodeFailure::Known(received) => received,
DecodeFailure::Unsupported(_) => return Ok(Unsupported),
DecodeFailure::Corrupt(error) => return Err(error),
};
// Deserialization is not a capability. Project again instead of relabeling or
// retaining raw loaded attachment bytes (including injected explanations/context).
let projected = project_failure::<Storage, _>(received.facts(), &())?;
let primary = StartupFailureAttachment::from_projected(&projected)?;
let facts = dto.facts;
let failure = WorkerCreateFailure::new(
WorkerStartupFailure {
primary,
secondary: facts.secondary,
resources: facts.resources,
persistence: facts.persistence,
io_kind: facts.io_kind,
},
facts.operation,
)?
.with_backend_result_persistence(facts.backend_result_persistence);
Ok(Known(failure))
}
@@ -0,0 +1 @@
{"type":"urn:yoi:problem:dependency","title":"Dependency failure","status":502,"detail":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"outcome_unknown","category":"unknown","details_version":1,"details":{"reason":"outcome_unknown","principal":"unknown","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}}
@@ -0,0 +1 @@
{"type":"urn:yoi:problem:conflict","title":"Operation conflict","status":409,"detail":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"permission_denied","category":"authorization","details_version":1,"details":{"reason":"permission_denied","principal":"subject","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}}
@@ -0,0 +1,58 @@
{
"detail": "Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.",
"instance": "/workers/create/fixture-812",
"status": 409,
"title": "Operation conflict",
"type": "urn:yoi:problem:conflict",
"yoi_failure": {
"category": "authorization",
"context": {
"causes": [],
"correlation": null,
"diagnostics": [],
"operations": [],
"supplementary_omitted": false
},
"details": {
"principal": "subject",
"reason": "permission_denied",
"required_operation": "session.record"
},
"details_version": 1,
"domain": "session_registration",
"explanation": "Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.",
"reason": "permission_denied",
"required_extensions": [],
"version": 1
},
"yoi_startup": {
"io_kind": "permission_denied",
"operation": {
"acceptance": "rejected",
"backend_compensation": null,
"correlation": "startup-fixture-812",
"execution": "rejected",
"finalize": null
},
"persistence": "outcome_unknown",
"resources": {
"cleanup": {
"failed": "workdir"
},
"durable_effects_possible": true,
"resources": "worker_startup"
},
"secondary": [
{
"failure": "workdir",
"kind": "cleanup"
},
{
"kind": "persistence",
"outcome": "outcome_unknown",
"phase": "metadata_save"
}
],
"version": 1
}
}
@@ -0,0 +1,918 @@
//! Producer/consumer checks against the production constructor and decoder.
use error_contract::problem::ApiBoundary as _;
use error_contract::{
Browser, ContractError, DecodeFailure, FailureCause, FailureFacts, InternalTransfer,
OperationContext, ProjectFor, WireDetails, project_failure,
};
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
use runtime_api::startup::*;
use runtime_api::{
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
StartupFailureAttachment, StartupFailurePhase, StartupIoKind, StartupPersistenceOutcome,
StartupResourceFacts, StartupResources, StartupSecondaryFailure, StartupStage,
WorkerStartupFailure,
};
use serde_json::{Value, json};
fn visible() -> SessionFailureVisibility {
SessionFailureVisibility {
can_view_cause: true,
}
}
fn operation(acceptance: CreationAcceptance, execution: ExecutionOutcome) -> StartupOperationFacts {
StartupOperationFacts {
acceptance,
execution,
finalize: None,
backend_compensation: None,
correlation: Some(StartupCorrelation::new("startup-fixture-812").unwrap()),
}
}
fn session() -> StartupCause {
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
}
fn failure(cause: StartupCause) -> WorkerCreateFailure {
WorkerCreateFailure::from_cause(
cause,
operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown),
)
.unwrap()
}
fn decode(value: &Value, status: u16, boundary: StartupBoundary) -> DecodeStartupProblem {
decode_startup_problem(
&serde_json::to_vec(value).unwrap(),
status,
PROBLEM_CONTENT_TYPE,
boundary,
)
}
fn known(result: DecodeStartupProblem) -> WorkerCreateFailure {
match result {
DecodeStartupProblem::Known(failure) => failure,
other => panic!("{other:?}"),
}
}
fn fixture_failure() -> WorkerCreateFailure {
let base = failure(session());
let (mut startup, _) = base.into_parts();
startup.secondary = vec![
StartupSecondaryFailure::Cleanup {
failure: StartupCleanupFailure::Workdir,
},
StartupSecondaryFailure::Persistence {
phase: StartupFailurePhase::MetadataSave,
outcome: StartupPersistenceOutcome::OutcomeUnknown,
},
];
startup.resources = Some(StartupResourceFacts {
resources: StartupResources::WorkerStartup,
cleanup: StartupCleanup::Failed(StartupCleanupFailure::Workdir),
durable_effects_possible: true,
});
startup.persistence = Some(StartupPersistenceOutcome::OutcomeUnknown);
startup.io_kind = Some(StartupIoKind::PermissionDenied);
WorkerCreateFailure::new(
startup,
operation(CreationAcceptance::Rejected, ExecutionOutcome::Rejected),
)
.unwrap()
}
#[test]
fn production_public_fixture_is_stable_and_consumable() {
let failure = fixture_failure();
let response = failure
.problem(
StartupBoundary::Browser,
&visible(),
"/workers/create/fixture-812",
)
.unwrap();
assert_eq!(response.status, 409);
assert_eq!(response.content_type, PROBLEM_CONTENT_TYPE);
let value: Value = serde_json::from_slice(response.body()).unwrap();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/startup-session-record-denied.problem.json");
let fixture: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(
value, fixture,
"fixture must be generated by the public production constructor"
);
let decoded = known(decode(&fixture, 409, StartupBoundary::Browser));
assert_eq!(decoded.operation(), failure.operation());
assert_eq!(decoded.public_facts(), failure.public_facts());
assert_eq!(
decoded
.startup()
.primary
.decode::<StartupCause>()
.known_cause(),
session()
);
assert!(fixture["detail"].as_str().unwrap().contains("Subject"));
assert!(
fixture["detail"]
.as_str()
.unwrap()
.contains("session.record")
);
assert!(fixture["detail"].as_str().unwrap().contains("Worker"));
assert!(fixture["yoi_startup"].get("primary").is_none());
}
/// Captured from the real signed Session registration → Runtime → Browser tests
/// in workspace-server. These are observed operation facts, not a declaration
/// that an HTTP conflict rejected creation or that successful cleanup erased effects.
#[test]
fn connected_browser_fixtures_preserve_observed_operation_and_storage_facts() {
for (body, status, cause) in [
(
include_bytes!("fixtures/startup-browser-session-record-denied.problem.json")
.as_slice(),
409,
SessionRegistrationCause::denied(SessionPrincipal::Subject),
),
(
include_bytes!("fixtures/startup-browser-registration-response-lost.problem.json")
.as_slice(),
502,
SessionRegistrationCause::outcome_unknown(),
),
] {
let failure = known(decode_startup_problem(
body,
status,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser,
));
assert_eq!(
failure
.startup()
.primary
.decode::<StartupCause>()
.known_cause(),
StartupCause::Session(cause)
);
assert_eq!(failure.operation().acceptance, CreationAcceptance::Unknown);
assert_eq!(failure.operation().execution, ExecutionOutcome::Errored);
assert_eq!(
failure.operation().backend_compensation,
Some(CompensationOutcome::Unknown)
);
assert_eq!(
failure.startup().persistence,
Some(StartupPersistenceOutcome::Persisted)
);
assert_eq!(
failure.backend_result_persistence(),
Some(StartupPersistenceOutcome::Persisted)
);
let encoded = failure.encode_storage().unwrap();
let DecodeStoredWorkerCreateFailure::Known(saved) =
decode_stored_worker_create_failure(&encoded)
else {
panic!("connected result must survive HTTP-independent storage");
};
assert_eq!(saved.public_facts(), failure.public_facts());
let response = saved
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
assert_eq!(response.status, status);
let original: Value = serde_json::from_slice(body).unwrap();
let projected: Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(projected["detail"], original["detail"]);
assert_eq!(projected["yoi_startup"], original["yoi_startup"]);
}
}
// A tiny test-only adapter so assertions never format raw received data.
trait CauseFromDecode {
fn known_cause(self) -> StartupCause;
}
impl CauseFromDecode for DecodeFailure<StartupCause> {
fn known_cause(self) -> StartupCause {
match self {
DecodeFailure::Known(received) => received.facts().cause().clone(),
_ => panic!("expected known cause"),
}
}
}
#[test]
fn original_domain_wire_identity_and_details_have_no_union_wrapper() {
for cause in [
session(),
StartupCause::Runtime(RuntimeStartupCause {
stage: StartupStage::Binding,
reason: RuntimeStartupReason::AttributionFailed,
}),
StartupCause::Boundary(StartupBoundaryCause {
reason: StartupBoundaryReason::Transport,
}),
] {
let response = failure(cause.clone())
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let value: Value = serde_json::from_slice(response.body()).unwrap();
let id = cause.identity();
assert_eq!(value["yoi_failure"]["domain"], id.domain);
assert_eq!(value["yoi_failure"]["reason"], id.reason);
assert_eq!(value["yoi_failure"]["details_version"], id.details_version);
assert_eq!(
value["yoi_failure"]["details"],
serde_json::to_value(&cause).unwrap()
);
assert!(value["yoi_failure"]["details"].get("Session").is_none());
assert!(StartupCause::supports(
id.domain,
id.reason,
id.details_version
));
let decoded = known(decode(&value, response.status, StartupBoundary::Browser));
assert_eq!(
decoded
.startup()
.primary
.decode::<StartupCause>()
.known_cause(),
cause
);
}
}
#[test]
fn browser_session_projection_requires_current_visibility_even_after_receipt() {
let failure = failure(session());
assert_eq!(
failure
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility::default(),
""
)
.unwrap_err(),
ContractError::InvalidDetails
);
let wire = failure
.problem(StartupBoundary::Runtime, &visible(), "")
.unwrap();
let received = known(decode_startup_problem(
wire.body(),
wire.status,
wire.content_type,
StartupBoundary::Runtime,
));
assert!(
received
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility::default(),
""
)
.is_err()
);
assert_eq!(
received
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap()
.status,
409
);
}
#[test]
fn boundary_classification_is_explicit_not_category_text_or_inner_status() {
let cases = [
(
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Operator)),
403,
403,
),
(session(), 409, 403),
(
StartupCause::Session(SessionRegistrationCause::attribution_missing()),
409,
409,
),
(
StartupCause::Session(SessionRegistrationCause::binding_mismatch()),
409,
409,
),
(
StartupCause::Session(SessionRegistrationCause::transport()),
502,
502,
),
(
StartupCause::Session(SessionRegistrationCause::outcome_unknown()),
502,
502,
),
(
StartupCause::Session(SessionRegistrationCause::invalid_response()),
502,
502,
),
];
for (cause, browser, runtime) in cases {
assert_eq!(
StartupBoundary::Browser.classify(&cause).status.code(),
browser
);
assert_eq!(
StartupBoundary::Runtime.classify(&cause).status.code(),
runtime
);
}
for (reason, status) in [
(StartupBoundaryReason::Timeout, 504),
(StartupBoundaryReason::Transport, 502),
(StartupBoundaryReason::Decode, 502),
(StartupBoundaryReason::InvalidResponse, 502),
(StartupBoundaryReason::TooLarge, 502),
(StartupBoundaryReason::UnsupportedVersion, 502),
(StartupBoundaryReason::UnsupportedReason, 502),
(StartupBoundaryReason::FinalizeFailed, 500),
] {
let cause = StartupCause::Boundary(StartupBoundaryCause { reason });
for boundary in [StartupBoundary::Browser, StartupBoundary::Runtime] {
assert_eq!(boundary.classify(&cause).status.code(), status);
}
}
for (reason, status) in [
(RuntimeStartupReason::SegmentEmpty, 400),
(RuntimeStartupReason::SystemPromptMissing, 400),
(RuntimeStartupReason::MetadataPending, 409),
(RuntimeStartupReason::MetadataMissing, 409),
(RuntimeStartupReason::MetadataInactive, 409),
(RuntimeStartupReason::ManifestMissing, 409),
(RuntimeStartupReason::AllocationFailed, 503),
(RuntimeStartupReason::SessionReadFailed, 500),
(RuntimeStartupReason::MetadataReadFailed, 500),
(RuntimeStartupReason::ProviderFailed, 500),
(RuntimeStartupReason::AttributionFailed, 500),
(RuntimeStartupReason::FeatureFailed, 500),
(RuntimeStartupReason::StartupFailed, 500),
] {
let cause = StartupCause::Runtime(RuntimeStartupCause {
stage: StartupStage::Controller,
reason,
});
for boundary in [StartupBoundary::Browser, StartupBoundary::Runtime] {
assert_eq!(boundary.classify(&cause).status.code(), status);
}
}
}
#[test]
fn all_operation_facts_remain_independent_including_unknown_and_finalize() {
for acceptance in [
CreationAcceptance::Rejected,
CreationAcceptance::Accepted,
CreationAcceptance::Unknown,
] {
for execution in [
ExecutionOutcome::Accepted,
ExecutionOutcome::Busy,
ExecutionOutcome::Rejected,
ExecutionOutcome::Errored,
ExecutionOutcome::Unsupported,
ExecutionOutcome::Unknown,
] {
for compensation in [
CompensationOutcome::Completed,
CompensationOutcome::Residual,
CompensationOutcome::Unknown,
] {
let (startup, _) = fixture_failure().into_parts();
let mut operation = operation(acceptance, execution);
operation.finalize = Some(FinalizeFacts {
stage: FinalizeStage::WorkerRegistry,
compensation,
});
let original = WorkerCreateFailure::new(startup, operation).unwrap();
let response = original
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
assert_eq!(response.status, 409); // primary cause only, not outcomes/cleanup
let received = known(decode_startup_problem(
response.body(),
response.status,
response.content_type,
StartupBoundary::Browser,
));
assert_eq!(received.public_facts(), original.public_facts());
}
}
}
}
#[test]
fn transport_unknown_never_becomes_rejected_or_accepted_from_http_or_hints() {
let response = failure(StartupCause::Boundary(StartupBoundaryCause {
reason: StartupBoundaryReason::Timeout,
}))
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
assert_eq!(response.status, 504);
let mut value: Value = serde_json::from_slice(response.body()).unwrap();
value["retry_after"] = json!(0);
value["accepted"] = json!(false); // unrecognized RFC extension is not outcome authority
let decoded = known(decode(&value, 504, StartupBoundary::Browser));
assert_eq!(decoded.operation().acceptance, CreationAcceptance::Unknown);
assert_eq!(decoded.operation().execution, ExecutionOutcome::Unknown);
value["yoi_startup"]["operation"]
.as_object_mut()
.unwrap()
.remove("acceptance");
assert_eq!(
decode(&value, 504, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
}
#[test]
fn received_text_and_context_are_reprojected_not_forwarded() {
let original = failure(session())
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let mut value: Value = serde_json::from_slice(original.body()).unwrap();
let secret = "RAW_UPSTREAM_CREDENTIAL_OR_PATH";
value["detail"] = json!(secret);
value["yoi_failure"]["explanation"] = json!(secret);
value["yoi_failure"]["context"]["diagnostics"] =
json!([{ "code":"raw", "explanation":secret }]);
value["yoi_failure"]["context"]["operations"] = json!([
{"operation":"session.record","stage":"registration","operation_ref":secret},
{"operation":secret,"stage":"raw","operation_ref":secret}]);
value["yoi_failure"]["context"]["correlation"] = json!(secret);
let received = known(decode(&value, 409, StartupBoundary::Browser));
let public = received
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let text = std::str::from_utf8(public.body()).unwrap();
assert!(!text.contains(secret));
assert!(text.contains("Subject"));
assert!(text.contains("session.record"));
assert_eq!(public.status, original.status);
let (mut startup, operation) = received.into_parts();
startup.secondary.reverse();
assert_eq!(
WorkerCreateFailure::new(startup, operation)
.unwrap()
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap()
.status,
409
);
}
#[test]
fn concrete_domain_allowlists_filter_projection_context() {
let mut facts = FailureFacts::new(session()).unwrap();
let mut allowed = OperationContext::new("session.record", "authorization").unwrap();
allowed.operation_ref = Some("DO_NOT_PUBLISH".into());
facts.with_context(allowed).unwrap();
facts
.with_context(OperationContext::new("DO_NOT_PUBLISH", "raw").unwrap())
.unwrap();
let projected = project_failure::<Browser, _>(&facts, &visible()).unwrap();
assert_eq!(projected.context().operations.len(), 1);
assert_eq!(projected.context().operations[0].operation_ref, None);
assert!(
<StartupCause as ProjectFor<InternalTransfer>>::disclose(facts.cause(), &facts, &())
.is_ok()
);
}
#[test]
fn unknown_version_reason_and_bad_envelopes_are_distinct() {
let response = fixture_failure()
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let original: Value = serde_json::from_slice(response.body()).unwrap();
for field in ["version", "details_version"] {
let mut value = original.clone();
value["yoi_failure"][field] = json!(77);
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::UnsupportedVersion
);
}
let mut value = original.clone();
value["yoi_startup"]["version"] = json!(77);
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::UnsupportedVersion
);
value = original.clone();
value["yoi_failure"]["required_extensions"] = json!(["future-contract"]);
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::UnsupportedVersion
);
value = original.clone();
value["yoi_failure"]["reason"] = json!("future_reason");
value["yoi_failure"]["category"] = json!("future_category");
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::UnsupportedReason
);
value = original.clone();
value["yoi_failure"]["domain"] = json!("future_domain");
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::UnsupportedReason
);
for mutate in ["category", "domain", "reason"] {
let mut value = original.clone();
// Another KNOWN identity with mismatching typed details must be invalid.
value["yoi_failure"][mutate] = match mutate {
"category" => json!("internal"),
"domain" => json!("startup_boundary"),
_ => json!("transport"),
};
let result = decode(&value, 409, StartupBoundary::Browser);
if mutate != "domain" {
assert_eq!(result, DecodeStartupProblem::Invalid);
} else {
assert_eq!(result, DecodeStartupProblem::UnsupportedReason);
}
}
for field in ["version", "details_version"] {
let mut value = original.clone();
value["yoi_failure"][field] = json!(0);
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
}
}
#[test]
fn decoder_rejects_incoherence_bad_shapes_duplicate_fields_and_limits() {
let response = failure(session())
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let original: Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(
decode_startup_problem(
response.body(),
403,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::Invalid
);
assert_eq!(
decode_startup_problem(
response.body(),
409,
"application/json",
StartupBoundary::Browser
),
DecodeStartupProblem::Invalid
);
assert!(matches!(
decode_startup_problem(
response.body(),
409,
"Application/Problem+Json; charset=utf-8",
StartupBoundary::Browser
),
DecodeStartupProblem::Known(_)
));
assert_eq!(
decode_startup_problem(
response.body(),
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Runtime
),
DecodeStartupProblem::Invalid
);
for (field, replacement) in [
("status", json!(502)),
("type", json!("urn:wrong")),
("title", json!("raw upstream")),
("detail", json!("different")),
("instance", json!("%zz")),
] {
let mut value = original.clone();
value[field] = replacement;
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
}
for field in ["yoi_failure", "yoi_startup"] {
let mut value = original.clone();
value.as_object_mut().unwrap().remove(field);
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
}
let text = std::str::from_utf8(response.body()).unwrap();
for (needle, duplicated) in [
("\"status\":409", "\"status\":409,\"status\":409"),
(
"\"acceptance\":\"unknown\"",
"\"acceptance\":\"unknown\",\"acceptance\":\"rejected\"",
),
(
"\"principal\":\"subject\"",
"\"principal\":\"subject\",\"principal\":\"operator\"",
),
] {
let duplicate = text.replace(needle, duplicated);
assert_ne!(duplicate, text);
assert_eq!(
decode_startup_problem(
duplicate.as_bytes(),
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::Invalid
);
}
let oversized = vec![b' '; MAX_STARTUP_PROBLEM_BYTES + 1];
assert_eq!(
decode_startup_problem(
&oversized,
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::TooLarge
);
let deep = format!("{}0{}", "[".repeat(1000), "]".repeat(1000));
assert_eq!(
decode_startup_problem(
deep.as_bytes(),
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::Invalid
);
for bytes in [b"not json".as_slice(), b"\xff".as_slice(), b"{}".as_slice()] {
assert_eq!(
decode_startup_problem(bytes, 409, PROBLEM_CONTENT_TYPE, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
}
}
#[test]
fn safe_correlation_and_extension_validation_cannot_be_bypassed_by_serde() {
for unsafe_value in [
"",
"path/to/file",
"https://host",
"Bearer secret",
"query?token=secret",
"control\n",
] {
assert!(StartupCorrelation::new(unsafe_value).is_err());
assert!(serde_json::from_value::<StartupCorrelation>(json!(unsafe_value)).is_err());
}
assert!(StartupCorrelation::new("a".repeat(129)).is_err());
let failure = fixture_failure();
let mut facts = failure.public_facts();
facts.secondary = vec![
StartupSecondaryFailure::Cleanup {
failure: StartupCleanupFailure::Workdir
};
9
];
assert!(facts.validate().is_err());
let (mut startup, operation) = failure.into_parts();
startup.secondary = facts.secondary;
assert!(WorkerCreateFailure::new(startup, operation).is_err());
let response = fixture_failure()
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let mut value: Value = serde_json::from_slice(response.body()).unwrap();
value["yoi_startup"]["operation"]["correlation"] = json!("/private/path");
assert_eq!(
decode(&value, 409, StartupBoundary::Browser),
DecodeStartupProblem::Invalid
);
assert!(
fixture_failure()
.problem(StartupBoundary::Browser, &visible(), "invalid%uri")
.is_err()
);
}
#[test]
fn unknown_or_corrupt_internal_attachments_are_retained_but_not_publishable() {
for raw in ["{invalid SECRET", "{}"] {
let primary: StartupFailureAttachment = serde_json::from_value(json!(raw)).unwrap();
let failure = WorkerCreateFailure::new(
WorkerStartupFailure {
primary,
secondary: vec![],
resources: None,
persistence: None,
io_kind: None,
},
operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown),
)
.unwrap();
assert!(
failure
.problem(StartupBoundary::Browser, &visible(), "")
.is_err()
);
assert!(!format!("{failure:?}").contains("SECRET"));
}
}
#[test]
fn unsupported_envelopes_still_reject_duplicate_safe_metadata() {
let response = failure(session())
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let text = std::str::from_utf8(response.body())
.unwrap()
.replace("\"version\":1", "\"version\":77");
assert_eq!(
decode_startup_problem(
text.as_bytes(),
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::UnsupportedVersion
);
for (needle, duplicate) in [
(
"\"reason\":\"permission_denied\"",
"\"reason\":\"permission_denied\",\"reason\":\"future\"",
),
(
"\"domain\":\"session_registration\"",
"\"domain\":\"session_registration\",\"domain\":\"future\"",
),
(
"\"category\":\"authorization\"",
"\"category\":\"authorization\",\"category\":\"future\"",
),
] {
let bytes = text.replace(needle, duplicate);
assert_ne!(bytes, text);
assert_eq!(
decode_startup_problem(
bytes.as_bytes(),
409,
PROBLEM_CONTENT_TYPE,
StartupBoundary::Browser
),
DecodeStartupProblem::Invalid
);
}
}
#[test]
fn backend_compensation_is_independent_of_finalize_runtime_cleanup_and_outcomes() {
for backend in [
None,
Some(CompensationOutcome::Completed),
Some(CompensationOutcome::Residual),
Some(CompensationOutcome::Unknown),
] {
for finalize in [
None,
Some(FinalizeFacts {
stage: FinalizeStage::WorkdirRegistry,
compensation: CompensationOutcome::Residual,
}),
] {
let (startup, mut operation) = fixture_failure().into_parts();
operation.backend_compensation = backend;
operation.finalize = finalize;
let original = WorkerCreateFailure::new(startup, operation).unwrap();
let response = original
.problem(StartupBoundary::Browser, &visible(), "")
.unwrap();
let received = known(decode_startup_problem(
response.body(),
response.status,
response.content_type,
StartupBoundary::Browser,
));
assert_eq!(received.public_facts(), original.public_facts());
assert_eq!(received.operation().backend_compensation, backend);
assert_eq!(received.operation().finalize, finalize);
}
}
for stage in [
FinalizeStage::TicketAssignmentBind,
FinalizeStage::TicketAssignmentCurrent,
FinalizeStage::TicketStateAccept,
FinalizeStage::WorkdirRegistry,
] {
assert_eq!(
serde_json::from_value::<FinalizeStage>(serde_json::to_value(stage).unwrap()).unwrap(),
stage
);
}
}
#[test]
fn telemetry_uses_typed_context_and_operation_correlation_not_attachment_identity() {
use error_contract::{CorrelationRef, Telemetry, observation_fields};
let mut facts = FailureFacts::new(session()).unwrap();
let mut context = OperationContext::new("session.record", "registration").unwrap();
context.operation_ref = Some("PRIVATE_OPERATION_ID".into());
facts.with_context(context).unwrap();
facts
.with_context(OperationContext::new("SECRET_CONTEXT", "SECRET_STAGE").unwrap())
.unwrap();
facts
.set_correlation(CorrelationRef::new("UNTRUSTED_ATTACHMENT_ID").unwrap())
.unwrap();
let projected = project_failure::<Telemetry, _>(&facts, &()).unwrap();
let fields = observation_fields(&projected);
assert_eq!(fields.domain, "session_registration");
assert_eq!(fields.reason, "permission_denied");
assert_eq!(fields.operation, Some("worker_startup"));
assert_eq!(fields.stage, Some("binding"));
assert_eq!(fields.correlation, Some("UNTRUSTED_ATTACHMENT_ID"));
assert!(
projected
.context()
.operations
.iter()
.all(|op| op.operation_ref.is_none())
);
let transfer = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
let startup = WorkerStartupFailure {
primary: StartupFailureAttachment::from_projected(&transfer).unwrap(),
secondary: vec![],
resources: None,
persistence: None,
io_kind: None,
};
let mut operation = operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown);
operation.correlation = Some(StartupCorrelation::new("fresh-backend-correlation").unwrap());
let failure = WorkerCreateFailure::new(startup, operation).unwrap();
let telemetry = failure.telemetry().unwrap();
assert_eq!(
observation_fields(&telemetry).correlation,
Some("fresh-backend-correlation")
);
assert_eq!(telemetry.identity(), session().identity());
let encoded = error_contract::encode_projected_failure(
&telemetry,
error_contract::Budget::new(32768, 32768),
)
.unwrap();
let text = std::str::from_utf8(encoded.as_bytes()).unwrap();
for secret in [
"PRIVATE_OPERATION_ID",
"SECRET_CONTEXT",
"SECRET_STAGE",
"UNTRUSTED_ATTACHMENT_ID",
] {
assert!(!text.contains(secret));
}
let (startup, mut operation) = failure.into_parts();
operation.correlation = None;
assert_eq!(
observation_fields(
&WorkerCreateFailure::new(startup, operation)
.unwrap()
.telemetry()
.unwrap()
)
.correlation,
None
);
}
#[test]
fn public_dto_schema_is_accessible_for_generator() {
let schema = schemars::schema_for!(StartupProblemDetails);
let value = serde_json::to_value(schema).unwrap();
for field in [
"type",
"title",
"status",
"detail",
"instance",
"yoi_failure",
"yoi_startup",
] {
assert!(value["properties"].get(field).is_some(), "{field}");
}
let schema_text = serde_json::to_string(&value).unwrap();
assert!(!schema_text.contains("StartupFailureAttachment"));
assert!(schema_text.contains("CreationAcceptance"));
assert!(schema_text.contains("ExecutionOutcome"));
}
+313
View File
@@ -0,0 +1,313 @@
//! Actual generated RuntimeApi client error hook with bounded HTTP metadata/bytes.
use api_macros::HttpError as _;
use error_contract::{DecodeFailure, FailureCause};
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
use runtime_api::startup::*;
use runtime_api::{RuntimeApiClient, RuntimeApiError, client_support, server_support};
use serde_json::{Value, json};
fn failure() -> WorkerCreateFailure {
WorkerCreateFailure::from_cause(
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject)),
StartupOperationFacts {
acceptance: CreationAcceptance::Accepted,
execution: ExecutionOutcome::Errored,
finalize: Some(FinalizeFacts {
stage: FinalizeStage::TicketAssignmentBind,
compensation: CompensationOutcome::Unknown,
}),
backend_compensation: Some(CompensationOutcome::Residual),
correlation: Some(StartupCorrelation::new("backend-local-812").unwrap()),
},
)
.unwrap()
}
fn cause(error: &RuntimeApiError) -> StartupCause {
match error
.create_failure()
.unwrap()
.startup()
.primary
.decode::<StartupCause>()
{
DecodeFailure::Known(received) => received.facts().cause().clone(),
_ => panic!("expected locally typed cause"),
}
}
fn assert_unknown(error: &RuntimeApiError, reason: StartupBoundaryReason) {
let operation = error.create_failure().unwrap().operation();
assert_eq!(operation.acceptance, CreationAcceptance::Unknown);
assert_eq!(operation.execution, ExecutionOutcome::Unknown);
assert_eq!(operation.finalize, None);
assert_eq!(operation.backend_compensation, None);
assert_eq!(
cause(error),
StartupCause::Boundary(StartupBoundaryCause { reason })
);
}
async fn receive(status: u16, types: Vec<&'static str>, body: Vec<u8>) -> RuntimeApiError {
use server_support::framework::{Body, Response, Router, get};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let router = Router::new().route(
"/v1/workers/{worker_id}",
get(move || {
let types = types.clone();
let body = body.clone();
async move {
let mut response = Response::builder().status(status);
for content_type in types {
response = response.header("content-type", content_type);
}
response.body(Body::from(body)).unwrap()
}
}),
);
let server = tokio::spawn(async move {
server_support::framework::serve(listener, router)
.await
.unwrap();
});
let client = RuntimeApiClient::builder(&format!("http://{address}"))
.unwrap()
.request_timeout(std::time::Duration::from_secs(3))
.build()
.unwrap();
let result = client.get_worker("test".into()).await;
server.abort();
match result {
Err(client_support::ClientError::Public {
status: actual,
error,
}) => {
assert_eq!(actual.as_u16(), status);
assert_eq!(error.status(), status);
error
}
_ => panic!("expected normalized public error"),
}
}
#[test]
fn runtime_error_projects_public_schema_fresh_and_keeps_local_facts_off_serde() {
let failure = failure();
let error = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
.with_create_failure(failure.clone());
let legacy = serde_json::to_value(&error).unwrap();
assert!(legacy.get("create_failure").is_none());
let reparsed: RuntimeApiError = serde_json::from_value(legacy).unwrap();
assert!(reparsed.create_failure().is_none());
let response = error.custom_response().unwrap();
assert_eq!(response.status, 403); // runtime boundary is not Browser's 409
assert_eq!(response.content_type, PROBLEM_CONTENT_TYPE);
let text = std::str::from_utf8(response.body.as_ref()).unwrap();
assert!(!text.contains("PRIVATE_CODE"));
assert!(!text.contains("PRIVATE_MESSAGE"));
let decoded = match decode_startup_problem(
response.body.as_ref(),
response.status,
response.content_type,
StartupBoundary::Runtime,
) {
DecodeStartupProblem::Known(failure) => failure,
_ => panic!("expected known public-safe Runtime failure"),
};
assert_eq!(decoded.public_facts(), failure.public_facts());
assert_eq!(error.into_create_failure(), Some(failure));
let ordinary = RuntimeApiError::new(404, "not_found", "not found");
assert!(ordinary.custom_response().is_none());
}
#[test]
fn invalid_projection_uses_static_fallback_not_legacy_raw_serialization() {
let (mut startup, operation) = failure().into_parts();
startup.primary = serde_json::from_value(json!("RAW_PRIVATE_CREDENTIAL")).unwrap();
let error = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
.with_create_failure(WorkerCreateFailure::new(startup, operation).unwrap());
let response = error.custom_response().unwrap();
assert_eq!(response.status, 500);
assert_eq!(
response.body.as_ref(),
error_contract::problem::STATIC_PROBLEM_FALLBACK
);
let mut legacy = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE");
legacy.error.startup = error
.create_failure()
.map(|failure| failure.startup().clone());
assert_eq!(
legacy.custom_response().unwrap().body.as_ref(),
error_contract::problem::STATIC_PROBLEM_FALLBACK
);
}
#[tokio::test]
async fn generated_client_normalizes_known_problem_and_actual_metadata() {
let original = failure();
let response = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
.with_create_failure(original.clone())
.custom_response()
.unwrap();
let mut value: Value = serde_json::from_slice(response.body.as_ref()).unwrap();
value["detail"] = json!("WIRE_SECRET_EXPLANATION");
value["yoi_failure"]["explanation"] = json!("WIRE_SECRET_EXPLANATION");
let error = receive(
response.status,
vec![PROBLEM_CONTENT_TYPE],
serde_json::to_vec(&value).unwrap(),
)
.await;
assert_eq!(
error.create_failure().unwrap().public_facts(),
original.public_facts()
);
assert_eq!(
cause(&error).identity(),
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
.identity()
);
assert!(!error.error.message.contains("WIRE_SECRET"));
let browser = error
.create_failure()
.unwrap()
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
assert_eq!(browser.status, 409);
let wrong_status = receive(
409,
vec![PROBLEM_CONTENT_TYPE],
serde_json::to_vec(&value).unwrap(),
)
.await;
assert_unknown(&wrong_status, StartupBoundaryReason::InvalidResponse);
}
#[tokio::test]
async fn unknown_versions_reasons_and_invalid_responses_remain_local_unknown_observations() {
let response = failure()
.problem(
StartupBoundary::Runtime,
&SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
let original: Value = serde_json::from_slice(response.body()).unwrap();
for (field, replacement, reason) in [
(
"version",
json!(99),
StartupBoundaryReason::UnsupportedVersion,
),
(
"details_version",
json!(99),
StartupBoundaryReason::UnsupportedVersion,
),
(
"reason",
json!("future_reason"),
StartupBoundaryReason::UnsupportedReason,
),
(
"category",
json!("invalid_category"),
StartupBoundaryReason::InvalidResponse,
),
] {
let mut value = original.clone();
value["yoi_failure"][field] = replacement;
let error = receive(
403,
vec![PROBLEM_CONTENT_TYPE],
serde_json::to_vec(&value).unwrap(),
)
.await;
assert_unknown(&error, reason);
}
for content_types in [
vec![],
vec!["text/plain"],
vec![PROBLEM_CONTENT_TYPE, "application/json"],
] {
let error = receive(403, content_types, response.body().to_vec()).await;
assert_unknown(&error, StartupBoundaryReason::InvalidResponse);
}
let error = receive(
502,
vec![PROBLEM_CONTENT_TYPE],
vec![b'x'; MAX_STARTUP_PROBLEM_BYTES + 1],
)
.await;
assert_unknown(&error, StartupBoundaryReason::TooLarge);
let error = receive(
502,
vec![PROBLEM_CONTENT_TYPE],
b"not JSON PRIVATE_SECRET".to_vec(),
)
.await;
assert_unknown(&error, StartupBoundaryReason::InvalidResponse);
assert!(!error.error.message.contains("PRIVATE_SECRET"));
}
#[tokio::test]
async fn legacy_errors_are_bounded_and_startup_attachments_reprojected_without_outcome_inference() {
let ordinary = RuntimeApiError::new(404, "old_not_found", "old not found");
let error = receive(
404,
vec!["application/json"],
serde_json::to_vec(&ordinary).unwrap(),
)
.await;
assert_eq!(error.error, ordinary.error);
assert!(error.create_failure().is_none());
let original = failure();
let mut legacy = RuntimeApiError::new(502, "OLD_PRIVATE_CODE", "OLD_PRIVATE_MESSAGE");
legacy.error.startup = Some(original.startup().clone());
let error = receive(
502,
vec!["application/json; charset=utf-8"],
serde_json::to_vec(&legacy).unwrap(),
)
.await;
assert_eq!(
cause(&error).identity(),
cause(&RuntimeApiError::new(500, "unused", "unused").with_create_failure(original.clone()))
.identity()
);
let failure = error.create_failure().unwrap();
assert_eq!(failure.operation().acceptance, CreationAcceptance::Unknown);
assert_eq!(failure.operation().execution, ExecutionOutcome::Unknown);
assert_eq!(failure.startup().secondary, original.startup().secondary);
assert!(!error.error.message.contains("OLD_PRIVATE"));
let mut value = serde_json::to_value(&legacy).unwrap();
value["error"]["startup"]["primary"] = json!("PRIVATE_MALFORMED_ATTACHMENT");
let malformed = receive(
502,
vec!["application/json"],
serde_json::to_vec(&value).unwrap(),
)
.await;
assert_unknown(&malformed, StartupBoundaryReason::InvalidResponse);
let invalid = receive(
502,
vec!["application/json"],
b"{invalid PRIVATE_SECRET".to_vec(),
)
.await;
assert_unknown(&invalid, StartupBoundaryReason::Decode);
let oversized = receive(
502,
vec!["application/json"],
vec![b'x'; MAX_STARTUP_PROBLEM_BYTES + 1],
)
.await;
assert_unknown(&oversized, StartupBoundaryReason::TooLarge);
}
+341
View File
@@ -0,0 +1,341 @@
//! Storage codecs use the production attachment projector, never an HTTP body.
use error_contract::{Budget, ContractError, DecodeFailure, validate_json_input};
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
use runtime_api::startup::*;
use runtime_api::{
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
StartupFailureAttachment, StartupIoKind, StartupPersistenceOutcome, StartupResourceFacts,
StartupResources, StartupSecondaryFailure, StartupStage, WorkerStartupFailure,
};
use serde_json::{Value, json};
fn operation() -> StartupOperationFacts {
StartupOperationFacts {
acceptance: CreationAcceptance::Accepted,
execution: ExecutionOutcome::Errored,
finalize: Some(FinalizeFacts {
stage: FinalizeStage::Publication,
compensation: CompensationOutcome::Residual,
}),
backend_compensation: Some(CompensationOutcome::Unknown),
correlation: Some(StartupCorrelation::new("storage-test-812").unwrap()),
}
}
fn failure(cause: StartupCause) -> WorkerCreateFailure {
let base = WorkerCreateFailure::from_cause(cause, operation()).unwrap();
let (mut startup, operation) = base.into_parts();
startup.secondary.push(StartupSecondaryFailure::Cleanup {
failure: StartupCleanupFailure::Workdir,
});
startup.resources = Some(StartupResourceFacts {
resources: StartupResources::WorkerStartup,
cleanup: StartupCleanup::Failed(StartupCleanupFailure::Workdir),
durable_effects_possible: true,
});
// Session metadata write and Backend result write deliberately disagree.
startup.persistence = Some(StartupPersistenceOutcome::NotSaved);
startup.io_kind = Some(StartupIoKind::PermissionDenied);
WorkerCreateFailure::new(startup, operation)
.unwrap()
.with_backend_result_persistence(Some(StartupPersistenceOutcome::Persisted))
}
fn session() -> StartupCause {
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
}
fn known(input: &str) -> WorkerCreateFailure {
match decode_stored_worker_create_failure(input) {
DecodeStoredWorkerCreateFailure::Known(failure) => failure,
other => panic!("expected known storage snapshot, got {other:?}"),
}
}
fn change_primary(value: &mut Value, change: impl FnOnce(&mut Value)) {
let mut primary: Value = serde_json::from_str(value["primary"].as_str().unwrap()).unwrap();
change(&mut primary);
value["primary"] = Value::String(serde_json::to_string(&primary).unwrap());
}
#[test]
fn storage_semantic_roundtrip_covers_domains_and_independent_facts() {
for cause in [
session(),
StartupCause::Runtime(RuntimeStartupCause {
stage: StartupStage::Feature,
reason: RuntimeStartupReason::FeatureFailed,
}),
StartupCause::Boundary(StartupBoundaryCause {
reason: StartupBoundaryReason::FinalizeFailed,
}),
] {
let original = failure(cause.clone());
let encoded = original.encode_storage().unwrap();
let value: Value = serde_json::from_str(&encoded).unwrap();
assert_eq!(value["version"], 1);
assert!(value.get("status").is_none());
assert!(value.get("yoi_failure").is_none());
let loaded = known(&encoded);
assert_eq!(loaded.public_facts(), original.public_facts());
assert_eq!(loaded.operation(), original.operation());
assert_eq!(
loaded.startup().persistence,
Some(StartupPersistenceOutcome::NotSaved)
);
assert_eq!(
loaded.backend_result_persistence(),
Some(StartupPersistenceOutcome::Persisted)
);
match loaded.startup().primary.decode::<StartupCause>() {
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), &cause),
_ => panic!("primary cause lost"),
}
assert_eq!(loaded.encode_storage().unwrap(), encoded);
}
}
#[test]
fn encoding_uses_current_marker_without_replacing_primary_or_inferring_persistence() {
let original = failure(session());
for marker in [
None,
Some(StartupPersistenceOutcome::Persisted),
Some(StartupPersistenceOutcome::NotSaved),
Some(StartupPersistenceOutcome::Conflict),
Some(StartupPersistenceOutcome::OutcomeUnknown),
] {
let updated = original.clone().with_backend_result_persistence(marker);
assert_eq!(updated.startup(), original.startup());
assert_eq!(updated.operation(), original.operation());
let loaded = known(&updated.encode_storage().unwrap());
assert_eq!(loaded.backend_result_persistence(), marker);
assert_eq!(loaded.startup().persistence, original.startup().persistence);
assert_eq!(loaded.public_facts(), updated.public_facts());
}
}
#[test]
fn legacy_absent_marker_is_none_in_storage_and_public_reader() {
let original = failure(session()).with_backend_result_persistence(None);
let mut value: Value = serde_json::from_str(&original.encode_storage().unwrap()).unwrap();
value["facts"]
.as_object_mut()
.unwrap()
.remove("backend_result_persistence");
assert_eq!(known(&value.to_string()).backend_result_persistence(), None);
let response = original
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
let public: Value = serde_json::from_slice(response.body()).unwrap();
assert!(
public["yoi_startup"]
.get("backend_result_persistence")
.is_none()
);
match decode_startup_problem(
response.body(),
response.status,
response.content_type,
StartupBoundary::Browser,
) {
DecodeStartupProblem::Known(failure) => {
assert_eq!(failure.backend_result_persistence(), None)
}
other => panic!("legacy public response failed: {other:?}"),
}
}
#[test]
fn public_receiver_restores_backend_marker_and_current_visibility_is_required() {
let original = failure(session());
let loaded = known(&original.encode_storage().unwrap());
assert!(
loaded
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility {
can_view_cause: false
},
""
)
.is_err()
);
let response = loaded
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
let received = match decode_startup_problem(
response.body(),
response.status,
response.content_type,
StartupBoundary::Browser,
) {
DecodeStartupProblem::Known(failure) => failure,
other => panic!("public response failed: {other:?}"),
};
assert_eq!(received.public_facts(), original.public_facts());
let mut updated = operation();
updated.acceptance = CreationAcceptance::Unknown;
let received = received.with_operation(updated.clone());
assert_eq!(received.operation(), &updated);
assert_eq!(
received.backend_result_persistence(),
Some(StartupPersistenceOutcome::Persisted)
);
// The receiving Backend clears remote evidence before its own write.
assert_eq!(
received
.with_backend_result_persistence(None)
.backend_result_persistence(),
None
);
}
#[test]
fn unknown_versions_and_primary_identities_are_unsupported() {
let base: Value = serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
for mutate in [
|v: &mut Value| v["version"] = json!(2),
|v: &mut Value| v["facts"]["version"] = json!(2),
|v: &mut Value| change_primary(v, |p| p["version"] = json!(2)),
|v: &mut Value| change_primary(v, |p| p["reason"] = json!("future_reason")),
|v: &mut Value| {
change_primary(v, |p| {
p["required_extensions"] = json!(["future_extension"])
})
},
] {
let mut value = base.clone();
mutate(&mut value);
assert_eq!(
decode_stored_worker_create_failure(&value.to_string()),
DecodeStoredWorkerCreateFailure::Unsupported
);
}
}
#[test]
fn malformed_and_duplicate_known_records_are_corrupt() {
let base: Value = serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
for input in [
"".to_owned(),
"null".to_owned(),
"{}".to_owned(),
"{".to_owned(),
base.to_string()
.replacen("\"version\":1", "\"version\":1,\"version\":1", 1),
] {
assert!(matches!(
decode_stored_worker_create_failure(&input),
DecodeStoredWorkerCreateFailure::Corrupt(_)
));
}
for mutate in [
|v: &mut Value| v["version"] = json!(0),
|v: &mut Value| v["primary"] = json!("{broken"),
|v: &mut Value| v["facts"]["backend_result_persistence"] = json!("invented"),
|v: &mut Value| {
v["facts"]["secondary"] = json!(vec![json!({"kind":"cleanup","failure":"workdir"}); 9])
},
|v: &mut Value| change_primary(v, |p| p["category"] = json!("internal")),
] {
let mut value = base.clone();
mutate(&mut value);
assert!(matches!(
decode_stored_worker_create_failure(&value.to_string()),
DecodeStoredWorkerCreateFailure::Corrupt(_)
));
}
}
#[test]
fn complete_input_and_embedded_attachment_are_bounded_before_parsing() {
let oversized = " ".repeat(32 * 1024 + 1);
assert_eq!(
decode_stored_worker_create_failure(&oversized),
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooLarge)
);
let deep = format!("{}0{}", "[".repeat(17), "]".repeat(17));
assert_eq!(
decode_stored_worker_create_failure(&deep),
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
);
// A supported-looking prefix must not bypass the whole-input guard.
let deep = format!("{{\"version\":2,\"extra\":{deep}}}");
assert_eq!(
decode_stored_worker_create_failure(&deep),
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
);
let mut value: Value =
serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
change_primary(&mut value, |p| {
p["extra"] =
serde_json::from_str(&format!("{}0{}", "[".repeat(17), "]".repeat(17))).unwrap()
});
assert_eq!(
decode_stored_worker_create_failure(&value.to_string()),
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
);
// Shared wrapper is a bounds check, not a grammar validator or encoder.
assert_eq!(validate_json_input(b"[", Budget::new(1024, 1024)), Ok(()));
}
#[test]
fn storage_encoder_and_reader_strip_untrusted_context_and_explanations() {
let original = failure(session());
let mut value: Value = serde_json::from_str(&original.encode_storage().unwrap()).unwrap();
change_primary(&mut value, |p| {
p["explanation"] = json!("TEST_ONLY_SECRET_RAW_HTTP");
p["context"]["correlation"] = json!("private-worker-id");
p["context"]["operations"] = json!([{
"operation":"session.record", "stage":"binding", "operation_ref":"TEST_ONLY_SECRET_RAW_HTTP"
}, {
"operation":"/private/path", "stage":"http-body", "operation_ref":null
}]);
});
// Simulate a received attachment without granting a projection capability.
let primary: StartupFailureAttachment =
serde_json::from_value(value["primary"].clone()).unwrap();
let (startup, operation) = original.clone().into_parts();
let incoming =
WorkerCreateFailure::new(WorkerStartupFailure { primary, ..startup }, operation).unwrap();
for encoded in [
incoming.encode_storage().unwrap(),
known(&value.to_string()).encode_storage().unwrap(),
] {
for secret in [
"TEST_ONLY_SECRET_RAW_HTTP",
"private-worker-id",
"/private/path",
"http-body",
] {
assert!(
!encoded.contains(secret),
"unexpected secret in stored snapshot"
);
}
let loaded = known(&encoded);
let response = loaded
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility {
can_view_cause: true,
},
"",
)
.unwrap();
assert!(!String::from_utf8_lossy(response.body()).contains("TEST_ONLY_SECRET"));
}
}
+3 -1
View File
@@ -10,7 +10,9 @@ default = []
typescript = ["dep:ts-rs", "protocol/typescript"] typescript = ["dep:ts-rs", "protocol/typescript"]
[dependencies] [dependencies]
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] } error-contract = { workspace = true, features = ["json-schema"] }
feature-permissions.workspace = true
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi", "error-contract"] }
memory.workspace = true memory.workspace = true
plugin-plan.workspace = true plugin-plan.workspace = true
merge-request.workspace = true merge-request.workspace = true
+1
View File
@@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API
- [`ServerApi`] — HTTP operations and transport metadata - [`ServerApi`] — HTTP operations and transport metadata
- [`ServerRequestContext`] — Request context passed to Server authentication adapters - [`ServerRequestContext`] — Request context passed to Server authentication adapters
- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation
- [`RequestActor`] — Human identity and authentication method - [`RequestActor`] — Human identity and authentication method
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result - [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
@@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
print!(
"{}",
server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?
);
Ok(())
}
@@ -0,0 +1,14 @@
#[cfg(feature = "typescript")]
fn main() {
print!(
"{}",
server_api::normalized_typescript(
server_api::configuration_adoption::configuration_typescript()
)
);
}
#[cfg(not(feature = "typescript"))]
fn main() {
eprintln!("generate_configuration_adoption_types requires --features typescript");
std::process::exit(2);
}
@@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
print!(
"{}",
server_api::feature_permissions::permission_contract_openapi()?.to_json()?
);
Ok(())
}
@@ -0,0 +1,192 @@
//! Workspace-owner source adoption transport. The Server authenticates the owner
//! before reading source, previewing, sealing migration data, adopting or reconciling.
//! A result lookup is not a new adoption and absence is not proof of failure.
use crate::{
ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse,
};
use feature_permissions::{PermissionAssignment, PermissionCatalog};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionRequest {
pub operation_id: String,
pub expected_head: Option<String>,
pub expected_catalog_digest: String,
pub expected_legacy_export: Option<String>,
pub source: ConfigCommitRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimePolicyApplication {
NextCurrentPolicyCheck,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionReceipt {
pub workspace_id: String,
pub operation_id: String,
pub history_head: String,
pub state: WorkspaceConfigTreeResponse,
pub source_stored: bool,
pub policy_committed: bool,
pub runtime_application: RuntimePolicyApplication,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationSubjectPolicy {
pub subject_id: String,
pub change_id: String,
pub execution: PermissionAssignment,
pub delegation: PermissionAssignment,
/// Exact retained domain restrictions, not editable payloads on permission keys.
pub legacy_constraints: Option<crate::authorization::SubjectPermissions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationMigrationDiagnostic {
Converted {
subject_id: String,
operation: String,
},
DomainConstraint {
subject_id: String,
permission: crate::authorization::ScopedPermission,
},
ExplicitConfigurationRequired {
subject_id: String,
permission: crate::authorization::ScopedPermission,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationSourceResponse {
/// Before the first adoption this is an editable, evaluated migration draft.
/// source_stored distinguishes it from the unchanged stored source.
pub state: WorkspaceConfigTreeResponse,
pub history_head: Option<String>,
pub source_stored: bool,
pub catalog: PermissionCatalog,
pub catalog_digest: String,
pub expected_legacy_export: Option<String>,
pub policies: Vec<ConfigurationSubjectPolicy>,
pub migration_diagnostics: Vec<ConfigurationMigrationDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationPreviewResponse {
pub expected_head: Option<String>,
pub state: WorkspaceConfigTreeResponse,
pub changed_paths: Vec<String>,
pub changed_subjects: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationReconciliation {
Adopted {
receipt: ConfigurationAdoptionReceipt,
},
PendingTransfer {
transfer_identity: String,
export_digest: String,
},
/// No matching evidence was observed. This is not permission to change the ID.
NotObserved,
}
#[api_macros::api(reqwest, axum, openapi)]
pub trait ConfigurationAdoptionApi {
#[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)]
async fn configuration_source(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<ConfigurationSourceResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_preview(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationPreviewResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_adopt(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationAdoptionReceipt, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_reconcile(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationReconciliation, RepositoryApiError>;
}
impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {}
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionRequest {}
impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {}
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {}
impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {}
pub fn configuration_contract_openapi()
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
use sha2::{Digest, Sha256};
let document = |source_digest: &str| {
configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo {
title: "Workspace configuration adoption",
version: env!("CARGO_PKG_VERSION"),
source_digest,
})
};
let normalized =
document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?
.to_json()?;
let digest: String = Sha256::digest(normalized.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
document(&format!("sha256:{digest}"))
}
#[cfg(feature = "typescript")]
pub fn configuration_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
ConfigurationAdoptionRequest::decl(&config),
RuntimePolicyApplication::decl(&config),
ConfigurationAdoptionReceipt::decl(&config),
ConfigurationSubjectPolicy::decl(&config),
ConfigurationMigrationDiagnostic::decl(&config),
ConfigurationSourceResponse::decl(&config),
ConfigurationPreviewResponse::decl(&config),
ConfigurationReconciliation::decl(&config),
];
format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse }} from \"./legacy-server-api.ts\";\nimport type {{ ScopedPermission, SubjectPermissions }} from \"./memory-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
@@ -0,0 +1,149 @@
//! Declaration/resolution management transport only. These generated routes are
//! intentionally not mounted by this crate. The Workspace authority must bind
//! authenticated context, read current policy and authorize management access
//! before implementing/mounting them; no input can issue a namespace handle.
use crate::{RepositoryApiError, ServerRequestContext};
use feature_permissions::types::{
PermissionCatalog, PermissionResolution, ValidatePermissionsRequest,
};
// Transparent wrappers keep the wire representation identical while the REST
// owner, not the resolver crate, attests to OpenAPI/Serde agreement.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(transparent)]
pub struct PermissionCatalogResponse(pub PermissionCatalog);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(transparent)]
pub struct PermissionValidationRequest(pub ValidatePermissionsRequest);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(transparent)]
pub struct PermissionResolutionResponse(pub PermissionResolution);
impl api_macros::openapi::OpenApiSchema for PermissionCatalogResponse {}
impl api_macros::openapi::OpenApiSchema for PermissionValidationRequest {}
impl api_macros::openapi::OpenApiSchema for PermissionResolutionResponse {}
#[api_macros::api(reqwest, axum, openapi)]
pub trait FeaturePermissionsApi {
#[get("/api/w/{workspace_id}/permission-catalog", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true)]
async fn permission_catalog(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<PermissionCatalogResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/permissions/validate", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn validate_permissions(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: PermissionValidationRequest,
) -> Result<PermissionResolutionResponse, RepositoryApiError>;
}
/// Deterministic standalone contract. Its source identity hashes the normalized
/// schema, not a deployment URL or another API's unrelated route inventory.
pub fn permission_contract_openapi()
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
use sha2::{Digest, Sha256};
let document = |source_digest: &str| {
feature_permissions_api_openapi(api_macros::openapi::OpenApiInfo {
title: "Feature permission declaration contract",
version: env!("CARGO_PKG_VERSION"),
source_digest,
})
};
let normalized =
document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?
.to_json()?;
let digest: String = Sha256::digest(normalized.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
document(&format!("sha256:{digest}"))
}
#[cfg(test)]
mod tests {
use super::*;
use feature_permissions::*;
/// Wire contract + actual resolver, not a mounted HTTP authorization test.
#[test]
fn management_wire_preserves_tagged_assignments_and_explicit_children_into_resolution() {
let catalog: PermissionCatalog = serde_json::from_str(include_str!(
"../../feature-permissions/tests/fixtures/catalog.json"
))
.unwrap();
let catalog: PermissionCatalogResponse = serde_json::from_slice(
&serde_json::to_vec(&PermissionCatalogResponse(catalog)).unwrap(),
)
.unwrap();
let catalog = catalog.0;
let mut host = PermissionHost::new(catalog.workspace.clone());
for namespace in catalog.namespaces {
let owner = host.register_feature(namespace.feature).unwrap();
let handle = host.bind_namespace(&owner, namespace.namespace).unwrap();
host.declare(&handle, namespace.declaration).unwrap();
}
let assignment: PermissionAssignment = serde_json::from_str(include_str!(
"../../feature-permissions/tests/fixtures/assignment.json"
))
.unwrap();
let request = PermissionValidationRequest(ValidatePermissionsRequest { assignment });
let request: PermissionValidationRequest =
serde_json::from_slice(&serde_json::to_vec(&request).unwrap()).unwrap();
let result = host.resolve(Some(&request.0.assignment));
let response: PermissionResolutionResponse = serde_json::from_slice(
&serde_json::to_vec(&PermissionResolutionResponse(result.projection().clone()))
.unwrap(),
)
.unwrap();
let response = response.0;
assert!(
response
.allowed
.contains_key(&PermissionKey::parse("ticket.hoge.fuga.finish").unwrap())
);
assert!(
!response
.allowed
.contains_key(&PermissionKey::parse("ticket.hoge.unlinked").unwrap())
);
assert!(
serde_json::from_value::<ValidatePermissionsRequest>(
serde_json::json!({"assignment":request.0.assignment, "workspace":"other"})
)
.is_err()
);
}
#[test]
fn generated_management_contract_publishes_payload_free_schema_and_context_bound_routes() {
let document = permission_contract_openapi().unwrap();
let document: serde_json::Value =
serde_json::from_str(&document.to_json().unwrap()).unwrap();
let checked_in: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../openapi/feature-permissions-api.json"),
)
.expect("run generate_feature_permissions_openapi"),
)
.unwrap();
assert_eq!(document, checked_in);
let paths = document["paths"].as_object().unwrap();
assert!(paths.contains_key("/api/w/{workspace_id}/permission-catalog"));
assert!(paths.contains_key("/api/w/{workspace_id}/permissions/validate"));
let schema =
serde_json::to_value(schemars::schema_for!(ValidatePermissionsRequest)).unwrap();
let serialized = serde_json::to_string(&schema).unwrap();
assert!(serialized.contains("namespace"));
assert!(serialized.contains("set"));
assert!(!serialized.contains("payload"));
assert_eq!(schema["additionalProperties"], false);
}
}
+30 -6
View File
@@ -1,4 +1,6 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
mod worker_create_error;
pub use worker_create_error::WorkerCreateApiError;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -8,7 +10,10 @@ pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization; pub mod authorization;
mod chat; mod chat;
pub mod configuration_adoption;
pub mod feature_permissions;
pub use chat::*; pub use chat::*;
pub use configuration_adoption::*;
mod subject_body; mod subject_body;
pub use subject_body::*; pub use subject_body::*;
mod chat_transport; mod chat_transport;
@@ -87,6 +92,10 @@ pub struct RepositoryApiError {
pub message: String, pub message: String,
#[serde(default)] #[serde(default)]
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
/// An origin-projected error-contract attachment, not raw HTTP error text.
/// Receiving it does not authorize redisclosure to a different audience.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<protocol::startup_failure::StartupFailureAttachment>,
#[serde(skip, default = "default_repository_error_status")] #[serde(skip, default = "default_repository_error_status")]
status: u16, status: u16,
} }
@@ -106,9 +115,18 @@ impl RepositoryApiError {
error: error.into(), error: error.into(),
message: message.into(), message: message.into(),
diagnostics, diagnostics,
failure: None,
status, status,
} }
} }
pub fn with_failure(
mut self,
failure: protocol::startup_failure::StartupFailureAttachment,
) -> Self {
self.failure = Some(failure);
self
}
} }
impl api_macros::HttpError for RepositoryApiError { impl api_macros::HttpError for RepositoryApiError {
@@ -1891,7 +1909,9 @@ pub trait ServerApi {
"/api/workers", "/api/workers",
status = 200, status = 200,
error_status = 400, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
additional_error_content_types = ["application/problem+json"],
client_error_hook = true,
bearer_auth = true, bearer_auth = true,
browser_auth = true browser_auth = true
)] )]
@@ -1899,7 +1919,7 @@ pub trait ServerApi {
&self, &self,
#[extension] context: ServerRequestContext, #[extension] context: ServerRequestContext,
#[body] request: CreateWorkspaceWorkerRequest, #[body] request: CreateWorkspaceWorkerRequest,
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>; ) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
#[get( #[get(
"/api/w/{workspace_id}/orchestrator", "/api/w/{workspace_id}/orchestrator",
@@ -1957,7 +1977,8 @@ pub trait ServerApi {
"/api/w/{workspace_id}/worker-control/workers", "/api/w/{workspace_id}/worker-control/workers",
status = 200, status = 200,
error_status = 400, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
client_error_hook = true,
openapi = false, openapi = false,
)] )]
async fn worker_control_spawn( async fn worker_control_spawn(
@@ -1965,7 +1986,7 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext, #[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
#[body] request: CreateWorkspaceWorkerRequest, #[body] request: CreateWorkspaceWorkerRequest,
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>; ) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
#[post( #[post(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input", "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input",
status = 200, status = 200,
@@ -2082,7 +2103,9 @@ pub trait ServerApi {
"/api/w/{workspace_id}/workers", "/api/w/{workspace_id}/workers",
status = 200, status = 200,
error_status = 400, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
additional_error_content_types = ["application/problem+json"],
client_error_hook = true,
bearer_auth = true, bearer_auth = true,
browser_auth = true browser_auth = true
)] )]
@@ -2091,7 +2114,7 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext, #[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
#[body] request: CreateWorkspaceWorkerRequest, #[body] request: CreateWorkspaceWorkerRequest,
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>; ) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
#[get( #[get(
"/api/w/{workspace_id}/workers/{worker_ref}", "/api/w/{workspace_id}/workers/{worker_ref}",
@@ -11083,6 +11106,7 @@ pub struct BrowserWorkspaceOrchestratorResponse {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerOperationState { pub enum WorkerOperationState {
Unknown,
Accepted, Accepted,
Unsupported, Unsupported,
Rejected, Rejected,
@@ -304,6 +304,7 @@ fn validate_worker_operations(
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/workers", path: "/api/workers",
method: "get", method: "get",
operation_id: "workspace_worker_list_alias", operation_id: "workspace_worker_list_alias",
@@ -311,6 +312,7 @@ fn validate_worker_operations(
successes: &[("200", "WorkerListResponse")], successes: &[("200", "WorkerListResponse")],
}, },
OperationContract { OperationContract {
error: "WorkerCreateApiError",
path: "/api/workers", path: "/api/workers",
method: "post", method: "post",
operation_id: "workspace_worker_create_alias", operation_id: "workspace_worker_create_alias",
@@ -318,6 +320,7 @@ fn validate_worker_operations(
successes: &[("200", "BrowserCreateWorkerResponse")], successes: &[("200", "BrowserCreateWorkerResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/orchestrator", path: "/api/w/{workspace_id}/orchestrator",
method: "get", method: "get",
operation_id: "workspace_orchestrator_status", operation_id: "workspace_orchestrator_status",
@@ -325,6 +328,7 @@ fn validate_worker_operations(
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/orchestrator", path: "/api/w/{workspace_id}/orchestrator",
method: "post", method: "post",
operation_id: "workspace_orchestrator_start", operation_id: "workspace_orchestrator_start",
@@ -332,6 +336,7 @@ fn validate_worker_operations(
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/workers", path: "/api/w/{workspace_id}/workers",
method: "get", method: "get",
operation_id: "workspace_worker_list", operation_id: "workspace_worker_list",
@@ -339,6 +344,7 @@ fn validate_worker_operations(
successes: &[("200", "WorkerListResponse")], successes: &[("200", "WorkerListResponse")],
}, },
OperationContract { OperationContract {
error: "WorkerCreateApiError",
path: "/api/w/{workspace_id}/workers", path: "/api/w/{workspace_id}/workers",
method: "post", method: "post",
operation_id: "workspace_worker_create", operation_id: "workspace_worker_create",
@@ -346,6 +352,7 @@ fn validate_worker_operations(
successes: &[("200", "BrowserCreateWorkerResponse")], successes: &[("200", "BrowserCreateWorkerResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/workers/{worker_ref}", path: "/api/w/{workspace_id}/workers/{worker_ref}",
method: "get", method: "get",
operation_id: "workspace_worker_detail", operation_id: "workspace_worker_detail",
@@ -353,6 +360,7 @@ fn validate_worker_operations(
successes: &[("200", "WorkerSummary")], successes: &[("200", "WorkerSummary")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/workers/launch-options", path: "/api/workers/launch-options",
method: "get", method: "get",
operation_id: "workspace_worker_launch_options_alias", operation_id: "workspace_worker_launch_options_alias",
@@ -360,6 +368,7 @@ fn validate_worker_operations(
successes: &[("200", "WorkerLaunchOptionsResponse")], successes: &[("200", "WorkerLaunchOptionsResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/workers/launch-options", path: "/api/w/{workspace_id}/workers/launch-options",
method: "get", method: "get",
operation_id: "workspace_worker_launch_options", operation_id: "workspace_worker_launch_options",
@@ -375,6 +384,7 @@ fn validate_companion_operations(
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/companion/status", path: "/api/companion/status",
method: "get", method: "get",
operation_id: "companion_status_alias", operation_id: "companion_status_alias",
@@ -382,6 +392,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionStatusResponse")], successes: &[("200", "CompanionStatusResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/companion/status", path: "/api/w/{workspace_id}/companion/status",
method: "get", method: "get",
operation_id: "companion_status", operation_id: "companion_status",
@@ -389,6 +400,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionStatusResponse")], successes: &[("200", "CompanionStatusResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/companion/transcript", path: "/api/companion/transcript",
method: "get", method: "get",
operation_id: "companion_transcript_alias", operation_id: "companion_transcript_alias",
@@ -396,6 +408,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionTranscriptProjection")], successes: &[("200", "CompanionTranscriptProjection")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/companion/transcript", path: "/api/w/{workspace_id}/companion/transcript",
method: "get", method: "get",
operation_id: "companion_transcript", operation_id: "companion_transcript",
@@ -403,6 +416,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionTranscriptProjection")], successes: &[("200", "CompanionTranscriptProjection")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/companion/messages", path: "/api/companion/messages",
method: "post", method: "post",
operation_id: "companion_message_alias", operation_id: "companion_message_alias",
@@ -410,6 +424,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionMessageResponse")], successes: &[("200", "CompanionMessageResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/companion/messages", path: "/api/w/{workspace_id}/companion/messages",
method: "post", method: "post",
operation_id: "companion_message", operation_id: "companion_message",
@@ -417,6 +432,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionMessageResponse")], successes: &[("200", "CompanionMessageResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/companion/cancel", path: "/api/companion/cancel",
method: "post", method: "post",
operation_id: "companion_cancel_alias", operation_id: "companion_cancel_alias",
@@ -424,6 +440,7 @@ fn validate_companion_operations(
successes: &[("200", "CompanionMessageResponse")], successes: &[("200", "CompanionMessageResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/companion/cancel", path: "/api/w/{workspace_id}/companion/cancel",
method: "post", method: "post",
operation_id: "companion_cancel", operation_id: "companion_cancel",
@@ -439,6 +456,7 @@ fn validate_ticket_browser_operations(
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/link-summary", path: "/api/w/{workspace_id}/tickets/{id}/link-summary",
method: "get", method: "get",
operation_id: "ticket_link_summary_get", operation_id: "ticket_link_summary_get",
@@ -446,6 +464,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketLinkSummary")], successes: &[("200", "TicketLinkSummary")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/parent", path: "/api/w/{workspace_id}/tickets/{id}/parent",
method: "get", method: "get",
operation_id: "ticket_parent_get", operation_id: "ticket_parent_get",
@@ -453,6 +472,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketParentLink")], successes: &[("200", "TicketParentLink")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/parent", path: "/api/w/{workspace_id}/tickets/{id}/parent",
method: "put", method: "put",
operation_id: "ticket_parent_set", operation_id: "ticket_parent_set",
@@ -460,6 +480,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketParentLink")], successes: &[("200", "TicketParentLink")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/children", path: "/api/w/{workspace_id}/tickets/{id}/children",
method: "get", method: "get",
operation_id: "ticket_children_query", operation_id: "ticket_children_query",
@@ -467,6 +488,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketChildrenPage")], successes: &[("200", "TicketChildrenPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/ticket-tags", path: "/api/w/{workspace_id}/ticket-tags",
method: "get", method: "get",
operation_id: "workspace_ticket_tag_list", operation_id: "workspace_ticket_tag_list",
@@ -474,6 +496,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "Array_of_WorkspaceTag")], successes: &[("200", "Array_of_WorkspaceTag")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/ticket-tags", path: "/api/w/{workspace_id}/ticket-tags",
method: "post", method: "post",
operation_id: "workspace_ticket_tag_mutate", operation_id: "workspace_ticket_tag_mutate",
@@ -481,6 +504,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "WorkspaceTag")], successes: &[("200", "WorkspaceTag")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/tags", path: "/api/w/{workspace_id}/tickets/{id}/tags",
method: "put", method: "put",
operation_id: "ticket_tags_set", operation_id: "ticket_tags_set",
@@ -488,6 +512,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "Array_of_WorkspaceTag")], successes: &[("200", "Array_of_WorkspaceTag")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/browse", path: "/api/w/{workspace_id}/tickets/browse",
method: "post", method: "post",
operation_id: "ticket_browse", operation_id: "ticket_browse",
@@ -495,6 +520,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketBrowseQueryResponse")], successes: &[("200", "TicketBrowseQueryResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/query", path: "/api/w/{workspace_id}/tickets/query",
method: "post", method: "post",
operation_id: "ticket_query", operation_id: "ticket_query",
@@ -502,6 +528,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketQueryResponse")], successes: &[("200", "TicketQueryResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets", path: "/api/w/{workspace_id}/tickets",
method: "get", method: "get",
operation_id: "ticket_list", operation_id: "ticket_list",
@@ -509,6 +536,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketListResponse")], successes: &[("200", "TicketListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets", path: "/api/w/{workspace_id}/tickets",
method: "post", method: "post",
operation_id: "ticket_create_record", operation_id: "ticket_create_record",
@@ -516,6 +544,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketRef")], successes: &[("200", "TicketRef")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}", path: "/api/w/{workspace_id}/tickets/{id}",
method: "get", method: "get",
operation_id: "ticket_get", operation_id: "ticket_get",
@@ -523,6 +552,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}", path: "/api/w/{workspace_id}/tickets/{id}",
method: "patch", method: "patch",
operation_id: "ticket_edit", operation_id: "ticket_edit",
@@ -530,6 +560,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
method: "get", method: "get",
operation_id: "ticket_post_get", operation_id: "ticket_post_get",
@@ -537,6 +568,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketEventDetail")], successes: &[("200", "TicketEventDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
method: "patch", method: "patch",
operation_id: "ticket_post_edit", operation_id: "ticket_post_edit",
@@ -544,6 +576,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
method: "put", method: "put",
operation_id: "ticket_assignment_set", operation_id: "ticket_assignment_set",
@@ -551,6 +584,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketRoleAssignmentMutationResponse")], successes: &[("200", "TicketRoleAssignmentMutationResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
method: "post", method: "post",
operation_id: "ticket_implementation_cancel", operation_id: "ticket_implementation_cancel",
@@ -558,6 +592,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
method: "post", method: "post",
operation_id: "ticket_case_update", operation_id: "ticket_case_update",
@@ -565,6 +600,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketSummary")], successes: &[("200", "TicketSummary")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
method: "get", method: "get",
operation_id: "ticket_assignments_page", operation_id: "ticket_assignments_page",
@@ -572,6 +608,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentsPage")], successes: &[("200", "TicketAssignmentsPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/notification-diagnostics", path: "/api/w/{workspace_id}/notification-diagnostics",
method: "get", method: "get",
operation_id: "notification_diagnostics", operation_id: "notification_diagnostics",
@@ -579,6 +616,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "NotificationDiagnosticsPage")], successes: &[("200", "NotificationDiagnosticsPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
method: "get", method: "get",
operation_id: "ticket_assignment_diagnostics", operation_id: "ticket_assignment_diagnostics",
@@ -586,6 +624,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentDiagnosticsPage")], successes: &[("200", "TicketAssignmentDiagnosticsPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results",
method: "post", method: "post",
operation_id: "ticket_implementation_result", operation_id: "ticket_implementation_result",
@@ -593,6 +632,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketImplementationResultReceipt")], successes: &[("200", "TicketImplementationResultReceipt")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results", path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results",
method: "post", method: "post",
operation_id: "ticket_implementation_result_browser", operation_id: "ticket_implementation_result_browser",
@@ -600,6 +640,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketImplementationResultReceipt")], successes: &[("200", "TicketImplementationResultReceipt")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}",
method: "get", method: "get",
operation_id: "ticket_implementation_result_get", operation_id: "ticket_implementation_result_get",
@@ -607,6 +648,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketImplementationResultReceipt")], successes: &[("200", "TicketImplementationResultReceipt")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
method: "post", method: "post",
operation_id: "ticket_assignment_answer", operation_id: "ticket_assignment_answer",
@@ -614,6 +656,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentAnswerReceipt")], successes: &[("200", "TicketAssignmentAnswerReceipt")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
method: "get", method: "get",
operation_id: "ticket_assignments_page_browser", operation_id: "ticket_assignments_page_browser",
@@ -621,6 +664,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentsPage")], successes: &[("200", "TicketAssignmentsPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
method: "get", method: "get",
operation_id: "ticket_assignment_diagnostics_browser", operation_id: "ticket_assignment_diagnostics_browser",
@@ -628,6 +672,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentDiagnosticsPage")], successes: &[("200", "TicketAssignmentDiagnosticsPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer",
method: "post", method: "post",
operation_id: "ticket_assignment_answer_browser", operation_id: "ticket_assignment_answer_browser",
@@ -635,6 +680,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentAnswerReceipt")], successes: &[("200", "TicketAssignmentAnswerReceipt")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
method: "get", method: "get",
operation_id: "ticket_assignment_tracking_get", operation_id: "ticket_assignment_tracking_get",
@@ -642,6 +688,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")], successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
method: "post", method: "post",
operation_id: "ticket_assignment_tracking_update", operation_id: "ticket_assignment_tracking_update",
@@ -649,6 +696,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentTrackingProjection")], successes: &[("200", "TicketAssignmentTrackingProjection")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history",
method: "get", method: "get",
operation_id: "ticket_assignment_tracking_history", operation_id: "ticket_assignment_tracking_history",
@@ -656,6 +704,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketAssignmentTrackingHistoryPage")], successes: &[("200", "TicketAssignmentTrackingHistoryPage")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/ready", path: "/api/w/{workspace_id}/tickets/{id}/ready",
method: "post", method: "post",
operation_id: "ticket_ready", operation_id: "ticket_ready",
@@ -663,6 +712,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/events", path: "/api/w/{workspace_id}/tickets/{id}/events",
method: "post", method: "post",
operation_id: "ticket_event_append", operation_id: "ticket_event_append",
@@ -670,6 +720,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")], successes: &[("200", "TicketDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/tickets/{id}/queue", path: "/api/w/{workspace_id}/tickets/{id}/queue",
method: "post", method: "post",
operation_id: "ticket_queue", operation_id: "ticket_queue",
@@ -677,6 +728,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketQueueOutcome")], successes: &[("200", "TicketQueueOutcome")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/objectives", path: "/api/w/{workspace_id}/objectives",
method: "get", method: "get",
operation_id: "objective_list", operation_id: "objective_list",
@@ -684,6 +736,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "ObjectiveListResponse")], successes: &[("200", "ObjectiveListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/objectives/{objective_id}", path: "/api/w/{workspace_id}/objectives/{objective_id}",
method: "get", method: "get",
operation_id: "objective_get", operation_id: "objective_get",
@@ -691,6 +744,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "ObjectiveDetail")], successes: &[("200", "ObjectiveDetail")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/merge-requests", path: "/api/w/{workspace_id}/merge-requests",
method: "get", method: "get",
operation_id: "merge_request_list", operation_id: "merge_request_list",
@@ -698,6 +752,7 @@ fn validate_ticket_browser_operations(
successes: &[("200", "MergeRequestListResponse")], successes: &[("200", "MergeRequestListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}", path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}",
method: "get", method: "get",
operation_id: "merge_request_show", operation_id: "merge_request_show",
@@ -713,6 +768,7 @@ fn validate_repository_operations(
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/repositories", path: "/api/repositories",
method: "get", method: "get",
operation_id: "repository_list_alias", operation_id: "repository_list_alias",
@@ -720,6 +776,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryListResponse")], successes: &[("200", "RepositoryListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/repositories/{repository_key}", path: "/api/repositories/{repository_key}",
method: "get", method: "get",
operation_id: "repository_detail_alias", operation_id: "repository_detail_alias",
@@ -727,6 +784,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryDetailResponse")], successes: &[("200", "RepositoryDetailResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories", path: "/api/w/{workspace_id}/repositories",
method: "get", method: "get",
operation_id: "repository_list", operation_id: "repository_list",
@@ -734,6 +792,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryListResponse")], successes: &[("200", "RepositoryListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories", path: "/api/w/{workspace_id}/repositories",
method: "post", method: "post",
operation_id: "repository_create", operation_id: "repository_create",
@@ -744,6 +803,7 @@ fn validate_repository_operations(
], ],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories/{repository_key}", path: "/api/w/{workspace_id}/repositories/{repository_key}",
method: "get", method: "get",
operation_id: "repository_detail", operation_id: "repository_detail",
@@ -751,6 +811,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryDetailResponse")], successes: &[("200", "RepositoryDetailResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
method: "post", method: "post",
operation_id: "repository_ssh_connection_probe", operation_id: "repository_ssh_connection_probe",
@@ -758,6 +819,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositorySshConnectionProbeResponse")], successes: &[("200", "RepositorySshConnectionProbeResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
method: "put", method: "put",
operation_id: "repository_ssh_host_trust_confirm", operation_id: "repository_ssh_host_trust_confirm",
@@ -765,6 +827,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositorySshHostTrust")], successes: &[("200", "RepositorySshHostTrust")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/repositories/{repository_key}/log", path: "/api/w/{workspace_id}/repositories/{repository_key}/log",
method: "get", method: "get",
operation_id: "repository_log", operation_id: "repository_log",
@@ -772,6 +835,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryLogResponse")], successes: &[("200", "RepositoryLogResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/repositories/{repository_key}/log", path: "/api/repositories/{repository_key}/log",
method: "get", method: "get",
operation_id: "repository_log_alias", operation_id: "repository_log_alias",
@@ -779,6 +843,7 @@ fn validate_repository_operations(
successes: &[("200", "RepositoryLogResponse")], successes: &[("200", "RepositoryLogResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/hosts", path: "/api/w/{workspace_id}/hosts",
method: "get", method: "get",
operation_id: "host_list", operation_id: "host_list",
@@ -786,6 +851,7 @@ fn validate_repository_operations(
successes: &[("200", "HostListResponse")], successes: &[("200", "HostListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/hosts", path: "/api/hosts",
method: "get", method: "get",
operation_id: "host_list_alias", operation_id: "host_list_alias",
@@ -793,6 +859,7 @@ fn validate_repository_operations(
successes: &[("200", "HostListResponse")], successes: &[("200", "HostListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/hosts/{host_id}/workers", path: "/api/w/{workspace_id}/hosts/{host_id}/workers",
method: "get", method: "get",
operation_id: "host_worker_list", operation_id: "host_worker_list",
@@ -800,6 +867,7 @@ fn validate_repository_operations(
successes: &[("200", "HostWorkerListResponse")], successes: &[("200", "HostWorkerListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/hosts/{host_id}/workers", path: "/api/hosts/{host_id}/workers",
method: "get", method: "get",
operation_id: "host_worker_list_alias", operation_id: "host_worker_list_alias",
@@ -815,6 +883,7 @@ fn validate_workdir_operations(
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
method: "get", method: "get",
operation_id: "runtime_workdir_list", operation_id: "runtime_workdir_list",
@@ -822,6 +891,7 @@ fn validate_workdir_operations(
successes: &[("200", "WorkingDirectoryListResponse")], successes: &[("200", "WorkingDirectoryListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
method: "post", method: "post",
operation_id: "runtime_workdir_create", operation_id: "runtime_workdir_create",
@@ -832,6 +902,7 @@ fn validate_workdir_operations(
], ],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
method: "get", method: "get",
operation_id: "runtime_workdir_detail", operation_id: "runtime_workdir_detail",
@@ -839,6 +910,7 @@ fn validate_workdir_operations(
successes: &[("200", "WorkingDirectoryDetailResponse")], successes: &[("200", "WorkingDirectoryDetailResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
method: "delete", method: "delete",
operation_id: "runtime_workdir_cleanup", operation_id: "runtime_workdir_cleanup",
@@ -846,6 +918,7 @@ fn validate_workdir_operations(
successes: &[("200", "WorkingDirectoryRemovalResponse")], successes: &[("200", "WorkingDirectoryRemovalResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/external-workdir-grants", path: "/api/w/{workspace_id}/external-workdir-grants",
method: "post", method: "post",
operation_id: "external_workdir_grant_create", operation_id: "external_workdir_grant_create",
@@ -853,6 +926,7 @@ fn validate_workdir_operations(
successes: &[("201", "ExternalWorkdirGrantResponse")], successes: &[("201", "ExternalWorkdirGrantResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
method: "get", method: "get",
operation_id: "external_workdir_grant_get", operation_id: "external_workdir_grant_get",
@@ -860,6 +934,7 @@ fn validate_workdir_operations(
successes: &[("200", "ExternalWorkdirGrantResponse")], successes: &[("200", "ExternalWorkdirGrantResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
method: "delete", method: "delete",
operation_id: "external_workdir_grant_revoke", operation_id: "external_workdir_grant_revoke",
@@ -867,6 +942,7 @@ fn validate_workdir_operations(
successes: &[("200", "ExternalWorkdirGrantResponse")], successes: &[("200", "ExternalWorkdirGrantResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/working-directories", path: "/api/w/{workspace_id}/working-directories",
method: "get", method: "get",
operation_id: "workspace_workdir_list", operation_id: "workspace_workdir_list",
@@ -874,6 +950,7 @@ fn validate_workdir_operations(
successes: &[("200", "WorkingDirectoryListResponse")], successes: &[("200", "WorkingDirectoryListResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/working-directories", path: "/api/w/{workspace_id}/working-directories",
method: "post", method: "post",
operation_id: "workspace_workdir_create", operation_id: "workspace_workdir_create",
@@ -884,6 +961,7 @@ fn validate_workdir_operations(
], ],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
method: "get", method: "get",
operation_id: "workspace_workdir_detail", operation_id: "workspace_workdir_detail",
@@ -891,6 +969,7 @@ fn validate_workdir_operations(
successes: &[("200", "WorkingDirectoryDetailResponse")], successes: &[("200", "WorkingDirectoryDetailResponse")],
}, },
OperationContract { OperationContract {
error: "RepositoryApiError",
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
method: "delete", method: "delete",
operation_id: "workspace_workdir_cleanup", operation_id: "workspace_workdir_cleanup",
@@ -1332,12 +1411,28 @@ fn validate_operation_contracts(
&format!("{} response {status}", operation.operation_id), &format!("{} response {status}", operation.operation_id),
)?; )?;
let schema = json_content_schema(response, operation.operation_id)?; let schema = json_content_schema(response, operation.operation_id)?;
let startup_error = operation.error == "WorkerCreateApiError"
&& !successes.contains_key(status.as_str());
let expected = successes let expected = successes
.get(status.as_str()) .get(status.as_str())
.copied() .copied()
.unwrap_or("RepositoryApiError"); .unwrap_or(if startup_error {
"RepositoryApiError"
} else {
operation.error
});
require_schema_ref(schema, expected, operation.operation_id)?; require_schema_ref(schema, expected, operation.operation_id)?;
roots.insert(expected.to_owned()); roots.insert(expected.to_owned());
if startup_error {
let content = object_field(response, "content", operation.operation_id)?;
let problem =
object_field(content, "application/problem+json", operation.operation_id)?;
let schema = problem.get("schema").ok_or_else(|| {
GenerationError::invalid("missing startup Problem Details schema")
})?;
require_schema_ref(schema, "StartupProblemDetails", operation.operation_id)?;
roots.insert("StartupProblemDetails".to_owned());
}
} }
for (status, expected) in successes { for (status, expected) in successes {
if !responses.contains_key(status) { if !responses.contains_key(status) {
@@ -1352,6 +1447,7 @@ fn validate_operation_contracts(
} }
struct OperationContract { struct OperationContract {
error: &'static str,
path: &'static str, path: &'static str,
method: &'static str, method: &'static str,
operation_id: &'static str, operation_id: &'static str,
@@ -1789,8 +1885,31 @@ fn render_typed_schema(
) -> Result<String, GenerationError> { ) -> Result<String, GenerationError> {
match kind { match kind {
"string" => { "string" => {
ensure_schema_keywords(schema, &["type"], context)?; ensure_schema_keywords(
schema,
&["type", "minLength", "maxLength", "pattern"],
context,
)?;
let mut constraints = Vec::new();
if schema.contains_key("minLength") || schema.contains_key("maxLength") {
let range =
render_length_range(schema.get("minLength"), schema.get("maxLength"), context)?;
constraints.push(format!("readonly \"__yoiStringLength\": \"{range}\";"));
}
if let Some(pattern) = schema.get("pattern") {
let pattern = pattern.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} string pattern must be a string"))
})?;
constraints.push(format!(
"readonly \"__yoiStringPattern\": {};",
serde_json::to_string(pattern).expect("string encoding")
));
}
if constraints.is_empty() {
Ok("string".to_owned()) Ok("string".to_owned())
} else {
Ok(format!("string & {{ {} }}", constraints.join(" ")))
}
} }
"boolean" => { "boolean" => {
ensure_schema_keywords(schema, &["type"], context)?; ensure_schema_keywords(schema, &["type"], context)?;
@@ -1880,9 +1999,12 @@ fn render_typed_schema(
.join(", ") .join(", ")
)) ))
} }
_ => Err(GenerationError::invalid(format!( (minimum, maximum) => {
"{context} contains unsupported variable array bounds" let range = render_length_range(minimum, maximum, context)?;
))), Ok(format!(
"Array<{item}> & {{ readonly \"__yoiArrayLength\": \"{range}\"; }}"
))
}
} }
} }
"object" => { "object" => {
@@ -1899,6 +2021,36 @@ fn render_typed_schema(
} }
} }
// Nominal markers preserve wire bounds in generated declarations; they are not
// runtime validators. Only finite safe non-negative integer constraints are admitted.
fn render_length_range(
minimum: Option<&Value>,
maximum: Option<&Value>,
context: &str,
) -> Result<String, GenerationError> {
let bound = |value: &Value| {
value
.as_u64()
.filter(|value| *value <= MAX_SAFE_INTEGER as u64)
.ok_or_else(|| {
GenerationError::invalid(format!(
"{context} length bound must be a safe non-negative integer"
))
})
};
let minimum = minimum.map(bound).transpose()?.unwrap_or(0);
let maximum = maximum.map(bound).transpose()?;
if maximum.is_some_and(|maximum| minimum > maximum) {
return Err(GenerationError::invalid(format!(
"{context} length range is inverted"
)));
}
Ok(format!(
"{minimum}..{}",
maximum.map_or_else(|| "unbounded".into(), |maximum| maximum.to_string())
))
}
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> { fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
let empty_properties = Map::new(); let empty_properties = Map::new();
let properties = match schema.get("properties") { let properties = match schema.get("properties") {
@@ -2282,6 +2434,50 @@ mod tests {
)); ));
} }
#[test]
fn bounded_strings_and_variable_arrays_preserve_constraints_without_becoming_validators() {
assert_eq!(
render_schema(
&serde_json::json!({"type":"string", "minLength":1, "maxLength":128}),
"bounded"
)
.unwrap(),
"string & { readonly \"__yoiStringLength\": \"1..128\"; }"
);
assert_eq!(
render_schema(
&serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":8}),
"bounded"
)
.unwrap(),
"Array<string> & { readonly \"__yoiArrayLength\": \"0..8\"; }"
);
assert_eq!(
render_schema(
&serde_json::json!({"type":"string", "pattern":"^[a-z]+$"}),
"pattern"
)
.unwrap(),
"string & { readonly \"__yoiStringPattern\": \"^[a-z]+$\"; }"
);
assert!(
render_schema(
&serde_json::json!({"type":"string", "pattern":42}),
"pattern"
)
.is_err()
);
for schema in [
serde_json::json!({"type":"string", "maxLength":-1}),
serde_json::json!({"type":"string", "minLength":2, "maxLength":1}),
serde_json::json!({"type":"string", "maxLength":1.5}),
serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":9007199254740992u64}),
serde_json::json!({"type":"array", "items":{"type":"string"}, "minItems":9, "maxItems":8}),
] {
assert!(render_schema(&schema, "bounded").is_err(), "{schema}");
}
}
#[test] #[test]
fn generator_rejects_lossy_reference_siblings() { fn generator_rejects_lossy_reference_siblings() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
@@ -2303,13 +2499,13 @@ mod tests {
#[test] #[test]
fn generator_rejects_unimplemented_ordinary_schema_keywords() { fn generator_rejects_unimplemented_ordinary_schema_keywords() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] = document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["format"] =
serde_json::json!(1); serde_json::json!("email");
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!( assert!(
error error
.to_string() .to_string()
.contains("unsupported schema keyword `maxLength`") .contains("unsupported schema keyword `format`")
); );
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
@@ -0,0 +1,200 @@
//! Worker creation's HTTP projection is separate from its HTTP-independent result.
//! A decoded DTO is never an encoder capability: only a current visibility decision
//! can populate the cached public response. Legacy preflight routes keep their body.
use api_macros::{HttpError, HttpErrorResponse};
use runtime_api::startup::{
DecodeStartupProblem, SessionFailureVisibility, StartupBoundary, StartupProblemDetails,
WorkerCreateFailure, decode_startup_problem,
};
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use crate::RepositoryApiError;
#[derive(Debug)]
pub struct WorkerCreateApiError {
legacy: RepositoryApiError,
failure: Option<WorkerCreateFailure>,
projected: Option<runtime_api::startup::ProblemResponse>,
}
impl WorkerCreateApiError {
/// The service has authenticated and authorized the current creation caller.
/// Visibility is a local decision; neither wire labels nor saved results supply it.
pub fn startup(failure: WorkerCreateFailure, visibility: &SessionFailureVisibility) -> Self {
let instance = failure
.operation()
.correlation
.as_ref()
.map(|correlation| format!("urn:yoi:worker-startup:{}", correlation.as_str()))
.unwrap_or_default();
let projected = failure
.problem(StartupBoundary::Browser, visibility, &instance)
.ok();
let status = projected.as_ref().map_or(500, |response| response.status);
Self {
legacy: RepositoryApiError::new(
status,
"worker_startup",
"Worker creation did not complete; inspect the typed result.",
Vec::new(),
),
failure: Some(failure),
projected,
}
}
pub fn failure(&self) -> Option<&WorkerCreateFailure> {
self.failure.as_ref()
}
pub fn legacy(&self) -> &RepositoryApiError {
&self.legacy
}
}
impl From<RepositoryApiError> for WorkerCreateApiError {
fn from(legacy: RepositoryApiError) -> Self {
Self {
legacy,
failure: None,
projected: None,
}
}
}
impl HttpError for WorkerCreateApiError {
fn status_code(&self) -> u16 {
self.projected
.as_ref()
.map_or_else(|| self.legacy.status_code(), |response| response.status)
}
fn custom_response(&self) -> Option<HttpErrorResponse> {
if let Some(projected) = &self.projected {
return Some(HttpErrorResponse {
status: projected.status,
content_type: projected.content_type,
body: projected.body().to_vec().into(),
});
}
// Projection failure does not serialize a possibly private internal result.
self.failure.as_ref().map(|_| {
error_contract::problem::problem_fallback(error_contract::Budget::new(
error_contract::MAX_FAILURE_BYTES,
error_contract::MAX_FAILURE_BYTES,
))
.expect("static fallback fits the fixed boundary budget")
.into()
})
}
}
impl api_macros::HttpRequestError for WorkerCreateApiError {
fn from_request_rejection(status: u16, message: String) -> Self {
<RepositoryApiError as api_macros::HttpRequestError>::from_request_rejection(
status, message,
)
.into()
}
}
impl Serialize for WorkerCreateApiError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Generated HTTP adapters use custom_response. The ordinary serializer remains
// legacy-only and cannot serialize the retained internal startup attachment.
if let Some(projected) = &self.projected {
let dto: StartupProblemDetails =
serde_json::from_slice(projected.body()).map_err(serde::ser::Error::custom)?;
dto.serialize(serializer)
} else {
self.legacy.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for WorkerCreateApiError {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
RepositoryApiError::deserialize(deserializer).map(Into::into)
}
}
#[allow(dead_code)]
#[derive(JsonSchema)]
#[serde(untagged)]
enum WorkerCreateErrorWire {
Problem(StartupProblemDetails),
Legacy(RepositoryApiError),
}
impl JsonSchema for WorkerCreateApiError {
fn schema_name() -> std::borrow::Cow<'static, str> {
"WorkerCreateApiError".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
WorkerCreateErrorWire::json_schema(generator)
}
}
impl api_macros::openapi::OpenApiSchema for WorkerCreateApiError {
fn response_schema(
builder: &mut api_macros::openapi::OpenApiBuilder,
content_type: &str,
) -> Result<serde_json::Value, api_macros::openapi::OpenApiError> {
match content_type {
"application/json" => builder.schema_ref::<RepositoryApiError>(),
runtime_api::startup::PROBLEM_CONTENT_TYPE => {
builder.schema_ref::<StartupProblemDetails>()
}
_ => Err(api_macros::openapi::OpenApiError::InvalidContract(
"unsupported Worker creation error representation".into(),
)),
}
}
}
impl api_macros::reqwest::HttpClientError for WorkerCreateApiError {
fn decode_error(
response: &api_macros::reqwest::ReceivedResponse,
) -> Result<Self, api_macros::reqwest::ClientFailure> {
use api_macros::reqwest::ClientFailure;
let content_type = response.content_type()?;
if content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case(runtime_api::startup::PROBLEM_CONTENT_TYPE)
{
return match decode_startup_problem(
response.body(),
response.status().as_u16(),
content_type,
StartupBoundary::Browser,
) {
DecodeStartupProblem::Known(failure) => Ok(Self {
legacy: RepositoryApiError::new(
response.status().as_u16(),
"worker_startup",
"Worker creation returned a typed failure.",
Vec::new(),
),
failure: Some(failure),
projected: None,
}),
DecodeStartupProblem::TooLarge => Err(ClientFailure::ResponseTooLarge {
limit: runtime_api::startup::MAX_STARTUP_PROBLEM_BYTES,
}),
DecodeStartupProblem::UnsupportedVersion
| DecodeStartupProblem::UnsupportedReason => {
Err(ClientFailure::UnsupportedErrorResponse {
status: response.status().as_u16(),
})
}
DecodeStartupProblem::Invalid => Err(ClientFailure::ErrorResponseDecode {
status: response.status().as_u16(),
}),
};
}
let legacy = response
.decode_json::<RepositoryApiError>(api_macros::reqwest::DecodeKind::PublicError)?;
Ok(legacy.into())
}
}
@@ -0,0 +1,96 @@
//! Generated Backend client → isolated loopback HTTP response → typed result.
//! The server response producer is substituted; authorization and persistence are
//! exercised by workspace-server's connected tests, not by this codec connection.
use runtime_api::startup::*;
use server_api::{CreateWorkspaceWorkerRequest, ServerApiClient, client_support, server_support};
use server_support::framework::{Body, Response, Router, post};
struct ServerTask(tokio::task::JoinHandle<()>);
impl Drop for ServerTask {
fn drop(&mut self) {
self.0.abort();
}
}
fn request() -> CreateWorkspaceWorkerRequest {
serde_json::from_value(serde_json::json!({
"runtime_id": "runtime", "display_name": "Worker", "feature_connections": {}
}))
.unwrap()
}
#[tokio::test]
async fn every_creation_client_decodes_actual_problem_status_and_operation_facts() {
let timeout = WorkerCreateFailure::from_cause(
StartupCause::Boundary(StartupBoundaryCause {
reason: StartupBoundaryReason::Timeout,
}),
StartupOperationFacts {
acceptance: CreationAcceptance::Unknown,
execution: ExecutionOutcome::Unknown,
finalize: None,
backend_compensation: None,
correlation: None,
},
)
.unwrap()
.problem(
StartupBoundary::Browser,
&SessionFailureVisibility::default(),
"",
)
.unwrap();
for (status, body) in [
(409, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json").to_vec()),
(502, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json").to_vec()),
(504, timeout.into_body()),
] {
let DecodeStartupProblem::Known(expected) = decode_startup_problem(&body, status, PROBLEM_CONTENT_TYPE, StartupBoundary::Browser) else {
panic!("producer fixture must have a known typed cause");
};
let router = ["/api/workers", "/api/w/workspace/workers", "/api/w/workspace/worker-control/workers"]
.into_iter().fold(Router::new(), |router, path| {
let body = body.clone();
router.route(path, post(move || {
let body = body.clone();
async move { Response::builder().status(status).header("content-type", PROBLEM_CONTENT_TYPE).body(Body::from(body)).unwrap() }
}))
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let _server = ServerTask(tokio::spawn(async move {
server_support::framework::serve(listener, router).await.unwrap();
}));
let client = ServerApiClient::builder(format!("http://{address}"))
.unwrap().request_timeout(std::time::Duration::from_secs(3)).build().unwrap();
for result in [
client.workspace_worker_create("workspace".into(), request()).await,
client.workspace_worker_create_alias(request()).await,
client.worker_control_spawn("workspace".into(), request()).await,
] {
let Err(client_support::ClientError::Public { status: actual_status, error }) = result else {
panic!("generated route must invoke the Problem decoder, not legacy Deserialize");
};
assert_eq!(actual_status.as_u16(), status);
let actual = error.failure().expect("retain typed non-HTTP failure");
assert_eq!(actual.startup().primary, expected.startup().primary);
assert_eq!(actual.public_facts(), expected.public_facts());
}
}
}
#[test]
fn creation_openapi_declares_gateway_timeout_with_both_error_representations() {
let document: serde_json::Value = serde_json::from_str(
&server_api::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap(),
)
.unwrap();
for path in ["/api/workers", "/api/w/{workspace_id}/workers"] {
let content = &document["paths"][path]["post"]["responses"]["504"]["content"];
assert!(content.get("application/problem+json").is_some());
assert!(content.get("application/json").is_some());
}
}
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
error-contract.workspace = true
base64.workspace = true base64.workspace = true
agen = { workspace = true } agen = { workspace = true }
fs4.workspace = true fs4.workspace = true
+5 -3
View File
@@ -114,9 +114,11 @@ pub use uploaded_file::{
UploadedFileUploadContext, UploadedFileUploadContext,
}; };
pub use worker_metadata::{ pub use worker_metadata::{
CombinedStore, FsWorkerStore, SubjektivSessionAttributionState, WorkerActiveSegmentRef, CombinedStore, FsWorkerStore, SessionStartupFailure, StartupFailureBindingMismatch,
WorkerAggregateStore, WorkerMetadata, WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, StartupFailureExpectation, StartupFailureRead, StartupFailureSaveResult,
WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name, SubjektivSessionAttributionState, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name,
}; };
pub use worker_session_store::WorkerSessionStore; pub use worker_session_store::WorkerSessionStore;
@@ -0,0 +1,727 @@
//! Component coverage of startup attachments through real FsWorkerStore files,
//! reopen and process-local CAS. Only the directory-sync IO boundary is replaced
//! for the post-rename failure case; these tests do not claim OS-crash durability.
//! Selected by the normal session-store lib test target.
use super::*;
use error_contract::{FailureFacts, OperationContext, Storage, project_failure};
use protocol::startup_failure::{RuntimeStartupCause, RuntimeStartupReason, StartupStage};
fn attachment(stage: StartupStage) -> StoredStartupFailureAttachment {
let mut facts = FailureFacts::new(RuntimeStartupCause {
stage,
reason: RuntimeStartupReason::StartupFailed,
})
.unwrap();
facts
.with_context(OperationContext {
operation: "worker_startup".into(),
stage: "runtime".into(),
operation_ref: None,
})
.unwrap();
let projected = project_failure::<Storage, _>(&facts, &()).unwrap();
StoredStartupFailureAttachment::from_projected(&projected).unwrap()
}
fn metadata() -> WorkerMetadata {
let session_id = crate::new_session_id();
let mut metadata = WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::pending_segment(session_id)),
)
.with_workspace_id("workspace-one");
metadata.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: "legacy diagnostic is not a typed cause".into(),
});
metadata.resolved_manifest_snapshot = Some(serde_json::json!({"retained": true}));
metadata.peers.push(WorkerPeer {
worker_name: "peer".into(),
});
metadata
}
fn bound_failure(
metadata: &WorkerMetadata,
attachment: &StoredStartupFailureAttachment,
) -> SessionStartupFailure {
SessionStartupFailure::from_expected(
&StartupFailureExpectation::from_metadata(metadata).unwrap(),
attachment,
)
}
fn assert_stored_cause(metadata: &WorkerMetadata, stage: StartupStage) {
match metadata.decode_startup_failure::<RuntimeStartupCause>() {
StartupFailureRead::Present(DecodeFailure::Known(received)) => {
assert_eq!(received.facts().cause().stage, stage);
assert_eq!(
received.facts().cause().reason,
RuntimeStartupReason::StartupFailed
);
assert_eq!(received.facts().context().operations.len(), 1);
assert_eq!(
received.facts().context().operations[0].operation,
"worker_startup"
);
}
_ => panic!("expected a known typed startup cause"),
}
}
#[test]
fn startup_failure_cas_reopens_typed_cause_and_preserves_unrelated_metadata() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let cause = attachment(StartupStage::Binding);
let received = cause.clone();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Persisted
));
assert_eq!(cause, received);
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
assert_stored_cause(&restored, StartupStage::Binding);
let mut wanted = original;
wanted.startup_failure = Some(SessionStartupFailure::from_expected(&expected, &received));
assert_eq!(restored, wanted);
}
#[test]
fn startup_failure_wrapper_fingerprints_attribution_without_copying_raw_diagnostics() {
const CREDENTIAL: &str = "credential=TEST_ONLY_METADATA_SENTINEL";
const PRIVATE_PATH: &str = "/private/test-only-attribution/session.json";
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let mut original = metadata();
original.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
session_id: original.active.as_ref().unwrap().session_id,
diagnostic: format!("{CREDENTIAL} {PRIVATE_PATH}"),
});
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Persisted
));
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
assert_eq!(
restored.subjektiv_session_attribution,
original.subjektiv_session_attribution
);
// The original field still retains the diagnostic: absence from the wrapper
// must not be explained by removing or redacting the source metadata instead.
let retained_attribution =
serde_json::to_string(&restored.subjektiv_session_attribution).unwrap();
assert!(retained_attribution.contains(CREDENTIAL));
assert!(retained_attribution.contains(PRIVATE_PATH));
let wrapper = restored.startup_failure.as_ref().unwrap();
let wrapper_json = serde_json::to_string(wrapper).unwrap();
assert!(!wrapper_json.contains(CREDENTIAL));
assert!(!wrapper_json.contains(PRIVATE_PATH));
let wrapper_value = serde_json::to_value(wrapper).unwrap();
assert!(wrapper_value.get("attribution").is_none());
let expected_fingerprint: [u8; 32] = Sha256::digest(retained_attribution.as_bytes()).into();
assert_eq!(bound(wrapper).attribution_fingerprint, expected_fingerprint);
assert_stored_cause(&restored, StartupStage::Binding);
}
#[test]
fn startup_failure_cas_rejects_changed_session_owner_attribution_and_cause_without_rewrite() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let cause = attachment(StartupStage::Binding);
let path = temp.path().join("agent/metadata.json");
// Each row changes one compared fact. Worker-name mismatch is an on-disk
// identity mismatch, not a write to a different key.
let changed = [
{
let mut value = original.clone();
value.active = Some(WorkerActiveSegmentRef::pending_segment(
crate::new_session_id(),
));
value
},
{
let mut value = original.clone();
value.workspace_id = Some("new-owner".into());
value
},
{
let mut value = original.clone();
value.worker_name = "new-worker".into();
value
},
{
let mut value = original.clone();
value.subjektiv_session_attribution =
Some(SubjektivSessionAttributionState::Confirmed {
session_id: expected.session_id,
subject_id: "new-subject".into(),
});
value
},
{
let mut value = original.clone();
value.subjektiv_session_attribution = None;
value
},
{
let mut value = original.clone();
value.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
session_id: expected.session_id,
diagnostic: "new attribution observation".into(),
});
value
},
{
let mut value = original.clone();
value.startup_failure = Some(bound_failure(&value, &attachment(StartupStage::Feature)));
value
},
{
let mut value = original.clone();
value.active = None;
value
},
];
for value in changed {
fs::create_dir_all(path.parent().unwrap()).unwrap();
// Retain noncanonical whitespace to detect even a no-op rewrite.
let bytes = format!(" {}\n", serde_json::to_string(&value).unwrap());
fs::write(&path, &bytes).unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Conflict
));
assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes());
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), value);
}
}
#[test]
fn startup_failure_replacement_requires_the_observed_existing_attachment() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let mut original = metadata();
original.startup_failure = Some(bound_failure(&original, &attachment(StartupStage::Feature)));
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let mut stale = expected.clone();
stale.startup_failure = None;
let cause = attachment(StartupStage::Binding);
assert!(matches!(
store.compare_and_swap_startup_failure(&stale, &cause),
StartupFailureSaveResult::Conflict
));
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Persisted
));
assert_stored_cause(
&store.read_by_name("agent").unwrap().unwrap(),
StartupStage::Binding,
);
}
#[test]
fn startup_failure_cas_missing_metadata_does_not_create_a_worker() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let expected = StartupFailureExpectation::from_metadata(&metadata()).unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Conflict
));
assert!(store.read_by_name("agent").unwrap().is_none());
assert!(!temp.path().join("agent").exists());
}
#[test]
fn startup_failure_cas_allows_segment_movement_within_expected_session() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let active =
WorkerActiveSegmentRef::active_segment(expected.session_id, crate::new_segment_id());
store
.set_active("agent", Some(active.clone()), None)
.unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Persisted
));
let restored = store.read_by_name("agent").unwrap().unwrap();
assert_eq!(restored.active, Some(active));
assert_eq!(
restored.subjektiv_session_attribution,
original.subjektiv_session_attribution
);
// A later Segment switch is not a Session/ownership change either.
store
.set_active(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
expected.session_id,
crate::new_segment_id(),
)),
None,
)
.unwrap();
assert_stored_cause(
&store.read_by_name("agent").unwrap().unwrap(),
StartupStage::Binding,
);
}
#[test]
fn startup_failure_saved_binding_rejects_session_owner_and_attribution_changes_after_reopen() {
for case in 0..7 {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let cause = attachment(StartupStage::Binding);
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Persisted
));
let saved = store
.read_by_name("agent")
.unwrap()
.unwrap()
.startup_failure;
store
.update_by_name("agent", |value| match case {
0 => {
value.active = Some(WorkerActiveSegmentRef::pending_segment(
crate::new_session_id(),
))
}
1 => value.workspace_id = Some("new-workspace-owner".into()),
2 => {
value.subjektiv_session_attribution =
Some(SubjektivSessionAttributionState::Confirmed {
session_id: expected.session_id,
subject_id: "new-subject-owner".into(),
})
}
3 => {
value.subjektiv_session_attribution =
Some(SubjektivSessionAttributionState::Failed {
session_id: expected.session_id,
diagnostic: "changed attribution observation".into(),
})
}
4 => value.active = None,
5 => value.workspace_id = None,
6 => value.subjektiv_session_attribution = None,
_ => unreachable!(),
})
.unwrap();
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
// State changes retain the old cause as evidence; they do not rebind it
// to the new Session/owner and do not turn a mismatch into absence.
assert_eq!(restored.startup_failure, saved);
let path = temp.path().join("agent/metadata.json");
let bytes_before_read = fs::read(&path).unwrap();
match restored.decode_startup_failure::<RuntimeStartupCause>() {
StartupFailureRead::Mismatch(mismatch) => assert_eq!(
mismatch,
StartupFailureBindingMismatch {
worker_name: false,
session_id: matches!(case, 0 | 4),
workspace_id: matches!(case, 1 | 5),
attribution: matches!(case, 2 | 3 | 6),
}
),
_ => panic!("saved cause must not decode for a changed binding (case {case})"),
}
assert_eq!(fs::read(&path).unwrap(), bytes_before_read);
assert!(matches!(
reopened.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Conflict
));
}
}
#[test]
fn startup_failure_copy_to_another_worker_keeps_original_binding() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Persisted
));
let mut copied = store.read_by_name("agent").unwrap().unwrap();
copied.worker_name = "replacement".into();
store.write(&copied).unwrap();
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("replacement").unwrap().unwrap();
assert!(matches!(
restored.decode_startup_failure::<RuntimeStartupCause>(),
StartupFailureRead::Mismatch(StartupFailureBindingMismatch {
worker_name: true,
session_id: false,
workspace_id: false,
attribution: false,
})
));
assert_stored_cause(
&reopened.read_by_name("agent").unwrap().unwrap(),
StartupStage::Binding,
);
}
#[test]
fn startup_failure_cas_compares_existing_binding_even_when_attachment_is_unchanged() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let mut original = metadata();
original.startup_failure = Some(bound_failure(&original, &attachment(StartupStage::Feature)));
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let mut stale = expected.clone();
bound_mut(stale.startup_failure.as_mut().unwrap()).session_id = crate::new_session_id();
assert!(matches!(
store.compare_and_swap_startup_failure(&stale, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Conflict
));
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
let mut changed_fingerprint = expected.clone();
bound_mut(changed_fingerprint.startup_failure.as_mut().unwrap()).attribution_fingerprint[0] ^=
1;
assert!(matches!(
store.compare_and_swap_startup_failure(
&changed_fingerprint,
&attachment(StartupStage::Binding)
),
StartupFailureSaveResult::Conflict
));
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
}
#[test]
fn startup_failure_bound_to_absent_optional_owner_fields_reopens_as_present() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let mut original = metadata();
original.workspace_id = None;
original.subjektiv_session_attribution = None;
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
StartupFailureSaveResult::Persisted
));
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
assert_stored_cause(&restored, StartupStage::Binding);
let expected_none_fingerprint: [u8; 32] = Sha256::digest(b"null").into();
assert_eq!(
bound(restored.startup_failure.as_ref().unwrap()).attribution_fingerprint,
expected_none_fingerprint
);
}
#[test]
fn startup_failure_cas_independently_opened_stores_have_one_concurrent_winner() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [StartupStage::Binding, StartupStage::Feature].map(|stage| {
let store = FsWorkerStore::new(temp.path()).unwrap();
let expected = expected.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
(
stage,
store.compare_and_swap_startup_failure(&expected, &attachment(stage)),
)
})
});
barrier.wait();
let mut winner = None;
let mut conflicts = 0;
for handle in handles {
let (stage, result) = handle.join().unwrap();
match result {
StartupFailureSaveResult::Persisted => {
assert!(winner.replace(stage).is_none());
}
StartupFailureSaveResult::Conflict => conflicts += 1,
other => panic!("unexpected persistence result: {other:?}"),
}
}
assert_eq!(conflicts, 1);
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
assert_stored_cause(
&reopened.read_by_name("agent").unwrap().unwrap(),
winner.unwrap(),
);
}
#[test]
fn startup_failure_legacy_missing_unknown_and_corrupt_survive_reopen_with_attribution() {
let temp = tempfile::tempdir().unwrap();
let original = metadata();
let mut future: serde_json::Value = serde_json::from_str(
serde_json::to_value(attachment(StartupStage::Binding))
.unwrap()
.as_str()
.unwrap(),
)
.unwrap();
future["version"] = serde_json::json!(65535);
let fixtures = [
None,
Some(future.to_string()),
Some("not an envelope".to_owned()),
];
for (case, encoded) in fixtures.into_iter().enumerate() {
let mut fixture = serde_json::to_value(&original).unwrap();
assert!(fixture.get("startup_failure").is_none());
if let Some(encoded) = encoded {
// The new field is always a binding wrapper. Unknown/corrupt fixtures
// change only its opaque codec payload, not the surrounding binding.
let mut bound =
serde_json::to_value(bound_failure(&original, &attachment(StartupStage::Binding)))
.unwrap();
bound["attachment"] = serde_json::Value::String(encoded);
fixture["startup_failure"] = bound;
}
let path = temp.path().join("agent/metadata.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, serde_json::to_vec(&fixture).unwrap()).unwrap();
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
match (
case,
restored.decode_startup_failure::<RuntimeStartupCause>(),
) {
(0, StartupFailureRead::Missing)
| (1, StartupFailureRead::Present(DecodeFailure::Unsupported(_)))
| (2, StartupFailureRead::Present(DecodeFailure::Corrupt(_))) => {}
_ => panic!("legacy absence, unknown and corrupt must be distinguishable"),
}
assert_eq!(
restored.subjektiv_session_attribution,
original.subjektiv_session_attribution
);
assert_eq!(restored.active, original.active);
assert_eq!(restored.workspace_id, original.workspace_id);
if case != 0 {
reopened
.update_by_name("agent", |value| {
value.workspace_id = Some("new-owner".into())
})
.unwrap();
let changed = reopened.read_by_name("agent").unwrap().unwrap();
assert_eq!(changed.startup_failure, restored.startup_failure);
assert!(matches!(
changed.decode_startup_failure::<RuntimeStartupCause>(),
StartupFailureRead::Mismatch(StartupFailureBindingMismatch {
workspace_id: true,
..
})
));
}
}
}
#[test]
fn startup_failure_save_read_error_is_not_saved_and_preserves_received_cause() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let path = temp.path().join("agent/metadata.json");
fs::write(&path, b"invalid metadata").unwrap();
let cause = attachment(StartupStage::Binding);
let received = cause.clone();
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::NotSaved(WorkerStoreError::Serde(_))
));
assert_eq!(cause, received);
assert_eq!(fs::read(path).unwrap(), b"invalid metadata");
}
#[test]
fn startup_failure_rename_error_is_not_saved_but_post_rename_sync_error_is_unknown() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let cause = attachment(StartupStage::Binding);
let received = cause.clone();
// Real IO rename failure before publication. The destination is an isolated
// directory rather than metadata.json, so no retained metadata is destroyed.
let blocked_path = temp.path().join("blocked/metadata.json");
fs::create_dir_all(&blocked_path).unwrap();
let result =
save_startup_failure_locked(&store, &store.update_lock, &expected, &cause, |value| {
write_metadata_atomically(value, &blocked_path)
});
assert!(matches!(
result,
StartupFailureSaveResult::NotSaved(WorkerStoreError::Io(_))
));
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
assert_eq!(
fs::read_dir(blocked_path.parent().unwrap())
.unwrap()
.count(),
1
);
// Production serialization + file sync + rename, replacing only directory
// sync with an IO failure. Reopen sees publication, but durability is unknown.
let path = store.metadata_path("agent").unwrap();
let result =
save_startup_failure_locked(&store, &store.update_lock, &expected, &cause, |value| {
write_metadata_with_directory_sync(value, &path, |_| {
Err(std::io::Error::other("directory sync unavailable"))
})
});
assert!(matches!(
result,
StartupFailureSaveResult::OutcomeUnknown(WorkerStoreError::Io(_))
));
assert_eq!(cause, received);
drop(store);
let reopened = FsWorkerStore::new(temp.path()).unwrap();
let restored = reopened.read_by_name("agent").unwrap().unwrap();
assert_stored_cause(&restored, StartupStage::Binding);
let mut wanted = original;
wanted.startup_failure = Some(SessionStartupFailure::from_expected(&expected, &received));
assert_eq!(restored, wanted);
// An uncertain attempt is not replayed against its stale expected cause.
assert!(matches!(
reopened.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Conflict
));
}
#[test]
fn startup_failure_aggregate_and_combined_stores_use_atomic_cas() {
let temp = tempfile::tempdir().unwrap();
let aggregate = WorkerAggregateStore::new(temp.path().join("aggregate"), "agent").unwrap();
let original = metadata();
aggregate.write(&original).unwrap();
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
let store = CombinedStore::new((), aggregate);
let cause = attachment(StartupStage::Binding);
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &cause),
StartupFailureSaveResult::Persisted
));
assert!(matches!(
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Feature)),
StartupFailureSaveResult::Conflict
));
let reopened =
WorkerAggregateStore::open_read_only(temp.path().join("aggregate"), "agent").unwrap();
assert_stored_cause(
&reopened.read_read_only().unwrap().unwrap(),
StartupStage::Binding,
);
}
fn bound(failure: &SessionStartupFailure) -> &StartupFailureBinding {
match failure {
SessionStartupFailure::Bound(binding) => binding,
_ => panic!("expected current binding"),
}
}
fn bound_mut(failure: &mut SessionStartupFailure) -> &mut StartupFailureBinding {
match failure {
SessionStartupFailure::Bound(binding) => binding,
_ => panic!("expected current binding"),
}
}
#[test]
fn startup_binding_unknown_version_and_malformed_value_do_not_destroy_lifecycle_metadata() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let original = metadata();
store.write(&original).unwrap();
let mut wire = serde_json::to_value(&original).unwrap();
for (value, unsupported) in [
(
serde_json::json!({"version": 900, "future_binding": true}),
true,
),
(
serde_json::json!({"version": 1, "attachment": {"wrong_type": true}}),
false,
),
(serde_json::json!("invalid binding"), false),
(serde_json::json!([1, 2]), false),
] {
wire["startup_failure"] = value.clone();
std::fs::write(
temp.path().join("agent/metadata.json"),
serde_json::to_vec(&wire).unwrap(),
)
.unwrap();
let reopened = FsWorkerStore::new(temp.path())
.unwrap()
.read_by_name("agent")
.unwrap()
.unwrap();
assert_eq!(reopened.active, original.active);
assert_eq!(
reopened.subjektiv_session_attribution,
original.subjektiv_session_attribution
);
assert_eq!(
reopened.resolved_manifest_snapshot,
original.resolved_manifest_snapshot
);
match reopened.decode_startup_failure::<RuntimeStartupCause>() {
StartupFailureRead::UnsupportedBindingVersion(900) if unsupported => {}
StartupFailureRead::CorruptBinding if !unsupported => {}
_ => panic!("binding diagnosis lost"),
}
assert_eq!(
serde_json::to_value(&reopened).unwrap()["startup_failure"],
value
);
}
}
+342 -51
View File
@@ -16,7 +16,10 @@
//! execution data, not proof of liveness. //! execution data, not proof of liveness.
use crate::{SegmentId, SessionId}; use crate::{SegmentId, SessionId};
use error_contract::{DecodeFailure, WireDetails};
use protocol::startup_failure::StoredStartupFailureAttachment;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -181,6 +184,168 @@ pub struct WorkerMetadata {
/// absence is also the legacy representation and never grants visibility. /// absence is also the legacy representation and never grants visibility.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub subjektiv_session_attribution: Option<SubjektivSessionAttributionState>, pub subjektiv_session_attribution: Option<SubjektivSessionAttributionState>,
/// Projected startup cause bound to its Session and ownership observation.
/// Pointer/ownership changes retain this evidence, but the read accessor
/// rejects a stale binding before decoding. This is not execution state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_failure: Option<SessionStartupFailure>,
}
/// Opaque cause and the exact local binding under which it was saved.
///
/// Only the CAS save path constructs this binding from its matched expectation.
/// Attribution is fingerprinted rather than copied: its existing diagnostics may
/// contain credentials or private paths and must not enter this new attachment.
/// The fingerprint is an equality fence, not encryption or ownership authority.
/// Deserialization merely restores evidence, not current ownership or authority;
/// consumers read it through `WorkerMetadata::decode_startup_failure`.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SessionStartupFailure {
Bound(StartupFailureBinding),
/// Malformed or future binding representations remain alongside lifecycle metadata.
Undecodable(serde_json::Value),
}
impl std::fmt::Debug for SessionStartupFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionStartupFailure")
.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StartupFailureBinding {
version: u16,
worker_name: String,
session_id: SessionId,
workspace_id: Option<String>,
attribution_fingerprint: [u8; 32],
attachment: StoredStartupFailureAttachment,
}
// The compact JSON serialization of this typed Option is the fingerprint input:
// None is `null`; Some retains every enum field in its declared Serialize order.
// There are no maps whose iteration order could vary. Changing that representation
// requires a new binding version. Version 1 uses SHA-256 over precisely these bytes.
// This enum and SessionId serialize infallibly
// to JSON; no caller-controlled Serialize implementation participates here.
fn attribution_fingerprint(attribution: &Option<SubjektivSessionAttributionState>) -> [u8; 32] {
let encoded = serde_json::to_vec(attribution).expect("typed attribution serializes to JSON");
Sha256::digest(encoded).into()
}
impl SessionStartupFailure {
fn from_expected(
expected: &StartupFailureExpectation,
attachment: &StoredStartupFailureAttachment,
) -> Self {
Self::Bound(StartupFailureBinding {
version: 1,
worker_name: expected.worker_name.clone(),
session_id: expected.session_id,
workspace_id: expected.workspace_id.clone(),
attribution_fingerprint: attribution_fingerprint(&expected.attribution),
attachment: attachment.clone(),
})
}
}
impl StartupFailureBinding {
fn binding_mismatch(&self, metadata: &WorkerMetadata) -> Option<StartupFailureBindingMismatch> {
let mismatch = StartupFailureBindingMismatch {
worker_name: self.worker_name != metadata.worker_name,
session_id: Some(self.session_id)
!= metadata.active.as_ref().map(|active| active.session_id),
workspace_id: self.workspace_id != metadata.workspace_id,
attribution: self.attribution_fingerprint
!= attribution_fingerprint(&metadata.subjektiv_session_attribution),
};
(mismatch.worker_name
|| mismatch.session_id
|| mismatch.workspace_id
|| mismatch.attribution)
.then_some(mismatch)
}
}
/// Each `true` field identifies a binding that differs from current metadata.
/// Values and legacy diagnostic text are not copied into the diagnosis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartupFailureBindingMismatch {
pub worker_name: bool,
/// Also true when there is no longer an active Session.
pub session_id: bool,
pub workspace_id: bool,
pub attribution: bool,
}
/// A current binding must match before the consumer's typed codec runs.
/// `Present` is only cause evidence, never execution or retry authorization.
pub enum StartupFailureRead<D> {
Missing,
/// Binding encoding is versioned independently of the domain error envelope.
UnsupportedBindingVersion(u16),
CorruptBinding,
/// Retained cause belongs to a different Session/ownership observation.
/// No cause decoding is attempted, even for unknown or corrupt attachments.
Mismatch(StartupFailureBindingMismatch),
/// Known, unsupported and corrupt causes remain distinct in the codec.
Present(DecodeFailure<D>),
}
/// The observed ownership and cause values for one startup-cause write.
///
/// Segment movement within the same Session does not invalidate this expectation.
/// Attribution is compared in full, including legacy diagnostics, never inferred
/// from those diagnostics. The existing cause comparison includes its entire saved
/// binding, not just attachment bytes. A cause write changes neither attribution
/// nor ownership; it builds the new binding from this matched expectation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartupFailureExpectation {
pub worker_name: String,
pub workspace_id: Option<String>,
pub session_id: SessionId,
pub attribution: Option<SubjektivSessionAttributionState>,
pub startup_failure: Option<SessionStartupFailure>,
}
impl StartupFailureExpectation {
/// Capture an expectation only when metadata identifies an active Session.
pub fn from_metadata(metadata: &WorkerMetadata) -> Option<Self> {
Some(Self {
worker_name: metadata.worker_name.clone(),
workspace_id: metadata.workspace_id.clone(),
session_id: metadata.active.as_ref()?.session_id,
attribution: metadata.subjektiv_session_attribution.clone(),
startup_failure: metadata.startup_failure.clone(),
})
}
/// Compare the complete observation without adopting newer ownership or causes.
pub fn matches(&self, metadata: &WorkerMetadata) -> bool {
metadata.worker_name == self.worker_name
&& metadata.workspace_id == self.workspace_id
&& metadata.active.as_ref().map(|active| active.session_id) == Some(self.session_id)
&& metadata.subjektiv_session_attribution == self.attribution
&& metadata.startup_failure == self.startup_failure
}
}
/// Local persistence evidence, never a replacement for the received startup cause.
#[derive(Debug)]
pub enum StartupFailureSaveResult {
/// The attachment and containing directory were synced successfully.
Persisted,
/// The observed Session, ownership, attribution, or cause no longer matches.
/// No write was attempted. Missing metadata is also a conflict, not creation.
Conflict,
/// This attempt did not publish metadata. The caller still owns its cause.
NotSaved(WorkerStoreError),
/// Publication may have happened; reconcile retained metadata before retrying.
/// For the filesystem stores this means rename succeeded but directory sync failed.
OutcomeUnknown(WorkerStoreError),
} }
impl WorkerMetadata { impl WorkerMetadata {
@@ -196,9 +361,44 @@ impl WorkerMetadata {
peers: Vec::new(), peers: Vec::new(),
resolved_manifest_snapshot: None, resolved_manifest_snapshot: None,
subjektiv_session_attribution: None, subjektiv_session_attribution: None,
startup_failure: None,
} }
} }
/// Absence is legacy/missing evidence, not a cause inferred from diagnostics.
/// A retained cause whose Session or ownership observation no longer matches
/// yields `Mismatch`, never `Missing` or a decoded cause for the new owner.
/// Segment movement within the same Session does not invalidate the binding.
/// Matched attachments use only the consumer's typed codec, preserving known,
/// unsupported and corrupt results. No result authorizes execution or retry.
pub fn decode_startup_failure<D: WireDetails>(&self) -> StartupFailureRead<D> {
let Some(failure) = &self.startup_failure else {
return StartupFailureRead::Missing;
};
#[derive(Deserialize)]
struct BindingVersion {
version: u16,
}
let failure = match failure {
SessionStartupFailure::Bound(binding) if binding.version == 1 => binding,
SessionStartupFailure::Bound(binding) => {
return StartupFailureRead::UnsupportedBindingVersion(binding.version);
}
SessionStartupFailure::Undecodable(raw) => {
return match serde_json::from_value::<BindingVersion>(raw.clone()) {
Ok(header) if header.version != 1 => {
StartupFailureRead::UnsupportedBindingVersion(header.version)
}
_ => StartupFailureRead::CorruptBinding,
};
}
};
if let Some(mismatch) = failure.binding_mismatch(self) {
return StartupFailureRead::Mismatch(mismatch);
}
StartupFailureRead::Present(failure.attachment.decode())
}
pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self { pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self {
self.workspace_root = Some(workspace_root); self.workspace_root = Some(workspace_root);
self self
@@ -265,6 +465,25 @@ pub trait WorkerMetadataStore: Send + Sync {
Ok(matched) Ok(matched)
} }
/// Persist a received cause only against the exact observed Session and owner.
///
/// Implementations must serialize this comparison and publication with their
/// other read/modify/write operations. The filesystem stores use the same
/// process-local mutex as `update_by_name`; this is not a cross-process lock.
/// Uncoordinated direct writes are outside that guarantee. A default backend
/// refuses rather than offering a non-atomic read/write approximation.
/// The borrowed attachment is never consumed or changed on any result.
fn compare_and_swap_startup_failure(
&self,
_expected: &StartupFailureExpectation,
_failure: &StoredStartupFailureAttachment,
) -> StartupFailureSaveResult {
StartupFailureSaveResult::NotSaved(WorkerStoreError::Io(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"atomic startup failure persistence is unsupported by this backend",
)))
}
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot. /// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active( fn set_active(
&self, &self,
@@ -404,6 +623,90 @@ pub trait WorkerMetadataStore: Send + Sync {
} }
} }
/// Whether an atomic metadata replacement reached its publication point.
struct MetadataWriteFailure {
published: bool,
error: WorkerStoreError,
}
fn write_metadata_atomically(
metadata: &WorkerMetadata,
path: &Path,
) -> Result<(), MetadataWriteFailure> {
write_metadata_with_directory_sync(metadata, path, |parent| {
std::fs::File::open(parent)?.sync_all()
})
}
// Keep the directory durability boundary explicit: a failure here follows rename,
// unlike failures writing/syncing the temporary file. Tests replace only this IO
// boundary, retaining the real serialization, temporary file and rename path.
fn write_metadata_with_directory_sync(
metadata: &WorkerMetadata,
path: &Path,
sync_directory: impl FnOnce(&Path) -> std::io::Result<()>,
) -> Result<(), MetadataWriteFailure> {
let mut published = false;
let mut temp = None;
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut content = serde_json::to_vec_pretty(metadata)?;
content.push(b'\n');
let parent = path.parent().expect("metadata path has parent");
fs::create_dir_all(parent)?;
let temporary_path = parent.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)?;
temp = Some(temporary_path.clone());
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temporary_path, path)?;
published = true;
sync_directory(parent)?;
Ok(())
})();
if result.is_err() {
if let Some(temp) = temp {
let _ = fs::remove_file(temp);
}
}
result.map_err(|error| MetadataWriteFailure { published, error })
}
fn save_startup_failure_locked<S: WorkerMetadataStore>(
store: &S,
update_lock: &Mutex<()>,
expected: &StartupFailureExpectation,
failure: &StoredStartupFailureAttachment,
publish: impl FnOnce(&WorkerMetadata) -> Result<(), MetadataWriteFailure>,
) -> StartupFailureSaveResult {
let _guard = update_lock.lock().expect("metadata update lock poisoned");
let mut metadata = match store.read_by_name(&expected.worker_name) {
Ok(Some(metadata)) if expected.matches(&metadata) => metadata,
Ok(_) => return StartupFailureSaveResult::Conflict,
Err(error) => return StartupFailureSaveResult::NotSaved(error),
};
metadata.startup_failure = Some(SessionStartupFailure::from_expected(expected, failure));
match publish(&metadata) {
Ok(()) => StartupFailureSaveResult::Persisted,
Err(MetadataWriteFailure {
published: false,
error,
}) => StartupFailureSaveResult::NotSaved(error),
Err(MetadataWriteFailure {
published: true,
error,
}) => StartupFailureSaveResult::OutcomeUnknown(error),
}
}
/// Metadata store for one canonical Worker aggregate. /// Metadata store for one canonical Worker aggregate.
/// ///
/// The Worker key is fixed by Runtime authority and metadata lives directly at /// The Worker key is fixed by Runtime authority and metadata lives directly at
@@ -489,31 +792,7 @@ impl WorkerAggregateStore {
impl WorkerMetadataStore for WorkerAggregateStore { impl WorkerMetadataStore for WorkerAggregateStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
self.validate_name(&metadata.worker_name)?; self.validate_name(&metadata.worker_name)?;
let mut content = serde_json::to_vec_pretty(metadata)?; write_metadata_atomically(metadata, &self.metadata_path()).map_err(|failure| failure.error)
content.push(b'\n');
let path = self.metadata_path();
let temp = self.root.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(&self.root)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
} }
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
@@ -553,6 +832,16 @@ impl WorkerMetadataStore for WorkerAggregateStore {
Ok(metadata) Ok(metadata)
} }
fn compare_and_swap_startup_failure(
&self,
expected: &StartupFailureExpectation,
failure: &StoredStartupFailureAttachment,
) -> StartupFailureSaveResult {
save_startup_failure_locked(self, &self.update_lock, expected, failure, |metadata| {
write_metadata_atomically(metadata, &self.metadata_path())
})
}
fn compare_and_swap_active( fn compare_and_swap_active(
&self, &self,
worker_name: &str, worker_name: &str,
@@ -627,32 +916,7 @@ impl FsWorkerStore {
impl WorkerMetadataStore for FsWorkerStore { impl WorkerMetadataStore for FsWorkerStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(&metadata.worker_name)?; let path = self.metadata_path(&metadata.worker_name)?;
let mut content = serde_json::to_vec_pretty(metadata)?; write_metadata_atomically(metadata, &path).map_err(|failure| failure.error)
content.push(b'\n');
let parent = path.parent().expect("metadata path has parent");
fs::create_dir_all(parent)?;
let temp = parent.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
} }
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
@@ -685,6 +949,20 @@ impl WorkerMetadataStore for FsWorkerStore {
Ok(metadata) Ok(metadata)
} }
fn compare_and_swap_startup_failure(
&self,
expected: &StartupFailureExpectation,
failure: &StoredStartupFailureAttachment,
) -> StartupFailureSaveResult {
let path = match self.metadata_path(&expected.worker_name) {
Ok(path) => path,
Err(error) => return StartupFailureSaveResult::NotSaved(error),
};
save_startup_failure_locked(self, &self.update_lock, expected, failure, |metadata| {
write_metadata_atomically(metadata, &path)
})
}
fn compare_and_swap_active( fn compare_and_swap_active(
&self, &self,
worker_name: &str, worker_name: &str,
@@ -1007,6 +1285,15 @@ where
self.worker_metadata_store self.worker_metadata_store
.update_by_name(worker_name, update) .update_by_name(worker_name, update)
} }
fn compare_and_swap_startup_failure(
&self,
expected: &StartupFailureExpectation,
failure: &StoredStartupFailureAttachment,
) -> StartupFailureSaveResult {
self.worker_metadata_store
.compare_and_swap_startup_failure(expected, failure)
}
fn compare_and_swap_active( fn compare_and_swap_active(
&self, &self,
worker_name: &str, worker_name: &str,
@@ -1512,3 +1799,7 @@ mod tests {
assert_eq!(winners, 1); assert_eq!(winners, 1);
} }
} }
#[cfg(test)]
#[path = "startup_failure_tests.rs"]
mod startup_failure_tests;

Some files were not shown because too many files have changed in this diff Show More