II.
Page JSON
Structured · livepage:docs-design-proof-based-policy-enforcement
Proof-Based Policy Enforcement — Design Specification json
Inspect the normalized record payload exactly as the atlas UI reads it.
{
"id": "page:docs-design-proof-based-policy-enforcement",
"_kind": "Page",
"_file": "wiki/docs/design/proof-based-policy-enforcement.md",
"_cluster": "wiki",
"attributes": {
"nodeKind": "Page",
"sourcePath": "docs/design/proof-based-policy-enforcement.md",
"sourceKind": "repo-docs",
"title": "Proof-Based Policy Enforcement — Design Specification",
"displayName": "Proof-Based Policy Enforcement — Design Specification",
"slug": "docs/design/proof-based-policy-enforcement",
"articlePath": "wiki/docs/design/proof-based-policy-enforcement.md",
"article": "\n# Proof-Based Policy Enforcement — Design Specification\n\nStatus: **Draft 3 (post second adversarial security review)** · Date: 2026-07-03 · Owner: Security/Platform\nResearch input (frozen, read in full before implementing): [`.a5c/processes/proof-policy-enforcement.research.md`](../../.a5c/processes/proof-policy-enforcement.research.md)\n\n> **Revision note (Draft 2).** An adversarial security review (score 62/100) found 5 viable\n> attacks against Draft 1. This revision closes every blocking issue with concrete, testable\n> acceptance criteria. Stable AC ids are preserved; ACs whose fix changed them are updated in\n> place; new criteria are added as **AC-34+**. The five closed issues and their governing ACs:\n>\n> | # | Blocking issue (Draft 1) | Closed by |\n> |---|--------------------------|-----------|\n> | 1 | Model-decision cannot bind a specific tool call (attestation replayable to a different tool in the same turn) | **AC-34** (signed `toolCalls[]` with `toolCallId`+`argsHash`), AC-4/§4.1, AC-12/13/15/16, AC-30 |\n> | 2 | Trust-root key material unspecified; `verifySignature` trusts caller-supplied keys; cross-kind confusion | **AC-35** (trusted-store resolution + fingerprint binding + kind check), AC-5/6/7/26 |\n> | 3 | Trust-root / policy files not integrity-protected against the workspace-writable adversary | **AC-36/AC-37** (out-of-agent root-of-trust signs the config; bootstrap story), threat model, §1.2, AC-26/33 |\n> | 4 | \"Must-be-signed\" invariant is only a build-time lint | **AC-2/AC-8/AC-10** rewritten (runtime `signedFields` completeness assertion at the trust boundary) |\n> | 5 | Uncanonicalized regex command matching → alias bypass; global default-allow | **AC-38** (canonicalized argv matcher) + §7 schema + AC-23 (per-env opt-in default-allow) |\n>\n> Non-blocking review improvements folded in: proxy attestation default for credential-touching\n> actions (AC-17/AC-39), trusted out-of-agent credential→scope source (AC-40), quorum distinct-holder\n> rule (AC-19/AC-41), argsHash recomputed at GATE 3 (AC-32/AC-23a), `evidenceEnvelopeHashes` covers\n> every required step (AC-9/AC-42), proven-bridge derived evidence still evaluates as `human`\n> (AC-3/AC-43), and a non-blocking-GATE-2 + passthrough-denial acceptance test (AC-44).\n\n> **Revision note (Draft 3).** A **second** adversarial security review (score 74/100) confirmed the\n> five Draft-1 attacks are closed but found **3 residual blocking attacks** against Draft 2. This\n> revision closes each precisely and completely, preserving every previously-closed AC. Stable AC ids\n> are preserved; ACs whose fix changed are updated in place; new criteria are added as **AC-46+**.\n> The three residual blocking issues and their governing ACs:\n>\n> | # | Residual blocking issue (Draft 2) | Closed by |\n> |---|-----------------------------------|-----------|\n> | 6 | **Config rollback / downgrade.** AC-36/37 sign `sha256(file bytes)` with no version/counter, so an older validly-signed, more-permissive `trust-roots + policy` set (incl. an older revocation list — un-revoking stolen keys) can be swapped back in. | **AC-46** (signed config **manifest** with a monotonic `configEpoch` covering ALL config files together), **AC-47** (off-workspace minimum-epoch floor pinned beside `POLICY_CONFIG_ROOT_FP`; every gate rejects `configEpoch < floor`), AC-27/AC-36/AC-37/AC-45 revised |\n> | 7 | **Proven-bridge unsigned `approved` bit.** `proven/verify.ts:60` rebuilds the signing payload from attacker-supplied `provenAnswer.signedFields` and never requires `approved` to be signed; the bridge then trusts `payload.approved` and mints \"human\" evidence. | **AC-48** (bridge asserts legacy `signedFields ⊇ {breakpointId, approved, responderId}` — the AC-2 completeness rule applied to the legacy proven answer — BEFORE deriving evidence; else deny), AC-3/AC-43 revised |\n> | 8 | **GATE 3 backstop scope.** GATE 3 only mediates scoped-credential ENV-VAR injection, so (i) policy-covered non-credential actions have no backstop when GATE 2 is advisory and GATE 1 is bypassed, and (ii) file/mount/IMDS-delivered credentials evade the env gate. | **AC-49** (GATE 1 + genty dispatcher/session seam declared LOAD-BEARING, un-bypassable for ALL covered actions; execution-path enumeration acceptance test), **AC-50** (GATE 3 extended to non-env credential channels `spawn-invocation.ts` controls — docker `-v`, k8s secrets — with an explicit bounded non-goal + warning for channels it cannot mediate, e.g. IMDS), AC-23a/AC-33/AC-40/AC-44 revised |\n>\n> Additional review items folded in cheaply (Draft 3): **domain separation** — a bound\n> `payloadType` constant is added to every payload and to `signedFields` so a signature is\n> non-transferable across evidence kinds (**AC-51**); **one canonical argv/args serialization** shared\n> byte-for-byte by proxy and every gate, with a conformance test (**AC-52**, closes argsHash\n> divergence); **argv wrapper allowlist** — the wrapper handling of AC-38 becomes an allowlist of\n> recognized programs per covered scope rather than a denylist (**AC-38c**); **heterogeneous quorum\n> composition** — how `quorum` composes with typed `steps[]` so \"human+opus AND 2-human-quorum\" is one\n> chain (**AC-41a**); and **collision-resistant credential identity** for AC-40 scope keying with\n> deny-on-ambiguous (**AC-40a**).\n\n## 0. Summary\n\nAdd a cryptographic policy-enforcement layer to the agent-orchestration monorepo so that a\nspecific command, run with a specific tool and specific credentials, executes **only** when a\ndeclarative policy's required *trust chain of signed evidence* is satisfied. Evidence includes\nsigned human breakpoint approvals (`PermissionEvidence`), signed model-decision attestations\n(`ModelDecision`, e.g. \"opus decided to call **this specific** tool call with **these** args\"),\nand delegation links. When a policy is satisfied, a short-lived **`CommandAuthorization`** envelope\nis issued binding the exact tool + tool-call id + command hash + args hash + credential scope +\nevidence fingerprints + evidence content hashes + expiry. The tool layer verifies that\nauthorization at the point of execution and **fails closed** for policy-covered actions.\nFallbacks are forbidden: any error during verification is a denial.\n\nThe design **reuses** genty's `SignedEnvelope<T>` + JSON canonical form\n(`packages/genty/core/src/trust/`) as the universal proof format, **extends** the two existing\ndeclarative policy engines rather than duplicating them, and adds one new workspace package,\n`@a5c-ai/policy-adapter` (`packages/adapters/policy`), that both genty and adapters consume\nwithout a circular dependency.\n\n**Three trust boundaries the reused genty primitives do not enforce on their own — this design\nadds them in the policy adapter:**\n\n1. **Tool-call binding.** The reused `ModelResponsePayload` has no field naming the tool call it\n authorized, so a valid attestation is replayable to a *different* tool call in the same turn.\n This design extends the model-decision payload (the one producer where the no-new-schema rule is\n relaxed) with a signed `toolCalls[]` array — each entry `{ toolCallId, name, argsHash }` — and\n binds `CommandAuthorization.toolCallId == attestation.toolCalls[i].toolCallId` with matching\n `argsHash` (AC-34).\n2. **Trusted-store key resolution.** Genty's `verifySignature` verifies a signature against a\n **caller-supplied** public key and never checks `sha256(publicKey) == envelope.publicKeyFingerprint`\n or that the key's declared *kind* matches the policy step. The policy adapter wraps it with a\n verifier that resolves key material **only** from the trusted store, selects by\n `(requiredKind, allowedFingerprints)`, binds the fingerprint, and rejects cross-kind (AC-35).\n3. **Config integrity + rollback resistance.** Trust-roots and policy files live on the workspace,\n which the in-scope adversary (compromised/workspace-writable agent) can edit. This design requires\n them to be signed by an **out-of-agent root of trust**, verified before any root or policy is\n honored (AC-36/AC-37). Draft 3 adds that the config root signs **one manifest** covering **all**\n config files together, carrying a **monotonic `configEpoch`** integer; every gate rejects any\n config whose epoch is below an off-workspace pinned floor (`POLICY_CONFIG_MIN_EPOCH`), so an older\n validly-signed, more-permissive config set — including an older revocation list — cannot be rolled\n back in (AC-46/AC-47).\n\n**Two cross-cutting hardening rules Draft 3 makes explicit, because the reused primitives do not\nprovide them:**\n\n4. **Domain separation.** Genty's `canonicalize` binds `signedFields` but no payload-type tag, so a\n signature over one payload kind is byte-identical to (and thus transferable to) a structurally\n compatible payload of a *different* kind. Every payload in this design carries a bound\n `payloadType` constant that MUST be in `signedFields`; the verifier binds the expected\n `payloadType` per kind and denies on mismatch (AC-51).\n\n5. **One canonical serialization for argv and args.** Proxy-side and every gate-side `argsHash` /\n `commandHash` MUST be produced by a **single, total, loss-preserving** canonicalizer exported from\n the policy adapter, so hashes are byte-identical across producers and consumers; a conformance\n test pins this (AC-52).\n\nEvery requirement below has a stable acceptance-criterion ID (`AC-n`) and is mapped to exactly\none milestone (A–E) in §12.\n\n---\n\n## 1. Goals, non-goals, and milestones\n\n### 1.1 Goals\n\n- A single canonical proof envelope for every producer and consumer.\n- A declarative, per-action policy language expressing *flexible* trust chains (multiple shapes).\n- Non-spoofable evidence producers for human approvals and model decisions.\n- Enforcement at every tool-execution gate, fail-closed for covered actions.\n\n### 1.2 Non-goals (scope guard) — **AC-24**\n\nThe following are explicitly **out of scope** for this design and must not be built under it:\n\n1. **Journal hash-chaining / audit hardening** (`storage/journal.ts` `prevChecksum`). Supporting,\n tracked separately; the research doc §Gap-5 lists it as non-core.\n2. **General secret-management / vault integration.** Credential *scoping* is modeled; a secrets\n backend is not.\n3. **Replacing the proxy's bearer-token auth** (`server.ts` `isAuthorized`, 148-175). It stays for\n transport auth; it is not reused as proof (research §6 caveat).\n4. **A network trust-root distribution service / PKI CA.** Trust roots are file-based config\n (§10). No online revocation service (OCSP-style) is built; revocation is a local list.\n **In-scope correction (review issue 3):** although distribution stays file-based, the trust-roots\n file and every policy document **are** integrity-protected by an out-of-agent root-of-trust\n signature (AC-36/AC-37). \"File-based config\" no longer means \"trusted because it is on disk.\"\n5. **Policy authorship UI / TUI.** Policies are YAML/JSON files edited by hand in this iteration.\n Signing them (AC-37) is a mechanical `policy-adapter sign-config` CLI step, not a UI.\n6. **Rewriting proven's canonical form immediately.** proven keeps its text canonical form for\n backward-compatible verification; new evidence uses the JSON form with a bridge (§4.3). **Draft-3\n note:** the load-bearing defense against a proven answer with a stripped `signedFields` is the\n bridge-side AC-48 completeness assertion at the trust boundary; hardening `proven/verify.ts:60`\n itself to reject an under-specified `signedFields` is desirable defense-in-depth but is a\n follow-up, not required for the guarantee.\n7. **Signing the passthrough-proxy path in this iteration** (documented gap, §6.5).\n8. **Gating substrate-delivered credentials** (cloud instance metadata / IMDS / IRSA /\n workload-identity, credentials in a container image, pre-existing files on a mount the agent\n already controls, secret endpoints the process calls itself). `spawn-invocation.ts` does not\n construct these deliveries, so GATE 3 cannot see or gate them (AC-50). This is a **bounded,\n warned** non-goal: the evaluator emits an audit-logged warning and refuses to claim a GATE-3\n backstop for any scoped action whose credential would arrive by such a channel; mitigation is a\n substrate control (no broad instance role for agent pods, minimal images, egress policy).\n\n### 1.3 Milestones\n\n| ID | Milestone | Scope |\n|----|-----------|-------|\n| **A** | trust-core | Unified envelope (with bound `payloadType`), evidence taxonomy, identity/key model, `CommandAuthorization` type, trusted-store verifier wrapper, trust-roots config + config-integrity root-of-trust + monotonic-epoch config manifest + off-workspace epoch floor + key ops. |\n| **B** | policy-engine | Policy document schema (incl. canonicalized argv matcher + config-signature verification), evaluator, `@a5c-ai/policy-adapter` package, authorization issuance. |\n| **C** | evidence-producers | Proxy model-attestation, in-process genty attestation, enforced signed breakpoint approvals. |\n| **D** | tool-layer-enforcement | Verification at adapters GATE 1/2/3 + genty dispatcher/session. |\n| **E** | e2e-integration | End-to-end trust chain (human approval + opus attestation → aws command), default-deny scopes, threat-case tests. |\n\n---\n\n## 2. Architecture overview\n\n```\n ┌─────────────────────────────────────────────┐\n evidence producers │ @a5c-ai/policy-adapter │ consumers\n │ (packages/adapters/policy) │\n human approval ──────► │ │\n (PermissionEvidence) │ ┌────────────┐ ┌──────────────────────┐ │\n │ │ Policy │ │ Authorization │ │ ◄── GATE 1 tools/dispatch.ts\n model decision ──────► │ │ Document │──►│ Issuer │ │ ◄── GATE 2 core/spawn-runtime-hooks.ts\n (ModelResponse attest) │ │ (schema) │ │ (CommandAuthorization│ │ ◄── GATE 3 core/spawn-invocation.ts\n ▲ proxy (authoritative)│ └────────────┘ │ SignedEnvelope) │ │ ◄── genty session.ts / MCP dispatcher\n ▲ genty (in-process) │ ┌────────────┐ └──────────────────────┘ │\n delegation links ────► │ │ Trust │ ┌──────────────────────┐ │\n │ │ Roots │ │ Authorization │ │\n │ │ (config) │ │ Verifier │ │\n │ └────────────┘ └──────────────────────┘ │\n └──────────────────────────────────────────────┘\n uses @a5c-ai/genty-core/trust primitives\n```\n\n`@a5c-ai/policy-adapter` depends **only** on `@a5c-ai/genty-core` (for the trust primitives) and\nNode built-ins. `@a5c-ai/genty-core` already exists as a leaf that adapters can depend on, so\nplacing the package under `packages/adapters/policy` lets `@a5c-ai/tools-adapter`,\n`@a5c-ai/comm-adapter`, `@a5c-ai/tasks-adapter`, and `@a5c-ai/transport-adapter` consume it, while\n`@a5c-ai/genty-platform` (which already depends on genty-core and can depend on adapters) consumes\nit too — with no cycle (§8).\n\n---\n\n## 3. Unified proof envelope (Milestone A)\n\n### 3.1 Adopt `SignedEnvelope<T>` + JSON canonical form\n\n**AC-1.** The universal proof format is genty's `SignedEnvelope<T>`\n(`packages/genty/core/src/trust/types.ts:1-8`): `{ payload, signature, publicKeyFingerprint,\nsignedAt, signedFields, algorithm: 'Ed25519' }`. All new evidence, `CommandAuthorization`, and\nmigrated proven answers use it. Canonical serialization is genty's\n`canonicalize` (`signing.ts:65-68`):\n`JSON.stringify({ _meta: deepSortKeys(meta), _payload: deepSortKeys(extractFields(payload,\nsignedFields)) })`. **No new envelope type or canonicalization routine may be introduced** — the\npolicy adapter imports `signPayload` / `verifySignature` from `@a5c-ai/genty-core`\n(re-exported at `trust/index.ts:1`). Rationale: proven's text canonical form\n(`proven/sign.ts:23-30`, `field=value\\n`) cannot represent the nested structures (evidence\nfingerprint arrays, args objects) this design requires; genty's form deep-sorts keys and supports\narbitrary JSON, and the research §Gap-1 designates it authoritative.\n\n**AC-2 (revised — runtime, not lint).** `verifySignature` (`signing.ts:40-56`) is the underlying\nsignature-check primitive, but it is **never called directly** by a consumer; it is only reached\nthrough the policy adapter's `verifyEnvelopeTrusted` wrapper (AC-35). Verification recomputes the\ncanonical form from `envelope.payload` and `envelope.signedFields`; a field present on `payload` but\nabsent from `signedFields` is **not** covered by the signature.\n\nThe Draft-1 \"documented invariant enforced by a lint/test\" is **insufficient** (review issue 4): a\ncompromised producer can emit an envelope that omits a security-critical field from `signedFields`,\nand a build-time lint never runs at the trust boundary. Therefore the **runtime** verifier MUST,\nbefore honoring any envelope, assert `signedFields` completeness for that envelope's declared kind\nand **DENY** on any missing field:\n\n- For every evidence/authorization kind, the adapter defines a `REQUIRED_SIGNED_FIELDS[kind]` set.\n- `verifyEnvelopeTrusted(envelope, kind)` fails closed unless\n `REQUIRED_SIGNED_FIELDS[kind] ⊆ new Set(envelope.signedFields)` **and** every such field is\n actually present on `envelope.payload`.\n- This runs at each gate on the actual envelope being consumed — not once at build time. A missing\n required field is a verification failure (a denial), identical in effect to a bad signature.\n\nRequired-field sets (each MUST appear in `signedFields`): see AC-8 (authorization) and AC-10 step 8\n(per-evidence). The build-time lint is retained only as defense-in-depth for repo-authored payloads;\nit is **not** the enforcement mechanism.\n\n**AC-51 (domain separation — bound `payloadType`).** Genty's `canonicalize` (`signing.ts:65-68`)\nbinds `signedFields` and `_meta` but **no payload-type tag**, so a signature over payload kind X is\nbyte-identical to a signature over a structurally compatible payload of kind Y — an\n`engine`-signed `ModelDecisionPayload` whose field shape overlaps another engine-signed type could be\npresented for the wrong step. To make signatures **non-transferable across payload types**, every\npayload type in this design carries a constant discriminant field\n`payloadType: 'human-approval' | 'model-decision' | 'delegation' | 'command-authorization' |\n'config-manifest'`, and `payloadType` MUST be a member of `REQUIRED_SIGNED_FIELDS[kind]` for **every**\nkind (so it is always inside the signature, per AC-2). `verifyEnvelopeTrusted(envelope, kind, ...)`\ntakes the expected `payloadType` for that kind and denies unless `envelope.payload.payloadType`\nequals it **and** `payloadType ∈ signedFields`. This is a bound constant, not caller-supplied trust\n(the wrapper `kind` claim is checked against the trust root's kind per AC-5/AC-35 as before);\n`payloadType` closes the *cross-payload-type* transfer that the `kind`/trust-root check alone does\nnot (two different payload types can both be `engine`-signed).\n\n**AC-52 (one total, loss-preserving canonical argv/args serialization — closes argsHash divergence).**\n`argsHash` and `commandHash` are computed in at least three places — the proxy producer (AC-12/13),\nGATE 1 (§9.1), and GATE 3 (§9.3, the last recomputation before exec) — and any byte-level divergence\nbetween producer and consumer silently breaks the binding (a mismatch denies a legitimate call, or,\nworse, a lossy normalization lets two different arg objects collide). This design therefore mandates\n**one** canonicalizer, `canonicalizeArgs(value): string` and `canonicalizeArgv(command): string[]`,\nexported from `@a5c-ai/policy-adapter` and imported by **every** producer and gate (the proxy imports\nit too — it is a leaf util with no adapter-runtime dependency). It MUST be:\n- **Total** — defined for every JSON-representable args value; it never throws on well-formed input\n and has an explicit deny-path for input it cannot represent (e.g. non-finite numbers), never a\n silent coercion.\n- **Loss-preserving** — it does not drop, reorder-collide, or fold distinct inputs to the same bytes\n (it builds on genty `deepSortKeys` for object-key ordering but preserves array order, string bytes,\n and value types; it does not lowercase, trim, or unicode-fold). `argsHash = sha256(canonicalizeArgs(args))`.\n- **Shared byte-for-byte** — the proxy-side and every gate-side hash are produced by the identical\n function. **AC-52a (conformance test):** an acceptance test feeds a fixed corpus of args/argv\n fixtures through the proxy path and each gate path and asserts the resulting `argsHash`/`commandHash`\n are byte-identical, and that two distinct fixtures never collide. A hash produced by any code path\n that does **not** route through `canonicalizeArgs`/`canonicalizeArgv` is a design violation caught\n by this test.\n\n### 3.2 Migration / bridge for proven breakpoint answers\n\n**AC-3 (revised — Draft 3).** A bridge in `@a5c-ai/policy-adapter` converts a legacy\n`ProvenBreakpointAnswer` (text-canonical, `proven/sign.ts`) into a\n`SignedEnvelope<PermissionEvidencePayload>` **without re-signing the human's intent as the adapter's\nown**: the bridge verifies the legacy answer via proven `verifyAnswer` (`proven/verify.ts:20-72`)\n**against a fingerprint that is a `human` trust root** (AC-35), **passes the AC-48 legacy-completeness\nassertion**, and only on success emits a *derived* `PermissionEvidence` envelope whose payload records\nthe original human `publicKeyFingerprint`, `breakpointId`, and `approved`.\n\n**AC-48 (proven-bridge legacy `signedFields` completeness — closes residual issue 7).** `verifyAnswer`\n(`proven/verify.ts:60`) rebuilds the signing payload from the **attacker-supplied**\n`provenAnswer.signedFields` (`buildSigningPayload(provenAnswer, provenAnswer.signedFields)`) and\nimposes **no** requirement that `approved` — or any security-critical field — actually appears in the\nsigned set. An attacker with any single validly-signed proven answer from a `human` key (e.g. a\nsigned *rejection*, or an answer signed over only `{id, text}`) can present a forged\n`ProvenBreakpointAnswer` that sets `approved: true`, `breakpointId: <target>` as **unsigned** payload\nfields while listing a `signedFields` that omits them; `verifyAnswer` returns `valid: true` because\nthe signature covers only the fields the attacker chose to include, and the Draft-2 bridge then reads\n`provenAnswer.approved` and mints \"human approved\" evidence. This is the same class of hole AC-2\nclosed for the new-form envelopes, but it was **not** applied to the legacy proven answer inside the\nbridge.\n\nThe bridge MUST, **before deriving any evidence**, apply the AC-2 completeness rule to the legacy\nanswer and fail closed (deny, no evidence emitted) unless **all** hold:\n\n- `{ breakpointId, approved, responderId } ⊆ new Set(provenAnswer.signedFields)` — i.e. `approved`\n and the identity/target-binding fields were **actually within the human-signed set**. (The\n canonical proven signer signs all three today, `proven/sign.ts:9-17`; a legitimate answer therefore\n passes, a stripped-`signedFields` forgery does not.)\n- Each of those fields is **present** on `provenAnswer` (a `signedFields` entry naming an absent field\n is treated as missing → deny), mirroring AC-2's \"present on payload\" clause.\n- Only then does the bridge trust `provenAnswer.approved`; and it derives evidence **only when\n `approved === true`** (a signed rejection never yields an approval envelope).\n\nAny exception in this check is a denial. This assertion runs at the bridge (the trust boundary),\nnot as a proven-side lint, so a compromised producer emitting a stripped-`signedFields` answer is\nrejected at consumption. (Hardening `proven/verify.ts` itself to reject an under-specified\n`signedFields` is desirable defense-in-depth and noted in §1.2 non-goal 6's follow-up, but the\n**load-bearing** check is AC-48 in the bridge, because the bridge is where the `approved` bit is\npromoted to human evidence.)\n\n**AC-43 (derived evidence still evaluates as `kind:'human'`).** The derived envelope is\n*co-signed* by the adapter's bridging identity for storage integrity, but the policy evaluator MUST\nNOT treat the bridging (engine) signature as the trust anchor for a `human-approval` step. The\nbridge is honored **only if** the recorded `originalHumanFingerprint` is a currently-valid,\nnon-revoked `human` trust root, the original proven verification passed, **and the AC-48\nlegacy-completeness assertion passed**; the evaluator resolves the `human-approval` step against\n`originalHumanFingerprint` (kind `human`), not against the adapter issuer key. Otherwise a\ncompromised adapter could launder any approval into a \"human\" one by re-signing it. The\nderived-payload MUST carry `{ payloadType: 'human-approval', originalHumanFingerprint, breakpointId,\napproved, provenVerified: true }` in `signedFields` (AC-51 binds `payloadType`).\n\nDuring the transition, breakpoint producers MAY emit **both** the legacy `.proven.json` and a new\n`PermissionEvidence` envelope (dual-write); the research §Gap-1 permits \"emit both during\ntransition.\" The proven text form is not deleted in this iteration (§1.2 non-goal 6).\n\n**AC-4.** New breakpoint approvals (post-milestone-C) are signed directly as\n`SignedEnvelope<PermissionEvidencePayload>` using `signPermissionEvidence`\n(`trust/tool-signing.ts:36-41`); the git-native backend's auto-sign path\n(`backends/git-native.ts` auto-sign on answer) is extended to write the envelope alongside\n`.answer.json`, keyed by `breakpointId`. The bridge (AC-3) becomes a no-op for these.\n\n---\n\n## 4. Evidence taxonomy, identity & key model (Milestone A)\n\n### 4.1 Evidence types\n\nTwo of three evidence kinds reuse existing genty payload types unchanged. The **model-decision**\nkind is the single, deliberate exception to the no-new-schema rule (review issue 1): its reused\n`ModelResponsePayload` has no field that names the tool call it authorized, so it cannot bind\n\"opus decided to call **this** tool call.\" §4.1a extends it with a signed `toolCalls[]` array.\n\n| Evidence | Payload type | Source | Producer key | Trust-root kind |\n|----------|-----------------------|--------|--------------|-----------------|\n| **human-approval** | `PermissionEvidencePayload` (`tool-signing.ts:13-20`), reused with a bound `payloadType:'human-approval'` added (AC-51): `{payloadType, action, scope, approvedBy, approvedAt, expiresAt?, conditions?}` | breakpoint answer | human responder key (proven `.keys/private`) | `human` |\n| **model-decision** | **`ModelDecisionPayload`** — `ModelResponsePayload` (`model-signing.ts:4-12`) **extended** with `payloadType:'model-decision'` + signed `toolCalls[]` (§4.1a, AC-34/AC-51) | transport proxy (authoritative) **or** genty session (in-process) | proxy engine key **or** genty adapter key | `engine` |\n| **delegation** | `DelegationChainLink` (`types.ts:29-33`) carried in `AgentRequestPayload.delegationChain` (`agent-signing.ts:12`), with a bound `payloadType:'delegation'` (AC-51) | agent | agent identity key | `agent` |\n\n### 4.1a Model-decision payload extension — the tool-call binding (**AC-34**, review issue 1)\n\n**Problem.** Draft 1 reused `ModelResponsePayload` verbatim. That payload carries `modelId`,\n`inputMessagesHash`, and `outputContent`, but **no `toolCallId` and no per-call `argsHash`**. A turn\ncan emit many tool calls (session loop `session.ts:1235` iterates `result.toolCalls`, each a\n`NormalizedToolCall` with a distinct `id`). A single valid attestation for the turn therefore\nsatisfies a policy step for **any** tool call in that turn — an attacker replays the \"opus decided\"\nproof onto a *different*, unapproved call with different args. This is the model-decision-cannot-bind\nattack.\n\n**AC-34.** The model-decision producer (proxy at §6.1/6.2, in-process genty at §6.2) signs a\n`ModelDecisionPayload` that **extends** `ModelResponsePayload` (this is the one relaxed no-new-schema\nexception — flagged; the new type lives in `@a5c-ai/genty-core/trust` next to `model-signing.ts`\nso both producers share it) with:\n\n```ts\ninterface SignedToolCall {\n toolCallId: string; // the provider/harness tool-call id (NormalizedToolCall.id)\n name: string; // tool name the model chose\n argsHash: string; // sha256 of canonical JSON of that call's arguments (deepSortKeys)\n}\ninterface ModelDecisionPayload extends ModelResponsePayload {\n payloadType: 'model-decision'; // AC-51 domain-separation constant, MUST be in signedFields\n toolCalls: SignedToolCall[]; // EVERY tool call the model emitted this turn, each bound\n}\n```\n\n`payloadType`, `toolCalls` (and its `toolCallId`/`name`/`argsHash` sub-fields) MUST be in\n`signedFields` (enforced at runtime by AC-2 / AC-10 step 8 and AC-51). The `argsHash` is computed by\nthe shared `canonicalizeArgs` (AC-52) — the **same** helper as `CommandAuthorization.argsHash`\n(AC-8) — so proxy-side and gate-side hashes are byte-identical.\n\n**AC-34a (binding at issuance and verification).** A model-decision step is satisfied for a given\nexecuting tool call **iff** the attestation contains a `SignedToolCall` whose `toolCallId` equals\nthe executing tool-call id **and** whose `argsHash` equals the sha256 of the args about to run.\n`CommandAuthorization` records that `toolCallId` (AC-8), and every gate asserts\n`authorization.toolCallId == executing toolCallId` (AC-10 step 3a). An attestation with no matching\n`toolCallId`, or a matching id with a mismatched `argsHash`, is a **denial** — so the same turn's\nattestation cannot be replayed to a sibling call. This closes AC-30's replay-within-turn variant.\n\n**AC-5 (revised).** The policy adapter exposes an `Evidence` discriminated union\n`{ kind: 'human-approval' | 'model-decision' | 'delegation'; envelope: SignedEnvelope<...> }`\nthat wraps these three payloads and nothing else in v1. Adding a new evidence kind is a typed,\nreviewable change (closed set), not an open string. The `kind` on the `Evidence` wrapper is a\n*claim*, not a trust decision: it selects which `requiredKind` the verifier binds against\n(AC-35), and the verifier rejects if the resolved trust root's `kind` disagrees. A caller cannot\nupgrade an engine-signed envelope to `human-approval` by relabeling the wrapper.\n\n### 4.2 Identity & key model — who holds which key\n\n**AC-6 (revised — trust root carries key material).** Each producer has a distinct key and a\ndeclared trust-root **kind**. A `TrustRoot` record is:\n\n```ts\ninterface TrustRoot {\n fingerprint: string; // sha256 of the SPKI/DER public key\n kind: 'human' | 'engine' | 'agent' | 'tool' | 'config'; // 'config' = out-of-agent config root (AC-36)\n publicKey: string; // REQUIRED: the SPKI public key material (PEM or DER-base64)...\n publicKeyPath?: string; // ...OR a repo-relative path to it (exactly one of the two)\n label: string;\n expiresAt?: string;\n revoked?: boolean;\n}\n```\n\nDraft 1's `trust-roots.json` carried **no key material** and no rule binding a `requiredKind` to a\nspecific key, so verification depended on a caller-supplied public key — the review's cross-kind\nconfusion (an engine key satisfying a human step) and caller-supplied-key attacks. **AC-6 now\nrequires every root to carry its public key** (inline `publicKey` or `publicKeyPath`, exactly one),\nand the verifier (AC-35) resolves material **only** from this store.\n\nFingerprints are SHA-256 of the SPKI/DER public key, exactly as both existing systems compute them\n(`genty signing.ts:9-11`; `proven keys.ts:18`) — the two are interchangeable, so proven-generated\nhuman keys are valid `human` trust roots without re-fingerprinting. The verifier still recomputes\n`sha256(resolvedPublicKey)` and rejects if it disagrees with the stored `fingerprint` (AC-35 c),\nso a mismatched or swapped key file is caught.\n\n- **Human keys**: generated + rotated by proven (`proven/keys.ts:9-37`, `122-148`), stored at\n `.breakpoints/.keys/private/<fp>.key.json` (gitignored) with the public half git-tracked under\n `trusted/`. Registered as `kind: 'human'`.\n- **Engine (proxy) key**: held **outside the agent process** by the transport proxy (§6).\n Registered as `kind: 'engine'`. This is the authoritative model-decision producer.\n- **Engine (in-process genty) key**: the genty adapter identity key, used only on the non-proxied\n path. Also `kind: 'engine'` but a *different fingerprint*; policies MAY require the proxy\n fingerprint specifically (§6.4, AC-15).\n- **Agent keys**: `createAgentIdentity` (`trust/identity.ts:4-11`); `kind: 'agent'`.\n- **Policy-adapter issuer key**: signs `CommandAuthorization` and bridged evidence. `kind:\n 'engine'`, held by whichever process runs the policy adapter (typically the orchestrator).\n\n**AC-7 (revised).** Trust roots are configured in a single, integrity-protected file (§10, AC-36)\nwhose entries map fingerprints to kinds **and carry key material** (AC-6). Verification uses trusted\npublic key material **only** from this config (and the proven `trusted/` directory, re-expressed as\n`human` roots); a signature from any fingerprint **not** present as a trust root of the *required*\nkind is a verification failure (no implicit trust). The single verification entry point is AC-35's\n`verifyEnvelopeTrusted` — no consumer calls genty `verifySignature` / `verifyTrustChain` directly.\n\n**AC-35 (trusted-store verifier wrapper — the core key-resolution rule; review issue 2).** The\npolicy adapter provides a thin wrapper over genty `verifySignature` / `verifyTrustChain`\n(**flagged as new code in `@a5c-ai/policy-adapter`**, because genty's primitives verify against a\ncaller-supplied key and cannot be safely called directly at a trust boundary):\n\n```ts\nverifyEnvelopeTrusted(envelope, requiredKind, allowedFingerprints?): TrustedVerification\n```\n\nIt MUST, in order, and fail closed (deny) at the first failure:\n\n- **(a) Resolve key material only from the trusted store.** Load the `TrustRoot` for\n `envelope.publicKeyFingerprint` from the (integrity-verified, AC-36) trust-roots config / proven\n `trusted/`. If absent → deny. The envelope's own embedded key, if any, is **ignored**.\n- **(b) Select by `(requiredKind, allowedFingerprints)`.** The resolved root's `kind` MUST equal\n `requiredKind`; if the policy step supplied `allowedFingerprints` (or roles that resolve to\n fingerprints), the resolved fingerprint MUST be in that set. Otherwise → deny.\n- **(c) Bind fingerprint to material.** Compute `sha256(resolvedPublicKey)` and require it to equal\n `envelope.publicKeyFingerprint`. This is the check genty `verifySignature` omits; without it a\n caller can present material that does not match the claimed fingerprint. Mismatch → deny.\n- **(d) Reject cross-kind.** Redundant with (b) but stated explicitly: if the resolved root's kind\n ≠ the step's required kind (e.g. an `engine` root presented for a `human-approval` step) → deny.\n This closes \"engine key satisfies a human step.\"\n- **(e) Enforce `signedFields` completeness + bound `payloadType`** for the envelope's kind (AC-2,\n AC-51) → deny on any missing required field, or if `payload.payloadType` is absent from\n `signedFields` or does not equal the expected constant for `requiredKind`.\n- **(f) Check root validity** — not `revoked`, and (for key-expiry-bearing roots) not expired at\n the envelope's `signedAt` (AC-27) → deny otherwise.\n- **(g) Only now** call genty `verifySignature(resolvedPublicKey, envelope)` (or `verifyTrustChain`\n for delegation chains, with each link's public key resolved the same way, never taken from the\n link). Any thrown exception anywhere in (a)–(g) is a **denial**, never a pass.\n\n---\n\n## 5. `CommandAuthorization` envelope (Milestone A / B)\n\n**AC-8.** `CommandAuthorizationPayload` (new type in `@a5c-ai/policy-adapter`, signed as\n`SignedEnvelope<CommandAuthorizationPayload>` by the issuer key) has exactly these fields, all of\nwhich MUST be in `signedFields`:\n\n```ts\ninterface CommandAuthorizationPayload {\n payloadType: 'command-authorization'; // AC-51 domain-separation constant, MUST be in signedFields\n policyId: string; // which policy document granted this\n policyDocHash: string; // sha256 of the integrity-verified policy doc (AC-36) that granted this\n configEpoch: number; // AC-46: monotonic config epoch under which this authorization was issued\n matchedChainId: string; // the specific chain that was satisfied (§7)\n toolName: string; // exact tool identity (e.g. \"Bash\", MCP tool name)\n toolCallId: string; // REQUIRED: the exact tool-call id this authorization is bound to (AC-34a)\n commandHash: string; // sha256 of the canonicalized argv (AC-38), empty-string sentinel if N/A\n argsHash: string; // sha256 of canonical JSON of the tool input/args (deepSortKeys)\n credentialScope: string; // opaque scope label the creds are bound to (e.g. \"aws:prod:s3-ro\")\n evidenceFingerprints: string[]; // fingerprints of every evidence envelope that satisfied the chain\n evidenceEnvelopeHashes: string[]; // sha256 of each satisfying evidence envelope (binds identity AND content)\n evidenceStepBindings: { stepIndex: number; requiredKind: string; envelopeHash: string }[]; // one per REQUIRED step (AC-42)\n runId?: string;\n sessionId?: string;\n issuedAt: string; // ISO\n expiresAt: string; // ISO, short-lived (default 120s, per-policy override)\n}\n```\n\n**All of the above fields MUST appear in `signedFields`** (the runtime completeness assertion of\nAC-2 enforces this — `REQUIRED_SIGNED_FIELDS['command-authorization']` is the full field set above).\nAn authorization missing any field from `signedFields` is denied at every gate.\n\n`commandHash`/`argsHash` are computed with the **single shared** `canonicalizeArgv`/`canonicalizeArgs`\nhelper (AC-52), which builds on genty's `deepSortKeys` ordering so hashing is byte-identical across\nthe proxy producer and every gate. `toolCallId` is mandatory (Draft 1 made it optional \"when known\")\nbecause tool-call binding is now the load-bearing defense against replay-within-turn (AC-34).\n`payloadType` (AC-51) and `configEpoch` (AC-46) are part of the authorization's signed field set.\n\n**Issuance rules — AC-9 (revised).** The issuer produces an authorization **iff** the policy engine\n(§7) returns `granted` for the requested `{toolName, canonicalArgv, args, credentialScope,\ntoolCallId}` context; it binds `evidenceFingerprints` + `evidenceEnvelopeHashes` to the *specific*\nevidence envelopes consumed (not the fingerprints alone — content hash prevents swapping a different\nenvelope from the same signer). `expiresAt = issuedAt + policy.authorizationTtl` (default 120s).\n\n**AC-42 (evidence coverage of every required step).** `evidenceStepBindings` MUST contain exactly\none entry per **required** step of the matched chain (including every step of a satisfied\n`quorum`), each pinning `{stepIndex, requiredKind, envelopeHash}`. The issuer MUST refuse to issue\n(and the evaluator MUST NOT report `granted`) if any required step lacks a bound, verified evidence\nenvelope. `evidenceEnvelopeHashes` is the multiset of those `envelopeHash` values — so it covers\n**every** required step, not merely \"the evidence that happened to be present.\" A gate later\nre-verifies each binding (AC-10 step 7), guaranteeing no required step was silently skipped at issue\ntime. `matchedChainId` and `policyDocHash` are recorded so a gate can confirm the authorization was\nissued under the same policy document it is now enforcing.\n\n**Verification rules — AC-10 (revised).** A gate accepts an authorization **iff all** hold, else it\ndenies:\n1. `verifyEnvelopeTrusted(authorization, requiredKind: 'engine', allowedFingerprints: issuerRoots)`\n passes (AC-35) — this subsumes signature check, trusted-store key resolution, fingerprint\n binding, cross-kind rejection, and `signedFields` completeness for the authorization.\n2. `now < expiresAt` (not expired) and `now >= issuedAt`.\n3. `toolName` equals the tool being executed.\n3a. `toolCallId` equals the id of the tool call about to execute (AC-34a binding).\n4. `commandHash` equals sha256 of the **canonicalized argv** (AC-38) of the actual command about to\n run — recomputed at this gate, not carried from an earlier gate (empty-sentinel tolerated only\n when the policy for this action declares the tool non-command-bearing).\n5. `argsHash` equals sha256 of the actual args about to run, **recomputed at this gate** (TOCTOU\n binding, §11; at GATE 3 this is the last recomputation before exec, AC-23a).\n6. `credentialScope` equals the scope of the credentials about to be injected (GATE 3, §9.3), where\n that scope is supplied by the trusted out-of-agent source (AC-40), not by the agent.\n7. Every `evidenceStepBindings[i]` re-verifies: the referenced evidence envelope hashes to\n `envelopeHash`, and `verifyEnvelopeTrusted(evidenceEnvelope, requiredKind = binding.requiredKind,\n allowedFingerprints = step.trustedIdentities)` passes against a currently-valid, non-revoked trust\n root (AC-35). For a `model-decision` step, the envelope MUST additionally contain a\n `SignedToolCall` matching this call's `toolCallId` + `argsHash` (AC-34a).\n8. **Per-evidence `signedFields` completeness** (AC-2) **and bound `payloadType`** (AC-51): for each\n evidence kind, its `REQUIRED_SIGNED_FIELDS` set is present in that envelope's `signedFields` and\n `payload.payloadType` equals the expected constant — human-approval: `{payloadType:'human-approval',\n action, scope, approvedBy, approvedAt, expiresAt?}`; model-decision: `{payloadType:'model-decision',\n modelId, provider, inputMessagesHash, toolCalls}` (incl. each `toolCalls[].toolCallId/name/argsHash`);\n delegation: `{payloadType:'delegation', delegatorFingerprint, delegatorSignature, delegatedAt}`. A\n missing field or wrong `payloadType` → deny.\n9. `policyDocHash` equals the sha256 of the integrity-verified policy document (AC-36) governing this\n action at the gate, and `matchedChainId` names a chain that still exists in it.\n10. **`configEpoch` floor** (AC-46/AC-47): `authorization.configEpoch` equals the `configEpoch` of the\n currently-honored config manifest at this gate **and** `authorization.configEpoch >=` the\n off-workspace pinned `POLICY_CONFIG_MIN_EPOCH` floor. A stale authorization issued under a\n rolled-back or below-floor epoch → deny.\n\nAny exception thrown during steps 1–10 is a **denial**, never a pass (research §Constraints;\nCLAUDE.md \"fallbacks are evil\").\n\n---\n\n## 6. Model-attestation producer strategy (Milestone C)\n\nTwo producers emit the **same** `ModelDecision` evidence type (AC-34, `ModelResponsePayload`\nextended with signed `toolCalls[]`); both register as `kind:'engine'` trust roots with distinct\nfingerprints. Both MUST populate `toolCalls[]` — an attestation with an empty/absent `toolCalls`\ncannot satisfy any model-decision step for a tool call (AC-34a).\n\n### 6.1 Authoritative: transport proxy (`@a5c-ai/transport-adapter`)\n\n**AC-11.** The proxy signs `ModelResponse` attestations at the wire seam, using a key held by the\nproxy process (outside the agent — the agent cannot forge what model answered). `ProxyConfig`\n(`transport/src/types.ts:15-24`, built in `config.ts:11-30`, env in `config.ts:32-43`) is extended\nwith attestation identity: `attestationEnabled: boolean`, `attestationKeyPath: string`,\n`attestationFingerprint: string`, `attestationSidecarDir: string`, read from new\n`AGENT_MUX_PROXY_ATTESTATION_*` env vars. The proxy identity key becomes an `engine` trust root.\n\n**AC-12 (non-streaming, revised).** In each route handler (`server.ts` `/v1/messages` 1595-1611,\n`/v1/chat/completions` 1613-1629, `/v1/responses` 1631-1648), *after* `trackCompletionOutcome`\n(1324-1348) and *before* protocol encoding, when the result is a `CompletionResult` (not a\n`Response`), sign a `ModelDecisionPayload` (AC-34) from `{ modelId: config.targetModel, provider:\nconfig.targetProvider, inputMessagesHash, outputContent, toolCalls }` where `toolCalls` is built\nfrom the `CompletionResult.toolCalls[]` (each `{id, name, arguments}`) as\n`{ toolCallId: id, name, argsHash: sha256(canonicalize(JSON.parse(arguments))) }`. The `argsHash`\nuses the **same** canonical helper as the gate (AC-8) so proxy and gate hashes match byte-for-byte.\n`inputMessagesHash` is sha256 over `plan.request.messages` (available at the handler from\n`createExecutionPlan`, 369-389). The envelope is **not** injected into the response body (bodies\nstay provider-compatible, research §6 delivery-channel caveat) — it is written to the sidecar store\nkeyed by request id, with a per-`toolCallId` index so the policy component can resolve by tool-call\nid (AC-16).\n\n**AC-13 (streaming, revised).** For streamed completions, tool calls finalize only at the terminal\n`done` event. `trackCompletionStream` (`server.ts:1287-1322`) — already an async-iterable wrapper —\nis extended to accumulate tool-call deltas and, at `event.type === 'done'`, build the per-call\n`SignedToolCall[]` (id + name + `argsHash` over the fully-accumulated arguments) and sign the\n`ModelDecisionPayload` from those + usage, writing it to the sidecar with the per-`toolCallId` index.\nThe existing terminal-event points (anthropic ~740, openai-chat ~826, responses ~1008) are where\naccumulated calls are complete. Signing MUST happen only after every tool call's arguments are fully\naccumulated, so `argsHash` is over the final argument bytes.\n\n**AC-14 (correlation).** An `x-request-id` middleware (~`server.ts:1545`) echoes / mints a request\nid. The attestation sidecar entry is keyed by that request id; the same id is returned as a\nresponse header so the harness can thread it forward. Where the engine already carries per-tool-call\nmetadata (google `thoughtSignature` map, `server.ts:1537`; openai finish-reason accumulation,\n`engines/openai.ts:280-288`) that mechanism is the precedent for carrying a per-tool-call\ncorrelation hint. The policy component later resolves attestations by `requestId` (and, when\npresent, `toolCallId`) from the sidecar store.\n\n### 6.2 In-process: genty session (`@a5c-ai/genty-core`)\n\n**AC-15 (revised).** For the non-proxied genty runtime path, `endpoint.model` (in scope at\n`session.ts:1122-1123`) is flowed into `ToolExecutionContext` (`core/src/types.ts:156-165`, extended\nwith an optional `modelAttestation?: SignedEnvelope<ModelDecisionPayload>` and `modelId?: string`)\nand emitted on the `tool_use` event (`session.ts:1236`). The genty adapter signs **one**\n`ModelDecisionPayload` per model turn in `runCompletionLoop` (around 1216, where `result.toolCalls`\nis known), whose `toolCalls[]` binds **every** call in `result.toolCalls` with `{toolCallId: id,\nname, argsHash}` — computed once before the tool loop at `session.ts:1235` iterates. The same signed\nenvelope is attached to the `ToolExecutionContext` of **each** call in the turn; each gate then\nmatches its own `toolCallId` against the signed `toolCalls[]` (AC-34a). This producer's fingerprint\nis a *different* `engine` trust root, and (per AC-39) is **not** accepted for credential-touching\nactions unless the policy explicitly opts out of `requireProxyAttestation`.\n\n### 6.3 Attestation resolution by the policy component\n\n**AC-16 (revised).** The policy adapter resolves a model-decision evidence for a given tool call by\nlooking it up in the sidecar store (proxy path) **by tool-call id** (the per-`toolCallId` index of\nAC-12/13), then by the in-process attestation carried on `ToolExecutionContext` (genty path).\n\"Falling back\" here is **not** a security fallback: resolution returns whichever envelope is present,\nand the step still verifies it (§7, AC-35) and requires a `SignedToolCall` matching this call's\n`toolCallId` + `argsHash` (AC-34a). If neither source yields an envelope that binds this exact tool\ncall, the model-decision step is **unsatisfied → deny**. There is no path where a missing or\nnon-matching attestation is treated as satisfied.\n\n### 6.4 High-assurance: requiring the proxy-signed variant\n\n**AC-17 (revised).** A policy step MAY set `requireProxyAttestation`, which constrains the accepted\nmodel-decision evidence to the **proxy** engine fingerprint(s) only, rejecting the in-process genty\nattestation.\n\n**AC-39 (proxy attestation is the DEFAULT for credential-touching actions; label the in-process\nvariant correlation-grade).** Because the in-process genty attestation is signed by a key **inside**\nthe agent process, a compromised agent can forge it; it is therefore **correlation-grade only** and\nlabeled as such in code and docs. For any action whose `match` names a `credentialScope` (i.e. any\naction that can cause a scoped credential to be injected, §9.3), `requireProxyAttestation` **defaults\nto `true`** and must be *explicitly* set to `false` to accept the in-process attestation. The\ndefault flips the Draft-1 opt-in to an opt-out for exactly the actions that matter. Non-credential\nactions keep `requireProxyAttestation: false` as the default. The evaluator MUST reject an\nin-process (agent-held-key) attestation for a credential-touching action unless the policy author\nexplicitly opted out, and that opt-out is recorded in the audit log.\n\n### 6.5 Passthrough-mode gap (documented)\n\n**AC-18.** When the proxy runs in passthrough mode (no `completionEngine`; `proxyUpstream`,\n`server.ts:524-554`), request/response bodies are forwarded unparsed, so no attestation is produced.\nThis is a documented gap for this iteration. Policies that require proxy attestation — explicitly, or\nby the AC-39 default for **every credential-touching action** — therefore **deny** on passthrough\n(fail-closed, correct); passthrough model calls cannot satisfy a proxy-attestation requirement until\na future iteration adds buffering-based signing. Operators must not route high-assurance actions\nthrough passthrough transports. AC-44 tests this denial.\n\n---\n\n## 7. Policy document schema (Milestone B)\n\n**AC-19.** A policy document is a declarative file (YAML or JSON) with an array of `actions`. Each\naction declares matchers and **one or more** required trust-chain templates. Multiple chain shapes\nare a core requirement — an action lists alternative chains and is satisfied if **any** chain is\nsatisfied (OR across chains; AND across a chain's steps).\n\n```yaml\nversion: 1\nauthorizationTtlSeconds: 120 # default; per-action override allowed\ncommandDefaultAllow: false # AC-38b: default-allow for command-bearing tools is OPT-IN per env\ndefaultDeny: [] # credentialScope globs that default-deny when uncovered (§9.4)\nactions:\n - id: aws-prod-write\n match:\n tool: \"Bash\" # glob over tool name (reuses dispatch.ts globToRegex, :25-31)\n argv: # AC-38: match on CANONICALIZED argv, not a raw regex\n program: \"aws\" # resolved binary basename (abs path/symlink resolved to real path)\n subcommandEquals: [\"s3 cp\", \"s3 rm\", \"s3 sync\"] # normalized subcommand tokens\n credentialScope: \"aws:prod:*\" # glob over the requested credential scope\n # requireProxyAttestation omitted -> defaults to TRUE here (credentialScope present, AC-39)\n chains: # satisfied if ANY chain fully verifies\n - id: human-plus-opus\n steps:\n - kind: human-approval\n trustedIdentities: [\"fp:human:alice\", \"role:sre-oncall\"]\n conditions:\n scopeEquals: \"aws:prod:s3\"\n notExpired: true\n - kind: model-decision\n conditions:\n modelIdMatches: \"claude-opus-.*\" # model allowlist\n - id: two-human-quorum\n quorum: { of: \"human-approval\", min: 2 } # alternate shape: dual human approval\n steps:\n - kind: human-approval\n trustedIdentities: [\"role:sre-oncall\"]\n```\n\nSchema elements:\n\n- **`match`** — `{ tool: glob, argv?: ArgvMatch, credentialScope?: glob }`. Tool glob reuses\n `dispatch.ts` `globToRegex` (25-31). `argv` (AC-38) replaces Draft-1's `command: regex`. An action\n with no `argv` matches non-command tools.\n- **`argv` (AC-38 — canonicalized/tokenized command matching; review issue 5).** Draft 1 matched a\n raw command **string** with a regex (`\"^aws s3 (cp|rm|sync)\\\\b\"`). That is trivially bypassed:\n `sh -c 'aws s3 rm ...'`, `npx aws ...`, an absolute or symlinked path\n (`/usr/local/bin/aws`, `/tmp/link->aws`), or env indirection (`AWS=aws; $AWS s3 rm`) all fail the\n regex, so the action does **not match**, and an uncovered command-bearing action then\n default-allows — a default-allow bypass. AC-38 requires the matcher to operate on a\n **canonicalized argv**, not the raw string:\n 1. Tokenize the command into `argv[]` (respecting the tool's real quoting; for `Bash`, parse the\n command line, and if the program is a shell (`sh`/`bash`/`zsh`) with `-c`, recurse into the\n `-c` payload so the *inner* program is matched, not `sh`).\n 2. Resolve `argv[0]` to a **real absolute path** (follow symlinks, apply `PATH`), then take its\n canonical basename as `program`. `program` matching is on the resolved basename, so\n `/usr/local/bin/aws`, a symlink to it, and bare `aws` all canonicalize to `aws`.\n 3. Reject/deny (do not silently non-match) when the program cannot be resolved, or when a wrapper\n that defeats canonicalization is detected for a policy-covered scope — these MUST NOT fall\n through to default-allow; a covered scope with an unresolvable program is treated as a\n covered-but-unauthorized action → deny (AC-38a).\n 4. `subcommandEquals` / `subcommandMatches` operate on the **normalized** subcommand tokens\n (`argv[1..]` with flags separated), not the raw string.\n- **`argv` wrapper handling (AC-38c — allowlist, not denylist).** Draft 2 said to *detect* a\n denylist of defeating wrappers (`env`, `xargs`, `$()`, ...). A denylist is unbounded — the next\n unlisted wrapper (`nice`, `stdbuf`, `time`, `nohup`, `setsid`, `doas`, a shell builtin, an unknown\n launcher) slips through. Draft 3 inverts it: for a **policy-covered scope**, canonicalization\n **succeeds only if every leading token is on an allowlist of recognized, transparent wrappers**\n declared **per covered scope** (e.g. a scope may permit `sudo -u <user>` or `time` and recurse\n through them to the real program), and the finally-resolved program is a recognized program for\n that scope. Any leading token **not** on the scope's wrapper allowlist — or any construct that\n breaks static resolution (command substitution `$()`/backticks, variable-indirect program\n `$PROG`, `eval`, piping the program name in) — makes the program **unresolvable → deny** (AC-38a),\n never default-allow. The allowlist is closed and reviewable; adding a wrapper is a policy edit\n signed into the config manifest (AC-46), not an open bypass surface.\n- **`commandDefaultAllow` (AC-38b — default-allow becomes opt-in for command-bearing tools).** The\n global default-allow-for-uncovered behavior is **retained only for non-command-bearing tools**.\n For command-bearing tools (any tool that can execute a shell command / carries an `argv`),\n default-allow is **off unless** `commandDefaultAllow: true` is set for the environment. When it is\n `false` (the default), an uncovered command-bearing invocation is **denied**, not passed through.\n This makes the dangerous default (arbitrary uncovered commands) an explicit per-environment opt-in\n rather than the global default, while non-command tools keep default-allow so the world does not\n break (§9, AC-23).\n- **`chains[]`** — alternative trust-chain templates (OR). **AC-19a**: an action MUST support ≥2\n chains and the evaluator MUST grant on the first fully-satisfied chain.\n- **`steps[]`** — ordered required evidence steps (AND). Each step: `kind` (evidence kind),\n `trustedIdentities` (fingerprints or role labels that resolve to fingerprints via trust roots and\n are passed to `verifyEnvelopeTrusted` as `allowedFingerprints`, AC-35 b), `conditions`.\n- **`conditions`** — reuse the existing operator vocabulary from the policy engines\n (`runtime/policy/types.ts:7` and `governance/engine.ts` `matchCondition`:\n `eq/neq/gt/lt/gte/lte/contains/matches`) plus evidence-specific sugar: `modelIdMatches` (regex,\n the \"opus decided\" allowlist), `scopeEquals`, `notExpired`, `tagContains`. Sugar compiles down to\n the base operators so there is one condition evaluator.\n- **`quorum` (AC-41 — distinct-holder rule).** `{ of: kind, min: n }` requires ≥n evidences of that\n kind from **n distinct trust-root fingerprints** — *and*, because one human may hold several keys,\n from **n distinct human identities** (the `responderId`/`approvedBy` behind the fingerprint, not\n merely n distinct fingerprints). A two-human quorum therefore **cannot** be met by one human's two\n keys: the evaluator groups accepted evidences by the underlying identity resolved from the trust\n root and counts distinct identities, not distinct keys. Complements the platform quorum in\n `approvalChains.ts:96-158`.\n- **`quorum` composition with heterogeneous `steps[]` (AC-41a).** Draft 2 left it ambiguous whether a\n chain could mix ordered typed steps *and* a quorum (so \"human+opus AND 2-human-quorum\" needed two\n separate chains, which changes the semantics from AND to OR). Draft 3 makes a chain a list of\n **requirements** evaluated with **AND**, where each requirement is one of:\n `{ step: {kind, trustedIdentities, conditions} }` (a single typed step) **or**\n `{ quorum: {of, min, trustedIdentities?, conditions?} }` (a distinct-holder quorum, AC-41). A chain\n is satisfied iff **every** requirement is satisfied (AND), and grant is on the first fully-satisfied\n chain (AC-19a). This lets one chain express `human-approval(alice) AND model-decision(opus) AND\n quorum(human-approval, min:2)` as three AND-ed requirements. `evidenceStepBindings` (AC-42) records\n one binding per satisfied requirement — for a quorum requirement, one binding **per contributing\n evidence** (min entries), each pinning its `{stepIndex, requiredKind, envelopeHash}`; the issuer\n refuses to issue if any requirement (or any of a quorum's `min` contributors) lacks a bound, verified\n envelope. The legacy top-level `quorum:` form in the §7 example remains valid sugar for a\n single-requirement chain. An evidence envelope MUST NOT be counted toward more than one requirement\n in the same chain (no double-use across a typed step and a quorum).\n- **`expiry`** — per-step `notExpired` uses `isPermissionValid` (`tool-signing.ts:50-55`) for\n `PermissionEvidence`, and `expiresAt`/key-expiry checks (proven `verify.ts:39-51`) for keys.\n\n**AC-20 (evaluation semantics).** The policy evaluator, given an action context and the resolved\nevidence set, returns `{ granted: boolean, matchedChainId?, reason, evidenceUsed: Evidence[] }`.\nPrecedence within the adapter mirrors the existing engines: an explicit `deny` action wins over\ngrants (deny > grant > default), consistent with `governance/engine.ts:94-145` and\n`runtime/policy/engine.ts:65-110`. When `granted`, the issuer (§5) mints the\n`CommandAuthorization` from `evidenceUsed`.\n\n---\n\n## 8. Component placement (Milestone B)\n\n**AC-21.** A **new workspace package `@a5c-ai/policy-adapter` at `packages/adapters/policy`** is\nintroduced (the only genuinely new module in this design). Placement rationale, grounded in the\ncurrent dependency graph:\n\n- It depends **only** on `@a5c-ai/genty-core` (trust primitives, `trust/index.ts`) + Node built-ins.\n- `@a5c-ai/genty-core` is a leaf that adapters may already depend on; `@a5c-ai/tools-adapter`,\n `@a5c-ai/comm-adapter`, `@a5c-ai/tasks-adapter`, `@a5c-ai/transport-adapter` all consume the new\n package with **no cycle** (they do not depend back into it).\n- `@a5c-ai/genty-platform` (which already depends on genty-core and can depend on adapters) consumes\n it at the MCP dispatcher seam.\n- Placing it inside `packages/adapters/*` means it is already covered by the `\"packages/adapters/*\"`\n workspace glob (`package.json:17`) — **no root workspace-list edit needed**, avoiding one class\n of lockfile churn.\n\n**AC-21a (lockfile constraint).** Adding the package still requires a lockfile regeneration.\nPer the research §Constraints and MEMORY, this **must not** be done with bare `npm install` on\nWindows (it pins win32 native bindings non-optional and breaks Linux `npm ci`). Regeneration is\ndone on Linux/CI or with the repo's sanctioned lockfile workflow; the design flags this as an\nexplicit implementation gate.\n\n**AC-22 (extend, do not duplicate the two engines).** The policy adapter **reuses** the condition\nevaluator shape and precedence of the two existing engines rather than forking a third:\n- `@a5c-ai/babysitter-sdk` `runtime/policy/` (`engine.ts:22-56` `matchCondition`, `65-110`\n precedence) — the effect-level engine. The new adapter's condition sugar compiles to the same\n `PolicyConditionOp` set (`runtime/policy/types.ts:7`). The SDK engine gains proof-awareness by\n delegating trust-chain steps to the policy adapter (it does not re-implement signature checks).\n- `@a5c-ai/genty-platform` `governance/engine.ts:94-145` — the harness-level engine. Same treatment:\n the platform engine calls the policy adapter for chain verification; it keeps its own rule\n precedence.\nNeither engine's public API is broken; both gain an optional \"trust-chain\" rule kind that hands off\nto `@a5c-ai/policy-adapter`.\n\n---\n\n## 9. Enforcement contract at each gate (Milestone D)\n\nCovered = the tool/command/creds match some policy `action`. Uncovered = no action matches.\n\n**AC-23 (uniform gate contract, revised).** At every gate, the action is first matched using the\n**canonicalized argv** (AC-38), never a raw string. For a **covered** action:\n1. Resolve the required evidence (human approval by breakpoint id; model decision **by tool-call id**\n via §6.3, AC-16; delegation from the agent request).\n2. Evaluate the policy (§7) against the integrity-verified policy doc (AC-36). If not granted →\n **deny** (fail closed).\n3. Obtain / verify the `CommandAuthorization` (§5) with the exact `toolCallId`/`argsHash`/\n `commandHash`(canonicalized argv)/`credentialScope` about to execute, recomputed at this gate. If\n verification fails or throws → **deny**.\n4. Only on success does the tool/command/credential proceed.\n\nFor an **uncovered** action, coverage-and-default is now split by tool class (AC-38b):\n- **Non-command-bearing tool:** pass through unchanged (default-allow), **unless** the action's\n `credentialScope` matches a configured `defaultDeny` glob (§9.4, AC-23c) → deny.\n- **Command-bearing tool:** pass through **only if** `commandDefaultAllow: true` for this\n environment; otherwise **deny** (default-deny). An uncovered command that could not even be\n canonicalized (AC-38a) under a covered scope is denied regardless.\nAny error in steps 1–3, or any argv-canonicalization failure for a covered scope, is a denial, never\na fallback-allow.\n\n### 9.1 GATE 1 — `@a5c-ai/tools-adapter` dispatch (`tools/src/dispatch.ts:133-188`)\n\nThe `beforeToolUse` hook (`dispatch.ts:149-164`, `ToolHookBridge`, `hooks.ts:28-49`) is the\ninjection point. A new `PolicyVerifierHookBridge` wraps or composes the existing bridge and returns\n`{ decision: 'deny', reason }` (`dispatch.ts:151-156`) when policy verification fails for a covered\ncall. `ToolCallContext` (`tools/src/types.ts:194-202`) already carries `toolName`, `input`,\n`runId`, `sessionId` — enough to compute `argsHash` and resolve model-decision evidence by\n`sessionId`/tool-call id.\n\n### 9.2 GATE 2 — `@a5c-ai/comm-adapter` runtime hooks (`core/spawn-runtime-hooks.ts:97-120`)\n\nThe `preToolUse` blocking dispatch (`spawn-runtime-hooks.ts:99-117`, `HookDecision` deny at\n113-116, type `runtime-hooks.ts:53-60`) gates spawned harnesses. A policy `preToolUse` handler\nreturns `{ decision: 'deny', reason }` for covered calls that lack a valid authorization. Only\n`mode === 'blocking'` adapters get hard enforcement here; for non-blocking adapters GATE 1 / GATE 3\nare the enforcing gates (documented per-gate coverage).\n\n### 9.3 GATE 3 — credential injection backstop (`core/spawn-invocation.ts`)\n\n**AC-23a (revised — Draft 3 — credential binding across ALL delivery channels).** Credentials\ncurrently flow with no gate through **multiple** channels that `spawn-invocation.ts` controls, not\nenv vars alone: docker `-e K=V` (86-89) **and** docker `-v` mounts (79, 81); ssh `K=V` prefix\n(120-124); k8s exec `env K=V` (211-216); k8s ephemeral `--env=K=V` (249-251) and\n`--serviceaccount=`/secret references (239). A credential about to be delivered by **any** of these\nchannels is tagged with a `credentialScope` from the trusted out-of-agent source (AC-40/AC-40a).\nBefore delivery, GATE 3 requires a valid `CommandAuthorization` whose `credentialScope` matches;\n**no valid authorization → the scoped credential is not delivered** by that channel (env var dropped,\n`-v` mount omitted, secret/serviceaccount reference stripped), and if the policy marks it required,\nthe spawn is denied. GATE 3 is the **last** point before exec, so it **recomputes** `argsHash` and\n`commandHash` (shared canonicalizer, AC-52 / AC-38) from the exact command/args being spawned and\nre-checks them against the authorization (AC-10 steps 4/5) — the hashes are never carried forward\nfrom GATE 1. This is a backstop for the credential-delivery surface; the **load-bearing**\nenforcement for every covered action (credential-bearing or not) is AC-49. AC-50 defines exactly\nwhich credential channels GATE 3 mediates and which are a bounded, warned non-goal.\n\n**AC-40 (trusted, out-of-agent credential→scope source).** The mapping from an actual credential to\nits `credentialScope` tag MUST come from a source **outside the agent process** — the same\norchestrator/proxy trust domain that holds the issuer/config keys (e.g. a credential-broker config\nsigned by the config root, or KMS/secret-store metadata). The agent process MUST NOT be able to\nassert \"these creds are scope `aws:prod:s3-ro`\" for creds that are actually broader. GATE 3 reads\nthe scope tag from this trusted source keyed by the credential's identity, not from any\nagent-supplied hint. If the trusted source has no scope for a credential that a policy marks scoped,\nGATE 3 denies (fail closed) rather than injecting an untagged credential.\n\n**AC-40a (collision-resistant credential identity; deny on ambiguous).** The \"credential's identity\"\nGATE 3 keys the scope lookup on MUST be **collision-resistant**, not a guessable/forgeable label: it\nis `sha256` of the credential's *stable secret-store identity* (broker key id, KMS ARN/key-version,\nor secret name+version), **never** the credential value's prefix, the env-var name, or an\nagent-supplied alias. The trusted source (AC-40) is a map keyed by this identity. If two distinct\ncredentials resolve to the **same** identity key (a collision or a mis-keyed broker entry), or if a\ncredential's identity is **absent/ambiguous** in the trusted map, GATE 3 **denies** (fail closed) —\nit never guesses a scope, and never picks the narrower of two candidate scopes. This prevents an\nattacker from getting a broad credential tagged with a narrow scope by exploiting a weak identity\nkey.\n\n**AC-49 (load-bearing, un-bypassable gates for ALL covered actions — closes residual issue 8(i)).**\nDraft 2 leaned on GATE 3 as \"the backstop,\" but GATE 3 only ever sees a *scoped credential injection*.\nA policy-covered action that injects **no** scoped credential (e.g. a covered `Bash rm -rf`, a\ncovered MCP tool, a `kubectl delete` with the pod's ambient service-account) has **nothing** for\nGATE 3 to gate; if GATE 2 is advisory (non-blocking adapter, §9.2) and GATE 1 is bypassed, such an\naction would execute unchecked. Draft 3 therefore **designates GATE 1 and the genty\ndispatcher/session seam (§9.4) as the LOAD-BEARING, un-bypassable enforcement for every covered\naction — credential-bearing or not** — and requires proof that every tool-execution path traverses at\nleast one blocking gate:\n\n- **Invariant.** For a **covered** action, at least one *blocking* gate on the path (GATE 1\n `beforeToolUse` deny, §9.1; **or** the genty dispatcher `dispatcher.dispatch` / session\n `definition.execute` verifier, §9.4) MUST evaluate the policy and be able to return `deny` **before**\n execution. GATE 2 (advisory for non-blocking adapters) and GATE 3 (credential-only) are **defense-in-depth,\n not the sole line**. A covered action reaching exec without having passed a blocking gate is a\n design defect.\n- **AC-49a (execution-path enumeration acceptance test).** From the research execution-path map\n (research §4 genty decision/execution points; §5 the three adapters gates; §6.3 dispatcher seam),\n the test **enumerates every tool-execution entry path** — genty session `session.ts:1251`\n `definition.execute`; genty MCP dispatcher `effects.ts:561-601` `dispatcher.dispatch`; adapters\n GATE 1 `dispatch.ts:133-188`; spawned-harness GATE 2 `spawn-runtime-hooks.ts:99-117`; and the spawn\n path `spawn-invocation.ts` (local/docker/ssh/k8s) — and asserts that for a covered action **each\n path hits at least one gate that blocks on policy denial**. The test fails if any enumerated path\n can reach execution of a covered action without a blocking gate. This converts \"enforcement at all\n gates\" from prose into an executable coverage check, and is the acceptance evidence for AC-33's\n alternate-path defense.\n\n**AC-50 (GATE 3 credential-channel coverage; bounded, warned non-goal — closes residual issue 8(ii)).**\nGATE 3 mediates the credential-delivery channels that `spawn-invocation.ts` **constructs and can\ntherefore see** — enumerated in AC-23a: env injection (docker `-e`, ssh `K=V`, k8s `env`/`--env`),\ndocker `-v` file mounts, and k8s secret/service-account references (`--serviceaccount`, mounted secret\nvolumes it emits). For each, \"no valid authorization\" means the channel is **omitted** (mount dropped,\nsecret ref stripped), not merely the env var. Channels GATE 3 **cannot** mediate are an explicit,\n**bounded non-goal with a warning**, not a silent gap:\n- **Out-of-scope (documented):** credentials the process obtains **without** `spawn-invocation.ts`\n constructing the delivery — cloud **instance metadata (IMDS/IRSA/workload-identity)**, pre-existing\n files on a shared/mounted path the agent already controls, credentials embedded in a container image,\n and network-reachable secret endpoints the process calls itself. GATE 3 does not see these, so it\n **cannot** enforce scope on them.\n- **Why env/mount is the primary channel:** `spawn-invocation.ts` is the one seam this design controls\n for *injecting* credentials into a spawned process, so it is where scoped injection is gated. The\n out-of-scope channels are properties of the **execution substrate** (VM metadata, image contents),\n mitigated by substrate controls (no broad instance role for agent pods, minimal images, network\n egress policy) — called out here as an operator warning, and cross-referenced from §1.2.\n- **Mandatory warning + fail-closed default.** When a policy action declares a `credentialScope` that\n the deployment **cannot** deliver through a GATE-3-mediated channel (i.e. it would arrive via IMDS or\n a pre-existing mount), the evaluator MUST emit a loud **audit-logged warning** at load time and, for\n such an action, MUST NOT report `granted` on the assumption GATE 3 will backstop it — because it\n will not. High-assurance scoped credentials MUST be delivered through a GATE-3-mediated channel; the\n design does not claim to gate substrate-delivered credentials.\n\n### 9.4 genty dispatcher / session\n\n**AC-23b.** The genty MCP dispatcher seam\n(`platform/.../orchestration/effects.ts:561-601` `dispatcher.dispatch(...)`) and the genty session\ntool-execution point (`session.ts:1251` `definition.execute(...)`) verify authorization before\nexecution, using the in-process model attestation (§6.2) carried on `ToolExecutionContext`. Genty's\nexisting `CustomToolDefinition.metadata.requiresApproval` (`core/src/types.ts:224`) is wired to\nmap onto policy coverage (declared-but-unenforced today → enforced via the adapter).\n\n**AC-23c (default-deny scopes).** `defaultDeny` (policy doc §7) is a configurable list of\ncredential-scope globs (e.g. `[\"aws:prod:*\"]`) for which an **uncovered** action is denied rather\nthan passed through. This makes \"default-deny for production scopes, default-allow elsewhere\"\nexpressible without breaking every uncovered dev action.\n\n---\n\n## 10. Key management & ops (Milestone A)\n\n**AC-25 (generation/provisioning).** Human keys use proven `generateKeyPair` +\n`saveTrustedPublicKey`/`savePrivateKey` (`proven/keys.ts:9-71`). Engine/agent/issuer keys use genty\n`createKeyPair` (`trust/signing.ts:4-13`) / `createAgentIdentity` (`identity.ts:4-11`). The proxy\nattestation key is provisioned to the proxy host and referenced by `attestationKeyPath` (§6.1);\nit is **never** placed where the agent process can read it.\n\n**AC-26 (revised — Draft 3 — trust-roots config format, location, signed manifest).** Trust roots\nlive in a single git-tracked file `.policy/trust-roots.json` at repo root (public material only), an\narray of `TrustRoot` records (§4.2, each now carrying key material per AC-6) plus a `revoked` list.\nPolicy documents live under `.policy/policies/*.yaml`. Private keys are **never** git-tracked: human\nprivate keys stay under `.breakpoints/.keys/private/` (gitignored, existing proven layout);\nengine/issuer private keys under `.policy/.keys/private/` (added to `.gitignore`). Public keys are\ngit-tracked (`trusted/` + `trust-roots.json`).\n\n**Draft 3 replaces per-file detached signatures with a single signed `configEpoch` manifest** (AC-46).\nRather than one `<name>.sig.json` per file — which permits **partial rollback** of one file to an\nolder validly-signed version — there is exactly **one** signed manifest,\n`.policy/config-manifest.json`, that lists **every** config file (`trust-roots.json` and every\n`policies/*.yaml`) with its sha256, plus a monotonically increasing `configEpoch` integer, all signed\nas one `SignedEnvelope<ConfigManifestPayload>` by the out-of-agent config root-of-trust (AC-36).\nGit-tracking a file does **not** make it trusted; presence in the current-epoch signed manifest does.\n\n### 10.1 Config integrity — the out-of-agent root of trust (**AC-36/AC-37**, review issue 3)\n\n**Threat being closed.** The threat model puts a compromised / workspace-writable agent in scope\n(§11 AC-33). Draft 1's trust-roots and policy files were merely git-tracked JSON/YAML on that same\nworkspace, so such an agent could **add its own fingerprint as a `human` root, relax an `argv`\nmatcher, or widen a `credentialScope` glob, then self-authorize.** No signature check stopped it.\n\n**AC-36 (revised — Draft 3 — config root-of-trust; one signed manifest, verified before honoring).**\nThere is a distinguished **config root-of-trust** key whose private half is held **outside every\nagent process** — the same place the transport proxy / issuer keys live (the orchestrator/proxy host,\nor a hardware token / KMS in production), never on the agent-writable workspace and never under any\npath an agent step can write. Its public half is pinned as a `kind:'config'` root. Configuration is\ncovered by **one** signed manifest:\n\n```ts\ninterface ConfigManifestPayload {\n payloadType: 'config-manifest'; // AC-51 domain-separation constant\n configEpoch: number; // AC-46: monotonically increasing; strictly greater on every re-sign\n files: { path: string; sha256: string }[]; // EVERY config file: trust-roots.json + every policies/*.yaml\n issuedAt: string; // ISO\n}\n```\n\nEvery gate and the evaluator MUST, before honoring **any** trust root or policy document:\n1. Load `.policy/config-manifest.json` and verify it via `verifyEnvelopeTrusted(manifest,\n requiredKind:'config', allowedFingerprints: [pinnedConfigRootFp])` (AC-35, which also enforces\n `payloadType === 'config-manifest'` per AC-51) against the pinned config-root fingerprint.\n2. **Enforce the epoch floor (AC-47):** require `manifest.payload.configEpoch >=\n POLICY_CONFIG_MIN_EPOCH` (the off-workspace pin). Below the floor → **deny all**.\n3. For **every** config file about to be honored, recompute sha256 of its canonical bytes and require\n it to equal the entry in `manifest.files` for that path; a config file **not listed** in the\n manifest, or whose hash disagrees, → **deny all**. (This binds trust-roots and every policy doc to\n the *same* epoch, so no single file can be rolled back independently — AC-46.)\n4. Mismatch, missing manifest, wrong signer, below-floor epoch, or an unlisted/edited file → **deny\n all** (fail closed): if the config itself is untrusted, no action is authorized.\n `CommandAuthorization.policyDocHash` (AC-8) is the per-doc hash from this same manifest and\n `CommandAuthorization.configEpoch` records the manifest epoch, so a gate confirms it enforced the\n exact signed documents, under the exact epoch, that the authorization was issued under (AC-10\n steps 9–10).\n\nBecause the signing key is off the workspace, an in-scope compromised agent can edit the files but\n**cannot produce a valid signature** for the edit; the tampered config is rejected. This is the\nintegrity guarantee Draft 1 lacked. (Alternative considered and rejected for this iteration: placing\nthe files on a read-only path the agent cannot write. Signing is preferred because it survives the\nfiles being copied, distributed, or served over git, and does not depend on per-deployment mount\npermissions.)\n\n**AC-46 (monotonic config epoch — one manifest covers all config, closes residual issue 6).** Draft 2\nsigned `sha256(file bytes)` per file with **no version, counter, or nonce**. A validly-signed *older*\nconfig set therefore remains valid forever: an in-scope adversary who retains a previously-signed\n`trust-roots.json` + policy set (e.g. from git history, or a prior deploy) can **swap the older,\nmore-permissive set back in** — and because the revocation list lives inside `trust-roots.json`, the\nrollback **un-revokes** any key that was revoked after that snapshot (re-enabling a stolen key), and\nrestores any policy loosened since (wider `credentialScope` globs, relaxed `argv` matchers). Each\nfile's signature verifies in isolation, so nothing detects the downgrade. **AC-46 binds a\nmonotonically increasing integer `configEpoch` into a single signed manifest** that covers **all**\nconfig files together (AC-36):\n- The config root signs **one** `ConfigManifestPayload` per config change; `configEpoch` MUST be\n **strictly greater** than the previous manifest's epoch on every re-sign (the `sign-config` CLI,\n AC-37, refuses to emit a manifest whose epoch is `<=` the epoch it is replacing, unless an explicit\n operator `--force-epoch` override records an intentional reset in the audit log).\n- Because trust-roots (incl. the revocation list) and every policy doc are hashed **into the same\n manifest under the same epoch**, **partial rollback of one file is impossible**: you cannot pair an\n old `trust-roots.json` with a new policy set (or vice-versa) — the manifest hash for the swapped\n file will not match, and there is no per-file signature to verify it independently. Revocation\n therefore cannot be downgraded independently of the epoch.\n\n**AC-47 (off-workspace minimum-epoch floor; every gate rejects below-floor config).** The rollback\ndefense requires a monotonic reference the adversary cannot rewind. That reference is an\n**off-workspace pin**: `POLICY_CONFIG_MIN_EPOCH` is pinned in the **same** code/deploy artifact as\n`POLICY_CONFIG_ROOT_FP` (AC-37) — the deploy image / env, never a workspace file an agent can edit.\nEvery gate and the evaluator, after verifying the manifest signature (AC-36), MUST reject any config\nwhose `configEpoch < POLICY_CONFIG_MIN_EPOCH` (**deny all**), and MUST reject any\n`CommandAuthorization` whose `configEpoch` is below the floor or does not equal the currently-honored\nmanifest epoch (AC-10 step 10). Advancing the floor is a deploy operation performed **together with**\nsigning a new config epoch, so a rolled-back-but-validly-signed older manifest (epoch below the\ncurrent floor) is rejected even though its signature verifies. The floor is what converts \"monotonic\ncounter\" into an enforceable anti-rollback bound: the manifest proves *which* epoch; the pinned floor\nproves the *minimum acceptable* epoch, and neither can be moved by editing the workspace. (Where a\nhardware monotonic counter / KMS key-version is available in production, the floor MAY be sourced from\nit instead of a static pin; the static deploy pin is the baseline.)\n\n**AC-37 (bootstrap / root-of-trust provisioning).** The top of the trust hierarchy is provisioned\nout-of-band, once, before any agent runs, and its story is:\n- The config root-of-trust keypair is generated on the operator's trusted host (or KMS/HSM) via\n `policy-adapter init-config-root` (**flagged new CLI in `@a5c-ai/policy-adapter`**). The private\n half is stored where agents cannot read it (proxy/orchestrator host secret store, KMS, or token);\n the public half's fingerprint is **pinned in code/deploy config** (`POLICY_CONFIG_ROOT_FP` env /\n a committed `pinned-config-root.json` that is itself part of the deploy image, not the workspace),\n so the pin cannot be swapped by editing a workspace file. **The minimum-epoch floor\n `POLICY_CONFIG_MIN_EPOCH` (AC-47) is pinned in the same artifact, beside `POLICY_CONFIG_ROOT_FP`.**\n- Signing a config change is an explicit operator step: `policy-adapter sign-config` runs on the\n trusted host, hashes **all** config files into one `ConfigManifestPayload` with the next\n `configEpoch` (strictly greater than the current epoch — AC-46), and emits the single signed\n `.policy/config-manifest.json`. Agents never hold this key, so agents cannot sign config or mint a\n higher epoch. Advancing the deploy-pinned floor is done together with rolling out a new epoch.\n- The config root signs (via the manifest) the initial `trust-roots.json` + policy docs at epoch 1\n (with the floor pinned at 1); `trust-roots.json` in turn names the `human`/`engine`/`agent` roots.\n Revocation of the config root itself is a deploy operation (rotate the pin), out of scope for online\n revocation (§1.2 non-goal 4).\n- **Chain summary:** pinned config root + pinned epoch floor (off-workspace) → sign one\n `config-manifest.json` (epoch ≥ floor) covering `trust-roots.json` + policy docs → those name the\n evidence-signing roots → evidence chains authorize commands. Every link is verified by\n `verifyEnvelopeTrusted`; the only links anchored by a code/deploy pin (not a file) are the config\n root **and the epoch floor**, which together make the tree resistant to both tampering and rollback\n by the workspace-writable adversary.\n\n**AC-27 (revised — Draft 3 — rotation & revocation, downgrade-protected).** Rotation reuses proven\n`rotateKey` (`keys.ts:122-148`): it marks the old public key `expiresAt` and provisions a new pair.\nVerification honors key expiry at **signing time** (`proven/verify.ts:39-51`; `isPermissionValid`\n`tool-signing.ts:50-55`) so an old signature made while the key was valid still verifies unless the\nkey is explicitly revoked. Revocation is a `revoked: true` flag / fingerprint list in\n`trust-roots.json`; a revoked fingerprint fails verification immediately regardless of signing time.\n\n**Revocation cannot be downgraded independently (AC-46).** Because the revocation list lives inside\n`trust-roots.json` and `trust-roots.json` is hashed into the single signed manifest under the current\n`configEpoch`, rolling the revocation list back to un-revoke a stolen key requires swapping in an\nolder `trust-roots.json` — which changes the manifest hash and lowers `configEpoch` below the pinned\nfloor (AC-47), so it is rejected. **Every revocation MUST therefore be published by signing a new,\nstrictly-higher config epoch and advancing the deploy floor**, so the un-revoked state (a lower\nepoch) is permanently below the floor. What is git-tracked: public keys, trust-roots config, policy\ndocs, revocation list, `config-manifest.json`. What is local-only: all private keys. What is\ndeploy-pinned (off-workspace): `POLICY_CONFIG_ROOT_FP` and `POLICY_CONFIG_MIN_EPOCH` (AC-37/AC-47).\n\n---\n\n## 11. Threat model (Milestone E test targets)\n\nEach row is an acceptance criterion with a defense grounded in the design.\n\n- **AC-28 — Spoofed approval** (fabricated `approved:true`). Defense: human-approval evidence must\n be a `SignedEnvelope<PermissionEvidencePayload>` signed by a `human` trust root; unsigned or\n wrong-signer approvals fail §5 step 7 / §7. Closes the current spoofable gap\n (research §3: SDK `task_post`/`commitEffectResult` accept unsigned answers today).\n- **AC-29 — Replay across commands** (reuse an old approval for a new command). Defense:\n `CommandAuthorization` binds `commandHash` + `argsHash` + `expiresAt`, and human-approval\n `conditions.scopeEquals` + `notExpired`; an old approval for scope A does not satisfy a chain for\n scope B, and expiry bounds the window.\n- **AC-30 — Model downgrade + attestation replay within a turn** (sonnet decides, claims opus; or a\n valid opus attestation for call A is replayed onto sibling call B in the same turn). Defense:\n model-decision evidence covers `modelId` + `inputMessagesHash` **and** a signed `toolCalls[]`\n binding each `toolCallId` to its `argsHash` (AC-34), signed by the **proxy engine key held outside\n the agent** (§6.1); the agent cannot forge which model answered nor which call it authorized. The\n gate requires a `SignedToolCall` matching the executing `toolCallId`+`argsHash` (AC-34a), so a\n turn's attestation is not replayable to a different call. `modelIdMatches: \"claude-opus-.*\"` plus\n `requireProxyAttestation` (defaulting to true for credential-touching actions, AC-39).\n- **AC-31 — Expired / stolen keys.** Defense: signing-time key-expiry checks (§10 AC-27), short\n authorization TTL (§5), and immediate revocation via `trust-roots.json`. A stolen key is revoked\n by fingerprint; new authorizations from it fail at once.\n- **AC-32 — TOCTOU between authorization and execution.** Defense: the authorization is verified at\n the execution gate itself (§9) against the **exact** `argsHash`/`commandHash`/`credentialScope`\n about to run, not at an earlier decision point; a mutated command/args after issuance produces a\n hash mismatch → deny.\n- **AC-33 — Bypass via alternate execution path.** Defense: GATE 1 and the genty dispatcher/session\n seam are the **load-bearing, un-bypassable** blocking gates for **every** covered action —\n credential-bearing or not (AC-49); the AC-49a execution-path enumeration test asserts each\n enumerated exec path hits a blocking gate. GATE 2 (advisory for non-blocking adapters) and the GATE\n 3 credential backstop across **all** delivery channels it controls (AC-50) are defense-in-depth:\n with no valid authorization the scoped credential is never delivered by any GATE-3-mediated channel,\n so an alternate path that skips GATE 1/2 still cannot obtain scoped credentials it controls. The\n credential→scope tag comes from the trusted out-of-agent source keyed by collision-resistant\n credential identity (AC-40/AC-40a), so the agent cannot relabel broad creds as a narrow scope. GATE\n 3's substrate-delivered-credential limits (IMDS, pre-existing mounts) are a bounded, warned non-goal\n (AC-50), not a silent hole.\n- **AC-45 — Config / policy tampering by a compromised agent** (adds its own trust root, relaxes an\n `argv` matcher, widens a `credentialScope`, then self-authorizes). Defense: `.policy/trust-roots.json`\n and every policy doc are hashed into the **single out-of-agent config-root-signed manifest** (AC-36)\n whose fingerprint is pinned in deploy config, not on the workspace; every gate verifies the manifest\n signature and per-file hashes before honoring any root or policy and denies-all on mismatch. The\n agent can edit the files but cannot produce a valid signature (AC-37), so tampered config is\n rejected. `CommandAuthorization.policyDocHash` + `configEpoch` tie each authorization to the exact\n signed policy doc under the exact epoch.\n- **AC-46 — Config rollback / downgrade by a compromised agent** (swaps a validly-signed *older*,\n more-permissive `trust-roots + policy` set back in, un-revoking a stolen key and re-widening scopes).\n Defense (residual issue 6): the single signed manifest carries a **monotonic `configEpoch`** (AC-46)\n covering all config files together, and every gate rejects any `configEpoch` below the off-workspace\n pinned `POLICY_CONFIG_MIN_EPOCH` floor (AC-47) and any authorization whose `configEpoch` is below the\n floor or ≠ the current manifest epoch (AC-10 step 10). Revocation is published by advancing the epoch\n + floor (AC-27), so the un-revoked older state is permanently below the floor. Partial rollback of one\n file is impossible — all files share one manifest hash + epoch. Test: sign epoch N, advance floor to\n N, present the epoch-(N-1) manifest → **deny all**.\n- **AC-48 — Proven-bridge unsigned `approved` bit** (a forged `ProvenBreakpointAnswer` sets\n `approved:true` as an **unsigned** field, listing a `signedFields` that omits it; `verifyAnswer`\n still returns valid because it rebuilds the payload from the attacker's `signedFields`,\n `proven/verify.ts:60`). Defense (residual issue 7): the bridge applies the AC-2 completeness rule to\n the legacy proven answer — it denies unless `{breakpointId, approved, responderId} ⊆\n provenAnswer.signedFields` and each is present — **before** deriving any human evidence, and derives\n only when `approved === true` (AC-48/AC-3/AC-43). Test: a proven answer valid under a `human` key but\n with `approved` absent from `signedFields` yields **no** human evidence → chain unsatisfied → deny.\n- **AC-49 — Covered non-credential action on a bypassed/advisory path** (a covered `Bash rm -rf` or MCP\n tool that injects no scoped credential, with GATE 2 advisory and GATE 1 skipped). Defense (residual\n issue 8(i)): GATE 1 and the genty dispatcher/session seam are load-bearing blocking gates for **all**\n covered actions (AC-49); the AC-49a enumeration test asserts every exec path hits a blocking gate, so\n no covered action reaches exec unchecked even when no credential is involved.\n- **AC-51 — Cross-payload-type signature transfer** (an `engine`-signed envelope of one payload type\n presented for a step expecting a different `engine`-signed type). Defense: every payload carries a\n bound `payloadType` constant in `signedFields`, and `verifyEnvelopeTrusted` denies unless\n `payload.payloadType` equals the expected constant for the step's kind (AC-51). Test: present a\n `model-decision` envelope for a `command-authorization` slot → deny.\n- **AC-44 — Non-blocking-GATE-2 coverage + passthrough denial (acceptance test).** Two fail-closed\n behaviors that Draft 1 asserted but did not test: (i) for a **non-blocking** GATE-2 adapter\n (`mode !== 'blocking'`, §9.2), GATE 2 cannot hard-enforce, so the test asserts that GATE 1 and/or\n GATE 3 still deny a covered-but-unauthorized call — i.e. the overall system is fail-closed even\n when GATE 2 is advisory. (ii) A model call routed through **passthrough proxy mode** (no\n `completionEngine`, §6.5) produces no attestation, so an action requiring proxy attestation (incl.\n any credential-touching action defaulting to it, AC-39) MUST be **denied** on that path. AC-44 is\n the explicit acceptance test for both. **(Draft 3)** The non-blocking-GATE-2 case is subsumed by the\n broader AC-49a enumeration test: with GATE 2 advisory, a blocking gate (GATE 1 or the genty seam)\n still denies a covered-but-unauthorized call whether or not it carries a credential.\n\n---\n\n## 12. Acceptance-criteria → milestone map\n\n| Milestone | Acceptance criteria |\n|-----------|---------------------|\n| **A — trust-core** | AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7, AC-8, AC-25, AC-26, AC-27, **AC-34** (model-decision payload extension type), **AC-35** (trusted-store verifier wrapper), **AC-36** (config-integrity verification), **AC-37** (root-of-trust bootstrap), **AC-43** (proven bridge → human), **AC-46** (monotonic config-epoch manifest), **AC-47** (off-workspace epoch floor), **AC-48** (proven-bridge legacy signedFields completeness), **AC-51** (domain-separation `payloadType`), **AC-52**+**AC-52a** (shared canonical argv/args serialization + conformance test) |\n| **B — policy-engine** | AC-9, AC-10, AC-19, AC-19a, AC-20, AC-21, AC-21a, AC-22, **AC-38** (canonicalized argv matcher), **AC-38a**, **AC-38b** (command default-allow opt-in), **AC-38c** (argv wrapper allowlist), **AC-41** (quorum distinct-holder), **AC-41a** (heterogeneous quorum composition), **AC-42** (evidence covers every step) |\n| **C — evidence-producers** | AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, **AC-34a** (tool-call binding at produce/verify), **AC-39** (proxy-attestation default for credential actions) |\n| **D — tool-layer-enforcement** | AC-23, AC-23a, AC-23b, AC-23c, **AC-40** (trusted credential→scope source), **AC-40a** (collision-resistant credential identity), **AC-49** (load-bearing un-bypassable gates), **AC-50** (GATE 3 credential-channel coverage + bounded non-goal) |\n| **E — e2e-integration** | AC-24 (non-goals guard), AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, **AC-44** (non-blocking-GATE-2 + passthrough-denial test), **AC-45** (config-tampering threat), **AC-46** (config-rollback threat test), **AC-48** (proven-bridge unsigned-approved threat test), **AC-49a** (execution-path enumeration test), **AC-51** (cross-payload-type transfer test) |\n\nEvery acceptance criterion maps to exactly one milestone. (AC-24, the non-goals guard, is verified\nin E as a scope-regression check.) New ACs from the Draft-2 security revision are assigned as above:\ntype/verifier/config-integrity work lands in A, schema/evaluator work in B, producer binding in C,\ncredential-scope sourcing in D, and the new threat/behavior tests in E. **Draft-3 note on dual-listed\nids:** AC-46, AC-48, AC-49, and AC-51 name a *mechanism* (implemented in its home milestone — A for\n46/47/48/51, D for 49/50) **and** a *threat test* (verified in E). The mechanism AC and its E-row test\nare the same criterion observed at implementation vs. verification time; the milestone of record for\nimplementation is the home milestone (A/B/D), and E lists the acceptance **test** that exercises it —\nconsistent with how AC-34/AC-34a and AC-30 already appear in both a producer milestone and the E\nthreat model. AC-49a and AC-52a are test-only sub-criteria owned by E and A respectively.\n\n---\n\n## 13. Reuse ledger (extend, do not rebuild)\n\n| Concern | Reused artifact (file:line) | New? |\n|---------|-----------------------------|------|\n| Envelope + canonical form | `genty/core/src/trust/signing.ts:4-86`, `types.ts:1-8` | reuse |\n| Human-approval evidence | `trust/tool-signing.ts:13-55` | reuse |\n| Model-decision evidence (base) | `trust/model-signing.ts:4-26` | reuse |\n| **Model-decision payload extension** (`ModelDecisionPayload` + `SignedToolCall`, AC-34) | new type beside `trust/model-signing.ts` in genty-core | **NEW type** (only relaxed no-new-schema exception) |\n| Delegation | `trust/agent-signing.ts:5-13`, `types.ts:29-33` | reuse |\n| Chain verify (raw) | `trust/chain.ts:20-55` | reuse (never called directly, see wrapper) |\n| **Trusted-store verifier wrapper** (`verifyEnvelopeTrusted`, AC-35) | `@a5c-ai/policy-adapter` | **NEW code** (wraps genty verify; adds fingerprint-binding + kind + trusted-store resolution) |\n| **Config-integrity verification + signing CLI** (AC-36/AC-37) | `@a5c-ai/policy-adapter` (`init-config-root`, `sign-config`, gate-side `verifyConfigManifest`) | **NEW code** |\n| **Config-epoch manifest + off-workspace floor** (AC-46/AC-47) | `ConfigManifestPayload` + `POLICY_CONFIG_MIN_EPOCH` pin; `sign-config` mints strictly-higher epoch; gates enforce floor | **NEW code** |\n| **Proven-bridge legacy signedFields completeness** (AC-48) | `@a5c-ai/policy-adapter` bridge (wraps `proven/verify.ts:20-72`; asserts `{breakpointId,approved,responderId}⊆signedFields` before deriving) | **NEW code** (bridge assertion) |\n| **Domain-separation `payloadType`** (AC-51) | bound constant added to every payload + `REQUIRED_SIGNED_FIELDS`; checked in `verifyEnvelopeTrusted` | **NEW field + check** |\n| **Shared canonical argv/args serialization** (AC-52) | `canonicalizeArgs`/`canonicalizeArgv` in `@a5c-ai/policy-adapter` (builds on genty `deepSortKeys`), imported by proxy + every gate | **NEW code** (single shared util) |\n| **Canonicalized argv matcher + wrapper allowlist** (AC-38/AC-38c) | `@a5c-ai/policy-adapter` | **NEW code** (tokenize + resolve program + per-scope wrapper allowlist + normalize subcommand) |\n| **Load-bearing gate coverage + exec-path enumeration test** (AC-49/AC-49a) | assertion over existing gates (GATE 1 `dispatch.ts`, genty seam `effects.ts`/`session.ts`); enumeration test | **NEW test** (no new runtime module) |\n| **GATE 3 non-env credential channels** (AC-50) | extend GATE 3 to docker `-v` (`spawn-invocation.ts:79,81`), k8s secret/serviceaccount refs (239) — env channels already in AC-23a | **extend** (bounded non-goal for IMDS/pre-mounts) |\n| **Trusted credential→scope source** (AC-40) | out-of-agent broker/KMS metadata; adapter reads it at GATE 3 | **NEW integration** |\n| Human key gen/rotate | `proven/keys.ts:9-148` | reuse |\n| proven bridge | `proven/verify.ts:20-72` | reuse (bridge new) |\n| Condition operators | `runtime/policy/engine.ts:22-56`; `governance/engine.ts:32-72` | reuse |\n| Engine precedence | `runtime/policy/engine.ts:65-110`; `governance/engine.ts:94-145` | reuse |\n| GATE 1 | `tools/src/dispatch.ts:133-188`, `hooks.ts:28-49` | reuse (verifier new) |\n| GATE 2 | `core/spawn-runtime-hooks.ts:99-117` | reuse (handler new) |\n| GATE 3 | `core/spawn-invocation.ts:86-89,120-124,211-216,249-251` | extend (binding new) |\n| genty dispatcher seam | `platform/.../orchestration/effects.ts:561-601` | reuse (verify hook new) |\n| genty session model flow | `session.ts:1122-1123,1216,1236,1251`; `types.ts:156-165` | extend |\n| Proxy attestation seam | `transport/server.ts:1287-1322,1394-1423,1595-1648`; `config.ts`; `types.ts:15-24` | extend |\n| **Policy adapter** | `packages/adapters/policy` (`@a5c-ai/policy-adapter`) | **NEW package** |\n\nThe **only** genuinely new *package* is `@a5c-ai/policy-adapter`. Everything else is an extension or\ncomposition of existing code, with the flagged exceptions the security revisions require:\n(1) a **new type** `ModelDecisionPayload`/`SignedToolCall` added beside `trust/model-signing.ts` in\n`@a5c-ai/genty-core` — the single, deliberate relaxation of the no-new-schema rule, needed to bind a\nmodel decision to a specific tool call (AC-34; Draft 3 adds a bound `payloadType` field to it and to\nevery other payload, AC-51); and (2) **new security-critical code inside** `@a5c-ai/policy-adapter` —\nthe `verifyEnvelopeTrusted` trusted-store wrapper (AC-35, incl. `payloadType` binding), the\nconfig-integrity verify/sign path with the **monotonic-epoch manifest** (AC-36/AC-37/AC-46/AC-47), the\n**proven-bridge legacy-completeness assertion** (AC-48), the **shared canonical argv/args serializer**\n(AC-52), and the canonicalized-argv matcher with a **per-scope wrapper allowlist** (AC-38/AC-38c) —\nwhich are extensions of, not forks of, existing genty/proven primitives. The Draft-3 GATE-3 extension\n(AC-50) and load-bearing-gate assertion (AC-49) add no new runtime module (they extend\n`spawn-invocation.ts` credential handling and add a coverage test over existing gates). No third policy\nengine is created (AC-22); the two existing engines delegate trust-chain steps to the adapter.\n\n## 14. Owner approval addendum (Draft 3 accepted, 2026-07-03)\n\nThe owner approved this design at the design-approval breakpoint (security-review score 91/100) with\nthe explicit condition that the four remaining non-blocking review notes are **promoted to REQUIRED\nacceptance criteria** — they are must-do before their milestone closes, not optional polish. These are\nnow in scope:\n\n- **AC-53 (Milestone C — argsHash boundary conformance).** AC-52a's conformance corpus MUST include a\n fixture that round-trips a real provider `arguments` JSON string through `JSON.parse` at the proxy\n seam (`transport/types.ts:57`) → optional benign hook mutation → each gate's `canonicalizeArgs`, and\n asserts byte-identity of the resulting `argsHash` against the object the harness actually delivers to\n `definition.execute` (`session.ts:1251`). The design MUST also state that input-mutating GATE-1 hooks\n (`dispatch.ts:158-163`) are incompatible with covered actions unless they run before attestation; a\n covered action whose input a hook mutates after attestation MUST fail closed (deny), and a test MUST\n assert this. Availability-grade, but required so covered actions do not silently break.\n\n- **AC-54 (Milestone A — proven canonical-form hardening).** The proven legacy signing canonical form\n (`proven/sign.ts:23-30`, unescaped `field=value\\n`) MUST be hardened with delimiter/length-prefix\n escaping (or the proven answer path MUST be migrated to the genty JSON canonical form) so the\n AC-48 bridge does not stand on an ambiguous concatenation. Existing proven signatures MUST continue to\n verify (dual-read during transition) and the AC-48 completeness assertion MUST be evaluated against the\n hardened form. A test MUST demonstrate two distinct field assignments can no longer collide to the same\n signing bytes.\n\n- **AC-55 (Milestone B — credential-identity alias canonicalization).** The trusted credential→scope\n source (AC-40/AC-40a) MUST canonicalize aliases of one physical credential (e.g. a KMS key referenced\n by ARN and by key-id) to a single collision-resistant identity BEFORE the GATE-3 deny-on-ambiguous rule\n is applied, so legitimate multi-alias credentials are not falsely denied. A test MUST cover a\n two-alias-one-credential case resolving to one scope.\n\n- **AC-56 (Milestone D — exhaustive exec-seam registry).** The AC-49a execution-path enumeration test\n MUST be structured as an exhaustiveness assertion over a checked-in registry of tool-execution /\n dispatch / spawn entry seams (the `execute`/`dispatch`/spawn points in genty + adapters). Any new exec\n entry point not present in the registry MUST fail the build, so a future unregistered execution path\n breaks CI rather than silently bypassing enforcement.\n\nEach of AC-53..AC-56 maps to exactly one milestone as noted above and inherits the fail-closed,\nno-fallback discipline of the rest of this spec.\n",
"documents": []
},
"outgoingEdges": [],
"incomingEdges": [
{
"from": "page:docs",
"to": "page:docs-design-proof-based-policy-enforcement",
"kind": "contains_page"
}
]
}