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
Hare d9295f4439 feat: integrate cause-preserving error contract utilities 2026-11-01 06:30:47 +09:00
Hare 5dfc50f293 fix: validate RFC 9457 URI references 2026-11-01 06:15:36 +09:00
Hare eb05300a32 feat(error-contract): add typed projections and bounded failure utilities 2026-11-01 06:02:10 +09:00
Hare 5d7213b9a4 fix: omit display-name-only diagnostics from embedded worker creation
Keep requested names as display names without emitting an informational diagnostic for ordinary creation. Preserve execution failure diagnostics and cover named success and failure paths.
2026-11-01 06:01:26 +09:00
Hare d0f22d871a fix: bound test shard size independently of concurrency
Remove the 32-shard cap so growing inventories do not overfill fixed timeout windows. Keep serial Runtime/Worker/Server shards at most 36 cases and default top-level concurrency at most eight, with explicit --jobs overrides retained. No test exclusions or timeout increases.

Validated the complete Server inventory through the normal runner helpers: 1724 passed, 4 existing ignored, no failures or timeouts; 50 suites including doctests, 580 seconds execution. Runner and naming checks: 26 passed. Root cargo check and formatting/diff checks passed.
2026-11-01 01:54:34 +09:00
Hare 922e5e3fc3 fix: align restore guard tests with stale observation errors
Assert the distinct stale-observation contract while retaining same-generation conflict, no-launch, resource and historical replay checks. Include actual errors in assertion diagnostics.
2026-11-01 01:54:34 +09:00
Hare bfbddfa443 fix: preserve external workdir session failure diagnostics
Retain allowlisted Backend failure codes in operation logs without exposing diagnostic prose. Distinguish missing provider connections from mismatched grant bindings while preserving rejection and HTTP status behavior.

Validate signed read/command failure logs, no-dispatch and attachment retention, and unknown-code redaction. Targeted logging tests, restore regression, root check and static checks pass. Broad Server validation remains incomplete due to shard timeouts and observed Plugin/restore-guard failures.
2026-11-01 00:56:41 +09:00
Hare f22b69bb92 test: reuse routers in workspace config fixtures 2026-11-01 00:32:06 +09:00
Hare f113bfa0ee fix: render concise YAML journal context without transport metadata 2026-11-01 00:32:06 +09:00
Hare 4134f9c2bb fix: simplify chat notifications with stable conversation references 2026-11-01 00:32:06 +09:00
Hare 846d35446b fix: centralize notification handling guidance 2026-11-01 00:32:06 +09:00
Hare d08a21e38c fix: release failed Subject creation reservations with Runtime proof 2026-10-31 20:14:09 +09:00
Hare f7ea2362dd fix: authorize same-origin conversation reads without Origin 2026-10-31 19:33:58 +09:00
Hare e8477882b5 test: verify real browser Body recovery and authenticated conversation continuity 2026-10-31 16:22:20 +09:00
Hare af074254ec fix: restore Journal history only after matching query data 2026-10-31 16:04:14 +09:00
Hare df8cd2353b Merge remote-tracking branch 'origin/develop' into work/t-808-subject-chat
# Conflicts:
#	openapi/server-api.json
#	web/workspace/deno.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-31 15:54:20 +09:00
Hare c254870ed6 Merge commit '8018f648e4054bad1881de8c74681604a4fbd66f' into work/t-808-subject-chat 2026-10-31 15:52:44 +09:00
Hare 8018f648e4 fix(subject-body): settle identity-bound stale lifecycle intents 2026-10-31 15:50:16 +09:00
Hare bb70c2304e test: connect shared Subject Body lifecycle and reproduce stale stop recovery 2026-10-31 15:09:02 +09:00
Hare 09b9755a16 feat(ticket): validate parent links across signed API, Tools, WIP and Web 2026-10-31 15:03:00 +09:00
Hare 2924e43773 fix: fence canonical chat children to their confirmed route identity 2026-10-31 14:53:56 +09:00
Hare 86b2981d63 Merge remote-tracking branch 'origin/work/t-817-subjektiv-navigation' into work/t-808-subject-chat 2026-10-31 14:46:37 +09:00
Hare c9cd2af845 Merge commit '82616c2a41088d13d363b44ac05b2154acdbb87d' into work/t-808-subject-chat
# Conflicts:
#	crates/server-api/Cargo.toml
#	web/workspace/deno.json
#	web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/PageContent.svelte
2026-10-31 14:44:58 +09:00
Hare 7a2d132c16 test: prepare connected chat checks for canonical and legacy Subject routes 2026-10-31 14:40:53 +09:00
Hare 9e7392256b Merge commit '226d0ecfce8a525ffef5dfcdadcbcf50cf9e4d17' into work/t-808-subject-chat 2026-10-31 14:38:39 +09:00
Hare 82616c2a41 feat: publish connected Subject body lifecycle candidate and validation fixes 2026-10-31 14:38:17 +09:00
Hare c05854b4b1 test: integrate published Subject browser Backend harness 2026-10-31 14:37:24 +09:00
Hare 226d0ecfce feat: move Subject navigation and routes into subjektiv 2026-10-31 14:36:57 +09:00
Hare 54dea496ff test: connect Subject Web conversation to real Backend and Runtime 2026-10-31 14:34:23 +09:00
Hare 24694dfddc fix: admit escaped full-size public conversation pages 2026-10-31 14:17:32 +09:00
Hare 1c35089c77 feat: connect Subject conversation route and validate exact retries 2026-10-31 14:15:27 +09:00
Hare 05649dd66f Merge remote-tracking branch 'origin/develop' into work/t-804-parent-links 2026-10-31 13:13:43 +09:00
Hare 290edf6808 feat: preserve Ticket parent-link source for integration validation 2026-10-31 13:13:43 +09:00
Hare bf23f5a4e4 Merge commit '52a1ce5607d45b8a424427f1d947e88b922b0dab' into work/t-808-subject-chat 2026-10-31 13:12:28 +09:00
Hare d91181912b feat: prepare Subject conversation transport and exact-input retries
Preserve the light-stage implementation before integrating published Subject defaults. Retry journal units pass; generated chat types, route integration and connected validation follow in this work branch.
2026-10-31 13:12:28 +09:00
Hare 7b594ded0d Merge branch 'develop' of /home/hare/Projects/yoi into work/t-807-subject-lifecycle 2026-10-31 13:08:48 +09:00
Hare 2f74e00e07 feat: preserve Subject body lifecycle candidate for integration
Checkpoint all source-only T807 work, including Runtime stop fencing, Subject adapters, shared UI and unexecuted regression sources before merging published T806. Not a validated or reviewed release.
2026-10-31 13:08:47 +09:00
Hare 52a1ce5607 fix: align Subject creation component assertion with required operation ID 2026-10-31 12:47:01 +09:00
Hare 807a476c88 Initialize self-scoped Subject conversation permissions and publish async chat profile 2026-10-31 12:21:52 +09:00
Hare 7252e61a23 fix: merge hare/develop and reconcile lifecycle contracts 2026-10-31 10:05:54 +09:00
Hare 6fffe4517b feat: integrate committed Ticket event services 2026-10-31 05:53:52 +09:00
Hare ab138d72fc fix: emit complete plugin Host TypeScript dependencies 2026-10-31 05:25:03 +09:00
Hare 5627f47e2b test: verify scoped authority storage lifetime and physical initialization 2026-10-31 04:58:20 +09:00
Hare 2c1f5dfafe fix: bound Subject database retention to synchronous plugin checks 2026-10-31 04:58:19 +09:00
Hare b508fef698 feat: deliver committed Ticket events to plugin services 2026-10-31 04:58:19 +09:00
Hare 27f3b36ce5 fix: retain Workdir close confirmation across retries 2026-10-30 22:52:43 +09:00
Hare 5d0e03dc53 fix: retain Runtime close ownership until provider teardown completes
Resolve MR runtime-close-ack-before-release: keep sessions registered while a task-owned close is pending; all DELETE waiters share its result, successful teardown alone removes the entry, and provider errors permit retry. Cancelling HTTP waiters cannot cancel Local provider command teardown.

Add deterministic Local teardown/Runtime active-use tests plus authenticated Backend registry cancellation and same-session retry through actual materializer busy/removal. Focused dependent Server 26 pass, Runtime new 3 and existing close tests pass; root check, fmt, diff, names pass. Full Runtime attempt: 505 pass/63 restore-environment failures; corrected exact 63 pass separately, not a full-suite green claim. Validation logs: target/t805-validation. Base remains 0f82; no T795/T796 import.
2026-10-30 22:25:27 +09:00
Hare 4f7dcfda61 feat: add authorized Plugin Ticket data capabilities 2026-10-30 21:45:03 +09:00
Hare 1c03758fc1 fix: confirm remote Workdir close before releasing session ownership 2026-10-30 21:44:51 +09:00
Hare e7351c16b0 fix: reuse Workdir attachments independently of requested alias 2026-10-30 21:22:00 +09:00
Hare 00d96b7163 fix: scope Workdir removal to explicit requests 2026-10-30 20:33:57 +09:00
Hare 2b4bf4b7bd fix: log Workdir cleanup busy reasons 2026-10-30 20:33:57 +09:00
Hare e7ebc92f83 fix: preserve complete merge request reviews 2026-10-30 20:33:57 +09:00
Hare 0f82d6fa8f fix: normalize each Gemini response call before Tool dispatch 2026-10-30 09:47:54 +09:00
Hare 5a317e6d2a fix: bind Plugin receipts to durable Host Tool-call history identity 2026-10-30 09:06:29 +09:00
Hare 5329a93e3e test: preserve saved policy fixtures and uncertain Plugin cancellation evidence 2026-10-30 07:39:15 +09:00
Hare 94d458883e Merge remote-tracking branch 'origin/develop' into work/t-533-plugin-worker-composed
# Conflicts:
#	crates/server-api/src/lib.rs
#	crates/server-api/src/plugin.rs
#	crates/server-api/src/plugin_types.rs
#	crates/worker-runtime/Cargo.toml
#	crates/worker-runtime/src/workspace_request/plugin_delivery.rs
#	crates/workspace-server/Cargo.toml
#	crates/workspace-server/src/lib.rs
#	crates/workspace-server/src/server.rs
#	crates/workspace-server/src/server/tests/subject_authorization_tests.rs
#	crates/workspace-server/src/server_plugin_delivery.rs
#	crates/workspace-server/src/server_plugin_delivery_tests.rs
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/plugin-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/workdir-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-30 07:20:50 +09:00
Hare b824195b36 feat: complete Plugin controller lifecycle and owner diagnostics with real runtime coverage 2026-10-30 07:15:03 +09:00
Hare 318c1a9ce9 fix: align permission management with integrated Worker and chat operations 2026-10-30 05:30:42 +09:00
Hare 9f13446afd fix: interrupt revoked Plugin transports and enforce full control-plane exclusions 2026-10-30 05:28:51 +09:00
Hare 479bf0872a feat: bind Plugin launch selections and authenticated Runtime providers 2026-10-30 05:28:51 +09:00
Hare 36932321e0 feat: integrate authorized Plugin delivery and verified cache 2026-10-30 05:07:00 +09:00
Hare 70ce8479b2 feat: integrate asynchronous Subject chat with permission management 2026-10-30 05:05:08 +09:00
Hare 57b57aed67 feat: integrate Subject permission management with Worker contracts 2026-10-30 05:02:30 +09:00
Hare 0cd4811739 fix: supply receipt-free Plugin preparation and authenticated Host entry 2026-10-30 04:43:40 +09:00
Hare 165153c63c feat: stage exact Plugin Tools through shared Worker composition
Extract the unchanged canonical plan model for typed Runtime snapshots without a Server dependency cycle. Bind Tool consumer identities to persisted Session/call IDs, use the owner-issued context and current publication authorization, and register through FeatureInstallPlan with cooperative shutdown.

Validation so far: root cargo check --locked, portable model 6 tests/doctest, Worker actual Component adapter 7 focused tests, Server plan 9 tests via shared isolation. Full product launch owner/pin lifecycle and Remote/Embedded vertical integration remain a subsequent coherent change.
2026-10-30 04:42:53 +09:00
Hare 3a13301f75 feat: deliver exact Plugin artifacts through current Host authority
Bind remote and in-process delivery to authenticated Worker lineage, live installation permission and persisted exact pins. Verify canonical bytes and complete descriptors in a bounded Runtime-only atomic cache; cache hits reauthorize and process-crash staging never becomes visible.

Shares the exact PluginInstallation permission shape published for T-793; no HTTPS or launch/restore integration is included. Depends on the T-465 source ancestry and the shared Host execution identity helper.
2026-10-30 04:09:12 +09:00
Hare 732fe5c4c1 feat: enforce live Plugin Host authority and durable HTTPS effects 2026-10-30 04:07:19 +09:00
Hare 7c238edc1a feat: deliver exact Plugin artifacts through current Host authority
Bind remote and in-process delivery to authenticated Worker lineage, live installation permission and persisted exact pins. Verify canonical bytes and complete descriptors in a bounded Runtime-only atomic cache; cache hits reauthorize and process-crash staging never becomes visible.

Shares the exact PluginInstallation permission shape published for T-793; no HTTPS or launch/restore integration is included. Depends on the T-465 source ancestry and the shared Host execution identity helper.
2026-10-30 03:51:57 +09:00
Hare 55ad8c4265 feat: connect async chat to current Ticket assignment and saved results 2026-10-30 03:16:00 +09:00
Hare 1eecccc21e feat: compose Subject Ticket assignment with selected dependency contracts
Adapt T-778 source 6d15569 and selected T-465 plugin plans on T-803 51f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
2026-10-30 03:16:00 +09:00
Hare c65f145376 feat: expose current Plugin execution identity to trusted Hosts 2026-10-30 02:53:18 +09:00
Hare 8661505b5a feat: bind Plugin Host identity to live Worker authority 2026-10-30 02:53:18 +09:00
Hare 4f096f26b5 feat: expose current Plugin execution identity to trusted Hosts 2026-10-30 02:52:25 +09:00
Hare e7506c5eb8 feat: bind Plugin Host identity to live Worker authority 2026-10-30 02:51:35 +09:00
Hare 967274bfbd feat: add exact Plugin installation operation resource 2026-10-30 02:41:52 +09:00
Hare 6d15569b9b test: cover public Subject Ticket delegation and result evaluation 2026-10-30 01:54:50 +09:00
Hare 8810d3245e fix: append Subject migration after retained Plugin plan schema 2026-10-30 01:54:50 +09:00
Hare bd70eda4e0 fix: connect Ticket consumers to selected Worker delegation candidate 2026-10-30 01:14:29 +09:00
Hare 771120778b feat: connect restricted async chat to background Worker results
Exercise public Spawn, real bounded work, other-conversation handling, ordinary Notify and explicit publication to the originating message. Retain stable complete spawn input across acknowledgement loss and retries; verify operation and target revocation without a second result or grant registry.
2026-10-30 01:10:16 +09:00
Hare d077302b92 fix: align Worker operation grants and known-target observation
Compose the published T-803 candidate 51f0c7e019 with T-766 without target adoption. Preserve operation-use versus directional control, notify-only replies, live revocation, and T-766 receipt/provenance boundaries.
2026-10-30 01:10:16 +09:00
Hare c82113f7c4 fix: scope Ticket notification scans to each assigned Subject 2026-10-30 00:53:31 +09:00
Hare 51f0c7e019 Authorize known Worker observations without collection discovery 2026-10-30 00:37:56 +09:00
Hare 26cc5e9306 fix: supply missing Workspace permission fixtures for Memory tests 2026-10-30 00:11:02 +09:00
Hare 6733aeef01 feat: connect Subject Ticket assignments across public execution paths 2026-10-30 00:11:02 +09:00
Hare c215c72873 fix: preserve Console Notify ACK identity and request ID limits 2026-10-30 00:00:05 +09:00
Hare 87a1b6f1eb fix: authorize bound standalone Subject body and Job operations 2026-10-30 00:00:05 +09:00
Hare 882316fb3b feat: add Subject async chat with explicit durable publication 2026-10-29 22:27:23 +09:00
Hare 74ddd568eb Separate Worker operation authorization from directional control grants 2026-10-29 22:26:35 +09:00
Hare 4c2bd3ea78 fix: synchronize Feature shutdown capacity test on its storage fence 2026-10-29 10:08:09 +09:00
Hare dd312b71ee docs: localize browser evidence boundary comments 2026-10-29 09:34:30 +09:00
Hare 2141672310 test: exercise Subject permission management in Chromium 2026-10-29 09:33:58 +09:00
Hare 3600843683 feat: manage direct and delegated Subject permissions from Web
Use typed catalog labels and resource inputs, preserve unsupported entries, preview lane-specific differences and retain exact save requests for unknown-outcome reconciliation. Re-read current policy instead of treating historical receipts as authority.
2026-10-29 09:26:11 +09:00
Hare 38a953cd91 feat: expose typed Subject permission management catalog
Validate management additions in the existing CAS/replay transaction while retaining exact legacy entries in their original lane. Expose read-only account capability and test real saved-policy authorization, conflicts, replay and deletion fences.
2026-10-29 09:25:09 +09:00
Hare fc1115b167 fix: pin evaluated Plugin config after Profile overrides 2026-10-29 09:17:52 +09:00
Hare 9b7a58d5c4 feat: resolve Workspace Plugin config into immutable execution plans 2026-10-29 08:46:39 +09:00
Hare 7ad6030498 feat: add Workspace Plugin catalog and immutable artifact authority 2026-10-29 06:49:51 +09:00
Hare 5087abc5ab fix: align signed Ticket fixtures with combined native contracts 2026-10-29 05:29:18 +09:00
Hare 90398391dd test: verify Feature rollback restores typed result publication 2026-10-29 05:29:18 +09:00
Hare 6486caf992 fix: expose typed Workspace config batch contracts 2026-10-29 05:08:40 +09:00
Hare 0ce4fc04ba fix: distinguish Drive service and typed entry results 2026-10-29 05:05:20 +09:00
Hare 3812126897 fix: expose typed Workdir result references 2026-10-29 05:05:20 +09:00
Hare ec275da601 fix: combine typed domain results with Feature publication 2026-10-29 05:05:12 +09:00
Hare 5f0c960048 fix: reconcile Feature composition with neutral Worker contracts 2026-10-29 05:04:39 +09:00
Hare e35cb50f3a feat: integrate isolated Plugin Tool executor 2026-10-29 05:01:31 +09:00
Hare b8f54eeb6d fix: expose notification MR origin checks as typed entries 2026-10-28 18:19:28 +09:00
Hare 959ed7a554 feat: publish typed Ticket Objective and MR result entries 2026-10-28 18:02:39 +09:00
Hare 0666434ec8 test: align signed native fixtures with current Ticket input contracts 2026-10-28 17:59:19 +09:00
Hare 14101d45dc refactor: compose Objective and SubWorker contributions through Feature installation 2026-10-28 17:59:19 +09:00
Hare 4e2f6fcb9c fix: expose typed Workspace config batch contracts 2026-10-28 17:49:22 +09:00
Hare 043465dc6b fix: separate Drive service identity and type native result entries 2026-10-28 17:46:05 +09:00
Hare 9b5fad3970 fix: publish typed workdir result entries and current content entrances 2026-10-28 17:35:14 +09:00
Hare 45e24375d5 fix: reject unmetered private Component resource allocators
Address formal review R1 by failing closed on guest resource definitions and canonical resource.new in outer and nested Components before Store creation. Preserve shared artifact/ABI verification and imported Host connections; regress resource ceilings zero/default, 64 live handles in core start, nested definitions and fresh execution after rejection.
2026-10-28 17:14:25 +09:00
Hare d9e9c0f13d feat: execute v2 Plugin tools in isolated Wasmtime invocations
Reuse canonical artifact and ABI verification; inject live Host admission and operation adapters without product authority or ambient WASI. Bound fresh Stores, resources, CPU, common deadlines and diagnostics, and regress actual PDK/adversarial Components.
2026-10-28 16:54:53 +09:00
Hare de4159b20f fix: align signed Ticket integration fixtures with native v2
Dependent authorization/backend tests still selected collection/v1 or supplied flat Tool arguments to the native surface introduced by T-797. Use the observed v2 interface, content/observed groups and page cursor/limit coordinates. Do not add a production compatibility fallback.

Validation on the final combined tree: cargo test -p worker (1254 lib plus integration targets), -p worker-runtime (562 lib, both 20-test binary targets), and -p yoi-workspace-server (1345 lib passed, 3 existing ignored, 12 binary passed); all normal doctests passed. Signed authorization 29 and Ticket Tool/WIP backend 4 focused cases also passed after fixture repair.
2026-10-28 16:51:46 +09:00
Hare 6826db7e0c refactor: own Worker and Ticket service contracts outside providers
Move shared control/lifecycle/observation/Ticket contracts into feature::contracts. Consumers and host bindings import the neutral owner; providers retain current authorization, explicit combined routing and persisted composition.

Document stable semantic IDs and exact compatibility epochs; connect consumer requirements to the existing planner without a new resolver. Exercise consumer installation/dispatch with contract-only test providers, atomic rejection paths, and parsed-source architecture guards including negative fixtures.

Validation: worker and worker-runtime normal tests and doctests passed; root cargo check, rustdoc with warnings denied, fmt, diff and state-contract names passed. Dependent Server authorization tests require separate stale native-v1 fixture updates captured next.
2026-10-28 16:51:46 +09:00
Hare a1fbe1e754 fix: preserve native plan filters and disclose input coordinates 2026-10-28 15:24:28 +09:00
Hare 8b261cdae1 feat: define native Ticket and MR input contracts with shared domain adapters 2026-10-28 14:56:42 +09:00
Hare 881e241e67 feat: integrate v2 Plugin contracts and authoring 2026-10-28 13:53:26 +09:00
Hare 00903264f3 fix: bind deletion Job fixtures to current Subject authority
Use a real connected origin with exact request/supply permissions, assert immutable Job origin, and materialize its cleanup storage. Reuse test-only setup helpers without weakening production authorization.
2026-10-28 13:38:47 +09:00
Hare f598a36ac0 feat: integrate operation-boundary authorization
Merge reviewed source a35d9a3d45 into develop after Subject deletion. Preserve deletion fencing before Job authorization, retain both test modules, and regenerate combined API artifacts.
2026-10-28 13:38:47 +09:00
Hare 2d195f4058 feat: provide v2 Plugin Component contracts and offline validation
Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs.
2026-10-28 13:31:47 +09:00
Hare 06883c51b7 docs: define Workspace Plugin API contracts 2026-10-28 13:01:45 +09:00
Hare a35d9a3d45 fix: retain Host-bound Job input authority through every model supply
Bind immutable Job kind/id/input digest to retained input provenance. Recheck current Host authority after hooks and at each compactor request; restored or rewritten input cannot become a local grant or switch Job identities. Add actual Controller, lifecycle, correction and signed Host/model regressions for Journal and consolidation.
2026-10-28 12:53:48 +09:00
Hare 181b0d637e docs: define Workspace Plugin API contracts 2026-10-28 12:15:35 +09:00
Hare b0733e53b2 fix: release removed Subject bodies while preserving journal history 2026-10-28 12:15:35 +09:00
Hare 0710738173 fix: reconcile unfinished retention before Subject deletion 2026-10-28 12:07:16 +09:00
Hare 960a1b4364 Authorize Feature effects and protected input at operation boundaries
Remove non-MCP activation gates without expanding legacy grants. Bind resource resolution, background Job attenuation, protected prompt provenance and repeated model supply to current Host authority. Align generated contracts and regression fixtures.
2026-10-28 11:44:34 +09:00
Hare a06f6bfb3d feat: replace Subject retirement with durable explicit deletion 2026-10-28 11:18:43 +09:00
Hare 618880d38d fix: merge develop and reconcile schema histories
Preserve both published schema-103 histories and converge their Passkey results and Job input retention features transactionally at schema 104. Retain local Reviewer, integration-event, auth and sidebar fixes while adopting develop restore and E2E updates; regenerate API contracts.

Validation: schema convergence regressions, full Server/Worker/Runtime suites, server-api and merge-request tests, Web unit/component tests, root cargo check, E2E harness tests and restore/auth target compilation, generated artifacts, fmt and naming checks. Product-process E2E not rerun; live services and databases untouched.
2026-10-28 04:01:34 +09:00
Hare 79b76c8789 fix: merge worker restore into develop host lifecycle
Integrate integration/worker-restore without reviving removed Worker socket/discovery paths. Keep restore candidate allocation and canonical effects deferred until activation, with registry reclamation and observer publication fences on the in-process controller.

Preserve develop BackendJob/provenance, immutable Journal body capture and erasure, and Workdir diagnostics. Adapt obsolete socket fault tests to in-process publication and registry drift boundaries.

Validation: root cargo check; ordinary worker, worker-runtime and standalone package tests; Server restore (43) and Journal (49) filters; mcp/server-api/runtime-api/session-store tests; optional Runtime feature check; OpenAPI and six generated TypeScript checks; Web restore unit tests (15); runner tests (21); fmt, naming and diff checks. Full workspace/Server suites, E2E, live/model and fixture generation not run.
2026-10-28 00:06:17 +09:00
Hare 897c025b85 feat(journal): execute frozen body inputs with durable delivery and erasure fences 2026-10-27 08:03:43 +09:00
Hare 5b12c3e085 fix: make mobile sidebar swipes track touch gestures 2026-10-27 06:49:28 +09:00
Hare 623c31615d fix: assign unique event refs to merge integration comments 2026-10-27 06:15:48 +09:00
Hare af719e387d fix: align reviewer guidance with Bash-enabled scope 2026-10-27 06:15:48 +09:00
Hare dddb21bd70 fix: preserve possible repair during v9 Restore migration 2026-10-27 01:24:50 +09:00
Hare e8d4e964cb fix: refresh Workdir API generated contract digest 2026-10-26 21:25:46 +09:00
Hare eb24cb04dc fix: revalidate Host authority when publishing prepared restore controllers 2026-10-26 20:39:28 +09:00
Hare e84057f402 fix: retain exact restore ownership in test execution backends 2026-10-26 20:39:28 +09:00
Hare 15c3006d09 fix: propagate fallible restore publication through server factory adapters 2026-10-26 19:59:56 +09:00
Hare b3209c2012 test: verify real orphan pin repair across owned restore process death 2026-10-26 19:38:24 +09:00
Hare 6f5a424661 fix: reclaim stale allocation authority before restoring committed run artifacts 2026-10-26 19:37:26 +09:00
Hare 8d47d2670b fix: initialize reconciliation fault evidence in offline store construction 2026-10-26 14:43:38 +09:00
Hare b19de45a82 test: verify original catalog projection after receipt reconciliation 2026-10-26 14:40:58 +09:00
Hare 1e88893ef9 fix: preserve non-restore cleanup evidence through bridge refactoring 2026-10-26 14:31:45 +09:00
Hare 1fd2e09971 test: cover real partial publication and terminal receipt durability faults 2026-10-26 14:28:37 +09:00
Hare f85d831104 fix: retain committed controller paired streams across bridge admission failure 2026-10-26 14:10:02 +09:00
Hare 2b24db5a15 test: fence superseded restore replay and old Shutdown after replacement 2026-10-26 13:49:48 +09:00
Hare e1945d977c test: prove real postcommit activation timeout ownership and Stop fencing 2026-10-26 13:40:51 +09:00
Hare a10875d68e fix: retire the exact stopped Controller from runtime observation 2026-10-26 13:36:37 +09:00
Hare f35dcef4d0 test: synchronize owned runtime crashes at restore decision checkpoints 2026-10-26 13:10:54 +09:00
Hare f5ebae1134 test: verify restore response loss and owned runtime restart 2026-10-26 12:27:33 +09:00
Hare 6be4e8388b feat: recover saved restore operations by their durable decision 2026-10-26 11:50:54 +09:00
Hare 9e3f87ac52 fix: runtime retain restore facts when Stop supersedes publication 2026-10-26 11:50:54 +09:00
Hare ceb01f6eaa feat: runtime own restore continuation beyond caller timeout 2026-10-26 11:50:54 +09:00
Hare 1b13563e19 feat: runtime commit restore decisions before execution publication
Persist explicit restore commit evidence, reconcile canonical write uncertainty, and publish the same prepared controller before Accepted. Keep owned cleanup and publication failures distinct. Preserve optional transport/store feature boundaries and adapt external controller constructors.
2026-10-26 11:50:54 +09:00
Hare 848a1e9a5b feat: worker separate restored controller preparation from publication
Prepare the real receiver, full snapshot and paired streams privately; retain activation effects and startup cleanup until the host decision.
2026-10-26 11:50:54 +09:00
Hare a77453338c feat: mcp retain stdio process ownership before initialization
Allow startup cleanup to own and reap the exact spawned client across handshake failure or cancellation.
2026-10-26 11:50:54 +09:00
Hare d12bc2ea0b fix: deduplicate live and committed task updates 2026-10-26 10:13:36 +09:00
Hare a1df3742b1 fix: integrate passkey results with current dogfooding shell
Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
2026-10-26 09:50:13 +09:00
Hare d8818b776b feat: open and close mobile sidebar with guarded swipes 2026-10-26 09:39:35 +09:00
Hare 40c36ccb1d fix: restore authenticated home and account navigation 2026-10-26 09:39:35 +09:00
Hare 80c151d084 fix: fence recovered passkey results against cross-tab changes 2026-10-26 07:52:06 +09:00
Hare c6a8759e7b fix: fence activation and setup against delayed cross-tab events
Capture the shared authentication stamp as well as generation before dispatch and across response/confirmation/application awaits. Do not let stale setup failure reconciliation overwrite a newer tab's stamp. Add no-event-yet logout and account-change regressions for activation and setup, including prompt, pre-complete and post-dispatch confirmation boundaries.

Addresses MR AC4_CROSS_TAB_ACTIVATION. Validation: activation21/setup40 narrow tests; Web720 unit+625 component, check0/0/build, activation and hosting browser suites; root check/fmt/diff; both explicit real Backend browser suites including native CTAP2. Existing Server exclusions and 15 identical baseline naming findings remain unchanged. No verifier, binding, migration or rendering contract weakened.
2026-10-26 07:34:04 +09:00
Hare f17b5212c7 fix(auth): confirm passkey operations and preserve safe legacy migration
Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
2026-10-26 07:18:23 +09:00
Hare 85f70d4bc1 test: adapt fixed Worker restore fixtures to integration lane 2026-10-26 06:20:22 +09:00
Hare b284934e34 test: add captured Codex worker restore fixture 2026-10-24 12:57:28 +09:00
Hare 2d33321fab fix: validate live fixture snapshots and shutdown boundaries 2026-10-24 12:57:28 +09:00
Hare 0975471986 fix: preserve diagnostic context in backend test assertions 2026-10-24 12:51:28 +09:00
Hare 214d6e21dd fix: assert classified workdir session closure in tests 2026-10-24 12:40:37 +09:00
Hare fca5c23345 fix: reject unrestorable and opaque fixture records 2026-10-24 12:39:22 +09:00
Hare f9b1500ba2 fix: align panel tests with current ticket case states 2026-10-24 12:33:31 +09:00
Hare 931bf3b27d test: add isolated saved worker restore scenarios 2026-10-24 12:33:31 +09:00
Hare 6bb671be21 test: add typed stopped worker fixture transport 2026-10-24 12:19:43 +09:00
Hare b8f2fa479c test: add isolated backend fixture seeding 2026-10-24 12:15:10 +09:00
Hare 1639be9841 fix: allow reviewers to read related tickets 2026-10-23 03:22:45 +09:00
Hare 907392cce8 fix: preserve restore effects and fence prepared attachment replay 2026-10-23 00:21:02 +09:00
Hare 6776973e4c fix: preserve original session coordinates through capture selection 2026-10-22 23:17:25 +09:00
Hare 73a3e674c9 feat(worker): separate restored candidates, repair, and activation 2026-10-22 22:58:57 +09:00
Hare 994e75233b feat(session): share immutable captures and bounded body transport 2026-10-22 22:25:47 +09:00
Hare 8b6e3070dd fix: align Workdir session cwd and model environment
Project alias, cwd, scope and command ceilings from actual sessions. Bind initial attachment cwd once in the shared broker/router while keeping root-relative delegation and detach generations. Preserve caller coordinates across reads, searches, checked checkout and commands; keep provider authorization and symlink boundaries.

Connect real SubWorkers, all filesystem Tools, scoped aliases, HTTP providers and limited Host grants in permanent regressions. Validated related normal suites (workdir http-client, tools, worker, worker-runtime, server-api typescript, fs-operation), workspace-server normal suite (1272 passed, 3 existing ignores), root check, product binary builds, rustdoc/doctests, generated contract checks, format and state-contract names. Test roots are checkout-local; Git parent discovery requires GIT_CEILING_DIRECTORIES at TMPDIR, confirmed against exact b860 baseline.
2026-10-22 20:46:10 +09:00
Hare b860c67ea2 fix: align Runtime restore diagnostics and socket test fixtures 2026-10-22 03:46:46 +09:00
Hare 533477c2e7 feat: unify headless execution under the Standalone Host
Expose an optional private same-uid Unix operator adapter and JSONL serve/attach CLI using the production Standalone Profile, store, restore lease and shutdown lifecycle. Remove direct Worker subprocess/discovery/readiness/controller sockets and parent socket callbacks; retain metadata-only peer registration and legacy safety readers.

Validated real Host/client input, events, provenance, disconnect, admitted shutdown and shared production restore/duplicate exclusion; normal standalone/worker/manifest/protocol/client/yoi/tui/server tests, root check, rustdoc, fmt and state-contract audit. Runtime lib: 476 passed, damaged_checkout_does_not_block_restore_but_is_rejected_when_used fails at missing_git diagnostic; unchanged restore logic, not relaxed or retried. Runtime bins/docs pass.
2026-10-22 01:29:39 +09:00
Hare 5aa2dc579a fix: integrate bounded Bash cleanup diagnostics 2026-10-22 00:46:41 +09:00
Hare 280b3d2a25 Merge branch 'develop' of /home/hare/Projects/yoi into work/T-775-cleanup-diagnostics 2026-10-22 00:24:00 +09:00
Hare ec74f4cdc4 fix: preserve Workdir failure diagnostics and audit outcomes 2026-10-22 00:16:11 +09:00
Hare 5af9dc8e4a test: focus checkout hardlink coverage on alias and symlink contracts 2026-10-21 23:57:01 +09:00
Hare ca6d4b4b72 fix: audit validated external workdir operation outcomes 2026-10-21 20:39:46 +09:00
Hare cccecbe7ca fix: integrate command output failure diagnostics 2026-10-21 06:52:23 +09:00
Hare 7a64c38493 fix: merge shared command diagnostic assertions
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-10-21 06:40:26 +09:00
Hare b1361d86e9 fix: distinguish external Workdir admission dispatch and response failures
Integrate the published T773 audit contract without overwriting operation outcomes. Preserve bounded admission, send refusal, response loss/timeout and invalid result reasons independently of audit failures and request stages. Signed HTTP/SQLite/log fault tests pass (10), API logging 14 and relevant focused assertions pass. Root check, docs, generated artifacts, fmt/diff and state-contract naming checks pass. Full six-package normal suite ran with no-fail-fast but is not green: long Unix socket fixture paths and existing shared close assertion plus environment-isolation failures are recorded in target/t774 logs. Isolated focused checks resolved the latter; socket validation requires a granted shorter test root. This is a partial checkpoint, not a reviewed completion.
2026-10-21 06:39:54 +09:00
Hare 6181426abd fix: assert retained checkout outcome diagnostics 2026-10-21 06:27:54 +09:00
Hare cd331b93c6 Merge remote-tracking branch 'origin/work/T-773-workdir-audit' into work/T-774-workdir-failure-diagnostics
# Conflicts:
#	crates/workdir/src/http.rs
2026-10-21 06:13:35 +09:00
Hare 60eabea19a fix: assert retained remote session close diagnostics 2026-10-21 06:13:16 +09:00
Hare 7394ec6784 fix: retain safe Workdir open authorization and response failure causes
Keep bounded reasons and derived failure stages separate from public classification. Reject provider-authored raw error text on transport decoding. Preserve identity mismatch when cleanup fails and retain its handle for explicit close without opening again. Add signed Runtime HTTP, Registry/API logging, remote mismatch, authorization no-dispatch and deferred fault coverage. Focused tests and Workdir suite pass; full-source validation follows T773 audit contract merge.
2026-10-21 06:13:07 +09:00
Hare 28b7cc414b fix: retain external workdir results independently of audit failures 2026-10-21 06:04:39 +09:00
Hare 86d5cf9403 fix: integrate safe Drive and config failure diagnostics 2026-10-21 06:01:51 +09:00
Hare 6f0a23e5cc Merge commit '62e2b4430588bacf90a2812a0a88772fc0a00c58' into work/T-775-cleanup-diagnostics 2026-10-21 05:56:16 +09:00
Hare 2f4a46bbdb Merge commit 'a1748b552c52fd5f68de6ba852421fd778aec010' into work/T-775-cleanup-diagnostics 2026-10-21 05:55:48 +09:00
Hare 4872cf2aac fix: retain independent Bash Drop cleanup diagnostics 2026-10-21 05:55:48 +09:00
Hare 734d4b44ae Merge remote-tracking branch 'origin/work/T-772-command-output-integrity' into work/T-774-workdir-failure-diagnostics 2026-10-21 05:55:33 +09:00
Hare 6810f51f46 fix: retain safe Drive and config adapter failure diagnostics 2026-10-21 05:53:58 +09:00
Hare a1748b552c fix: validate closed-session diagnostics and fault quotas 2026-10-21 05:52:48 +09:00
Hare 62b2d27b4a fix: retain command failure stages and output collection state 2026-10-21 05:40:39 +09:00
Hare 62e2b44305 fix: retain safe Journal compaction failure diagnostics
Keep stage/reason classification in the Journal Feature without changing frozen input, metadata or fallback behavior. Exercise connection, Host read failures, size budget and secret-bearing fixtures through compaction persistence.

Validation: old freeze function with new tests fails connection and snapshot/size diagnostics (3 failures); new code passes all six selected cases and cargo test -p worker. Serialization is currently unreachable for the derived JSON-safe snapshot fields; no artificial runtime failure is claimed.
2026-10-21 05:32:07 +09:00
Hare b5c105545c fix: close unattached deferred sessions in restore rollback
Logical Workdir routing can use a Workspace session instead of the operation's raw Deferred handle, and reconstruction can fail before routing. Close the exact handles frozen in the accepted operation request after router cleanup and before releasing allocation ownership. Do not resolve aliases against a new execution or consult current Host grants for cleanup.

Reproduced on 61c20b099 with diagnostic-only test output: rollback emptied routers with provider opens 0, but raw read succeeded and opened the provider (1). Retain the SessionClosed and zero-open regression, add pre-router Metadata/Reconstruction/Binding recovery and operation-mismatch/same-alias isolation coverage. Focused Profile and deferred cases pass; full exact-source validation follows this commit before publication.
2026-10-21 04:55:38 +09:00
Hare aa0fb2f787 fix: continue workdir output reads after long-poll expiry 2026-10-21 04:48:43 +09:00
Hare 1b6d4dbfd8 fix: preserve workdir failure diagnostics across transport 2026-10-21 01:22:04 +09:00
Hare 61c20b0995 fix: adapt T-769 recovery onto current restore lane checkpoint
UNCOMPILED TRANSPORT / PRESERVATION CHECKPOINT, NOT A VALIDATED CANDIDATE. One-time recovery authorization permits publication before validation so the parent can validate this exact source in a separate Workdir. Prior compilation attempts were interrupted by Workdir-session unavailability; cause and test outcome remain unknown. No Cargo build/check/test was started in this bounded checkpoint turn.

Merge current lane 4ef7b03651 into existing source b7a22146a0. Selectively adapt typed factory failure and same-operation cleanup/replay while preserving current saved Feature restore and operation-time authorization, logical/deferred Workdirs, T765 Host/schema and T768 replay-safe Subject grant. No T770/T771 implementation.

All 19 target-relative paths and added test bodies were source-audited. Workspace fmt check, scoped rustfmt, whitespace/diff checks passed; conflict markers zero. Excluded unrelated formatter changes and untracked compiler artifacts. Local audit: target/t769-checkpoint/AUDIT.md.

Handoff to parent: compile and run focused and package/API regressions on this exact source, fix as necessary, then obtain fresh independent review. Existing MR 01a15606-a6ba-7d23-8d60-0874607399e7 only; no new review requested here. No acceptance, approval, adoption, target/develop push, integration, Ticket closure or service update is asserted.
2026-10-21 00:20:54 +09:00
Hare 4ef7b03651 fix: stop background session checks and loading flicker 2026-10-20 22:35:38 +09:00
Hare af615ab177 fix: retain archive and worker restore failure diagnostics 2026-10-20 22:00:58 +09:00
Hare 338e39bcdb fix: defer restored workdir access until use 2026-10-20 22:00:58 +09:00
Hare 3cfe04be3a fix: preserve saved feature configuration during worker restore 2026-10-20 22:00:39 +09:00
Hare 70d153b365 fix: isolate unreadable archives from runtime startup 2026-10-20 17:39:38 +09:00
Hare ce93849948 chore: merge develop into hare/develop 2026-10-20 15:39:09 +09:00
Hare 3c499719fe feat: default Coder ticket work to review flow 2026-10-20 15:38:50 +09:00
Hare 9e1d3df7ad fix: remove overbroad Worker bypass instructions 2026-10-20 15:38:50 +09:00
Hare 18bac16a23 fix: require replay-safe control lifecycle for Subject delegation 2026-10-20 08:12:26 +09:00
Hare 9005856f77 fix: replay accepted delegation without reserving active Workdirs 2026-10-20 07:24:51 +09:00
Hare e39372a52a Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants 2026-10-20 06:38:12 +09:00
Hare 223a74a9e2 fix: retain byte command transport and explicit authorization fixtures 2026-10-20 06:38:12 +09:00
Hare f38d9a16ea Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants
# Conflicts:
#	crates/worker-runtime/src/worker_backend.rs
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-20 06:24:11 +09:00
Hare b135fa5e99 feat: enforce explicit Subject permissions and delegated Worker grants 2026-10-20 06:22:58 +09:00
Hare b7a22146a0 fix: recover confirmed worker restore failures 2026-10-20 06:00:58 +09:00
Hare 07b8b6d41b Merge commit 'b8e548b92d3c785c482dbb418517cc5a2cf9e7e0' into refactor/T-765-session-feature-boundary 2026-10-20 05:34:22 +09:00
Hare 8017c55d82 fix: retain annotated compaction history and honor non-System input policy 2026-10-20 05:34:22 +09:00
Hare b8e548b92d chore: integrate reviewed browser session navigation 2026-10-20 05:31:32 +09:00
Hare 90e38551cf Merge commit '00ad02d04728a8e32a82a4e72747fefcf8a82d4e' into refactor/T-765-session-feature-boundary
# Conflicts:
#	crates/worker/src/feature/builtin.rs
2026-10-20 05:25:16 +09:00
Hare 3e8164e757 fix: fence hosting setup verification against superseded sessions 2026-10-20 04:53:43 +09:00
Hare 3b3381900f fix: preserve role-neutral input policy across compaction and replay 2026-10-20 04:45:50 +09:00
Hare 00ad02d047 chore: integrate bounded shared text page refusal 2026-10-20 03:55:13 +09:00
Hare a774925dec fix: reject byte-truncated line windows before advancing search 2026-10-20 03:53:03 +09:00
Hare 8f759911ab fix: emit Session use policy type in browser bindings 2026-10-20 03:52:55 +09:00
Hare daf1f6ed50 fix: route retained Session migration through Runtime staging 2026-10-20 03:52:55 +09:00
Hare 86602240fa refactor: bind Subject metadata and prompt confirmation to Host lifecycle 2026-10-20 03:52:55 +09:00
Hare bfeee487fd feat(auth): separate anonymous entry from expired session recovery 2026-10-20 03:52:48 +09:00
Hare f9830fe7dd test: reject Bash search pages that would skip an unread long line 2026-10-20 03:40:36 +09:00
Hare de96c5b577 chore: update shared file contracts and validation fixtures
# Conflicts:
#	crates/worker/src/checkout_http_tests.rs
2026-10-20 03:26:56 +09:00
Hare 6e729ed06c refactor: isolate Feature System input migration and compaction policy 2026-10-20 03:21:22 +09:00
Hare f38b2a52b1 fix: record native prior Read only after result validation 2026-10-20 03:21:14 +09:00
Hare dd5384df3b feat: expose retained Bash streams through common WIP file contracts 2026-10-20 03:13:46 +09:00
Hare 6576850b7a chore: connect shared file contracts for Bash validation 2026-10-20 03:03:53 +09:00
Hare 4555aace28 feat: capture bounded Bash output over replayable byte transport 2026-10-20 03:01:22 +09:00
Hare 47b32ef6ab fix: bind capability changes to Object freshness and update contract consumers 2026-10-20 02:53:21 +09:00
Hare bb5c50f9ab feat: persist System input policies through public consumers 2026-10-20 02:44:28 +09:00
Hare 32a409c15d feat: share virtual file contracts and checked binary byte ranges 2026-10-20 02:18:49 +09:00
Hare 19514a5d6a feat: define bounded Feature-owned system input envelopes 2026-10-20 02:14:52 +09:00
Hare 887184796e docs: distinguish commit completion from Feature authority 2026-10-20 02:05:02 +09:00
Hare cd0eb5eb00 fix: allowlist public system input projections 2026-10-20 02:02:28 +09:00
Hare e8dfb3e68d refactor: notify system input producers after durable commit 2026-10-20 02:02:28 +09:00
Hare 434ce34da7 refactor: bind checkout target lifetimes to Object observations 2026-10-20 02:01:22 +09:00
Hare bfb51b7ca9 fix: bound encoded common text results including escape and record overhead 2026-10-20 01:48:23 +09:00
Hare 38d9069632 feat: publish stable root file contracts and target-owned dispatch 2026-10-20 01:46:30 +09:00
Hare e5749b9be0 test: integrate Drive connection and lifetime coverage 2026-10-20 00:37:32 +09:00
Hare 387b5ff7df docs: record Drive connection coverage and fault evidence 2026-10-20 00:35:11 +09:00
Hare ec6c5beda0 test: connect Drive publication to client and resource lifetimes 2026-10-20 00:34:50 +09:00
Hare 8e40fae6f4 fix: recover unpublished Subject creation and preserve auth rejections 2026-10-19 18:56:24 +09:00
Hare c961f244ab fix: preserve feature metadata for concise Journal startup context 2026-10-19 18:45:32 +09:00
1117 changed files with 326721 additions and 28271 deletions
Generated
+868 -12
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -17,6 +17,8 @@ members = [
"crates/runtime-api", "crates/runtime-api",
"crates/server-api", "crates/server-api",
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/plugin-plan",
"crates/plugin-runtime",
"crates/yoi", "crates/yoi",
"crates/protocol", "crates/protocol",
"crates/session-metrics", "crates/session-metrics",
@@ -24,14 +26,19 @@ members = [
"crates/lint-common", "crates/lint-common",
"crates/tools", "crates/tools",
"crates/fs-operation", "crates/fs-operation",
"crates/error-contract",
"crates/flow", "crates/flow",
"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",
@@ -57,6 +64,8 @@ default-members = [
"crates/runtime-api", "crates/runtime-api",
"crates/server-api", "crates/server-api",
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/plugin-plan",
"crates/plugin-runtime",
"crates/yoi", "crates/yoi",
"crates/protocol", "crates/protocol",
"crates/session-metrics", "crates/session-metrics",
@@ -64,14 +73,19 @@ default-members = [
"crates/lint-common", "crates/lint-common",
"crates/tools", "crates/tools",
"crates/fs-operation", "crates/fs-operation",
"crates/error-contract",
"crates/flow", "crates/flow",
"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",
@@ -112,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" }
@@ -120,6 +135,7 @@ project-record = { path = "crates/project-record" }
worker = { path = "crates/worker" } worker = { path = "crates/worker" }
worker-runtime = { path = "crates/worker-runtime" } worker-runtime = { path = "crates/worker-runtime" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi-plugin-pdk = { path = "crates/plugin-pdk" }
plugin-plan = { path = "crates/plugin-plan" }
yoi = { path = "crates/yoi" } yoi = { path = "crates/yoi" }
protocol = { path = "crates/protocol" } protocol = { path = "crates/protocol" }
session-metrics = { path = "crates/session-metrics" } session-metrics = { path = "crates/session-metrics" }
@@ -133,6 +149,10 @@ 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" }
fs-operation = { path = "crates/fs-operation" } fs-operation = { path = "crates/fs-operation" }
workdir = { path = "crates/workdir" } workdir = { path = "crates/workdir" }
tui = { path = "crates/tui" } tui = { path = "crates/tui" }
+76 -51
View File
@@ -1480,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) { fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
let mut pending_calls = Vec::new(); let mut pending_calls = Vec::new();
let mut started_call_ids = Vec::new(); let mut started_call_ids = Vec::new();
let mut answered_call_ids = std::collections::HashSet::new(); for entry in unanswered_tool_call_entries(history) {
for item in history.items() {
if let Item::ToolResult { call_id, .. } = item {
answered_call_ids.insert(call_id.clone());
}
}
for item in history.items() {
if let Item::ToolCall { if let Item::ToolCall {
call_id, call_id,
name, name,
@@ -1496,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
execution_id, execution_id,
status, status,
.. ..
} = item } = &entry.item
&& !answered_call_ids.contains(call_id)
{ {
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) { if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
started_call_ids.push(call_id.clone()); started_call_ids.push(call_id.clone());
@@ -1639,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
} = info; } = info;
tool_call = call; tool_call = call;
context.call_id = tool_call.id.clone(); context.call_id = tool_call.id.clone();
if let PreToolAction::SyntheticResult(result) = &action
&& result.tool_use_id != expected_tool_use_id
{
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let execution_id = if matches!(action, PreToolAction::Pause) {
None
} else {
Some(context.execution_id())
};
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
// Policy runs before this call exists in history. Only the successful
// append's annotation may bind the identity, overriding hook changes.
context.logical_call_id = history
.entries()
.last()
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
batch.call_info_map.insert( batch.call_info_map.insert(
tool_call.id.clone(), tool_call.id.clone(),
(tool_call.clone(), meta, tool.clone(), context.clone()), (tool_call.clone(), meta, tool.clone(), context.clone()),
@@ -1647,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
match action { match action {
PreToolAction::Continue => { PreToolAction::Continue => {
let attempt_id = context.execution_id(); let attempt_id = context.execution_id();
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(attempt_id.clone()),
)?;
batch batch
.attempt_fence .attempt_fence
.register(tool_call.id.clone(), attempt_id.clone()); .register(tool_call.id.clone(), attempt_id.clone());
@@ -1695,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true; batch.dispatched_any = true;
} }
PreToolAction::SyntheticResult(result) => { PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(result); batch.synthetic_results.push_back(result);
batch.dispatched_any = true; batch.dispatched_any = true;
} }
PreToolAction::Skip => { PreToolAction::Skip => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error( batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id, &tool_call.id,
"Tool execution skipped by interceptor", "Tool execution skipped by interceptor",
@@ -1730,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true; batch.dispatched_any = true;
} }
PreToolAction::Abort(reason) => { PreToolAction::Abort(reason) => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error( batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id, &tool_call.id,
format!("Tool execution aborted: {reason}"), format!("Tool execution aborted: {reason}"),
@@ -1745,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
return Err(EngineError::Aborted(reason)); return Err(EngineError::Aborted(reason));
} }
PreToolAction::Pause => { PreToolAction::Pause => {
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
batch.dispatched_any = true; batch.dispatched_any = true;
return Ok(EarlyToolAdmission::Pause); return Ok(EarlyToolAdmission::Pause);
} }
@@ -2010,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count); let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
self.tool_execution_batch_count += 1; self.tool_execution_batch_count += 1;
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
for entry in unanswered_tool_call_entries(history) {
if let Item::ToolCall { call_id, .. } = &entry.item {
logical_call_ids
.entry(call_id.clone())
.or_default()
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
}
}
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result) // Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new(); let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() { for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone(); let expected_tool_use_id = tool_call.id.clone();
let logical_call_id = logical_call_ids
.get_mut(&tool_call.id)
.and_then(VecDeque::pop_front)
.flatten();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index); let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if !tool_call.input.is_object() { if !tool_call.input.is_object() {
synthetic_results.push(ToolResult::error( synthetic_results.push(ToolResult::error(
@@ -2079,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let tool_call = info.call; let tool_call = info.call;
let mut context = info.context; let mut context = info.context;
context.call_id = tool_call.id.clone(); context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert( call_info_map.insert(
tool_call.id.clone(), tool_call.id.clone(),
(tool_call, info.meta.clone(), info.tool.clone(), context), (tool_call, info.meta.clone(), info.tool.clone(), context),
@@ -2098,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
tool_call = info.call; tool_call = info.call;
let mut context = info.context; let mut context = info.context;
context.call_id = tool_call.id.clone(); context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert( call_info_map.insert(
tool_call.id.clone(), tool_call.id.clone(),
@@ -2111,7 +2103,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
approved_calls.push((tool_call, context, Some(info.tool))); approved_calls.push((tool_call, context, Some(info.tool)));
} else { } else {
// Unknown tools go into approved list as-is (will error at execution) // Unknown tools go into approved list as-is (will error at execution)
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index); let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
context.logical_call_id = logical_call_id;
approved_calls.push((tool_call, context, None)); approved_calls.push((tool_call, context, None));
} }
} }
@@ -4333,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
} }
} }
/// Pair each result with one preceding unanswered occurrence, never with a later
/// call that happens to reuse the provider ID. Keep the exact annotated entries
/// so dispatch and restoration resolve the same host-owned identity.
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
let mut calls = Vec::new();
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
for entry in history.entries() {
match &entry.item {
Item::ToolCall { call_id, .. } => {
by_provider_id
.entry(call_id.as_str())
.or_default()
.push_back(calls.len());
calls.push(Some(entry));
}
Item::ToolResult { call_id, .. } => {
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
&& let Some(index) = queue.pop_front()
{
calls[index] = None;
}
}
_ => {}
}
}
calls.into_iter().flatten().collect()
}
#[cfg(test)]
#[path = "tests/tool_call_identity.rs"]
mod tool_call_identity_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+10 -2
View File
@@ -309,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
pub meta: ToolMeta, pub meta: ToolMeta,
/// Tool instance (for state access). /// Tool instance (for state access).
pub tool: Arc<dyn Tool>, pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call. /// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext, pub context: ToolExecutionContext,
} }
@@ -324,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
pub meta: ToolMeta, pub meta: ToolMeta,
/// Tool instance (for state access). /// Tool instance (for state access).
pub tool: Arc<dyn Tool>, pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call. /// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext, pub context: ToolExecutionContext,
} }
@@ -344,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
/// flows, permission checks, and other trusted host adaptation. /// flows, permission checks, and other trusted host adaptation.
#[async_trait] #[async_trait]
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync { pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Pure projection of a committed ToolCall's host annotation into an opaque,
/// durable call identity. Agen neither interprets storage metadata nor derives
/// this identity from provider IDs. Early pre-tool policy runs before commit;
/// the Engine binds this value after policy, before execution.
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
None
}
/// Whether side effects may start before [`Self::on_assistant_turn_end`]. /// Whether side effects may start before [`Self::on_assistant_turn_end`].
/// ///
/// The conservative default preserves the historical whole-response gate. /// The conservative default preserves the historical whole-response gate.
@@ -66,11 +66,15 @@ pub(crate) struct CandidatePart {
/// Function call. /// Function call.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FunctionCall { pub(crate) struct FunctionCall {
/// Function name. /// Function name.
pub name: String, pub name: String,
/// Arguments. /// Arguments.
pub args: Option<serde_json::Value>, pub args: Option<serde_json::Value>,
/// Partial-argument protocol is not a complete args object.
pub partial_args: Option<serde_json::Value>,
pub will_continue: Option<bool>,
} }
/// Usage metadata. /// Usage metadata.
@@ -110,6 +114,14 @@ pub(crate) struct SafetyRating {
// Event conversion // Event conversion
// ============================================================================ // ============================================================================
/// Response-local Gemini block allocation. Parts in separate SSE frames may
/// restart their array index, and function names are not call identities.
#[derive(Debug, Default)]
pub struct GeminiStreamState {
next_index: usize,
last_text_index: Option<usize>,
}
impl GeminiScheme { impl GeminiScheme {
/// Convert SSE data to Event. /// Convert SSE data to Event.
/// ///
@@ -120,7 +132,11 @@ impl GeminiScheme {
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded. /// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored. /// * `Ok(None)` - Event ignored.
/// * `Err(ClientError)` - Parse error. /// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> { pub(crate) fn parse_event(
&self,
data: &str,
state: &mut GeminiStreamState,
) -> Result<Option<Vec<Event>>, ClientError> {
// Skip empty or invalid data. // Skip empty or invalid data.
if data.is_empty() || data == "[DONE]" { if data.is_empty() || data == "[DONE]" {
return Ok(None); return Ok(None);
@@ -144,31 +160,39 @@ impl GeminiScheme {
// Process candidates. // Process candidates.
if let Some(candidates) = response.candidates { if let Some(candidates) = response.candidates {
for candidate in candidates { for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
if let Some(content) = candidate.content { if let Some(content) = candidate.content {
if let Some(parts) = content.parts { if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() { for part in &parts {
let index = state.next_index;
state.next_index += 1;
// Text delta. // Text delta.
if let Some(text) = &part.text { if let Some(text) = &part.text {
if !text.is_empty() { if !text.is_empty() {
// Gemini sends no explicit BlockStart, so emit TextDelta // Gemini sends no explicit BlockStart, so emit TextDelta
// directly (Timeline handles the implicit start). // directly (Timeline handles the implicit start).
events.push(Event::text_delta(part_index, text.clone())); state.last_text_index = Some(index);
events.push(Event::text_delta(index, text.clone()));
} }
} }
// Function call. // Function call.
if let Some(function_call) = &part.function_call { if let Some(function_call) = &part.function_call {
// Function call start. if function_call.partial_args.is_some()
// Gemini usually sends function calls all at once, but may || function_call.will_continue.is_some()
// send partial calls when streaming arguments are enabled. {
return Err(ClientError::Api {
// Gemini has no function call IDs, so use the name as the ID. status: None,
let function_id = format!("call_{}", function_call.name); code: Some("unsupported_partial_function_call".into()),
message: "Gemini partial function arguments require an unsupported streaming protocol".into(),
retry_after: None,
});
}
// These synthetic IDs correlate calls within a response;
// durable identity still comes from Host history entries.
let function_id = format!("call_{index}");
events.push(Event::BlockStart(BlockStart { events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // Composite index. index,
block_type: BlockType::ToolUse, block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse { metadata: BlockMetadata::ToolUse {
id: function_id, id: function_id,
@@ -180,12 +204,13 @@ impl GeminiScheme {
if let Some(args) = &function_call.args { if let Some(args) = &function_call.args {
let args_str = serde_json::to_string(args).unwrap_or_default(); let args_str = serde_json::to_string(args).unwrap_or_default();
if !args_str.is_empty() && args_str != "null" { if !args_str.is_empty() && args_str != "null" {
events.push(Event::tool_input_delta( events.push(Event::tool_input_delta(index, args_str));
candidate_index * 10 + part_index,
args_str,
));
} }
} }
// Each args object is complete in this wire part. Close
// its collector before the next function call starts;
// dispatch still waits for the response-complete gate.
events.push(Event::tool_use_stop(index));
} }
} }
} }
@@ -200,13 +225,14 @@ impl GeminiScheme {
_ => None, _ => None,
}; };
// Text block stop. if let Some(index) = state.last_text_index.take() {
events.push(Event::BlockStop(BlockStop { events.push(Event::BlockStop(BlockStop {
index: candidate_index, index,
block_type: BlockType::Text, block_type: BlockType::Text,
stop_reason, stop_reason,
reasoning: None, reasoning: None,
})); }));
}
} }
} }
} }
@@ -240,7 +266,10 @@ mod tests {
let data = let data =
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#; r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap(); let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
assert_eq!(events.len(), 1); assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] { if let Event::BlockDelta(delta) = &events[0] {
@@ -260,7 +289,10 @@ mod tests {
let scheme = GeminiScheme::new(); let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#; let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
let events = scheme.parse_event(data).unwrap().unwrap(); let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a Usage event. // Should contain a Usage event.
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_))); let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
@@ -278,7 +310,10 @@ mod tests {
let scheme = GeminiScheme::new(); let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#; let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap(); let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a BlockStart event. // Should contain a BlockStart event.
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_))); let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
@@ -309,7 +344,10 @@ mod tests {
let scheme = GeminiScheme::new(); let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#; let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap(); let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a BlockStop event. // Should contain a BlockStop event.
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_))); let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
@@ -320,10 +358,85 @@ mod tests {
} }
} }
#[test]
fn same_name_calls_have_distinct_ids_and_block_indexes_across_parts_and_frames() {
use crate::llm_client::scheme::Scheme;
let scheme = GeminiScheme::new();
for frames in [
vec![
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}},{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
],
vec![
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}}]}}]}"#,
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
],
] {
let mut state = Default::default();
let mut events = Vec::new();
for frame in frames {
events.extend(scheme.parse_sse("", frame, &mut state).unwrap());
}
let calls: Vec<_> = events
.iter()
.filter_map(|event| match event {
Event::BlockStart(BlockStart {
index,
metadata: BlockMetadata::ToolUse { id, name },
..
}) => Some((*index, id.as_str(), name.as_str())),
_ => None,
})
.collect();
assert_eq!(calls, [(0, "call_0", "echo"), (1, "call_1", "echo")]);
let inputs: Vec<_> = events
.iter()
.filter_map(|event| match event {
Event::BlockDelta(delta) => match &delta.delta {
DeltaContent::InputJson(json) => Some((delta.index, json.as_str())),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(
inputs,
[(0, r#"{"text":"first"}"#), (1, r#"{"text":"second"}"#)]
);
}
}
#[test]
fn partial_argument_protocol_is_not_misread_as_an_empty_complete_call() {
use crate::llm_client::scheme::Scheme;
let scheme = GeminiScheme::new();
for call in [
r#"{"name":"echo","partialArgs":[{"jsonPath":"$.text","stringValue":"part"}]}"#,
r#"{"name":"echo","willContinue":true,"args":{"text":"part"}}"#,
r#"{"name":"echo","willContinue":false}"#,
] {
let data = format!(
r#"{{"candidates":[{{"content":{{"parts":[{{"functionCall":{call}}}]}}}}]}}"#
);
assert!(
matches!(scheme.parse_sse("", &data, &mut Default::default()), Err(ClientError::Api { code: Some(code), .. }) if code == "unsupported_partial_function_call")
);
}
}
#[test] #[test]
fn test_parse_empty_data() { fn test_parse_empty_data() {
let scheme = GeminiScheme::new(); let scheme = GeminiScheme::new();
assert!(scheme.parse_event("").unwrap().is_none()); assert!(
assert!(scheme.parse_event("[DONE]").unwrap().is_none()); scheme
.parse_event("", &mut GeminiStreamState::default())
.unwrap()
.is_none()
);
assert!(
scheme
.parse_event("[DONE]", &mut GeminiStreamState::default())
.unwrap()
.is_none()
);
} }
} }
@@ -239,6 +239,8 @@ impl GeminiScheme {
let mut contents = Vec::new(); let mut contents = Vec::new();
let mut pending_model_parts: Vec<GeminiPart> = Vec::new(); let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
let mut pending_user_parts: Vec<GeminiPart> = Vec::new(); let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
let mut call_names: std::collections::HashMap<&str, std::collections::VecDeque<&str>> =
std::collections::HashMap::new();
for item in items { for item in items {
match item { match item {
@@ -270,8 +272,12 @@ impl GeminiScheme {
} }
Item::ToolCall { Item::ToolCall {
name, arguments, .. call_id,
name,
arguments,
..
} => { } => {
call_names.entry(call_id).or_default().push_back(name);
// Flush pending user parts first // Flush pending user parts first
if !pending_user_parts.is_empty() { if !pending_user_parts.is_empty() {
contents.push(GeminiContent { contents.push(GeminiContent {
@@ -309,11 +315,17 @@ impl GeminiScheme {
Some(c) => format!("{summary}\n{c}"), Some(c) => format!("{summary}\n{c}"),
None => summary.clone(), None => summary.clone(),
}; };
// Gemini responses identify the function by its declaration
// name, never by the adapter's response-local correlation ID.
let name = call_names
.get_mut(call_id.as_str())
.and_then(std::collections::VecDeque::pop_front)
.unwrap_or(call_id.as_str());
pending_user_parts.push(GeminiPart::FunctionResponse { pending_user_parts.push(GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponse { function_response: GeminiFunctionResponse {
name: call_id.clone(), name: name.to_string(),
response: GeminiFunctionResponseContent { response: GeminiFunctionResponseContent {
name: call_id.clone(), name: name.to_string(),
content: Value::String(text), content: Value::String(text),
}, },
}, },
@@ -468,6 +480,36 @@ mod tests {
assert_eq!(gemini_req.contents[0].role, "user"); assert_eq!(gemini_req.contents[0].role, "user");
assert_eq!(gemini_req.contents[1].role, "model"); assert_eq!(gemini_req.contents[1].role, "model");
assert_eq!(gemini_req.contents[2].role, "user"); assert_eq!(gemini_req.contents[2].role, "user");
let body = serde_json::to_value(gemini_req).unwrap();
assert_eq!(
body["contents"][2]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
}
#[test]
fn parallel_results_resolve_function_names_not_synthetic_call_ids() {
let scheme = GeminiScheme::new();
let request = Request::new()
.item(Item::tool_call("call_0", "echo", r#"{"text":"first"}"#))
.item(Item::tool_call("call_1", "echo", r#"{"text":"second"}"#))
.item(Item::tool_result("call_1", "second"))
.item(Item::tool_result("call_0", "first"))
.item(Item::tool_call("call_0", "other", "{}"))
.item(Item::tool_result("call_0", "later"));
let body = serde_json::to_value(scheme.build_request(&request, &cap())).unwrap();
let responses: Vec<_> = body["contents"]
.as_array()
.unwrap()
.iter()
.flat_map(|content| content["parts"].as_array().unwrap())
.filter_map(|part| part.get("functionResponse"))
.collect();
assert_eq!(responses.len(), 3);
for (response, name) in responses.iter().zip(["echo", "echo", "other"]) {
assert_eq!(response["name"], name);
assert_eq!(response["response"]["name"], name);
}
} }
#[test] #[test]
@@ -10,7 +10,7 @@ use crate::llm_client::{
use super::GeminiScheme; use super::GeminiScheme;
impl Scheme for GeminiScheme { impl Scheme for GeminiScheme {
type State = (); type State = super::events::GeminiStreamState;
fn default_base_url(&self) -> &'static str { fn default_base_url(&self) -> &'static str {
"https://generativelanguage.googleapis.com" "https://generativelanguage.googleapis.com"
@@ -38,9 +38,9 @@ impl Scheme for GeminiScheme {
&self, &self,
_event_type: &str, _event_type: &str,
data: &str, data: &str,
_state: &mut Self::State, state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> { ) -> Result<Vec<Event>, ClientError> {
Ok(self.parse_event(data)?.unwrap_or_default()) Ok(self.parse_event(data, state)?.unwrap_or_default())
} }
fn default_capability(&self) -> ModelCapability { fn default_capability(&self) -> ModelCapability {
+250
View File
@@ -0,0 +1,250 @@
//! Engine component tests with a recording tool and scripted host annotations.
//! Persistence is represented by the append callback; Server/IPC storage is not
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;
#[derive(Clone)]
struct NoLlm;
#[async_trait]
impl LlmClient for NoLlm {
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
panic!("identity tests must not request a model response")
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
struct HostIdentity {
early: bool,
}
#[async_trait]
impl Interceptor<String> for HostIdentity {
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
Some(annotation.clone())
}
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, String>,
) -> Result<PreToolAction, InterceptorError> {
if self.early {
assert!(info.history.is_empty());
}
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
info.context.logical_call_id = Some("hook-forged-identity".into());
Ok(PreToolAction::Continue)
}
}
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
#[async_trait]
impl crate::tool::Tool for RecordingTool {
async fn execute(
&self,
_: &str,
context: ToolExecutionContext,
) -> Result<crate::tool::ToolOutput, ToolError> {
self.0.lock().unwrap().push(context);
Ok("recorded".to_string().into())
}
}
fn engine(
early: bool,
) -> (
Engine<NoLlm, Mutable, String>,
Arc<Mutex<Vec<ToolExecutionContext>>>,
) {
let mut engine = Engine::new_annotated(NoLlm);
engine.set_interceptor(HostIdentity { early });
engine.max_turns = Some(0);
engine.start_logical_run();
let observed = Arc::new(Mutex::new(Vec::new()));
let tool = Arc::new(RecordingTool(observed.clone()));
engine.register_tool(Arc::new(move || {
(crate::tool::ToolMeta::new("record"), tool.clone())
}));
engine.tool_server_handle().flush_pending();
(engine, observed)
}
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
HistoryEntry::new(item, id.to_string())
}
fn call(id: &str) -> Item {
Item::tool_call_json(id, "record", json!({}))
}
#[test]
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
let history = History::from_entries(vec![
entry(
Item::tool_result("orphan", "no preceding call"),
"orphan-result",
),
entry(call("reused"), "host-first"),
entry(call("sibling"), "host-sibling"),
entry(
Item::tool_result("sibling", "out of order"),
"sibling-result",
),
entry(Item::tool_result("reused", "answered"), "first-result"),
entry(call("reused"), "host-pending"),
entry(call("orphan"), "host-after-orphan"),
]);
let pending = unanswered_tool_call_entries(&history);
assert_eq!(
pending
.iter()
.map(|entry| entry.annotation.as_str())
.collect::<Vec<_>>(),
["host-pending", "host-after-orphan"]
);
}
#[tokio::test]
async fn early_dispatch_binds_identity_only_after_successful_append() {
for reject_append in [false, true] {
let (mut engine, observed) = engine(true);
let mut history = History::new();
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
let result = engine
.admit_early_tool_calls(
&mut history,
&mut |_| {
if reject_append {
Err("storage unavailable".into())
} else {
Ok("host-committed-entry".into())
}
},
&mut batch,
vec![crate::timeline::CollectedToolCall {
call_index: 0,
call: ToolCall {
id: "provider-call".into(),
name: "record".into(),
input: json!({}),
},
}],
)
.await;
if reject_append {
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
assert!(history.is_empty());
assert!(batch.execution_handles.is_empty());
assert!(observed.lock().unwrap().is_empty());
} else {
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
let (_, terminal) = batch.futures.next().await.unwrap();
assert_eq!(terminal.tool_use_id, "provider-call");
let observed = observed.lock().unwrap();
assert_eq!(observed.len(), 1);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some(history.entries()[0].annotation.as_str())
);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some("host-committed-entry")
);
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
}
}
}
#[tokio::test]
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
for restore in [false, true] {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
]);
engine
.append_history_items(&mut history, [call("reused")], &mut |_| {
Ok("host-pending".into())
})
.unwrap();
if restore {
let persisted = serde_json::to_string(&history).unwrap();
history = serde_json::from_str(&persisted).unwrap();
}
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert_eq!(pending.len(), 1);
assert!(started.is_empty());
if restore {
engine
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
.await
.unwrap();
} else {
let result = engine
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
.await
.unwrap();
assert!(matches!(result, ToolExecutionResult::Completed));
}
assert_eq!(observed.lock().unwrap().len(), 1);
let context = observed.lock().unwrap()[0].clone();
assert_eq!(context.call_id, "reused");
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
// A subsequent resume sees the terminal result, not another live attempt.
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(observed.lock().unwrap().len(), 1);
assert!(unanswered_tool_call_entries(&history).is_empty());
}
}
#[tokio::test]
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
entry(
call("reused")
.with_tool_execution_metadata(0, Some("old-attempt".into()))
.with_status(ItemStatus::InProgress),
"host-started",
),
]);
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert!(pending.is_empty());
assert_eq!(started, ["reused"]);
engine
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
.await
.unwrap();
assert!(observed.lock().unwrap().is_empty());
assert!(matches!(
&history.entries().last().unwrap().item,
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
if call_id == "reused"
));
let len = history.len();
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(history.len(), len);
assert!(observed.lock().unwrap().is_empty());
}
+14 -6
View File
@@ -336,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
/// Per-call context supplied by the engine when executing a tool call. /// Per-call context supplied by the engine when executing a tool call.
/// ///
/// The context identifies a tool call within one assistant response's tool-call /// Live batch identity and ordering are response-local. An optional opaque host
/// batch without imposing any scheduling policy on the engine. Tool /// identity comes from the committed ToolCall annotation and survives restore.
/// implementations may use it for response-local ordering, diagnostics, or /// Neither is a handle to engine state, history, or session mutation.
/// correlation, but it is intentionally not a handle to engine state, history,
/// or session mutation.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolExecutionContext { pub struct ToolExecutionContext {
/// Provider/tool-call id for the call being executed. /// Provider/tool-call id for the call being executed.
pub call_id: String, pub call_id: String,
/// Opaque host identity from the committed ToolCall annotation, stable across
/// restoration. Provider call IDs and live batch IDs are not replay authority.
pub logical_call_id: Option<String>,
/// Engine-local identity shared by all tool calls from one execution batch. /// Engine-local identity shared by all tool calls from one execution batch.
pub batch_id: String, pub batch_id: String,
/// Zero-based order of this call in the model-returned tool-call list. /// Zero-based order of this call in the model-returned tool-call list.
@@ -355,11 +356,18 @@ impl ToolExecutionContext {
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self { pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
Self { Self {
call_id: call_id.into(), call_id: call_id.into(),
logical_call_id: None,
batch_id: batch_id.into(), batch_id: batch_id.into(),
call_index, call_index,
} }
} }
/// Bind an identity supplied by the host's persisted-call annotation.
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
self.logical_call_id = Some(logical_call_id.into());
self
}
/// Identifies one live execution attempt without making the batch id a durable /// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority. /// replay or idempotency authority.
pub fn execution_id(&self) -> String { pub fn execution_id(&self) -> String {
@@ -572,7 +580,7 @@ pub trait Tool: Send + Sync {
/// ///
/// # Arguments /// # Arguments
/// * `input_json` - JSON-formatted arguments generated by LLM /// * `input_json` - JSON-formatted arguments generated by LLM
/// * `ctx` - response-local call identity and ordering context /// * `ctx` - per-call identity and live execution ordering context
/// ///
/// # Returns /// # Returns
/// A [`ToolOutput`] with summary and optional detailed content. /// A [`ToolOutput`] with summary and optional detailed content.
+262 -113
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_decode = if api.adapters.client_error_hook || operation.client_error_hook {
quote! {
<#error_type as #api_crate::reqwest::HttpClientError>::decode_error(&__response)
.map_err(#api_crate::reqwest::ClientError::failure)?
}
} else {
quote! {
__response
.decode_json::<#error_type>(#api_crate::reqwest::DecodeKind::PublicError)
.map_err(|_| #api_crate::reqwest::ClientError::failure(
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
status: __status.as_u16(),
},
))?
}
};
let public_error = match operation.error_body.as_ref() { let public_error = match operation.error_body.as_ref() {
Some(error) => quote! { Some(_) => quote! {
if (400..=599).contains(&__actual) { if (400..=599).contains(&__actual) {
let __status = __response.status(); let __status = __response.status();
let __error = __response let __error = #public_error_decode;
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError)
.map_err(|_| #api_crate::reqwest::ClientError::failure(
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
status: __status.as_u16(),
},
))?;
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) }),
}; };
@@ -2315,110 +2449,115 @@ fn openapi_adapter_tokens(
matches!(operation.transport, Transport::Http) && !operation.openapi_skip matches!(operation.transport, Transport::Http) && !operation.openapi_skip
}) })
.map(|operation| { .map(|operation| {
let method = method_name(operation.method); let method = method_name(operation.method);
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!(
return None; parameter.location,
} Location::Body | Location::Binary | Location::Extension
let ty = &parameter.ty; ) {
let name = &parameter.wire_name; return None;
let location = match parameter.location {
Location::Path => "path",
Location::Query => "query",
Location::Header => "header",
Location::Body | Location::Binary | Location::Extension => unreachable!(),
};
Some(quote! {
operation.parameter::<#ty>(#name, #location)?;
})
});
let request = operation.request_body.as_ref().map(|body| match body.wire_kind {
RequestWireKind::Json => {
let ty = &body.ty;
quote! {
operation.request_body::<#ty>("application/json")?;
}
}
RequestWireKind::Binary => quote! {
operation.binary_request_body()?;
},
});
let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| {
let status = response.status;
let description = if index == 0 {
"Successful response"
} else {
"Alternate successful response"
};
let body = match response.body.as_ref() {
Some(ty) if is_binary_body_type(ty) => quote! {
operation.binary_response(#status, #description)?;
},
Some(ty) => quote! {
operation.response::<#ty>(#status, "application/json", #description)?;
},
None => quote! {
operation.empty_response(#status, #description)?;
},
};
let headers = response.headers.iter().map(|header| {
let ty = &header.ty;
let name = &header.wire_name;
quote! {
operation.response_header::<#ty>(#status, #name)?;
} }
let ty = &parameter.ty;
let name = &parameter.wire_name;
let location = match parameter.location {
Location::Path => "path",
Location::Query => "query",
Location::Header => "header",
Location::Body | Location::Binary | Location::Extension => unreachable!(),
};
Some(quote! {
operation.parameter::<#ty>(#name, #location)?;
})
}); });
quote! { let request = operation
#body .request_body
#(#headers)* .as_ref()
} .map(|body| match body.wire_kind {
}); RequestWireKind::Json => {
let error = operation let ty = &body.ty;
.error_status
.zip(operation.error_body.as_ref())
.map(|(status, ty)| {
quote! {
operation.response::<#ty>(#status, "application/json", "Error response")?;
}
});
let additional_errors = operation
.error_body
.as_ref()
.map(|ty| {
operation
.additional_error_statuses
.iter()
.map(|status| {
quote! { quote! {
operation.response::<#ty>(#status, "application/json", "Error response")?; operation.request_body::<#ty>("application/json")?;
} }
}) }
.collect::<Vec<_>>() RequestWireKind::Binary => quote! {
}) operation.binary_request_body()?;
.unwrap_or_default(); },
let bearer_auth = operation.bearer_auth.then(|| { });
quote! { operation.bearer_authentication(); } let success_responses = operation.success_responses.iter().enumerate().map(
}); |(index, response)| {
let browser_auth = operation.browser_auth.then(|| { let status = response.status;
quote! { operation.browser_authentication(); } let description = if index == 0 {
}); "Successful response"
} else {
"Alternate successful response"
};
let body = match response.body.as_ref() {
Some(ty) if is_binary_body_type(ty) => quote! {
operation.binary_response(#status, #description)?;
},
Some(ty) => quote! {
operation.response::<#ty>(#status, "application/json", #description)?;
},
None => quote! {
operation.empty_response(#status, #description)?;
},
};
let headers = response.headers.iter().map(|header| {
let ty = &header.ty;
let name = &header.wire_name;
quote! {
operation.response_header::<#ty>(#status, #name)?;
}
});
quote! {
#body
#(#headers)*
}
},
);
let errors = operation
.error_body
.as_ref()
.map(|ty| {
let additional_content_types = &operation.additional_error_content_types;
operation
.error_status
.into_iter()
.chain(operation.additional_error_statuses.iter().copied())
.map(|status| {
quote! {
operation.response_content_types::<#ty>(
#status,
&["application/json", #(#additional_content_types),*],
"Error response",
)?;
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let bearer_auth = operation.bearer_auth.then(|| {
quote! { operation.bearer_authentication(); }
});
let browser_auth = operation.browser_auth.then(|| {
quote! { operation.browser_authentication(); }
});
quote! { quote! {
{ {
let mut operation = builder.operation(#method, #path, #operation_id)?; let mut operation = builder.operation(#method, #path, #operation_id)?;
#(#parameters)* #(#parameters)*
#bearer_auth #bearer_auth
#browser_auth #browser_auth
#request #request
#(#success_responses)* #(#success_responses)*
#error #(#errors)*
#(#additional_errors)* operation.finish()?;
operation.finish()?; }
} }
} });
});
quote! { quote! {
#[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."] #[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."]
@@ -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)]
| ^^^^^^^^^^^^^^^^^
+7
View File
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.", description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
imports: BASE_IMPORT, imports: BASE_IMPORT,
}, },
BuiltinProfileResource {
selector: Some("builtin:async-chat"),
path: "profiles/async-chat.dcdl",
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
imports: NO_IMPORTS,
},
BuiltinProfileResource { BuiltinProfileResource {
selector: Some("builtin:companion"), selector: Some("builtin:companion"),
path: "profiles/companion.dcdl", path: "profiles/companion.dcdl",
+19
View File
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
visit(resource.path, &mut BTreeSet::new()); visit(resource.path, &mut BTreeSet::new());
} }
} }
#[test]
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
let catalog = builtin_profile_catalog_snapshot();
let path = &catalog.entrypoints["builtin:async-chat"];
let resource = BUILTIN_PROFILE_RESOURCES
.iter()
.find(|resource| resource.path == path)
.unwrap();
assert_eq!(resource.selector, Some("builtin:async-chat"));
assert!(!resource.description.is_empty());
assert!(resource.imports.is_empty());
assert!(
!catalog
.imports
.keys()
.any(|request| request.starts_with(&format!("{path}\0")))
);
}
+13
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),
} }
@@ -908,6 +910,7 @@ mod tests {
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: None,
execution_grant: None,
}; };
let response = create_backend_worker_with_client(&target, &create, &api) let response = create_backend_worker_with_client(&target, &create, &api)
@@ -1015,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),
}
}
+76 -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,21 @@ 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(
&self,
reference: &str,
) -> Result<ticket::TicketLinkSummary, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let reference = reference.to_owned();
self.generated(move |client| async move {
client
.ticket_link_summary_get(workspace_id, reference)
.await
.map(|response| response.0)
})
} }
pub fn list_workspace_tags( pub fn list_workspace_tags(
@@ -567,11 +598,12 @@ impl BackendWorkspaceProductClient {
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: 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
@@ -601,6 +633,47 @@ impl BackendWorkspaceProductClient {
} }
impl TicketBackend for BackendWorkspaceProductClient { impl TicketBackend for BackendWorkspaceProductClient {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketParentLink> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_parent_get(workspace_id, id)
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn set_ticket_parent(
&self,
id: TicketIdOrSlug,
request: ticket::TicketParentSetRequest,
) -> ticket::Result<ticket::TicketParentLink> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_parent_set(workspace_id, id, server_api::TicketParentRequest(request))
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn query_ticket_children(
&self,
id: TicketIdOrSlug,
request: ticket::TicketChildrenRequest,
) -> ticket::Result<ticket::TicketChildrenPage> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_children_query(workspace_id, id, server_api::TicketChildrenQuery(request))
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
+49
View File
@@ -4093,3 +4093,52 @@ mod tests {
assert_eq!(diagnostics[0].kind, "cycle"); assert_eq!(diagnostics[0].kind, "cycle");
} }
} }
#[cfg(test)]
mod plugin_preflight_tests {
use super::*;
#[test]
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../resources/config-schema/fixtures/plugin-preflight.json"
))
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-preflight",
"plugins",
"1",
fixture["schema"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
for case in fixture["cases"].as_array().unwrap() {
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
case["source"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
match case.get("diagnostic") {
Some(kind) => {
let errors = result.unwrap_err();
assert_eq!(errors[0].kind, kind.as_str().unwrap());
assert_eq!(errors[0].path.as_str(), "main.dcdl");
}
None => {
let evaluated = result.unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/plugins/enabled/echo/config/mode"),
Some(&case["mode"])
);
}
}
}
}
}
@@ -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`,
);
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "error-contract"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[features]
default = []
json-schema = ["dep:schemars"]
typescript = ["dep:ts-rs", "json-schema"]
test-support = []
[dependencies]
iri-string = { version = "0.7.12", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
schemars = { workspace = true, optional = true }
ts-rs = { version = "12.0.1", optional = true }
[[example]]
name = "two_domains"
required-features = ["test-support"]
+18
View File
@@ -0,0 +1,18 @@
# Error contract
This crate owns typed cause/context preservation, current-context disclosure interfaces,
bounded codecs and RFC 9457 primitives. It does not own operation outcomes, authorization,
retry, cleanup, persistence, HTTP servers, logging sinks or production domain mappings.
[FailureFacts](crate::FailureFacts) retains the concrete domain cause. A domain derives
identity from its variant and implements [ProjectFor](crate::ProjectFor) per audience,
selecting typed wire fields and common metadata using the boundary's current authenticated
context and resource visibility. Only [project_failure](crate::project_failure) produces an
encodable audience marker. Sources and Display are never automatically serialized.
[codec] distinguishes validated, unsupported and corrupt input. A received value must be
reprojected; opaque unsupported payload cannot be blindly forwarded to another audience.
[problem] uses API-boundary classification, not inner status forwarding. The `schema` module
(with `json-schema`) exposes the same DTOs to existing schema generators. The `test_support`
module (with `test-support`) provides two independent sample domains and shared wire fixtures for
consumer contract tests, not production authorization adapters.
@@ -0,0 +1,49 @@
use error_contract::{compatibility::*, problem::*, test_support::*, *};
fn main() -> Result<(), ContractError> {
let access = CurrentAccess {
can_view_resource: true,
language: Language::English,
};
let budget = Budget::new(1024 * 1024, 16 * 1024).with_depth(64, 15);
let subject = subject_facts();
let browser = project_failure::<Browser, _>(&subject, &access)?;
let response =
to_problem_details(&browser, &WorkerApi, "urn:yoi:occurrence:example-1", budget)?;
assert_eq!(response.status, 409); // The inner Subject API would classify this as 403.
assert_no_fixture_secrets(response.body());
let workdir = workdir_facts();
let tool = project_failure::<Tool, _>(&workdir, &access)?;
let encoded = encode_projected_failure(&tool, budget)?;
assert_semantic_roundtrip(encoded.as_bytes(), tool.details(), budget);
// Ship this reader before switching any existing result writer. Old diagnostic prose is
// deliberately not an input to classification. The real domain result remains separate.
assert!(matches!(
read_failure_attachment::<SubjectDenied>(None, budget),
StoredFailureRead::LegacyWithoutFacts
));
let storage = project_failure::<Storage, _>(&subject, &access)?;
let stored = encode_projected_failure(&storage, budget)?;
let received = match read_failure_attachment::<SubjectDenied>(Some(stored.as_bytes()), budget) {
StoredFailureRead::Structured(DecodeFailure::Known(value)) => value,
_ => return Err(ContractError::Invalid),
};
let current_access = CurrentAccess {
can_view_resource: false,
language: Language::Japanese,
};
let reread = project_failure::<Browser, _>(received.facts(), &current_access)?;
assert_eq!(
reread.details().required_operation,
RequiredOperation::SessionRecord
);
assert!(reread.details().subject_ref.is_none());
let telemetry = project_failure::<Telemetry, _>(&subject, &access)?;
let fields = observation_fields(&telemetry);
assert_eq!(fields.stage, Some("register"));
// Feed fields to the existing boundary's tracing sink; never log the source chain here.
Ok(())
}
@@ -0,0 +1 @@
{"version":1,"required_extensions":[],"domain":"subject","reason":"operation_denied","category":"authorization","details_version":1,"details":{"required_operation":"session.record","subject_ref":"subject-1"},"explanation":"Worker creation cannot register the session: session.record is denied.","context":{"operations":[{"operation":"worker.create","stage":"register","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-1","supplementary_omitted":false}}
@@ -0,0 +1 @@
{"version":1,"required_extensions":[],"domain":"workdir","reason":"in_use","category":"conflict","details_version":1,"details":{"operation":"open","workdir_ref":"workdir-1"},"explanation":"Workdir open cannot proceed because the workdir is in use.","context":{"operations":[{"operation":"workdir.open","stage":"prepare","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-2","supplementary_omitted":false}}
+304
View File
@@ -0,0 +1,304 @@
//! Bounded wire handling. A validated receiver value is still not a disclosure capability.
use serde::{Deserialize, Serialize};
use std::{io::Write, marker::PhantomData};
use crate::*;
/// Both the transport limit and the remaining enclosing-envelope budget are mandatory.
#[derive(Debug, Clone, Copy)]
pub struct Budget {
bytes: usize,
depth: usize,
}
impl Budget {
pub fn new(transport_limit: usize, remaining_envelope_bytes: usize) -> Self {
Self {
bytes: transport_limit
.min(remaining_envelope_bytes)
.min(MAX_FAILURE_BYTES),
depth: MAX_JSON_DEPTH,
}
}
/// Use the smaller transport depth and the remaining depth after enclosing wrappers.
/// A JSON-string embedding must additionally budget its escaped bytes at that boundary.
pub fn with_depth(mut self, transport_depth: usize, remaining_envelope_depth: usize) -> Self {
self.depth = self
.depth
.min(transport_depth)
.min(remaining_envelope_depth);
self
}
pub fn bytes(self) -> usize {
self.bytes
}
pub fn depth(self) -> usize {
self.depth
}
}
/// DTO/schema source only. Deserialization does not produce a ProjectedFailure.
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct FailureEnvelope<D> {
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
pub version: u16,
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
pub required_extensions: Vec<String>,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub domain: String,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub reason: String,
pub category: Category,
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
pub details_version: u16,
pub details: D,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
pub explanation: String,
pub context: FailureContext,
}
// A tolerant reader header keeps future category spellings opaque. Public schema/TS authority
// stays on FailureEnvelope; only supported identities are deserialized into its typed shape.
#[derive(Deserialize)]
struct ReaderEnvelope {
version: u16,
required_extensions: Vec<String>,
domain: String,
reason: String,
category: String,
details_version: u16,
#[serde(rename = "details")]
_details: serde_json::Value,
explanation: String,
context: FailureContext,
}
pub struct ReceivedFailure<D> {
facts: FailureFacts<D>,
}
impl<D: FailureCause> ReceivedFailure<D> {
/// Reprojection with current authorization is required before any encoder can use this.
pub fn facts(&self) -> &FailureFacts<D> {
&self.facts
}
}
/// Unknown bytes are retained opaquely, not serializable or available for blind forwarding.
/// Readers may use safe local stage/correlation separately; no reason inference is provided.
pub struct UnsupportedFailure {
payload: Vec<u8>,
}
impl UnsupportedFailure {
pub fn retained_bytes(&self) -> usize {
self.payload.len()
}
}
pub enum DecodeFailure<D> {
Known(ReceivedFailure<D>),
Unsupported(UnsupportedFailure),
Corrupt(ContractError),
}
pub fn decode_failure<D: WireDetails>(input: &[u8], budget: Budget) -> DecodeFailure<D> {
match decode_checked(input, budget) {
Ok(value) => value,
Err(error) => DecodeFailure::Corrupt(error),
}
}
pub(crate) fn decode_checked<D: WireDetails>(
input: &[u8],
budget: Budget,
) -> Result<DecodeFailure<D>, ContractError> {
check_json_bounds(input, budget)?;
let raw: ReaderEnvelope = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
validate_envelope_common(&raw)?;
if raw.version != SCHEMA_VERSION
|| !raw.required_extensions.is_empty()
|| !D::supports(&raw.domain, &raw.reason, raw.details_version)
{
return Ok(DecodeFailure::Unsupported(UnsupportedFailure {
payload: input.to_vec(),
}));
}
// Deserialize from the original bytes again, not a Value: typed duplicate fields must fail.
let typed: FailureEnvelope<D> =
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
accept_typed_envelope(typed).map(DecodeFailure::Known)
}
pub(crate) fn accept_typed_envelope<D: WireDetails>(
typed: FailureEnvelope<D>,
) -> Result<ReceivedFailure<D>, ContractError> {
let identity = typed.details.identity();
if identity.domain != typed.domain
|| identity.reason != typed.reason
|| identity.category != typed.category
|| identity.details_version != typed.details_version
{
return Err(ContractError::InvalidDetails);
}
typed.details.validate()?;
let facts = FailureFacts::from_received(typed.details, typed.context);
validate_failure(&facts)?;
Ok(ReceivedFailure { facts })
}
fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> {
if raw.version == 0 || raw.details_version == 0 {
return Err(ContractError::Invalid);
}
validate_identifier(&raw.domain)?;
if raw.required_extensions.len() > MAX_DIAGNOSTICS {
return Err(ContractError::Invalid);
}
for extension in &raw.required_extensions {
validate_identifier(extension)?;
}
validate_identifier(&raw.reason)?;
validate_identifier(&raw.category)?;
validate_explanation(&raw.explanation)?;
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.
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
if input.len() > budget.bytes() {
return Err(ContractError::TooLarge);
}
let (mut depth, mut in_string, mut escaped) = (0usize, false, false);
for &byte in input {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
} else {
match byte {
b'"' => in_string = true,
b'{' | b'[' => {
depth += 1;
if depth > budget.depth() {
return Err(ContractError::TooDeep);
}
}
b'}' | b']' => {
depth = depth.checked_sub(1).ok_or(ContractError::Invalid)?;
}
_ => {}
}
}
}
Ok(())
}
struct BoundedWriter {
bytes: Vec<u8>,
budget: usize,
exceeded: bool,
}
impl Write for BoundedWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if bytes.len() > self.budget.saturating_sub(self.bytes.len()) {
self.exceeded = true;
return Err(std::io::Error::other("failure byte budget"));
}
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) fn encode_bounded<T: Serialize>(
value: &T,
budget: Budget,
) -> Result<Vec<u8>, ContractError> {
let mut writer = BoundedWriter {
bytes: Vec::new(),
budget: budget.bytes(),
exceeded: false,
};
if serde_json::to_writer(&mut writer, value).is_err() {
return Err(if writer.exceeded {
ContractError::TooLarge
} else {
ContractError::Encoding
});
}
check_json_bounds(&writer.bytes, budget)?;
Ok(writer.bytes)
}
/// The audience remains present until the transport deliberately takes the bytes.
pub struct EncodedFailure<A> {
bytes: Vec<u8>,
audience: PhantomData<A>,
}
impl<A: Audience> EncodedFailure<A> {
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
}
pub fn encode_projected_failure<A: Audience, D: WireDetails>(
failure: &ProjectedFailure<A, D>,
budget: Budget,
) -> Result<EncodedFailure<A>, ContractError> {
let bytes = encode_bounded(&failure.envelope, budget)?;
Ok(EncodedFailure {
bytes,
audience: PhantomData,
})
}
/// Deliberately not a FailureEnvelope: it reports inability to disclose, not a new domain cause.
pub const STATIC_FALLBACK: &[u8] =
br#"{"failure_unavailable":true,"detail":"Failure details unavailable."}"#;
pub struct BoundaryEncoding<A> {
pub encoded: EncodedFailure<A>,
pub conversion_error: Option<ContractError>,
/// Kept separate from static payload; the boundary must allowlist it for disclosure.
pub correlation: Option<CorrelationRef>,
}
/// No formatting of errors, recursive projection or operation invocation occurs on this path.
/// If even the static payload cannot fit, return a boundary error; never truncate required facts.
pub fn encode_or_fallback<A: Audience, D: WireDetails>(
projection: Result<&ProjectedFailure<A, D>, ContractError>,
budget: Budget,
correlation: Option<CorrelationRef>,
) -> Result<BoundaryEncoding<A>, ContractError> {
let result = projection.and_then(|failure| encode_projected_failure(failure, budget));
let (encoded, conversion_error) = match result {
Ok(encoded) => (encoded, None),
Err(error) => {
check_json_bounds(STATIC_FALLBACK, budget)?;
(
EncodedFailure {
bytes: STATIC_FALLBACK.to_vec(),
audience: PhantomData,
},
Some(error),
)
}
};
Ok(BoundaryEncoding {
encoded,
conversion_error,
correlation,
})
}
@@ -0,0 +1,29 @@
//! Reader-first compatibility for optional structured failure attachments.
//!
//! Existing domain envelopes retain their operation outcome and legacy diagnostics. Ship a
//! reader accepting an absent attachment before enabling a writer of a versioned failure.
//! Absence is historical text without typed facts, not a fabricated `unknown` domain cause.
//! Present-but-corrupt and present-but-unsupported are distinct and must not fall back to
//! parsing legacy prose. Readers reproject validated attachments using current visibility.
//!
//! This module does not migrate DBs or rewrite old archives. The owning store must choose
//! its additive field/migration and rollout unit with its consumer. A rollback disables new
//! writes first and retains the reader while any new-format results remain. Unknown payload
//! retention is local to the original reader; it is never permission to forward to another
//! audience. No production writer is enabled merely by depending on this crate.
use crate::*;
pub enum StoredFailureRead<D> {
LegacyWithoutFacts,
Structured(DecodeFailure<D>),
}
pub fn read_failure_attachment<D: WireDetails>(
attachment: Option<&[u8]>,
budget: Budget,
) -> StoredFailureRead<D> {
match attachment {
None => StoredFailureRead::LegacyWithoutFacts,
Some(bytes) => StoredFailureRead::Structured(decode_failure(bytes, budget)),
}
}
+233
View File
@@ -0,0 +1,233 @@
use serde::{Deserialize, Serialize};
use std::{error::Error, fmt};
use crate::*;
#[derive(Debug, Clone, Copy, 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 Category {
Authorization,
InvalidInput,
Conflict,
Dependency,
Internal,
Unknown,
}
/// Identity is derived from a domain variant, never supplied independently by a caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FailureIdentity {
pub domain: &'static str,
pub reason: &'static str,
pub category: Category,
pub details_version: u16,
}
pub trait FailureCause {
fn identity(&self) -> FailureIdentity;
/// Domain-owned required fields, length and collection bounds.
fn validate(&self) -> Result<(), ContractError>;
}
/// Implement on a typed wire allowlist, not on arbitrary error strings or maps.
pub trait WireDetails: FailureCause + Serialize + serde::de::DeserializeOwned {
fn supports(domain: &str, reason: &str, details_version: u16) -> bool;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct OperationContext {
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub operation: String,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub stage: String,
/// A reference, not an idempotency/retry grant. Disclosure remains domain-owned.
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub operation_ref: Option<String>,
}
impl OperationContext {
pub fn new(
operation: impl Into<String>,
stage: impl Into<String>,
) -> Result<Self, ContractError> {
let context = Self {
operation: operation.into(),
stage: stage.into(),
operation_ref: None,
};
context.validate()?;
Ok(context)
}
pub fn validate(&self) -> Result<(), ContractError> {
validate_identifier(&self.operation)?;
validate_identifier(&self.stage)?;
if let Some(reference) = &self.operation_ref {
validate_identifier(reference)?;
}
Ok(())
}
}
/// Correlation is deliberately a distinct field/type from operation identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CorrelationRef(
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] String,
);
impl CorrelationRef {
pub fn new(value: impl Into<String>) -> Result<Self, ContractError> {
let value = value.into();
validate_identifier(&value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Only explicit causal references belong here. Cleanup/audit failures stay in domain results.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CausalLink {
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub domain: String,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub reason: String,
pub correlation: CorrelationRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", schemars(rename = "FailureDiagnostic"))]
pub struct Diagnostic {
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
pub code: String,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
pub explanation: String,
}
/// Common metadata is untrusted after deserialization. No audience label confers trust.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct FailureContext {
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
pub operations: Vec<OperationContext>,
/// Ordered immediate-to-root chain, at most four references; never recursive payloads.
#[cfg_attr(feature = "json-schema", schemars(length(max = 4)))]
pub causes: Vec<CausalLink>,
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
pub diagnostics: Vec<Diagnostic>,
pub correlation: Option<CorrelationRef>,
pub supplementary_omitted: bool,
}
impl FailureContext {
pub fn validate(&self) -> Result<(), ContractError> {
if self.operations.len() > MAX_CONTEXTS
|| self.causes.len() > MAX_CAUSES
|| self.diagnostics.len() > MAX_DIAGNOSTICS
{
return Err(ContractError::Invalid);
}
for op in &self.operations {
op.validate()?;
}
for cause in &self.causes {
validate_identifier(&cause.domain)?;
validate_identifier(&cause.reason)?;
validate_identifier(cause.correlation.as_str())?;
}
for diagnostic in &self.diagnostics {
validate_identifier(&diagnostic.code)?;
validate_explanation(&diagnostic.explanation)?;
}
if let Some(correlation) = &self.correlation {
validate_identifier(correlation.as_str())?;
}
Ok(())
}
}
/// Not serializable: the domain retains its concrete error and source chain.
#[derive(Debug)]
pub struct FailureFacts<D> {
cause: D,
context: FailureContext,
}
impl<D: FailureCause> FailureFacts<D> {
pub fn new(cause: D) -> Result<Self, ContractError> {
let facts = Self {
cause,
context: FailureContext::default(),
};
validate_failure(&facts)?;
Ok(facts)
}
pub fn cause(&self) -> &D {
&self.cause
}
pub fn context(&self) -> &FailureContext {
&self.context
}
/// A failed append leaves the original facts intact.
pub fn with_context(&mut self, context: OperationContext) -> Result<(), ContractError> {
context.validate()?;
if self.context.operations.len() == MAX_CONTEXTS {
return Err(ContractError::Invalid);
}
self.context.operations.push(context);
Ok(())
}
pub fn set_correlation(&mut self, correlation: CorrelationRef) -> Result<(), ContractError> {
validate_identifier(correlation.as_str())?;
self.context.correlation = Some(correlation);
Ok(())
}
pub fn with_causal_link(&mut self, cause: CausalLink) -> Result<(), ContractError> {
validate_identifier(&cause.domain)?;
validate_identifier(&cause.reason)?;
validate_identifier(cause.correlation.as_str())?;
if self.context.causes.len() == MAX_CAUSES {
return Err(ContractError::Invalid);
}
self.context.causes.push(cause);
Ok(())
}
pub(crate) fn from_received(cause: D, context: FailureContext) -> Self {
Self { cause, context }
}
}
pub fn validate_failure<D: FailureCause>(facts: &FailureFacts<D>) -> Result<(), ContractError> {
let identity = facts.cause.identity();
validate_identifier(identity.domain)?;
validate_identifier(identity.reason)?;
if identity.details_version == 0 {
return Err(ContractError::InvalidDetails);
}
facts.cause.validate()?;
facts.context.validate()
}
impl<D: FailureCause> fmt::Display for FailureFacts<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let identity = self.cause.identity();
write!(f, "{}.{}", identity.domain, identity.reason)
}
}
impl<D: FailureCause + Error + 'static> Error for FailureFacts<D> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.cause)
}
}
+59
View File
@@ -0,0 +1,59 @@
#![doc = include_str!("../README.md")]
pub mod codec;
pub mod compatibility;
pub mod facts;
pub mod problem;
pub mod projection;
#[cfg(feature = "json-schema")]
pub mod schema;
#[cfg(feature = "test-support")]
pub mod test_support;
pub use codec::{
Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure,
validate_json_input,
};
pub use facts::*;
pub use projection::*;
pub const SCHEMA_VERSION: u16 = 1;
pub const MAX_FAILURE_BYTES: usize = 32 * 1024;
pub const MAX_JSON_DEPTH: usize = 16;
pub const MAX_CONTEXTS: usize = 8;
pub const MAX_CAUSES: usize = 4;
pub const MAX_DIAGNOSTICS: usize = 8;
pub const MAX_EXPLANATION_BYTES: usize = 2 * 1024;
pub const MAX_IDENTIFIER_BYTES: usize = 128;
/// Conversion errors describe only the boundary, never the original operation's outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ContractError {
#[error("failure exceeds the available byte budget")]
TooLarge,
#[error("failure exceeds the JSON depth limit")]
TooDeep,
#[error("invalid failure structure")]
Invalid,
#[error("invalid typed failure details")]
InvalidDetails,
#[error("failure projection unavailable")]
ProjectionUnavailable,
#[error("failure serialization unavailable")]
Encoding,
}
pub fn validate_identifier(value: &str) -> Result<(), ContractError> {
if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control)
{
return Err(ContractError::Invalid);
}
Ok(())
}
pub fn validate_explanation(value: &str) -> Result<(), ContractError> {
if value.is_empty() || value.len() > MAX_EXPLANATION_BYTES || value.contains('\0') {
return Err(ContractError::Invalid);
}
Ok(())
}
+245
View File
@@ -0,0 +1,245 @@
//! RFC 9457 primitives, without an HTTP server/client dependency or inner-status forwarding.
use crate::{
codec::{self, FailureEnvelope},
*,
};
use serde::{Deserialize, Serialize};
pub const PROBLEM_CONTENT_TYPE: &str = "application/problem+json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpFailureStatus {
BadRequest,
Unauthorized,
Forbidden,
NotFound,
Conflict,
Unprocessable,
TooManyRequests,
Internal,
BadGateway,
Unavailable,
GatewayTimeout,
}
impl HttpFailureStatus {
pub fn code(self) -> u16 {
match self {
Self::BadRequest => 400,
Self::Unauthorized => 401,
Self::Forbidden => 403,
Self::NotFound => 404,
Self::Conflict => 409,
Self::Unprocessable => 422,
Self::TooManyRequests => 429,
Self::Internal => 500,
Self::BadGateway => 502,
Self::Unavailable => 503,
Self::GatewayTimeout => 504,
}
}
}
/// The API boundary, not Category or an inner HTTP response, chooses this mapping.
#[derive(Debug, Clone, Copy)]
pub struct ProblemClass {
pub status: HttpFailureStatus,
pub type_uri: &'static str,
pub title: &'static str,
}
impl ProblemClass {
pub fn authorization_denied() -> Self {
Self {
status: HttpFailureStatus::Forbidden,
type_uri: "urn:yoi:problem:authorization-denied",
title: "Authorization denied",
}
}
pub fn invalid_input() -> Self {
Self {
status: HttpFailureStatus::BadRequest,
type_uri: "urn:yoi:problem:invalid-input",
title: "Invalid input",
}
}
pub fn conflict() -> Self {
Self {
status: HttpFailureStatus::Conflict,
type_uri: "urn:yoi:problem:conflict",
title: "Operation conflict",
}
}
pub fn dependency_failure() -> Self {
Self {
status: HttpFailureStatus::BadGateway,
type_uri: "urn:yoi:problem:dependency",
title: "Dependency failure",
}
}
}
pub trait ApiBoundary<D> {
fn classify(&self, details: &D) -> ProblemClass;
}
/// Untrusted wire DTO. Unknown RFC extensions are ignored; yoi_failure is mandatory here.
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ProblemDetails<D> {
#[serde(rename = "type")]
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
pub type_uri: String,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
pub title: String,
#[cfg_attr(feature = "json-schema", schemars(range(min = 400, max = 599)))]
pub status: u16,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
pub detail: String,
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
pub instance: String,
pub yoi_failure: FailureEnvelope<D>,
}
#[derive(Deserialize)]
struct ReaderProblem {
#[serde(rename = "type")]
type_uri: String,
title: String,
status: u16,
detail: String,
instance: String,
yoi_failure: serde_json::Value,
}
pub struct ProblemResponse {
pub status: u16,
pub content_type: &'static str,
body: Vec<u8>,
}
impl ProblemResponse {
pub fn body(&self) -> &[u8] {
&self.body
}
}
fn validate_uri(value: &str) -> Result<(), ContractError> {
if value.len() > MAX_EXPLANATION_BYTES {
return Err(ContractError::Invalid);
}
// Reuse the RFC 3986 validator already in the transport dependency graph, without its
// optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation
// neither resolves a base URI nor normalizes malformed input into different text.
// Empty references, relative paths, queries and fragments are valid URI-references.
iri_string::types::UriReferenceStr::new(value)
.map(|_| ())
.map_err(|_| ContractError::Invalid)
}
pub fn to_problem_details<D: WireDetails>(
projected: &ProjectedFailure<Browser, D>,
api_boundary: &impl ApiBoundary<D>,
instance: &str,
budget: Budget,
) -> Result<ProblemResponse, ContractError> {
let class = api_boundary.classify(projected.details());
validate_uri(class.type_uri)?;
validate_uri(instance)?;
validate_explanation(class.title)?;
// Serialize by reference; no new projection or conversion from arbitrary Display exists.
let problem = ProblemDetails {
type_uri: class.type_uri.into(),
title: class.title.into(),
status: class.status.code(),
detail: projected.explanation().into(),
instance: instance.into(),
yoi_failure: FailureEnvelope {
version: projected.envelope.version,
required_extensions: projected.envelope.required_extensions.clone(),
domain: projected.envelope.domain.clone(),
reason: projected.envelope.reason.clone(),
category: projected.envelope.category,
details_version: projected.envelope.details_version,
details: &projected.envelope.details,
explanation: projected.envelope.explanation.clone(),
context: projected.envelope.context.clone(),
},
};
let body = codec::encode_bounded(&problem, budget)?;
Ok(ProblemResponse {
status: problem.status,
content_type: PROBLEM_CONTENT_TYPE,
body,
})
}
pub fn decode_problem_details<D: WireDetails>(
input: &[u8],
actual_status: u16,
content_type: &str,
api_boundary: &impl ApiBoundary<D>,
budget: Budget,
) -> DecodeFailure<D> {
match decode_problem_checked(input, actual_status, content_type, api_boundary, budget) {
Ok(result) => result,
Err(error) => DecodeFailure::Corrupt(error),
}
}
fn decode_problem_checked<D: WireDetails>(
input: &[u8],
actual_status: u16,
content_type: &str,
api_boundary: &impl ApiBoundary<D>,
budget: Budget,
) -> Result<DecodeFailure<D>, ContractError> {
codec::check_json_bounds(input, budget)?;
if !content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE)
{
return Err(ContractError::Invalid);
}
let raw: ReaderProblem = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
if raw.status != actual_status
|| !(400..=599).contains(&raw.status)
|| Some(raw.detail.as_str())
!= raw
.yoi_failure
.get("explanation")
.and_then(serde_json::Value::as_str)
{
return Err(ContractError::Invalid);
}
validate_uri(&raw.type_uri)?;
validate_uri(&raw.instance)?;
validate_explanation(&raw.title)?;
let failure_bytes = codec::encode_bounded(&raw.yoi_failure, budget)?;
match codec::decode_checked::<D>(&failure_bytes, budget)? {
DecodeFailure::Known(_) => {
// Use original JSON for typed parsing, preserving duplicate-field rejection.
let typed: ProblemDetails<D> =
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
let class = api_boundary.classify(&typed.yoi_failure.details);
if typed.status != class.status.code()
|| typed.type_uri != class.type_uri
|| typed.title != class.title
{
return Err(ContractError::Invalid);
}
codec::accept_typed_envelope(typed.yoi_failure).map(DecodeFailure::Known)
}
other => Ok(other),
}
}
pub const STATIC_PROBLEM_FALLBACK: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Failure details unavailable."}"#;
/// Static fallback is deliberately outside the domain contract and never formats a failure.
pub fn problem_fallback(budget: Budget) -> Result<ProblemResponse, ContractError> {
codec::check_json_bounds(STATIC_PROBLEM_FALLBACK, budget)?;
Ok(ProblemResponse {
status: 500,
content_type: PROBLEM_CONTENT_TYPE,
body: STATIC_PROBLEM_FALLBACK.to_vec(),
})
}
+174
View File
@@ -0,0 +1,174 @@
//! Domain allowlists are the only path from internal facts to encodable values.
use std::marker::PhantomData;
use crate::{codec::FailureEnvelope, *};
mod sealed {
pub trait Sealed {}
}
pub trait Audience: sealed::Sealed {}
macro_rules! audiences {
($($name:ident),*) => { $(
#[derive(Debug)] pub struct $name;
impl sealed::Sealed for $name {}
impl Audience for $name {}
)* };
}
audiences!(Browser, Tool, InternalTransfer, Storage, Telemetry);
/// Domain-owned allowlisted output. Context is selected too; it is not copied implicitly.
pub struct Disclosure<D> {
pub details: D,
pub explanation: String,
pub context: FailureContext,
}
impl<D> Disclosure<D> {
pub fn new(details: D, explanation: impl Into<String>) -> Self {
Self {
details,
explanation: explanation.into(),
context: FailureContext::default(),
}
}
}
/// Implement separately for each audience. The boundary supplies *current* authorization
/// and resource visibility, including when projecting a previously stored/received value.
/// This trait does not authenticate a caller or turn an audience name into authority.
pub trait ProjectFor<A: Audience>: FailureCause + Sized {
type Authorization: ?Sized;
type Details: WireDetails;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &Self::Authorization,
) -> Result<Disclosure<Self::Details>, ContractError>;
}
/// No Deserialize/Serialize implementation: only projection creates this trust marker.
///
/// ```compile_fail
/// use error_contract::{Browser, Storage, ProjectedFailure};
/// fn public_only<D>(_: &ProjectedFailure<Browser, D>) {}
/// fn cannot_relabel<D>(stored: &ProjectedFailure<Storage, D>) { public_only(stored); }
/// ```
///
/// ```compile_fail
/// use error_contract::{Browser, ProjectedFailure};
/// let _: ProjectedFailure<Browser, String> = serde_json::from_str("{}").unwrap();
/// ```
///
/// ```compile_fail
/// use error_contract::{Browser, Budget, ReceivedFailure, WireDetails, encode_projected_failure};
/// fn received_is_not_projected<D: WireDetails>(received: &ReceivedFailure<D>) {
/// encode_projected_failure::<Browser, D>(received, Budget::new(1024, 1024));
/// }
/// ```
pub struct ProjectedFailure<A, D> {
pub(crate) envelope: FailureEnvelope<D>,
pub(crate) audience: PhantomData<A>,
}
impl<A: Audience, D: FailureCause> ProjectedFailure<A, D> {
pub fn details(&self) -> &D {
&self.envelope.details
}
pub fn identity(&self) -> FailureIdentity {
self.envelope.details.identity()
}
pub fn explanation(&self) -> &str {
&self.envelope.explanation
}
pub fn context(&self) -> &FailureContext {
&self.envelope.context
}
}
pub fn project_failure<A: Audience, D: ProjectFor<A>>(
facts: &FailureFacts<D>,
authorization: &D::Authorization,
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
validate_failure(facts)?;
let disclosure = facts.cause().disclose(facts, authorization)?;
disclosure.details.validate()?;
let identity = disclosure.details.identity();
// A domain can remove optional details, not silently replace a known cause.
if identity != facts.cause().identity() {
return Err(ContractError::InvalidDetails);
}
validate_explanation(&disclosure.explanation)?;
disclosure.context.validate()?;
Ok(ProjectedFailure {
envelope: FailureEnvelope {
version: SCHEMA_VERSION,
required_extensions: Vec::new(),
domain: identity.domain.into(),
reason: identity.reason.into(),
category: identity.category,
details_version: identity.details_version,
details: disclosure.details,
explanation: disclosure.explanation,
context: disclosure.context,
},
audience: PhantomData,
})
}
/// Compose an allowlisted cause and impact without truncating either required part.
/// Domains select the translated fragments; consumers must not classify from this prose.
pub fn compose_explanation(cause: &str, impact: &str) -> Result<String, ContractError> {
validate_explanation(cause)?;
validate_explanation(impact)?;
let explanation = format!("{impact}: {cause}");
validate_explanation(&explanation)?;
Ok(explanation)
}
/// Safe local presentation when no supported cause can be disclosed. This is not a
/// replacement domain failure or permission to forward unsupported payload bytes.
#[derive(Debug)]
pub struct UnavailablePresentation {
pub detail: &'static str,
pub stage: &'static str,
pub correlation: Option<CorrelationRef>,
}
pub fn unavailable_presentation(
stage: &'static str,
correlation: Option<CorrelationRef>,
) -> Result<UnavailablePresentation, ContractError> {
validate_identifier(stage)?;
if let Some(reference) = &correlation {
validate_identifier(reference.as_str())?;
}
Ok(UnavailablePresentation {
detail: "Failure details unavailable.",
stage,
correlation,
})
}
/// Safe fields for an existing tracing sink. No implicit recording at wrappers.
#[derive(Debug, PartialEq, Eq)]
pub struct ObservationFields<'a> {
pub domain: &'static str,
pub reason: &'static str,
pub operation: Option<&'a str>,
pub stage: Option<&'a str>,
pub correlation: Option<&'a str>,
}
pub fn observation_fields<D: FailureCause>(
failure: &ProjectedFailure<Telemetry, D>,
) -> ObservationFields<'_> {
let identity = failure.identity();
let operation = failure.context().operations.last();
ObservationFields {
domain: identity.domain,
reason: identity.reason,
operation: operation.map(|op| op.operation.as_str()),
stage: operation.map(|op| op.stage.as_str()),
correlation: failure
.context()
.correlation
.as_ref()
.map(CorrelationRef::as_str),
}
}
+32
View File
@@ -0,0 +1,32 @@
//! The wire DTOs are the schema source. Domain details supply their own JsonSchema.
//! Byte/depth budgets and cross-field validation remain runtime requirements; JSON Schema
//! string lengths count codepoints and cannot replace the codec's UTF-8 byte limits.
use crate::{codec::FailureEnvelope, problem::ProblemDetails};
pub fn failure_schema<D: schemars::JsonSchema>() -> schemars::Schema {
schemars::schema_for!(FailureEnvelope<D>)
}
pub fn problem_schema<D: schemars::JsonSchema>() -> schemars::Schema {
schemars::schema_for!(ProblemDetails<D>)
}
/// Existing protocol TS generation consumes this fragment; no second DTO/generator is needed.
#[cfg(feature = "typescript")]
pub fn typescript_declarations() -> String {
use crate::*;
use ts_rs::TS;
let config = ts_rs::Config::default();
[
Category::decl(&config),
OperationContext::decl(&config),
CorrelationRef::decl(&config),
CausalLink::decl(&config),
Diagnostic::decl(&config),
FailureContext::decl(&config),
FailureEnvelope::<String>::decl(&config),
ProblemDetails::<String>::decl(&config),
]
.into_iter()
.map(|declaration| format!("export {declaration}\n"))
.collect()
}
+354
View File
@@ -0,0 +1,354 @@
//! Shared executable examples, not production authorization/domain adapters.
//! The fixture authorization context stands in for a boundary's current authenticated policy
//! decision; it must never be reconstructed from a received failure or an audience label.
use crate::*;
use serde::{Deserialize, Serialize};
pub const SUBJECT_FIXTURE: &str = include_str!("../fixtures/subject-denied.json");
pub const WORKDIR_FIXTURE: &str = include_str!("../fixtures/workdir-in-use.json");
pub const FIXTURE_SECRETS: &[&str] = &[
"credential-secret",
"/private/worktree",
"raw-http-body",
"internal-source",
];
#[derive(Clone, Copy)]
pub enum Language {
English,
Japanese,
}
/// Fixture policy for subject-1/workdir-1 only; a true visibility decision is not a grant
/// to disclose arbitrary resource references supplied by a received wire payload.
pub struct CurrentAccess {
pub can_view_resource: bool,
pub language: Language,
}
impl CurrentAccess {
fn visible_reference(&self, reference: Option<&str>, expected: &str) -> Option<String> {
reference
.filter(|value| self.can_view_resource && *value == expected)
.map(str::to_owned)
}
}
#[derive(Debug, Clone, Copy, 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 RequiredOperation {
#[serde(rename = "session.record")]
SessionRecord,
}
#[derive(Debug, Clone, 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 SubjectDenied {
pub required_operation: RequiredOperation,
pub subject_ref: Option<String>,
}
impl FailureCause for SubjectDenied {
fn identity(&self) -> FailureIdentity {
FailureIdentity {
domain: "subject",
reason: "operation_denied",
category: Category::Authorization,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
if let Some(reference) = &self.subject_ref {
validate_identifier(reference)?;
}
Ok(())
}
}
impl WireDetails for SubjectDenied {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "subject" && reason == "operation_denied" && version == 1
}
}
#[derive(Debug, Clone, Copy, 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 WorkdirOperation {
Open,
}
#[derive(Debug, Clone, 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 WorkdirInUse {
pub operation: WorkdirOperation,
pub workdir_ref: Option<String>,
}
impl FailureCause for WorkdirInUse {
fn identity(&self) -> FailureIdentity {
FailureIdentity {
domain: "workdir",
reason: "in_use",
category: Category::Conflict,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
if let Some(reference) = &self.workdir_ref {
validate_identifier(reference)?;
}
Ok(())
}
}
impl WireDetails for WorkdirInUse {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "workdir" && reason == "in_use" && version == 1
}
}
#[derive(Debug, thiserror::Error)]
#[error("internal-source credential-secret /private/worktree raw-http-body")]
pub struct PrivateSource;
#[derive(Debug, thiserror::Error)]
#[error("subject rejected operation")]
pub struct SubjectError {
pub details: SubjectDenied,
#[source]
pub source: PrivateSource,
}
#[derive(Debug, thiserror::Error)]
#[error("workdir in use")]
pub struct WorkdirError {
pub details: WorkdirInUse,
#[source]
pub source: PrivateSource,
}
macro_rules! cause {
($name:ty) => {
impl FailureCause for $name {
fn identity(&self) -> FailureIdentity {
self.details.identity()
}
fn validate(&self) -> Result<(), ContractError> {
self.details.validate()
}
}
};
}
cause!(SubjectError);
cause!(WorkdirError);
// The domain selects both details and common metadata. No raw source/Display is inspected.
fn allowed_context(context: &FailureContext, access: &CurrentAccess) -> FailureContext {
FailureContext {
operations: context
.operations
.iter()
.filter_map(|operation| {
if !matches!(
operation.operation.as_str(),
"worker.create" | "workdir.open"
) || !matches!(operation.stage.as_str(), "register" | "prepare")
{
return None;
}
Some(OperationContext {
operation: operation.operation.clone(),
stage: operation.stage.clone(),
operation_ref: access.visible_reference(
operation.operation_ref.as_deref(),
"visible-operation-ref",
),
})
})
.collect(),
// A wire correlation is not a local proof. This fixture boundary knows only these
// two safe occurrence references; production adapters use their current binding.
correlation: context
.correlation
.as_ref()
.filter(|reference| matches!(reference.as_str(), "correlation-1" | "correlation-2"))
.cloned(),
supplementary_omitted: !context.diagnostics.is_empty()
|| !context.causes.is_empty()
|| context.supplementary_omitted,
..FailureContext::default()
}
}
fn subject_disclosure(
details: &SubjectDenied,
context: &FailureContext,
access: &CurrentAccess,
) -> Result<Disclosure<SubjectDenied>, ContractError> {
let details = SubjectDenied {
required_operation: details.required_operation,
subject_ref: access.visible_reference(details.subject_ref.as_deref(), "subject-1"),
};
let explanation = match access.language {
Language::English => compose_explanation(
"session.record is denied.",
"Worker creation cannot register the session",
)?,
Language::Japanese => compose_explanation(
"session.record が拒否されました。",
"Worker作成でSessionを登録できません",
)?,
};
Ok(Disclosure {
details,
explanation,
context: allowed_context(context, access),
})
}
fn workdir_disclosure(
details: &WorkdirInUse,
context: &FailureContext,
access: &CurrentAccess,
) -> Disclosure<WorkdirInUse> {
let details = WorkdirInUse {
operation: details.operation,
workdir_ref: access.visible_reference(details.workdir_ref.as_deref(), "workdir-1"),
};
let explanation = match access.language {
Language::English => "Workdir open cannot proceed because the workdir is in use.",
Language::Japanese => "Workdirは使用中のため開けません。",
};
Disclosure {
details,
explanation: explanation.into(),
context: allowed_context(context, access),
}
}
macro_rules! adapters {
($($audience:ty),*) => { $(
impl ProjectFor<$audience> for SubjectDenied {
type Authorization = CurrentAccess; type Details = SubjectDenied;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
subject_disclosure(self, facts.context(), access)
}
}
impl ProjectFor<$audience> for WorkdirInUse {
type Authorization = CurrentAccess; type Details = WorkdirInUse;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
Ok(workdir_disclosure(self, facts.context(), access))
}
}
impl ProjectFor<$audience> for SubjectError {
type Authorization = CurrentAccess; type Details = SubjectDenied;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
subject_disclosure(&self.details, facts.context(), access)
}
}
impl ProjectFor<$audience> for WorkdirError {
type Authorization = CurrentAccess; type Details = WorkdirInUse;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
Ok(workdir_disclosure(&self.details, facts.context(), access))
}
}
)* };
}
adapters!(Browser, Tool, Storage, InternalTransfer, Telemetry);
pub fn subject_facts() -> FailureFacts<SubjectError> {
let mut facts = FailureFacts::new(SubjectError {
details: SubjectDenied {
required_operation: RequiredOperation::SessionRecord,
subject_ref: Some("subject-1".into()),
},
source: PrivateSource,
})
.unwrap();
facts
.with_context(OperationContext::new("worker.create", "register").unwrap())
.unwrap();
facts
.set_correlation(CorrelationRef::new("correlation-1").unwrap())
.unwrap();
facts
}
pub fn workdir_facts() -> FailureFacts<WorkdirError> {
let mut facts = FailureFacts::new(WorkdirError {
details: WorkdirInUse {
operation: WorkdirOperation::Open,
workdir_ref: Some("workdir-1".into()),
},
source: PrivateSource,
})
.unwrap();
facts
.with_context(OperationContext::new("workdir.open", "prepare").unwrap())
.unwrap();
facts
.set_correlation(CorrelationRef::new("correlation-2").unwrap())
.unwrap();
facts
}
/// Inner Subject API vs outer Worker API deliberately choose different HTTP classifications.
pub struct SubjectApi;
pub struct WorkerApi;
pub struct WorkdirApi;
impl crate::problem::ApiBoundary<SubjectDenied> for SubjectApi {
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
crate::problem::ProblemClass::authorization_denied()
}
}
impl crate::problem::ApiBoundary<SubjectDenied> for WorkerApi {
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
crate::problem::ProblemClass::conflict()
}
}
impl crate::problem::ApiBoundary<WorkdirInUse> for WorkdirApi {
fn classify(&self, _: &WorkdirInUse) -> crate::problem::ProblemClass {
crate::problem::ProblemClass::conflict()
}
}
pub fn assert_no_fixture_secrets(bytes: &[u8]) {
let text = std::str::from_utf8(bytes).expect("JSON is UTF-8");
for secret in FIXTURE_SECRETS {
assert!(!text.contains(secret), "secret escaped the allowlist");
}
}
/// Check the field allowlist on the same wire fixture used by HTTP/TS/storage consumers.
pub fn assert_detail_allowlist(bytes: &[u8], allowed: &[&str]) {
let value: serde_json::Value = serde_json::from_slice(bytes).expect("fixture JSON");
let details = value["details"]
.as_object()
.expect("fixture details object");
for key in details.keys() {
assert!(
allowed.contains(&key.as_str()),
"unexpected public detail field: {key}"
);
}
}
pub fn assert_context_preserves_details<D: WireDetails + Clone + PartialEq + std::fmt::Debug>(
facts: &mut FailureFacts<D>,
context: OperationContext,
) {
let original = facts.cause().clone();
facts
.with_context(context)
.expect("fixture context fits budget");
assert_eq!(facts.cause(), &original);
}
/// Semantic check shared by Rust/HTTP/storage fixture consumers. Compare typed details,
/// not translated prose or diagnostic order. Reprojection is intentionally a separate step.
pub fn assert_semantic_roundtrip<D: WireDetails + PartialEq + std::fmt::Debug>(
bytes: &[u8],
expected: &D,
budget: Budget,
) {
match decode_failure::<D>(bytes, budget) {
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), expected),
_ => panic!("expected a known validated fixture"),
}
}
File diff suppressed because it is too large Load Diff
+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);
}
+3
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
[features]
test-support = []
[dependencies] [dependencies]
rusqlite.workspace = true rusqlite.workspace = true
thiserror.workspace = true thiserror.workspace = true
+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))
});
}
@@ -0,0 +1,64 @@
use std::sync::atomic::{AtomicU64, Ordering};
/// Read-only observations for one registration (and its clones) across its scopes.
/// Counts real I/O boundaries, not API opens, and retains no physical database.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OpenObservation {
pub physical_open_attempts: u64,
pub physical_opens: u64,
pub completed_initializations: u64,
pub directory_sync_attempts: u64,
pub directory_sync_successes: u64,
}
impl OpenObservation {
pub fn since(self, before: Self) -> Self {
Self {
physical_open_attempts: self.physical_open_attempts - before.physical_open_attempts,
physical_opens: self.physical_opens - before.physical_opens,
completed_initializations: self.completed_initializations
- before.completed_initializations,
directory_sync_attempts: self.directory_sync_attempts - before.directory_sync_attempts,
directory_sync_successes: self.directory_sync_successes
- before.directory_sync_successes,
}
}
}
#[derive(Debug, Default)]
pub(super) struct Counters {
physical_open_attempts: AtomicU64,
physical_opens: AtomicU64,
completed_initializations: AtomicU64,
directory_sync_attempts: AtomicU64,
directory_sync_successes: AtomicU64,
}
impl Counters {
pub(super) fn snapshot(&self) -> OpenObservation {
OpenObservation {
physical_open_attempts: self.physical_open_attempts.load(Ordering::Relaxed),
physical_opens: self.physical_opens.load(Ordering::Relaxed),
completed_initializations: self.completed_initializations.load(Ordering::Relaxed),
directory_sync_attempts: self.directory_sync_attempts.load(Ordering::Relaxed),
directory_sync_successes: self.directory_sync_successes.load(Ordering::Relaxed),
}
}
pub(super) fn open_attempt(&self) {
self.physical_open_attempts.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn open_success(&self) {
self.physical_opens.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn initialized(&self) {
self.completed_initializations
.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn sync_attempt(&self) {
self.directory_sync_attempts.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn sync_success(&self) {
self.directory_sync_successes
.fetch_add(1, Ordering::Relaxed);
}
}
+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)
}
}
+30
View File
@@ -200,6 +200,7 @@ fn run_read_with_limits(
total_lines, total_lines,
content_hash: content_hasher.finalize().into(), content_hash: content_hasher.finalize().into(),
truncated: end < total_lines || byte_truncated, truncated: end < total_lines || byte_truncated,
byte_truncated,
}) })
} }
@@ -885,6 +886,34 @@ mod bounded_read_tests {
assert!(error.to_string().contains("exceeds provider limit 4096")); assert!(error.to_string().contains("exceeds provider limit 4096"));
} }
#[test]
fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join("text"), "雪雪needle\nnext").unwrap();
for (offset, max_bytes, bytes, more, cut) in [
(0, 5, "雪", true, true),
(0, 64, "雪雪needle\n", true, false),
(1, 64, "next", false, false),
] {
let result = run_read_bounded(
&root,
ReadRequest {
path: FsPath::new("text").unwrap(),
offset,
limit: 1,
max_bytes,
},
&RootAccess(root.clone()),
BoundedReadLimits::new(1024, 64).unwrap(),
)
.unwrap();
assert_eq!(result.bytes, bytes.as_bytes());
assert_eq!(result.truncated, more);
assert_eq!(result.byte_truncated, cut);
}
}
#[test] #[test]
fn bounded_read_caps_retained_response_independently_of_request() { fn bounded_read_caps_retained_response_independently_of_request() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -907,6 +936,7 @@ mod bounded_read_tests {
assert_eq!(result.bytes, b"alpha"); assert_eq!(result.bytes, b"alpha");
assert_eq!(result.total_lines, 3); assert_eq!(result.total_lines, 3);
assert!(result.truncated); assert!(result.truncated);
assert!(result.byte_truncated);
} }
} }
+4
View File
@@ -204,7 +204,11 @@ pub struct ReadResult {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub total_lines: usize, pub total_lines: usize,
pub content_hash: ContentHash, pub content_hash: ContentHash,
/// More source lines remain, or the selected window exceeded the response byte budget.
pub truncated: bool, pub truncated: bool,
/// The selected line window was cut by its byte budget, possibly within a line.
/// Line-based cursors must not treat such a window as completely scanned.
pub byte_truncated: bool,
} }
/// Binary-safe byte range read with an optional whole-file version fence. /// Binary-safe byte range read with an optional whole-file version fence.
-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,
+6
View File
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_ignored = "0.1.14" serde_ignored = "0.1.14"
sha2 = "0.10" sha2 = "0.10"
crc32fast = "1"
wasmparser = "0.244.0"
wit-parser = "0.244.0"
wit-component = "0.244.0"
jsonschema = { version = "0.33.0", default-features = false }
secrets = { workspace = true } secrets = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
wit-component = { version = "0.244.0", features = ["dummy-module"] }
serial_test = "3.4.0" serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }
+89 -5
View File
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Omission retains the lower layer; an explicit section replaces the entire
/// selection list, including an empty list. Never unions Plugin grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<crate::PluginProfileConfig>,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfigPartial>, pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
@@ -295,6 +299,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub consolidation_tools: Option<bool>, pub consolidation_tools: Option<bool>,
#[serde(default)] #[serde(default)]
pub chat: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>, pub extraction: Option<MemoryExtractionProfileConfigPartial>,
} }
@@ -303,6 +309,7 @@ impl SubjektivFeatureConfigPartial {
Self { Self {
enabled: other.enabled.or(self.enabled), enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools), consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
extraction: merge_option( extraction: merge_option(
self.extraction, self.extraction,
other.extraction, other.extraction,
@@ -554,6 +561,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
profile: SubjektivFeatureProfileConfig { profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(), enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(), consolidation_tools: value.consolidation_tools.unwrap_or_default(),
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
extraction: MemoryExtractionProfileConfig { extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true), enabled: extraction.enabled.unwrap_or(true),
model: extraction.model, model: extraction.model,
@@ -574,6 +582,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
Self { Self {
enabled: Some(value.profile.enabled), enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools), consolidation_tools: Some(value.profile.consolidation_tools),
chat: Some(value.profile.chat.into()),
extraction: Some(MemoryExtractionProfileConfigPartial { extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled), enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model, model: value.profile.extraction.model,
@@ -795,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)", "unknown field in manifest: memory (removed; configure feature.memory)",
)); ));
} }
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value if value
.get("feature") .get("feature")
.and_then(toml::Value::as_table) .and_then(toml::Value::as_table)
@@ -930,6 +934,7 @@ impl WorkerManifestConfig {
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
plugins: upper.plugins.or(self.plugins),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
upper.compaction, upper.compaction,
@@ -1445,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
mcp: cfg.mcp, mcp: cfg.mcp,
plugins: cfg.plugins.unwrap_or_default(),
compaction, compaction,
web: cfg.web, web: cfg.web,
skills: cfg.skills, skills: cfg.skills,
@@ -1492,6 +1498,7 @@ mod tests {
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
plugins: None,
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
@@ -2988,6 +2995,83 @@ worker_max_turns = 7
assert_eq!(encoded["enabled"], true); assert_eq!(encoded["enabled"], true);
} }
#[test]
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
for subject in [
"",
"[feature.subjektiv]\nenabled = true\n",
"[feature.subjektiv.chat]\n",
] {
let config = WorkerManifestConfig::from_toml(subject).unwrap();
let feature = FeatureConfig::from(config.feature);
assert!(!feature.subjektiv.profile.chat.enabled);
}
assert!(
!ResolvedSubjektivFeatureConfig::default()
.profile
.chat
.enabled
);
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
"profile": { "enabled": true }
}))
.unwrap();
assert!(!legacy.profile.chat.enabled);
for enabled in [true, false] {
let config = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
)).unwrap();
let resolved = FeatureConfig::from(config.feature).subjektiv;
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
assert_eq!(
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
resolved
);
assert!(resolved.workspace_settings.is_none());
assert!(!resolved.execution_enabled());
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
let toml = toml::to_string(&partial).unwrap();
assert_eq!(
ResolvedSubjektivFeatureConfig::from(
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
),
resolved
);
}
}
#[test]
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
let base = WorkerManifestConfig::from_toml(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
).unwrap();
for overlay in [
"",
"[feature.subjektiv]\nconsolidation_tools = false\n",
"[feature.subjektiv.chat]\n",
] {
let merged = base
.clone()
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
let feature = FeatureConfig::from(merged.feature);
assert!(feature.subjektiv.profile.chat.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
for enabled in [false, true] {
let overlay = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
))
.unwrap();
let merged = base.clone().merge(overlay);
let feature = FeatureConfig::from(merged.feature);
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
assert!(feature.subjektiv.profile.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
}
#[test] #[test]
fn merge_preserves_ref() { fn merge_preserves_ref() {
let lower = WorkerManifestConfig { let lower = WorkerManifestConfig {
+13 -4
View File
@@ -7,6 +7,7 @@ mod model;
pub mod model_catalog; pub mod model_catalog;
pub mod paths; pub mod paths;
pub mod plugin; pub mod plugin;
mod plugin_profile;
mod profile; mod profile;
mod scope; mod scope;
@@ -24,6 +25,7 @@ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
}; };
pub use paths::user_profiles_path; pub use paths::user_profiles_path;
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant};
pub use profile::{ pub use profile::{
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
/// consumer boundary. /// consumer boundary.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
/// their serialized fingerprint inputs.
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
pub plugins: PluginProfileConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// First-class web tools configuration. Network access remains fail-closed /// First-class web tools configuration. Network access remains fail-closed
@@ -412,6 +419,8 @@ pub struct SubjektivFeatureProfileConfig {
pub enabled: bool, pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority. /// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool, pub consolidation_tools: bool,
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
pub chat: FeatureFlagConfig,
pub extraction: MemoryExtractionProfileConfig, pub extraction: MemoryExtractionProfileConfig,
} }
@@ -420,6 +429,7 @@ impl Default for SubjektivFeatureProfileConfig {
Self { Self {
enabled: false, enabled: false,
consolidation_tools: false, consolidation_tools: false,
chat: FeatureFlagConfig::disabled(),
extraction: MemoryExtractionProfileConfig::default(), extraction: MemoryExtractionProfileConfig::default(),
} }
} }
@@ -1116,6 +1126,7 @@ impl Default for CompactionConfig {
impl WorkerManifest { impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool { pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() self.profile.is_some()
|| !self.plugins.is_empty()
|| self.feature.memory.workspace_settings.is_some() || self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some() || self.feature.subjektiv.workspace_settings.is_some()
} }
@@ -1595,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
); );
let error = WorkerManifest::from_toml(&toml).unwrap_err(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!( assert!(
error error.to_string().contains("unknown field `enabled`"),
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}" "unexpected error: {error}"
); );
} }
@@ -1617,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
}), }),
); );
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err(); let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`")); assert!(error.to_string().contains("unknown field `resolved`"));
let mut nested = base; let mut nested = base;
nested nested
+1 -38
View File
@@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option<PathBuf> {
worker_allocation_path_from_runtime_dir(runtime_dir()) worker_allocation_path_from_runtime_dir(runtime_dir())
} }
/// `<runtime_dir>/<worker_name>/` — per-Worker runtime directory.
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
}
/// `<runtime_dir>/<worker_name>/sock` — Worker's Unix socket path.
///
/// The `worker` crate's `RuntimeDir::socket_path()` creates the socket inside the
/// Worker process. This function **predicts** the path for external callers that
/// know the Worker name, such as the TUI attach flow. Both should return the same
/// path.
pub fn worker_socket_path(worker_name: &str) -> Option<PathBuf> {
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
}
// ---- internals -------------------------------------------------------------- // ---- internals --------------------------------------------------------------
fn resolve_config_dir_from_parts( fn resolve_config_dir_from_parts(
@@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Opti
Some(runtime_dir?.join("workers.json")) Some(runtime_dir?.join("workers.json"))
} }
fn worker_runtime_dir_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(runtime_dir?.join(worker_name))
}
fn worker_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
}
/// Treats empty environment values as unset. `std::env::var` distinguishes /// Treats empty environment values as unset. `std::env::var` distinguishes
/// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset /// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset
/// for intuitive behavior. /// for intuitive behavior.
@@ -455,16 +426,8 @@ mod tests {
PathBuf::from("/sand/sessions") PathBuf::from("/sand/sessions")
); );
assert_eq!( assert_eq!(
worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(), worker_allocation_path_from_runtime_dir(runtime_dir).unwrap(),
PathBuf::from("/sand/run/workers.json") PathBuf::from("/sand/run/workers.json")
); );
assert_eq!(
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
PathBuf::from("/sand/run/foo")
);
assert_eq!(
worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
PathBuf::from("/sand/run/foo/sock")
);
} }
} }
File diff suppressed because it is too large Load Diff
+656
View File
@@ -0,0 +1,656 @@
//! Static validation of the exact, supported Plugin Component ABI.
//!
//! Component metadata is inferred from validated bytes, never trusted from a
//! producer's custom sections. This module does not instantiate guest code.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use wit_component::{DecodedWasm, WitPrinter};
use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey};
const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit");
const HOST_WIT: &str =
include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentMetadata {
pub world: String,
pub imports: Vec<String>,
pub exports: Vec<String>,
}
/// Validates an executable Component against the embedded exact-version world.
///
/// `required_imports` is a set (duplicates are rejected); it must equal both the
/// selected world's imports and the actual Component imports. Returned names are
/// fully qualified and sorted. The world name comes from the supported contract,
/// not a producer-supplied custom section: executable Components do not encode
/// the original WIT world name.
pub fn validate_component(
bytes: &[u8],
world: &str,
required_imports: &[String],
) -> Result<ComponentMetadata, String> {
let world_name = match world {
"yoi:plugin/tool-plugin@2.0.0" => "tool-plugin",
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
"yoi:plugin/ticket-service-plugin@2.2.0" => "ticket-service-plugin",
_ => return Err(format!("unsupported Component world `{world}`")),
};
if !wasmparser::Parser::is_component(bytes) {
return Err("expected a WebAssembly Component, not a core module".into());
}
wasmparser::Validator::new()
.validate_all(bytes)
.map_err(|error| format!("invalid WebAssembly Component: {error}"))?;
let (actual, actual_world) = match wit_component::decode(bytes)
.map_err(|error| format!("cannot decode Component interface: {error:#}"))?
{
DecodedWasm::Component(resolve, world) => (resolve, world),
DecodedWasm::WitPackage(..) => {
return Err("expected an executable Component, not a WIT type package".into());
}
};
let (expected, expected_world) = contract(world_name)?;
let expected_imports = surface(&expected, expected_world, true)?;
let expected_exports = surface(&expected, expected_world, false)?;
let actual_imports = surface(&actual, actual_world, true)?;
let actual_exports = surface(&actual, actual_world, false)?;
let required: BTreeSet<_> = required_imports.iter().cloned().collect();
if required.len() != required_imports.len() {
return Err("duplicate required Component import in manifest".into());
}
let imports: Vec<_> = expected_imports.keys().cloned().collect();
let exports: Vec<_> = expected_exports.keys().cloned().collect();
if required != expected_imports.keys().cloned().collect() {
return Err(format!(
"manifest required_imports do not match world `{world}`: expected {imports:?}"
));
}
if actual_imports.keys().ne(expected_imports.keys()) {
return Err(format!(
"Component imports do not match world `{world}`: expected {imports:?}, found {:?}",
actual_imports.keys().collect::<Vec<_>>()
));
}
if actual_exports.keys().ne(expected_exports.keys()) {
return Err(format!(
"Component exports do not match world `{world}`: expected {exports:?}, found {:?}",
actual_exports.keys().collect::<Vec<_>>()
));
}
// Compare all decoded interfaces, including those owning referenced types,
// not only function names or the world surface. Printing preserves the
// qualified owner of type aliases and resource identity, borrow/own, field
// names/order, and nested signatures. Resolve::merge is not an ABI check.
let expected_interfaces = named_interfaces(&expected)?;
for (name, id) in named_interfaces(&actual)? {
let expected_id = expected_interfaces
.get(&name)
.ok_or_else(|| format!("unsupported Component interface `{name}`"))?;
if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? {
return Err(format!("Component interface `{name}` has an incorrect ABI"));
}
}
Ok(ComponentMetadata {
world: world.to_string(),
imports,
exports,
})
}
fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
let mut resolve = Resolve::default();
resolve
.push_str("yoi-host-v2.wit", HOST_WIT)
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
let old_package = resolve
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
let package = if matches!(world_name, "ticket-tool-plugin" | "ticket-service-plugin") {
resolve
.push_str(
"yoi-host-v2.1.wit",
include_str!(
"../../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit"
),
)
.map_err(|e| format!("invalid Ticket Host WIT: {e:#}"))?;
resolve
.push_str(
"yoi-plugin-v2.1.wit",
include_str!("../../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"),
)
.map_err(|e| format!("invalid Ticket Plugin WIT: {e:#}"))?
} else {
old_package
};
let package = if world_name == "ticket-service-plugin" {
resolve
.push_str(
"yoi-plugin-v2.2.wit",
include_str!("../../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit"),
)
.map_err(|e| format!("invalid Service WIT: {e:#}"))?
} else {
package
};
let world = resolve
.select_world(&[package], Some(world_name))
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
Ok((resolve, world))
}
fn surface(
resolve: &Resolve,
world: WorldId,
imports: bool,
) -> Result<BTreeMap<String, InterfaceId>, String> {
let items = if imports {
&resolve.worlds[world].imports
} else {
&resolve.worlds[world].exports
};
let mut names = BTreeMap::new();
for (key, item) in items {
let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else {
return Err(
"Component world contains an unsupported bare function/type or named interface"
.into(),
);
};
if key_id != id {
return Err("Component interface identity does not match its world key".into());
}
let name = resolve
.id_of(*id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), *id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn named_interfaces(resolve: &Resolve) -> Result<BTreeMap<String, InterfaceId>, String> {
let mut names = BTreeMap::new();
for (id, _) in resolve.interfaces.iter() {
let name = resolve
.id_of(id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result<String, String> {
// Declaration order is not ABI; record fields, variant cases, parameters,
// and results remain ordered. Sorting maps also canonicalizes use aliases
// and resource methods without changing their nominal owners.
let mut resolve = resolve.clone();
resolve.interfaces[id].types.sort_keys();
resolve.interfaces[id].functions.sort_keys();
let mut printer = WitPrinter::default();
printer.emit_docs(false);
printer
.print_interface(&resolve, id)
.map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?;
Ok(printer.output.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata};
use wit_parser::ManglingAndAbi;
const PURE: &str = "yoi:plugin/tool-plugin@2.0.0";
const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0";
const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0";
const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0";
fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec<u8> {
let mut resolve = Resolve::default();
resolve.push_str("host.wit", host).unwrap();
let package = resolve.push_str("plugin.wit", plugin).unwrap();
let world = resolve.select_world(&[package], Some(world_name)).unwrap();
encode(&resolve, world)
}
fn encode(resolve: &Resolve, world: WorldId) -> Vec<u8> {
let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32);
embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap();
ComponentEncoder::default()
.module(&module)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
fn https_imports() -> Vec<String> {
[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
]
.map(str::to_string)
.into()
}
fn logged_imports(https: bool) -> Vec<String> {
let mut imports = if https {
https_imports()
} else {
vec!["yoi:host/errors@2.0.0".into()]
};
imports.push("yoi:host/logging@2.0.0".into());
imports
}
#[test]
fn exact_world_components_return_sorted_metadata_without_running_guest() {
for (name, world, imports) in [
("tool-plugin", PURE, vec![]),
("https-tool-plugin", HTTPS, https_imports()),
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
assert_eq!(
validate_component(&component, world, &imports).unwrap(),
ComponentMetadata {
world: world.into(),
imports,
exports: vec!["yoi:plugin/tool@2.0.0".into()],
}
);
}
}
#[test]
fn logging_worlds_require_exact_manifest_and_actual_import_sets() {
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
let mut missing = imports.clone();
missing.retain(|name| !name.contains("/logging@"));
assert!(
validate_component(&component, world, &missing)
.unwrap_err()
.contains("manifest required_imports")
);
let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", "");
let component = fixture(HOST_WIT, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn logging_world_and_interface_versions_are_exact() {
let logging = HOST_WIT
.split_once("interface logging {")
.unwrap()
.1
.replace(
"use errors.{host-error};",
"use yoi:host/errors@2.0.0.{host-error};",
);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let supported = fixture(HOST_WIT, PLUGIN_WIT, name);
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let unsupported_world = world.replace("@2.0.0", &format!("@{version}"));
assert!(
validate_component(&supported, &unsupported_world, &imports)
.unwrap_err()
.contains("unsupported Component world")
);
let host = format!(
"{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}"
);
let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
}
#[test]
fn typed_logging_nested_payload_and_signature_changes_are_rejected() {
for (from, to) in [
(
"trace, debug, info, warn, error",
"debug, trace, info, warn, error",
),
("number(f64)", "number(f32)"),
("signed(s64)", "signed(u64)"),
("value: scalar", "value: string"),
("fields: list<field>", "fields: option<field>"),
("event-code: string", "event-name: string"),
(
"message: string,\n fields:",
"message: string, extra: bool,\n fields:",
),
("log: func(input: entry)", "log: func(input: string)"),
(
"log: func(input: entry) -> result<_, host-error>",
"log: func(input: entry) -> result<string, host-error>",
),
("log: func", "extra: func(); log: func"),
] {
assert!(HOST_WIT.contains(from));
let host = HOST_WIT.replace(from, to);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(&host, PLUGIN_WIT, name);
let error = validate_component(&component, world, &imports).unwrap_err();
assert!(
error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"),
"mutation: {from} -> {to}: {error}"
);
}
}
}
#[test]
fn validated_metadata_has_serializable_public_fields() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin");
let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap();
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(
json,
serde_json::json!({
"world": LOGGED,
"imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"],
"exports": ["yoi:plugin/tool@2.0.0"],
})
);
assert_eq!(
serde_json::from_value::<ComponentMetadata>(json).unwrap(),
metadata
);
}
#[test]
fn v1_unknown_and_wrong_manifest_worlds_are_rejected() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin");
for world in [
"yoi:plugin/tool-plugin@1.0.0",
"yoi:plugin/tool-plugin@2.0.1",
] {
assert!(
validate_component(&component, world, &[])
.unwrap_err()
.contains("unsupported Component world")
);
}
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
for version in ["1.0.0", "9.0.0"] {
let changed = PLUGIN_WIT.replace("2.0.0", version);
let changed_host = HOST_WIT.replace("2.0.0", version);
let component = fixture(&changed_host, &changed, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("Component exports")
);
}
}
#[test]
fn wrong_host_interface_versions_are_not_semver_compatible() {
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let host = HOST_WIT.replace("@2.0.0", &format!("@{version}"));
let plugin = PLUGIN_WIT
.replace("errors@2.0.0", &format!("errors@{version}"))
.replace("connections@2.0.0", &format!("connections@{version}"))
.replace("http@2.0.0", &format!("http@{version}"))
.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn manifest_import_set_must_equal_actual_and_supported_world() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin");
let mut reordered = https_imports();
reordered.reverse();
assert!(validate_component(&component, HTTPS, &reordered).is_ok());
let mut duplicate = https_imports();
duplicate.push(duplicate[0].clone());
for imports in [
vec![],
vec!["wasi:filesystem/types@0.2.0".into()],
duplicate,
] {
assert!(validate_component(&component, HTTPS, &imports).is_err());
}
}
#[test]
fn unadvertised_imports_and_extra_exports_are_rejected() {
for (from, to) in [
("export tool;", ""),
(
"world tool-plugin {",
"world tool-plugin { import surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { export surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { import yoi:host/logging@2.0.0;",
),
] {
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
let plugin = PLUGIN_WIT.replace(
"world tool-plugin {",
"world tool-plugin { import wasi:unpublished/io@0.2.0;",
);
let host = format!(
"{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}"
);
let component = fixture(&host, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
#[test]
fn same_interface_name_with_changed_nested_signature_is_rejected() {
for (from, to) in [
("remaining-ms: u32", "remaining-ms: u64"),
("remaining-ms: u32", "time-left: u32"),
("content: option<string>", "content: list<string>"),
("summary: string", "summary: string, extra: bool"),
("input: invocation", "input: output"),
("result<output, tool-error>", "result<tool-error, output>"),
("invoke: func", "extra: func(); invoke: func"),
] {
assert!(PLUGIN_WIT.contains(from));
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn host_resource_identity_ownership_and_nested_records_are_exact() {
for (from, to) in [
("borrow<connection>", "own<connection>"),
("use connections.{connection};", "resource connection;"),
("status: u16", "status: u32"),
("value: string", "value: list<u8>"),
(
"resource connection;",
"resource connection; resource other;",
),
("target: borrow<connection>", "target: borrow<other>"),
("diagnostic-id: string", "diagnostic-id: u64"),
] {
let mut host = HOST_WIT.replace(from, to);
if to == "target: borrow<other>" {
host = host.replace("interface http {", "interface http { resource other;");
}
assert_ne!(host, HOST_WIT);
let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn interface_declaration_order_is_not_part_of_the_abi() {
let (mut resolve, world) = contract("https-tool-plugin").unwrap();
for (_, interface) in resolve.interfaces.iter_mut() {
interface.types.reverse();
interface.functions.reverse();
}
let component = encode(&resolve, world);
assert!(validate_component(&component, HTTPS, &https_imports()).is_ok());
}
#[test]
fn docs_are_not_part_of_the_abi() {
let plugin = PLUGIN_WIT.replace(
"interface tool {",
"/// Different documentation.\ninterface tool {",
);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_ok());
}
#[test]
fn core_module_wit_type_package_and_invalid_component_are_rejected() {
let (resolve, world) = contract("tool-plugin").unwrap();
let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32);
assert!(
validate_component(&module, PURE, &[])
.unwrap_err()
.contains("core module")
);
let package = resolve.worlds[world].package.unwrap();
let types = wit_component::encode(&resolve, package).unwrap();
assert!(
validate_component(&types, PURE, &[])
.unwrap_err()
.contains("WIT type package")
);
let mut component = encode(&resolve, world);
component.pop();
assert!(validate_component(&component, PURE, &[]).is_err());
}
}
#[cfg(test)]
mod ticket_tests {
use super::*;
#[test]
fn additive_ticket_world_cannot_be_claimed_as_an_old_component_world() {
let (resolve, world) = contract("ticket-tool-plugin").unwrap();
let mut core =
wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
wit_component::embed_component_metadata(
&mut core,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
let imports: Vec<_> = surface(&resolve, world, true)
.unwrap()
.into_keys()
.collect();
let checked = validate_component(
&bytes,
crate::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD,
&imports,
)
.unwrap();
assert!(checked.imports.contains(&"yoi:host/objects@2.1.0".into()));
assert!(
checked
.imports
.contains(&"yoi:host/ticket-read@2.1.0".into())
);
assert!(
checked
.imports
.contains(&"yoi:host/extension-data@2.1.0".into())
);
assert_eq!(checked.exports, ["yoi:plugin/tool@2.0.0"]);
assert!(
validate_component(
&bytes,
crate::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD,
&imports
)
.is_err()
);
}
}

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