approval-md 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -914
- package/SPEC.md +98 -13
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +19 -0
- package/dist/src/channels/contract.js +105 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +51 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +50 -21
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +50 -6
package/SPEC.md
CHANGED
|
@@ -127,15 +127,19 @@ channels:
|
|
|
127
127
|
|
|
128
128
|
**An absent `approval_ttl` declares that nothing lapses.** `defaults.approval_ttl` is optional, and its absence is a statement rather than an omission for a runtime to fill in. Under a policy that omits it, a pending request stays actionable until a human decides it, the execution token a grant mints stays spendable until it is consumed or revoked (§10.4), and a harness grant stays carryable on the same terms. Implementations MUST NOT invent a default duration: a number the policy never wrote would expire approvals its author never asked to expire, and a runtime that instead read the absence as an immediate lapse would refuse every decision under a policy whose only fault is omitting an optional key, leaving no recovery path through its own gate. Where the key IS declared, the lapse is measured from the `approval.requested` timestamp, bounds the pending request and the token its grant mints alike, and is judged at decision time whether or not an `approval.expired` record has been written; an instant that cannot be parsed at either end reads as lapsed, because liveness that cannot be demonstrated is not assumed. `on_expiry` governs only how a lapse that a declared TTL produced is projected. A policy that fails to load carries no TTL either, so its fail-closed all-`manual` resolution comes with no deadline: failing closed raises the scrutiny an action gets, and it does not shorten the time a human has to answer. (Amended APRV-137.)
|
|
129
129
|
|
|
130
|
+
**A harness-stated ceiling is a bounded input, not an invented duration.** The prohibition above forbids a runtime supplying a number of its own; it does not forbid a runtime honouring a ceiling the requesting hook recorded at request time about its own process (`harness_cap_ms`, §6). That number is stated by the party under oversight, MUST be accompanied by `execution: "harness"`, and is applied only through a minimum, so it can shorten a request's window and never lengthen one (§11.1 invariant 4). A request carrying such a ceiling under a policy that omits `approval_ttl` therefore lapses at the ceiling less the implementation's margin, and a decision after that instant is refused `expired`; the absence of `approval_ttl` still means nothing lapses for every request that carries no ceiling. This is not the runtime expiring an approval its author never asked to expire: the asker itself will not be present to receive the answer past its ceiling, and a runtime that kept the question open would be collecting a decision for a call nobody holds. (Amended APRV-423.)
|
|
131
|
+
|
|
130
132
|
**Explicit irreversible-action autonomy.** A class rule MAY set `allow_irreversible: true` alongside `autonomy: autonomous`, `supervised`, `supervised-retro`, or `supervised-live`. For example, `communicate.zzz.external: { autonomy: supervised, allow_irreversible: true }` retains retrospective supervision when its action truthfully declares `reversible: false`. The key is optional and boolean; omission or `false` preserves the manual floor of §7. `true` on `manual` or `human-only`, a non-boolean value, or the key in `defaults` is schema-invalid and fails closed. A manual rule needs no override. (Amended APRV-317, pending sign-off.)
|
|
131
133
|
|
|
134
|
+
**Agent-requestable classes.** A class rule MAY set `agent_may_request: true` to let an agent open a request in that class with `approval propose` (§10.1), which names the class itself and carries the payload inline. Two conditions MUST both hold before a proposal is admitted: the class is an exact key of `classes` (a class reached only through a wildcard pattern is never proposable, so the set of proposable classes is a set of lines the operator wrote by name), and either that exact key or a declared family key `<prefix>.*` whose literal prefix the class extends sets the flag `true`. The exact key's own value wins when it states one, so `false` on a member closes it under an open family; otherwise the most specific family that states a value decides. Interior-wildcard patterns and the bare `*` are never read for this flag. The key is optional and boolean, absent means `false`, and `true` on a `human-only` rule is schema-invalid and fails closed. Adding or changing it is an edit to the policy like any other, so it takes effect only once a human attests the new bytes, and that attestation voids every request pending under the previous hash (`policy-drift`): a proposal pending across a policy edit must be filed again. The check runs before anything is registered and again inside the request's own read-check-append cycle, against the bytes that cycle attested. A refusal is `class-not-agent-requestable` (§11.2). (Amended APRV-445.)
|
|
135
|
+
|
|
132
136
|
### 5.2 Policy semantics
|
|
133
137
|
|
|
134
138
|
- **Matching.** Classes match most-specific-first; `*` is a single-segment wildcard, a trailing `.*` matches any depth. An action whose class matches no rule takes `defaults.autonomy`. Implementations MUST fail closed: unparseable policy means everything is `manual`. A trailing `.*` consumes one or more segments: `read.*` matches `read.web` and `read.web.page`, and it does not match the bare class `read`. The schema admits `read` and `read.*` as two distinct keys a policy may list separately with different autonomy, so neither is an alias of the other, and a policy that wants the bare class covered writes it as its own rule or leaves it to `defaults.autonomy`. A bare `*` is a single-segment wildcard rather than a trailing one, so it matches any one-segment class and nothing deeper, and `*.*` matches any class of two or more segments. (Amended APRV-137.)
|
|
135
139
|
- **Specificity.** Pattern specificity is compared as follows: (1) more literal (non-wildcard) segments is more specific; (2) ties broken by fewer wildcard segments; a trailing `.*` counts as a single wildcard segment and contributes no literal segments. Patterns still tied are equally specific and the strictest-autonomy rule applies. An earlier criterion (3) broke remaining ties by greater total segment count; it was removed because it could never fire. Every segment is either literal or wildcard, so two patterns tying on (1) and on (2) have equal totals by arithmetic, and a tie surviving both criteria is genuine equality. (Amended APRV-136.)
|
|
136
140
|
- **Deny beats allow.** If multiple rules match at equal specificity, the strictest autonomy wins (`human-only` > `manual` > `supervised-live` > `supervised-retro` > `autonomous`). That is the strictness ordering, and it binds everywhere an ordering is consulted: this tie-break, any comparator an implementation derives from it, and any surface that ranks or sorts levels. `human-only` heads it because it is strictly more scrutiny than `manual`: `manual` says a human decides and an agent then acts, `human-only` says the human acts, so a tie between the two resolves to the level under which no agent executes. (Amended APRV-185.) The declared `live_rate` is not part of the ordering: two equally specific `supervised-live` rules that disagree about a fraction are equally strict, and the tie falls through to the implementation's deterministic tie-break, so an author cannot move a rule's precedence by tuning a number. (Amended APRV-127.) Resolution selects one winning rule for autonomy, rates, approvers and limits. The explicit irreversible-action capability below is the sole exception. Among rules tied on the full specificity key the strictest autonomy wins; among rules tied on both specificity and strictness the lexicographically smallest pattern wins, so the outcome is deterministic and independent of the policy file's key order. The winning rule's `approvers` and `limits` govern, and the `approvers` and `limits` of every other matching rule are discarded. Implementations MUST NOT union, merge, or intersect the approver sets of tied rules, and MUST NOT apply a limit declared by a rule that did not win: class-scoped consumption is attributed by the winning rule's own pattern (see **The budget moment** below), so a ceiling taken from a different pattern would be compared against a window it does not scope. An author who wants several ceilings over one class writes them on the one rule that governs it. (Amended APRV-137.) For `allow_irreversible`, every matching rule tied at maximum specificity MUST explicitly set `true`; omission or `false` in any member denies the capability, irrespective of strictness or lexical tie-break. Lower-specificity matches do not participate. Defaults and failed policy loads never supply it. Where a protected `policy.edit` child inherits its parent resolution, it inherits this capability too. Protected-route floor checks MUST compare both ordinary resolution and effective resolution with `reversible: false`, as well as the existing supervision-rate constraints, so a routed child cannot weaken a protected parent through this key. (Amended APRV-317, pending sign-off.)
|
|
137
141
|
- **`approvers` binds the grant.** A class rule's `approvers` list names the people who may authorize actions of that class, and it MUST be enforced at the moment of the grant: a grant recorded by a `human:` actor the winning rule's `approvers` does not name is refused with its own reason, `actor-not-approver`, distinct from `actor-not-human`, which says the actor is not a person at all. The distinction is the repair. One says run the verb as a person; the other says ask a person the policy put in front of this class. The check binds `grant` alone. Rejection and revocation withdraw authority rather than confer it, so restricting them would leave a request standing, or an authorization live, because the wrong person tried to end it. A rule that declares no `approvers` restricts nobody, since the list is a narrowing and a narrowing nobody wrote narrows nothing; a resolution taken from `defaults.autonomy`, or produced by a failed load, carries no list for the same reason, which keeps a repository with a broken policy recoverable through its own gate and leaves attestation as the control on that path. An entry matches an actor when it equals the actor's identifier with the `human:` prefix removed, and matching is exact, with no case folding and no other normalization. The schema requires at least one entry, so a valid policy cannot declare an empty roster; where an implementation admits one it names nobody and refuses every grant. The list survives the §7 irreversibility floor, which raises autonomy and changes no roster. The control sits inside the trust boundary §11 states plainly: human identity in v0.1 is config-declared, so this defends against the wrong approver answering, and it does not defend against an actor choosing whose name to wear. (Amended APRV-137.)
|
|
138
|
-
- **`approvers[id].senders` binds a decision to an account.** An approver record MAY carry a `senders` map from channel name to the account id that channel's transport attributes a gesture to: `senders: {telegram: "12345678"}`, the numeric `callback_query.from.id` as a string. The key set is closed to channels that authenticate a sender at all (§10.3), which is `telegram` and
|
|
142
|
+
- **`approvers[id].senders` binds a decision to an account.** An approver record MAY carry a `senders` map from channel name to the account id that channel's transport attributes a gesture to: `senders: {telegram: "12345678"}`, the numeric `callback_query.from.id` as a string. The key set is closed to channels that authenticate a sender at all (§10.3), which is `telegram` and `edgeos` (amended APRV-455): `telegram` keyed on the numeric `callback_query.from.id`, `edgeos` on the EdgeOS `/humans/me` id an operator's relay reports, raw in the grammar `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` (colon-free, so never mistaken for the keyed form) or keyed exactly as `telegram` is; `web` and `cli` are schema violations, because a mapping on a surface that can authenticate nobody is a binding the runtime cannot check. The block is OPTIONAL and ADDITIVE, and its absence is the behaviour of every build before it: the decision is recorded against the identity the deciding process was configured with. That fallback is for a channel the policy maps no sender on: a channel with any mapping, raw or keyed, never falls back (below, and APRV-370 for the keyed form), and a relay never falls back even unmapped, because it holds no identity of its own (APRV-455, §10.3). (Amended APRV-454.) Declaring the first entry for a channel turns enforcement on for that whole channel — a decision arriving there with a sender the mapping does not name MUST be refused, with its own reason, `sender-unmapped`, and MUST NOT be recorded under the configured identity. Defaulting instead would be the pre-amendment behaviour wearing the amendment's clothes: a stranger in the configured chat approving as the operator. A sender id declared by two approvers is a LOAD failure, `sender-ambiguous`, so the policy does not load and every class resolves `manual` (the fail-closed rule above): one account names at most one person, and a runtime that resolved the operator's ambiguity by picking would be authoring the answer. The mapping is the operator's ASSERTION that an account belongs to a person, and what makes it accountable is that it lives in this file: `APPROVAL.md` is attested, so an agent can no more add itself as an approver's sender than as an approver. It is not, and an implementation MUST NOT present it as, proof of personhood. Matching is exact, on the id the transport reports, and never on a username, which is mutable and reusable, never a key, and never recorded. (Amended APRV-324.)
|
|
139
143
|
|
|
140
144
|
A mapping value MAY instead be written in a KEYED form, `hmac-sha256:<64 lowercase hex>`, the HMAC-SHA-256 of that same account id under an operator-held key the runtime reads from the environment and never from the policy file. It exists for the deployment that PUBLISHES its policy and its log, where the raw form discloses the account once in the file and then on every decision. A plain unkeyed digest MUST NOT be offered for this purpose: a transport account id is a short number, the space is enumerable, and a digest anybody can reverse states a privacy property it does not have. The form is per entry, so one policy MAY carry both while a migration is in progress, and the two cannot collide. Where a channel carries any keyed entry and the deciding process holds no key, every decision on that channel MUST be refused, with its own reason, `sender-key-unavailable` (§11.2), and an implementation MUST NOT fall back to comparing the observed id against the raw entries: without the key it cannot tell whether the account is also claimed by a keyed approver, so it cannot run the ambiguity check this mapping rests on. The key authenticates nothing and no authorization decision reads it; an implementation MUST NOT present losing it as a loss of safety, only as a loss of the ability to resolve accounts. (Amended APRV-370.)
|
|
141
145
|
- **The budget moment.** Exactly two event types consume budget. `approval.granted` is the authorizing moment of the manual path, and `execution.started` is the authorizing moment of the supervised and autonomous paths, which emit no approval event at all (§6.3). An `execution.started` consumes only where the same window holds no `approval.granted` bearing the same `action_key`, so a manual action that is granted and then started is charged once. Nothing else consumes: `task.registered` declares rather than authorizes, and every rejection, expiry, revocation, withdrawal and outcome event reports on a commitment already charged at authorization, or on one that was never made. A consuming event whose `est_cost_usd` is unusable contributes nothing to money limits and still counts as one action against action-count limits, since an authorization that declared no cost is still an authorization. A consuming event whose `payload.class` is unusable is invisible to class-scoped limits, which cannot show it belongs to the class, and is still charged by every global budget, which charge every authorization whatever its class. Class-scoped consumption is attributed by the winning rule's pattern rather than by string equality with the action's class, so one `financial.*` rule is one ceiling shared by every class it governs. The envelope cap of §6.2 is a lifetime total over the task's own authorizations rather than a rolling window, and it binds at intake, at grant, and at execution start, because a cap enforced at one door is a cap a caller routes around by choosing another. The double-charge guard above is scoped to the window: where a grant has aged out of the window and its `execution.started` has not, the start consumes, so an action straddling two windows is charged in both. That is the direction that charges more, and a ceiling an author could cross by waiting would be no ceiling. (Amended APRV-137.)
|
|
@@ -149,11 +153,12 @@ channels:
|
|
|
149
153
|
- **Request-volume limits.** `limits.max_pending` (per class or global): maximum simultaneously pending requests; further requests are refused at intake with reason `queue-full`. `limits.requests_per_hour` (per origin): rolling-window ceiling on request creation; excess is refused with reason `rate-limited` and logged. Defaults are generous; the purpose is a tripwire against queue-flooding and approver fatigue, which attack the human's attention rather than the action budget. Refused requests consume no budget and no attention beyond the log line. (Policy vocabulary in v0.1; enforcement lands with M4/M5.)
|
|
150
154
|
- **Agents MUST NOT be able to modify `APPROVAL.md`, the log, or channel credentials.** The reference runtime refuses to mint tokens if the policy file's mtime/hash changed without a signed `policy.updated` event from a human session. Attestation makes this mechanical: a human runs `approval policy attest`, which appends a `policy.updated` event carrying the SHA-256 of the policy file's bytes. Gate operations — request intake, grant recording, token minting — MUST refuse, with a distinct machine-readable reason, whenever the live file's hash differs from the latest attestation or no attestation exists. An edited policy is inoperative until a human re-attests it. Attestation also names the rules a decision was made under: gate-written `approval.requested` and `approval.granted` events carry `policy_sha256`, the attested hash in force when the runtime evaluated them. The field is assigned at the write boundary exactly as `ts` is (§8): no caller can supply it, and a value arriving from outside the runtime is refused. When the hash in force at grant time differs from the hash recorded on the matching request, the grant MUST refuse with its own reason, `policy-drift`, distinct from the unattested-file refusal above: the file is attested and is a different policy, so the pending request is void and must be re-requested under the rules now in force. The field is additive per §8: records written before it existed still validate and verify, and a verifier accepts both forms. (Amended APRV-118.)
|
|
151
155
|
- **An attestation stores the bytes it attests.** An attestation records a digest, and a digest alone cannot produce the file it names, so a reader asking what the policy IN FORCE actually says has nothing to read. That matters wherever a rule has to be decided against the policy in force rather than against the one being proposed, which is exactly the privileged-gesture rule of §10.3: whoever edited a proposed file must not be able to map their own account as the one entitled to attest it. Every attestation — at a terminal and from a channel alike — MUST therefore store the attested text in the payload store of §10.4 and bind its hash on the `policy.updated` record it appends, and an implementation that cannot store those bytes MUST refuse the attestation rather than append a record whose bytes are unrecoverable. A reader recovering the text MUST re-hash it against the attested digest from the verified log and MUST treat a mismatch as unrecoverable, so the store is checked rather than trusted. The bytes of the policy currently in force are not prunable under `payload_retention` at any age, for the reason a live approval's material is not: something in force binds them. A chain attested before an implementation adopted this rule carries no such binding, and a reader MUST keep its fail-closed answer for that case rather than assume. (APRV-356.)
|
|
152
|
-
- **Attestation of the gate's organs.** The organs are the harness files that install the enforcement hook (`.claude/settings*`, `.cursor/hooks.json`, `.cursor/hooks/`, `.cursor/agents/`, `.codex/hooks.json`, `.codex/hooks/`, `.codex/config.toml`): the `policy.core` surface outside the approval home and outside the policy file itself. A human attests one by content, exactly as they attest the policy file, with `approval policy attest --organ <path>`, which appends a `gate.organ.attested` event carrying the file's repository-relative path and the SHA-256 of its bytes. The verb is human-only, one path per call, and the digest is computed by the runtime from the file on disk: a caller who could supply the hash could attest bytes nobody read. The organs need this because they are `policy.core` and a policy may resolve `policy.core` to `human-only`, in which case the gate mints no record of any kind for a change to one (§11.1 invariant 9), so grant-shaped evidence for a hand edit cannot exist however carefully the edit was made. What reads these records is the after-the-fact enforcement of §10.1: a checker requiring evidence that a human saw a protected change accepts an organ whose bytes at the commit under review hash to a digest attested FOR THAT SAME PATH, and a digest attested for another path is not evidence. What does NOT read them is the gate: an organ attestation MUST NOT make an unattested policy operative, MUST NOT alter the `policy_sha256` a request or a grant is decided under, and MUST NOT satisfy any check about the policy file. Implementations SHOULD discharge that structurally, by giving the organ record its own event type, rather than by filtering a shared one in every reader. (APRV-272.)
|
|
156
|
+
- **Attestation of the gate's organs.** The organs are the harness files that install the enforcement hook (`.claude/settings*`, `.cursor/hooks.json`, `.cursor/hooks/`, `.cursor/agents/`, `.codex/hooks.json`, `.codex/hooks/`, `.codex/config.toml`): the `policy.core` surface outside the approval home and outside the policy file itself. Three organs are not repository-relative: a Hermes Agent home's `$HERMES_HOME/config.yaml`, `$HERMES_HOME/agent-hooks/` and `$HERMES_HOME/shell-hooks-allowlist.json`, with `$HERMES_HOME/.env` and `$HERMES_HOME/auth.json` beside them, a read of which classifies `account.credential`. Their protection is conditional on the spelling: the classifier recognises them by a `.hermes` path segment, so a home whose last segment is spelled otherwise is not protected, and the set is not closed under every `HERMES_HOME`. (Amended APRV-415.) A human attests one by content, exactly as they attest the policy file, with `approval policy attest --organ <path>`, which appends a `gate.organ.attested` event carrying the file's repository-relative path and the SHA-256 of its bytes. The verb is human-only, one path per call, and the digest is computed by the runtime from the file on disk: a caller who could supply the hash could attest bytes nobody read. The organs need this because they are `policy.core` and a policy may resolve `policy.core` to `human-only`, in which case the gate mints no record of any kind for a change to one (§11.1 invariant 9), so grant-shaped evidence for a hand edit cannot exist however carefully the edit was made. What reads these records is the after-the-fact enforcement of §10.1: a checker requiring evidence that a human saw a protected change accepts an organ whose bytes at the commit under review hash to a digest attested FOR THAT SAME PATH, and a digest attested for another path is not evidence. What does NOT read them is the gate: an organ attestation MUST NOT make an unattested policy operative, MUST NOT alter the `policy_sha256` a request or a grant is decided under, and MUST NOT satisfy any check about the policy file. Implementations SHOULD discharge that structurally, by giving the organ record its own event type, rather than by filtering a shared one in every reader. (APRV-272.)
|
|
153
157
|
- **Sign-off on a protected path.** A protected path is a file whose edit classifies `policy.edit` or one of its sub-classes: the built-in prose and configuration set, plus everything `protected_paths` widens to. A human signs one off by content with `approval policy attest --path <path>`, which appends a `gate.path.signed_off` event carrying the file's repository-relative path and the SHA-256 of its bytes. The verb is human-only, takes one path per call, and the digest is computed by the runtime from the file on disk, for the reasons the organ attestation above carries: a caller who could supply the hash could ratify bytes nobody read. This record is what RESOLVES the amendment-provenance suffix stated at the head of this document. Text that reached a protected file without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no more authority than a proposal until a human ratifies it; a sign-off over that file's exact bytes is the ratification, and until one exists the suffix stands. It is WHOLE-FILE evidence, and therefore weaker than a grant, which binds the exact change a human was shown. Two rules follow, and both are normative. The after-the-fact enforcement of §10.1 MUST consult a sign-off only after its search for authorization covering the change has failed, and MUST state in its finding that the verdict rests on whole-file evidence; a change some authorization does cover passes on that authorization and names it. And a sign-off is appropriate only for ratifying text a human has READ at that commit: it answers for an edit the gate never saw, and it is never a substitute for taking an edit to the gate while one still can be. The three content records stay distinct, on the reasoning the organ bullet gives: the policy file is `policy.updated`, an organ is `gate.organ.attested`, a protected path is `gate.path.signed_off`, no reader of one may be answered by another, and the verb MUST refuse the policy file, every `policy.core` surface and the log directory, each with its own machine-readable code. (APRV-338.)
|
|
154
158
|
- **`payload_retention`.** An optional top-level duration bounding how long the payload bytes in `.approval/payloads/` (§9) are kept. A payload is prunable once the action it is bound to has been in a terminal state (`executed`, `rejected`, `expired`, `revoked`) for longer than the duration. A payload whose action is not terminal is never prunable, at any age: a pending or granted approval binds to those exact bytes, and discarding them would leave a live authorization pointing at nothing. When the key is present, orphaned payloads (bytes with no recorded binding) are prunable at any age: the duration governs bound payloads and does not gate residue nothing ever bound. When the key is absent, the pruning subsystem does not run and nothing is deleted, orphaned or not; the store holds the material evidence of what a human approved, so forgetting anything is an operator's explicit choice, and an operator who never made that choice never asked the runtime to delete anything (amended APRV-49 to match the enforcement shipped in APRV-41). Pruning is performed by the daemon and by nothing else, and each removal appends a `payload.pruned` event, so a log states what its store no longer holds. The bytes an attestation binds (§5.2, §10.4) are never prunable while that attestation is the one in force: they are not bound to an action and have no terminal state, and the policy in force binds them exactly as a live approval binds its own material. (Policy vocabulary in v0.1; enforcement lands with the M5 daemon. The attestation sentence is APRV-356.)
|
|
155
159
|
- **`protected_paths`.** An optional top-level list widening the set of files whose edit is classified `policy.edit`, so a project can put its own governing documents (a specification, a constitution, a design directory) behind the gate that already stands in front of `APPROVAL.md`. Entries are repo-relative and literal: an exact file path (`SPEC.md`, `docs/constitution.md`) or a directory prefix ending in `/` (`design/`). Globs, negation, absolute paths and `..` segments are schema violations, because a pattern language the runtime half-implemented would leave an author believing a file is gated when it is not. Matching is by path segments and never resolves against a checkout, so a linked worktree and the primary answer alike: an exact path matches a candidate whose trailing segments are that path (a single-segment entry therefore matches that filename in any directory, exactly as the built-in filenames do), and a directory prefix matches a candidate containing those segments as a contiguous run. The key is ADDITIVE and can only widen: the runtime's built-in protected set (the policy file, the agent instruction files, the approval home, the harness settings, the release configuration) stays protected whatever this list says or omits, and a policy that fails to load leaves those built-ins in force while every class resolves to `manual`. (Amended APRV-107.) Every path a policy adds is `policy.edit`: a policy widening its own protected surface is naming prose and configuration, and cannot mint authority over the gate's organs, whose classes the runtime fixes. (Amended APRV-198.)
|
|
156
160
|
- **`read_scope`.** An optional top-level block, `{ roots: [...] }`, naming directories an agent's reads may stay inside, beyond the runtime's built-in ones. A read whose resolved target falls outside every root classifies `read.file.out_of_scope` (§7). The BUILT-IN roots are the GATE ROOT (the directory holding the policy file the runtime resolved), the session scratchpad the harness allots, and the system temp root; they stand whatever this block says or omits, because a runtime denied its own policy, log and workspace cannot run, and because a policy able to NARROW its own read scope would be a policy an agent could edit until nothing was gated. The key is therefore ADDITIVE in the same sense `protected_paths` is, in the opposite direction: that one may only widen what is protected, this one may only widen what is permitted, and neither may shrink. An entry is absolute or resolved against the gate root; globs, negation and values requiring expansion are schema violations, for the reason `protected_paths` rejects them. Comparison is by path segment after the runtime RESOLVES the candidate on disk, so a symlink inside a root that points out of it is outside. Omitting the block entirely is the ordinary case and needs no grammar: a session whose policy sits in one project directory is confined to that project. A policy that fails to load leaves the built-in roots in force while every class resolves to `manual`. (Amended APRV-347.)
|
|
161
|
+
- **`daemons`.** An optional top-level list of the daemon ids permitted to append to the log (§8, the writing daemon), read only from the attested policy: an absent key is no restriction, and an empty list admits no daemon at all. (Amended APRV-454.)
|
|
157
162
|
- **`vault.passphrase_env`.** An optional top-level key naming the environment variable that holds the passphrase for the credential vault of §10.4. The policy carries the variable's *name*, never the passphrase, on the same reasoning as `audit.sampling_secret_env` and the channel credential keys (`chat_id_env`, `token_env`): agents may read `APPROVAL.md`, and a passphrase they can read is a vault they can open. The passphrase MUST NOT appear in the log, in any command's output, or in any error message. When the key is absent the runtime reads `APPROVAL_VAULT_PASSPHRASE`; a variable name is not a permission, so an unnamed one MUST NOT lock an operator out of credentials they created, and a policy that fails to load leaves the default in force for this key alone. (Amended APRV-68, which implements the reference vault.)
|
|
158
163
|
- **`channels.telegram.token_env` and `channels.telegram.chat_id_env`** are honoured by the runtime exactly as `audit.sampling_secret_env` and `vault.passphrase_env` are: the policy carries the variable's *name*, the runtime reads the value from the environment under that name, and a policy that declares neither (or fails to load) gets the reference runtime's defaults, `APPROVAL_TG_TOKEN` and `APPROVAL_TG_CHAT`. (Amended APRV-72.)
|
|
159
164
|
- **The environment map.** Every key above that ends in `_env` carries a variable's name rather than its value, which leaves an operator with several values to establish before any gate operation works and nowhere to record where those values live. `.approval/env` is that place: a SOURCE MAP, sibling of the log directory as the vault is, one `KEY=VALUE` per line with `#` comments and blank lines ignored, no quoting and no interpolation. The VALUE says where the value lives, in one of four forms: `keychain:<service>` (macOS, `security find-generic-password -a "$USER" -s <service> -w`), `secret-service:<label>` (Linux desktop, `secret-tool lookup approval <label>`), `env:` (inherited from the ambient environment, and reported as inherited), or a bare literal. A literal is permitted and is ALWAYS reported as plaintext, by every diagnostic, because a rule people route around is not a control: an operator told plainly that their token sits in a file in the working tree can weigh that, while an operator forbidden from writing it there writes it into a shell profile where nothing can see it to say so. Values are never passed to a helper in an argv; they arrive on its stdout. The file MUST be mode 0600 (anything else is refused, with the `chmod` to run) and `approval init` adds it to `.gitignore`. **No command loads this file implicitly. A single verb, `approval env`, resolves it and emits an export block for a shell to evaluate, so the environment a gate operation runs under is always one a human established** (§11, §11.1 invariant 7). An already-exported value always wins over the file, and an absent file is not an error. The variables answered for are the human identity variable, the two Telegram variables, the vault passphrase, the sampling secret when the policy names one, and any other string-valued `_env` key in the policy. (Amended APRV-73.) The explicitly invoked human-only quickstart ceremony (§10.1) MAY resolve the declared sources of the fresh instance it just created solely for its health-preflight child. This is an explicit setup operation, not ambient loading by a gate, listener, agent tool or later process. It MUST NOT import another instance's ambient approval credentials, change the calling shell, or attest bytes that were not shown and confirmed. The operator still establishes the later gate environment through `approval env`. (Amended APRV-309, pending sign-off.)
|
|
@@ -246,6 +251,8 @@ proposed ──▶ awaiting ──▶ approved ──▶ executed
|
|
|
246
251
|
|
|
247
252
|
`approval.*` events are exclusive to the manual path. All but one of them record a human decision; the exception is `approval.withdrawn`, which records the requester taking its question back. A pending request MAY be withdrawn by the party that opened it, and by nobody else: the actor MUST equal the actor of the matching `approval.requested`, and MUST NOT be `system:`, because the runtime's way of ending a request it was not asked to end is the TTL. Withdrawal is legal only while the request is pending, judged exactly as a decision is judged (a lapsed TTL is not pending, event or no event), and it is terminal: a grant, rejection, or revocation attempted afterwards is refused, and so is a second withdrawal. The reason is recorded and closed to three values, `timeout`, `cancelled`, `superseded`. Withdrawal exists because human attention is the audit budget (§11) and a decision nobody can consume must not be solicited: a requester whose wait has elapsed can no longer act on an answer, so it retracts the question rather than leaving it standing until the TTL. Channels MUST NOT present a withdrawn request as pending, and a channel that has already delivered one SHOULD retract or annotate that delivery. Nothing about a withdrawal reduces scrutiny: it removes an authorization that was never granted, and a withdrawn action that is still wanted is requested again, which is a new request with a new decision. (Amended APRV-106.)
|
|
248
253
|
|
|
254
|
+
**A requester may shorten its own question, and only that.** A request opened by a harness hook MAY declare `harness_cap_ms`: the ceiling the harness imposes on the hook process that asked. The harness kills that process at the ceiling and reads the killed hook as a non-blocking error, so a question that outlives its asker collects a decision authorizing a call nobody holds. Where the field is present the request is judged against the SHORTER of the policy's TTL and the cap less an implementation-defined margin, and never anything longer. This is not a duration the runtime invented: the cap is stated by the party under oversight about its own process, a record carrying it MUST also declare `execution: "harness"`, and taking the minimum makes it structurally incapable of extending any deadline — so it bounds a request under a policy that declares no `approval_ttl` without the runtime supplying a number of its own. The margin MUST exceed the implementation's own expiry-sweep interval, so that the `approval.expired` record is written before the harness stops listening. (Amended APRV-423.)
|
|
255
|
+
|
|
249
256
|
Actions whose class resolves to `supervised` or `autonomous` emit no `approval.requested` or `approval.granted`; their execution is recorded by `execution.*` events, and supervised actions are additionally eligible for `audit.sampled` and `audit.reviewed`. A `supervised-live` action the live draw selects (§5.2) is the one exception, and it is not really one: the draw sends it down the manual path, so it emits exactly the events a manual action emits, byte for byte, and it is not additionally sampled for retrospective review because a person already answered it. (Amended APRV-127.)
|
|
250
257
|
|
|
251
258
|
Two payload fields serve sealed token delivery (§10.4), and both are OPTIONAL and additive: under the default `token_delivery: manual` neither appears and a record is byte-identical to one written before they existed. `approval.requested` MAY carry `token_recipient_key`, the public half of an ephemeral X25519 keypair the requester minted for this one request and whose private half never leaves the requesting machine. `approval.granted` MAY carry `token_sealed` beside `token_sha256`: the raw token encrypted to that key. The recipient key is an ADDRESS and not a credential — it can receive a token and cannot mint, forge, rebind, or respend one — so a requester supplying it changes who may READ a minted token and changes nothing about whether one is minted. Channels MUST render the recipient key as a COMPUTED field, because an approver is entitled to know that granting will put a readable token in the requesting process's hands rather than only on the screen in front of them. (Amended APRV-105.)
|
|
@@ -339,6 +346,8 @@ For `record.*` classes, grant means adoption: the action proposes a write to a s
|
|
|
339
346
|
- **A refused gesture that is not a decision.** `audit.gesture_refused` records a human gesture that a decision surface refused before any verb ran, where the gesture is not a grant, a reject or a revoke and therefore cannot be an `audit.decision_refused`: a checkpoint signature (§9) and a retrospective review (§10.1) are the two at v0.1. The refusal record that exists requires an `action_key` and a `decision`, and neither gesture has either, so an implementation MUST NOT manufacture them to reuse it; without a type of its own, an attempt made from an account the operator did not map spends a person's attention and leaves no trace in the log at all. The record carries a `system:` actor and never any other, for the reason `audit.dark_session` does, and the surface in `channel`; its payload carries `gesture` and `code`, each drawn from a closed set, an optional `message`, the observed `sender` where the surface authenticated one, and `actor` — the person it was refused for, `human:` — which is REQUIRED exactly when no `sender` is present, the same conditional the decision record takes. It is AUDIT TIER on the strict terms that record set: it authorizes nothing, settles no request, charges no budget, is not sampled, and no enforcement path reads it. Refusals handed to agents stay unlogged, as they do for a decision. (APRV-355.)
|
|
340
347
|
- **A question answered by something other than the gate.** `audit.question_preempted` records that another system resolved an approval question this gate exists to ask, before the gate was asked. The first instance is a harness-side auto-reviewer that answers with a model call and discloses it afterwards (`docs/codex-app-server-bridge.md`, question 3). None of the three audit records above it carries the fact: `audit.dark_session` is a sweep over activity with no records beside it, and both refusal records are about a human gesture; here the question existed, the gate would have asked it, and somebody else answered first, so a log that stayed silent would be indistinguishable from a session that never wanted to act. The record carries a `system:` actor and never any other, for the reason `audit.dark_session` does, and both parties to the event here are machines, which makes that rule sharper rather than softer. Its payload carries `source`, drawn from a closed set naming who answered, and `question`, the answering party's OWN identifiers for it (`id` required, with `method`, `thread` and `turn` where the disclosure named them), plus an optional `verdict`, verbatim in the answering party's vocabulary. An implementation MUST NOT default the verdict: a record that stated one because the disclosure named none would be this runtime inventing another party's decision. It is AUDIT TIER on the strict terms `audit.decision_refused` set: it authorizes nothing, settles no request, charges no budget, is not sampled, and no enforcement path reads it. A health check MAY read it, which is a diagnosis rather than an enforcement, and a health row that reports no such record MUST NOT be read as a statement that the other party is disabled. (APRV-378.)
|
|
341
348
|
- **The provider reference.** An `execution.completed` MAY carry `payload.provider_ref`, an object of exactly two non-empty strings and no others: `adapter`, the name of the adapter that executed the action, and `id`, the identifier the provider's own record files the effect under (an AgentMail `message_id`, a queue's receipt handle). Both are written by the runtime at the write boundary. `adapter` is the registered adapter's name, which the runtime already holds; `id` is lifted from what the adapter returned for that call and passes the same redaction sweep as every other string it returned (§11.1 invariant 3). The field exists to be a join key. `approval coverage` (§10.1) reads a provider's own record of what it did and asks whether this log ever saw each effect, and with no reference the strongest available answer is a record of a matching class inside the effect's window, so one gated send covers an ungated one of the same class beside it. With a reference, a record that names an effect by id is evidence about that exact effect, and an effect the provider recorded that no record names is a gap no window can close. It authorizes nothing: no grant, budget, verdict, or refusal reads it back, so §11.1 invariant 4 is untouched by a value that came in part from the far side. It is bounded in length and restricted to printable characters, so that an identifier cannot become a place to put a message body. It is OPTIONAL and additive: an execution whose adapter names no reference records none, every record written before the field existed still validates and still verifies, and a reader treats absence as the pre-amendment behaviour. An id the redaction sweep touched is omitted rather than recorded, because a redacted identifier matches nothing and would read like one that does. (Amended APRV-251.)
|
|
349
|
+
- **The writing daemon.** A record appended by a daemon MAY carry `daemon`, the id of the daemon instance that wrote it, so that a log hosted by one party for another states which host process acted. The value is the id declared in that process's launch environment or one derived from the instance the log belongs to; it is stamped by the runtime at the write boundary and is never a caller's parameter. It is OPTIONAL and additive: a record written without it validates and verifies unchanged, and its absence is absence rather than a claim that no daemon wrote the record. An implementation MAY let an attested policy list the ids permitted to append (`daemons`, §5.2) and MUST then refuse an append from an unlisted id at the write boundary with a distinct code (`daemon-not-allowed` in the reference runtime), leaving the file byte-identical. The id is self-reported, so §11.1 invariant 4 binds it completely: an implementation MUST NOT let a listed id reduce any scrutiny, and MUST NOT read the field as an input to a verdict, an autonomy, a budget, a floor, a sampling draw or a token. (Amended APRV-383.)
|
|
350
|
+
- **The policy a harness start was resolved under.** An `execution.started` that a harness path records (`execution: "harness"`) carries `payload.policy_sha256`, the SHA-256 of the attested policy bytes the write boundary re-checked the action's class against when it recorded the start, taken from the same attestation check and the same single read of the policy file that the write boundary's class resolution used. The runtime writes it at the write boundary, as it writes `ts`, and no caller parameter, option or field of the action's payload supplies it; a value of that name arriving by any of those routes is ignored. A start under a policy that is not attested, or whose bytes changed since attestation, is refused before anything is appended, so no stamp names bytes nobody attested. A policy-authorized start (autonomous, supervised, an unselected supervised-live draw) has no other record naming its policy, and the field lets a reader of the start alone say which rules were in force when it was recorded. A granted start, which could already recover its policy through `grant_seq`, carries it from the same code path, and there it equals the grant's own pin wherever the grant carries one, since a spend under a different attested policy is refused `policy-drift`. Where a harness adapter computes its verdict from an earlier read of the policy than the write boundary's, a re-attestation between the two leaves the stamp naming the policy in force at the append; an implementation SHOULD carry the verdict's attested hash to the write boundary and refuse a mismatch. It authorizes nothing: no verdict, budget, draw, loop floor or refusal reads it back, so §11.1 invariant 4 is untouched. It is covered by the record hash like every other payload field. It is OPTIONAL and additive: every record written before the field existed still validates and still verifies, a start written by a non-harness path (`approval run`, an adapter's `act`) records none at v0.1, and a reader treats absence as the pre-amendment behaviour, reconstructing the policy from the latest `policy.updated` before the record's seq. (Amended APRV-447.)
|
|
342
351
|
- Events MUST validate against the JSON Schemas in `schema/` before append. Validation at the write boundary is itself a control: an agent physically cannot request execution without declaring a class, key, and cost estimate.
|
|
343
352
|
- Every record MUST carry an explicit hash-scheme identifier, `alg`. Version 0.1 defines exactly one value: `sha256/jcs`, meaning SHA-256 over the RFC 8785 (JCS) canonical serialization of the record with `prev` included. Verifiers MUST reject records whose `alg` is missing or unrecognized. Records with different `alg` values MAY coexist in one log, so a future scheme change is a migration, never a schism.
|
|
344
353
|
- Actor identifiers use exactly three prefixes: `human:` for decisions made by a person, `agent:` for actions proposed or performed by an agent, and `system:` for runtime-originated events such as `approval.expired`. Verifiers MUST reject unrecognized prefixes.
|
|
@@ -350,7 +359,7 @@ Events written through the gate (`approval.*`, `execution.*`, `budget.*`, `audit
|
|
|
350
359
|
1. **The queue** (`.approval/QUEUE.md`): a rendered, read-only markdown view of pending requests (task, actions, declared effects, cost, TTL countdown) plus the sampled-audit backlog. Regenerated on every relevant event. This is the screenshot; it is never the truth.
|
|
351
360
|
2. **The index** (`.approval/index.sqlite`): rebuilt from the log (`approval reindex`), used for queries like "pending manual approvals touching `financial.*`, oldest first." Any SQLite client, including DuckDB, can read it; deleting it loses nothing.
|
|
352
361
|
|
|
353
|
-
`.approval/payloads/` sits beside these as a content-addressed material store: the bytes approvals bind to, keyed by their hash. It is not a projection, and unlike them it cannot be rebuilt from the log.
|
|
362
|
+
`.approval/payloads/` sits beside these as a content-addressed material store: the bytes approvals bind to, keyed by their hash. It is not a projection, and unlike them it cannot be rebuilt from the log. Every file an implementation writes there MUST be readable by its owner alone (mode `0600` on POSIX), inside a directory only its owner may list (`0700`), whatever the process umask and whatever mode an existing directory had; a store directory that cannot be narrowed refuses the write (`payload-store-failed`). A proposal's payload is a person's own words, and a request whose bytes any local user could read would publish them to everyone on the host before anyone approved anything. `approval init` lists the store in the `.gitignore` lines it scaffolds; an operator who wants the bytes in version history removes that line. (Amended APRV-445.)
|
|
354
363
|
|
|
355
364
|
**Anchoring.** A verifier MAY additionally compare the working log against the newest copy of it reachable from version control (the trunk, a records branch, or an advance anchor), and a runtime that offers the comparison MUST report a working log whose byte prefix or whose record at the anchored head does not match that copy as a distinct refusal (`anchor-diverged` in the reference runtime), MUST report the absence of any committed copy as a skip naming the revs it consulted rather than as a pass, and MUST NOT fetch to obtain one. The chain is unkeyed, so a party with write access to the log can truncate it and recompute a self-consistent chain that a cold walk cannot distinguish from the original (the boundary the conformance suite states as `chain-verification/truncation-unanchored`); a copy that party did not write is the only witness that survives them. (Amended APRV-219.)
|
|
356
365
|
|
|
@@ -373,7 +382,16 @@ approval register <task-file> # validate envelope, append task.registered
|
|
|
373
382
|
approval request <task> [--action <key>] # -> approval.requested (manual classes;
|
|
374
383
|
# supervised/autonomous proceed directly
|
|
375
384
|
# to execution)
|
|
385
|
+
approval propose --class <c> --key <k> --summary <s> --payload-json <json>
|
|
386
|
+
# register + request in one call, payload
|
|
387
|
+
# inline, class opened to agents by name
|
|
388
|
+
# (agent_may_request); retry-safe
|
|
389
|
+
approval start <task> --action <k> --payload-json <json>
|
|
390
|
+
# -> execution.started for a proposed
|
|
391
|
+
# action its requester carries out
|
|
376
392
|
approval wait <task> --timeout 6h # block until decided; exit code = decision
|
|
393
|
+
# (--timeout 0: current state, no sleep;
|
|
394
|
+
# 7 = void: the policy changed, ask again)
|
|
377
395
|
approval grant|reject|revoke <request-id> [--note …] # human-only verbs
|
|
378
396
|
approval token <action-key> # report execution-token status (the token itself
|
|
379
397
|
# is printed once, by `grant`; only its hash is logged)
|
|
@@ -409,12 +427,29 @@ approval hook cursor # gate a local Cursor Agent: native
|
|
|
409
427
|
# allow/deny out (never "ask")
|
|
410
428
|
approval hook codex # experimental native Codex hook: direct apply_patch
|
|
411
429
|
# only; Bash is explicitly refused (§10.6)
|
|
430
|
+
approval hook hermes # gate a Hermes Agent session: snake_case
|
|
431
|
+
# pre_tool_call/post_tool_call JSON in, ONE
|
|
432
|
+
# dialect out ({action,message}), deny at
|
|
433
|
+
# EXIT 2 which blocks unconditionally. A
|
|
434
|
+
# per-entry fail_closed BLOCKS on hook
|
|
435
|
+
# crash, timeout and unparseable output
|
|
436
|
+
# (observed on main 118984d7; its default
|
|
437
|
+
# is false, and v0.21.3 ignores it
|
|
438
|
+
# silently). execute_code is refused
|
|
439
|
+
# outright, and so is any call whose
|
|
440
|
+
# effective directory the event does not
|
|
441
|
+
# carry. See docs/hermes-hook.md
|
|
412
442
|
approval hook classify -- <cmd…> # what the classifier makes of a command
|
|
413
443
|
approval reindex | render
|
|
414
444
|
approval daemon run # the §10.2 watch loop, in the foreground
|
|
415
445
|
approval mcp serve # the §10.5 MCP server over stdio, in the
|
|
416
446
|
# foreground, under one agent identity the
|
|
417
447
|
# operator fixes when they start it
|
|
448
|
+
approval serve # the §10.7 HTTP transport of the same
|
|
449
|
+
# agent-facing surface, in the foreground,
|
|
450
|
+
# under one agent identity the operator
|
|
451
|
+
# fixes when they start it; two bearer
|
|
452
|
+
# credentials from the launch environment
|
|
418
453
|
approval journal write --message "<text>" | - # an ungated, local, free-text
|
|
419
454
|
approval journal read [--limit <n>] # channel: never classified,
|
|
420
455
|
# never approvable, never in
|
|
@@ -436,6 +471,8 @@ Machine-readable output: every command supports `--json`; schemas for inputs and
|
|
|
436
471
|
|
|
437
472
|
Six entries in this block are amendments awaiting sign-off: `setup` (Amended APRV-79), `env` (Amended APRV-73), `mcp serve` (Amended APRV-103), `hook claude-code` (Amended APRV-82), `hook cursor` (Amended APRV-133), and the `log sync` / `log advance` pair described immediately below. `values` and `feedback` (Amended APRV-237) are two more, the human-to-agent pair that mirrors the journal.
|
|
438
473
|
|
|
474
|
+
The `hook hermes` entry is not one of those; its evidence is in `docs/hermes-hook.md`. (Amended APRV-415.)
|
|
475
|
+
|
|
439
476
|
**Moving the log file: `log sync` and `log advance`.** The log is append-only, and a repository that carries one still has to pull it, commit it, and push it. Those two operations are part of the runtime rather than of the operator's shell, because performing them by hand rewinds the log file through git state while an appender holds it open, which produces two chains where there was one. Both verbs run in the primary checkout only and refuse elsewhere with a distinct machine-readable code. Both hold the append lockfile for the whole of their operation rather than for a single append: an append landing partway through either one is the interleaving that forks a chain.
|
|
440
477
|
|
|
441
478
|
`log sync` verifies the chain, copies the log aside inside the approval home (implementations MUST NOT route the log through `git stash` or any other git state mutation), fast-forwards the checkout (refusing anything that is not a fast-forward), and then **reconciles**: the committed chain MUST be a prefix of the snapshot, equal to it, or an extension of it. A prefix means the snapshot is restored, since the longer chain contains the shorter one whole; an extension means the pulled file is kept, for the same reason in the other direction; equality means there is nothing to do. Anything else is a fork, and the verb MUST refuse it, naming both heads and the first sequence number at which the chains disagree. Implementations MUST NOT merge or re-chain two chains under any circumstance: re-chaining fabricates records nobody wrote. Projections are rebuilt from the reconciled log and never restored from before the pull. Projections are also disposable. Implementations MUST discard the working copy of every projection the current commit or the incoming tree carries (the queue, and the index when the repository tracks it) immediately before the fast-forward, and MUST NOT refuse a synchronization because a projection is dirty. A projection is a rendering of the log with no truth of its own, it is regenerated by writers that hold no lock, and a stale rendering is never a reason to refuse a pull of the log it renders. Discarding it early enough for a concurrent renderer to undo is the same as not discarding it, so the discard is the last operation before the merge, and a merge that still fails with a projection dirty is retried once before it refuses. (Amended APRV-292.) Every failure at every step restores the snapshot before the verb exits, so a working log is never left in a partial state.
|
|
@@ -456,6 +493,10 @@ A request MAY declare self-delivery, which mints the sealed delivery address reg
|
|
|
456
493
|
|
|
457
494
|
**Constrained Codex preparation.** The opt-in `approval codex` command family prepares a separately installed execution boundary; package installation MUST NOT activate hooks, managed configuration, services, principals, credentials or policy. `prepare` writes only a fresh inert review bundle containing an explicit instance manifest, configuration and launcher templates, installation instructions and file digests. `setup --check` verifies the bundle's exact contents and reports its inert status; it does not install it. `doctor --strict` distinguishes manifest validity and filesystem custody from demonstrated enforcement, rejects unknown runtime versions or platforms and unsafe or ambiguous paths, and MUST NOT execute a manifest-selected binary before establishing its trusted custody. The manifest pins disjoint workspace, primary gate and installation roots, exact executable and interpreter paths and invocation, and distinct non-root Codex, broker and runner principals. Trusted runtime and configuration paths must be outside agent write custody. Missing components, unchecked confinement, or an unverified installation MUST remain not-ready, and `start` or `serve` MUST refuse rather than fall back to the ordinary broad MCP server or unconstrained execution. This command family is excluded from broad MCP publication. Neither a passing bundle check nor files present on disk proves that the session is bound to the gate; that claim requires verified execution-boundary behavior for the installed version and all exposed capabilities. (Amended APRV-325.1, pending sign-off.)
|
|
458
495
|
|
|
496
|
+
**Proposals: an agent that declares and asks in one call.** `approval request` reads an action's class from the `task.registered` record, so an agent cannot call a spend a read; that rule assumes the registrant and the requester are different parties, or at least that the registrant wrote a file a human could read. An agent in a sandbox, reaching the gate through `approval serve` with the agent credential, has no file on the gate's machine and no path flag it may use. `approval propose --class <c> --key <k> --summary <s> --payload-json <json>` serves it: the payload is a JSON object carried as the flag's value (implementations MUST refuse one larger than 256 KiB of UTF-8 as `payload-too-large`), the class is bounded by the operator instead of by a prior registration (§5.2, agent-requestable classes), and the verb registers a one-action envelope (`origin.app: approval-propose`) and requests it in one call, with `execution: "harness"` so the grant mints no token. The task id is derived, `propose:` followed by the first 32 hex digits of the SHA-256 over the RFC 8785 serialization of `[actor, class, key]`, so a retry lands on its own registration: the same class, key and bytes append nothing and report the request's current state, while the same key with different bytes is refused (`duplicate-request` while a request is live, `payload-mismatch` otherwise). A withdrawn or expired proposal is asked again by the same call. A class that resolves autonomous or supervised is registered and answered at once with no approval record, exactly as `request` answers it. `approval start <task> --action <key> --payload-json <json>` then records the execution: it is requester-only (`not-requester`), the presented bytes MUST hash to the registered binding (`payload-mismatch`), and it writes through the two existing harness spenders and nothing else, spending a harness grant where the key carries a request and recording a policy-authorized start where it carries none, so every refusal those two make (`not-granted` for a manual class, `already-executed` for a second start) applies unchanged. A poller on a shared server uses `approval wait <task> --timeout 0`, which reads the current state once and never sleeps, because a transport that serializes its verbs would otherwise hold every other call for the length of the wait. (Amended APRV-445.)
|
|
497
|
+
|
|
498
|
+
The key MUST begin with `<class>:` followed by an id of printable characters with no whitespace or control characters (`key-class-mismatch` otherwise), so a key is never read back under a class its record does not carry; a consumer reads the class from the record. The key is at most 1 KiB and the summary at most 4 KiB of UTF-8; a lone UTF-16 surrogate in either or anywhere in the payload, and a number the JSON parse made non-finite, are usage errors; an integer beyond 2^53 is hashed as the double it parses to. A plain `request` on a `propose:` task is refused (`task-is-proposal`), as is a task FILE claiming an id in that namespace, and `start` is refused on any task `propose` did not register (`task-not-proposal`). A retry answers from the log while the request still means something: pending, granted and spendable, rejected or revoked, or executed (`state: executed`). It asks again under the same registration when the request can no longer be answered usefully: withdrawn, expired, a GRANT whose request window has lapsed (a grant has no window of its own, so a tap at hour 71 of a 72 h window authorizes nothing at hour 73), or a grant or pending request pinned to a policy hash that is no longer the attested one, which the requester first withdraws (`superseded`). `wait` reports the same standing rather than the bare recorded state: such a grant reads `expired` (exit 3), and a pending request pinned to a superseded policy (any task), a HARNESS grant so pinned (every proposal; its spend refuses `policy-drift`), or a request the runtime withdrew for `policy-drift`, reads `void` with its own exit code, 7, whose repair is to ask again. A token grant spent through `approval run` is not void after a re-attest, because that spend does not compare policy hashes; it reads `granted`. Every re-ask is re-checked inside the append it leads to: the withdrawal only while the pending request is still void, and the new request only while no usable grant and no human refusal stands, so concurrent identical calls ask once. A policy-path `start` after the request ended reads `executed` to `wait`. `start` binds the key to the grant path only while its request is pending or granted and unspent; a withdrawn, expired, rejected or revoked request leaves an action the policy now authorizes to the policy path. A `supervised-live` class SHOULD NOT be opened to agents: the live draw would decide per proposal whether a human is asked, which a resident cannot predict and the requester cannot explain. (Amended APRV-445.)
|
|
499
|
+
|
|
459
500
|
### 10.2 Daemon
|
|
460
501
|
|
|
461
502
|
`approvald` watches the backlog folder and the log: validates new/changed envelopes, applies policy, dispatches channel notifications, expires TTLs, samples supervised actions for audit, re-renders projections, and (optionally) polls upstream sources. Loop safety: three consecutive `execution.failed` events for one task escalate to `manual` regardless of policy.
|
|
@@ -482,19 +523,22 @@ The reference runtime ships the daemon as a CLI verb, `approval daemon run`, run
|
|
|
482
523
|
|
|
483
524
|
### 10.3 Channels
|
|
484
525
|
|
|
485
|
-
Interface: `notify(request) -> delivery_id`, `poll()/webhook() -> decision`. Decisions become log events; channels hold no state. The reference runtime's interactive writer for a channel's transport credential is `approval setup channel <name>`, a separate noun from `approval setup adapter <name>` (§10.4) because the two fill different stores for the reason §4 separates the terms: a channel holds no state, so what it needs is the credential that lets the runtime reach a human, recorded in the environment source map of §5.2, while an adapter holds the credentials a side effect spends and those belong in the vault. (Amended APRV-79.) v0.1 ships **cli** (zero-config prompt), **web** (local queue page with grant/reject),
|
|
526
|
+
Interface: `notify(request) -> delivery_id`, `poll()/webhook() -> decision`. Decisions become log events; channels hold no state. The reference runtime's interactive writer for a channel's transport credential is `approval setup channel <name>`, a separate noun from `approval setup adapter <name>` (§10.4) because the two fill different stores for the reason §4 separates the terms: a channel holds no state, so what it needs is the credential that lets the runtime reach a human, recorded in the environment source map of §5.2, while an adapter holds the credentials a side effect spends and those belong in the vault. (Amended APRV-79.) v0.1 ships **cli** (zero-config prompt), **web** (local queue page with grant/reject), **telegram** (reference push channel: message with declared effects + inline Approve/Reject buttons; the callback is verified against the configured chat id, and, where the policy declares a sender mapping, against the account id the transport attributes it to), and **edgeos** (the relay-only channel APRV-455 added: an operator's authenticated relay reports the EdgeOS account a gesture came from, as the table below states). (Amended APRV-454.) Channel breadth is explicitly out of scope; HumanLayer exists for Slack/email/SMS enterprises.
|
|
486
527
|
|
|
487
528
|
**What each channel authenticates (amended APRV-324).** The sentence above used to say the Telegram callback was "verified against approver identity". It was not: `routeCallback` compared `message.chat.id` to the configured chat and never read the update's `from` object at all, so the verification was of a CONVERSATION and the identity on the record was the one the listener process was launched with. The correction and the capability arrive together, and the table states what a transport can prove rather than what an implementation would like it to.
|
|
488
529
|
|
|
489
530
|
| channel | what arrives | what it proves | what it does not |
|
|
490
531
|
|---|---|---|---|
|
|
491
532
|
| telegram | `callback_query.from.id`, `from.username`, `message.chat.id` | that Telegram attributes this tap to that account, in a chat the operator configured | that the account belongs to the person the operator thinks; nothing about device possession; nothing if the bot token leaks. `from.username` proves nothing durable at all: usernames are mutable and reusable, so they are never a mapping key and are never recorded |
|
|
533
|
+
| edgeos | a POST to `approval channel relay` carrying the relay secret, a gesture, and the EdgeOS `/humans/me` id of the session that made it | that the operator's relay (the holder of `APPROVAL_RELAY_SECRET`) attributes this gesture to that EdgeOS account | that the account belongs to the person the operator thinks; anything at all if the relay secret leaks, because the daemon trusts the relay's attribution. That is operator trust, stated in APRV-422's words: no hosted service has authority over decisions. The relay carries a person's gesture and decides nothing. (Amended APRV-455.) |
|
|
492
534
|
| web | an unauthenticated form POST | nothing | no session, no origin binding, no identity of any kind |
|
|
493
535
|
| cli | the process environment | local machine control | who is at the keyboard |
|
|
494
536
|
|
|
537
|
+
**A channel MAY receive updates by webhook instead of by polling (amended APRV-424).** The two are alternatives per transport account and an implementation MUST NOT run both against one: a runtime that did would answer a human's gesture from whichever arrival reached it first. Where a channel receives by webhook it MUST authenticate each post with a shared secret the runtime registered and the transport echoes, taken from the launch environment (§11.1 invariant 7) and never from a file the runtime discovered; a post whose secret does not match MUST be refused, MUST record no decision, and MUST NOT append to the log, for the reason an ignored callback does not: an endpoint reachable by anyone who can route to it would otherwise let a stranger grow the record a human is asked to trust. Nothing else about the gesture changes. The sender the transport attributes it to, the mapping of §5.2, the decision event, and the annotation of a settled delivery MUST be the same code path the polling arrival uses, and a conforming implementation SHOULD prove that rather than assert it. A refusal of either arrival MUST be machine-readable and distinct (§11.1 invariant 6).
|
|
538
|
+
|
|
495
539
|
**Every gesture a channel collects, not only decisions (amended APRV-324).** A push channel collects four kinds of human act, and a mapping that reached only the first would leave the other three deciding under the listener's own identity — which is the whole of what the mapping removes, kept on the most privileged gestures. So the rule binds all four. **Decisions** (grant, reject) resolve against the policy in force, as above. **Retrospective reviews** resolve the same way: a review confers no authority and is still a human's observation that §5.3's guidance surfaces hand to agents, so a review attributed to the wrong person is guidance in somebody else's name. **Checkpoint signatures** resolve the same way, because a signature says this log's head is what this person saw. **Attestation answers** are the exception, and are stricter rather than looser: the amendment a tap attests may itself add, remove or repoint the sender mapping, so resolving it against the file being attested would let whoever wrote that file name the account that approves their own edit. An attestation answer MUST therefore resolve against the policy **in force** — the bytes the latest attestation names — and never against the bytes it is attesting. Where those bytes cannot be recovered (an attestation records only their digest), or where the amendment changes the mapping and the policy in force maps no sender for that channel, the answer MUST be refused with its own reason, `attest-requires-terminal`: an amendment that introduces the identity system cannot be signed for by the identity system it introduces, and a terminal authenticates no sender and is where a `policy.core` edit happens anyway. A refused gesture of any of these kinds records no authoritative event. A checkpoint signature and a review MUST additionally resolve only against an **attested** policy: a decision is protected afterwards by the grant's own attestation and drift checks, and these two act and append with no such check behind them, so an edited `APPROVAL.md` would otherwise change who may sign or review from a phone before any human had attested it — which §5.2 says a policy cannot do. Where the bytes on disk are not the bytes in force, such a gesture MUST be refused with the existing `policy-not-attested` reason and MUST record nothing; a terminal, which authenticates no sender, is unaffected and remains the repair.
|
|
496
540
|
|
|
497
|
-
So
|
|
541
|
+
So two channels have a sender fact worth mapping, and an implementation MUST treat it as evidence about an account rather than about a person. A RELAY (amended APRV-455) is a channel whose transport is an operator's authenticated service rather than a third-party API: it holds no human identity of its own, so the configured-identity fallback of the compatibility promise MUST NOT apply to it. Where the attested policy maps no sender for the relay's channel, or maps a different one, its gesture MUST be refused `sender-unmapped`, with the one audit record that refusal writes, and MUST NOT be recorded under any identity the relay process was started with. A relay's own transport refusals (a missing or wrong secret, a malformed or replayed post) MUST append nothing; they are facts about the transport. Where the policy declares `approvers.<id>.senders` for a channel (§5.2), a decision collected on that channel MUST be recorded against the human the attested mapping names, and a sender the mapping does not name MUST be refused rather than attributed to the listener's own identity. Where the policy declares none, the decision is recorded against the configured identity exactly as it was before this amendment, which is the compatibility promise: a deployment that never writes a `senders` key never changes behaviour. The resolution happens at the channel boundary, before the gate's own verbs run, so `approvers`, `actor-not-approver` and every other authorization check of §5.2 apply unchanged to an identity that is now better evidenced. A channel that authenticates no sender MUST NOT supply one, and a `sender` field in a request body, a message, a callback payload or a username is never an identity (§11.1 invariant 4). (Amended APRV-324.)
|
|
498
542
|
|
|
499
543
|
Where dispatch runs (amended APRV-55). §10.2 gives the daemon the job of dispatching channel notifications. At v0.1 the reference runtime performs that dispatch in the channel listener instead, on every poll cycle, re-deriving the pending set from the verified log each time and sending only what that listener process has not sent yet. The listener already holds the channel credential and the approver identity, dispatch appends nothing, and a network round-trip inside the daemon's pass would couple TTL expiry and write-back to a chat service's availability. This is an implementation placement and not a change to the daemon's stated role: a later build MAY move dispatch into the daemon with no change to any event, projection, or channel interface. A listener's record of what it has already sent is not state in this section's sense, since it is never read as an answer to what is pending; its loss MUST degrade to a re-send (a duplicate in front of the approver), never to a pending request nobody is shown. Pull channels need no dispatch at all: a page that builds its queue from the log per view shows a newly requested action on the next refresh.
|
|
500
544
|
|
|
@@ -520,6 +564,8 @@ Channels MAY present multiple pending requests as a batch and collect one human
|
|
|
520
564
|
|
|
521
565
|
The ceremony owns its own git preconditions. It fetches the remote, refuses unless the remote's policy bytes are the attested baseline the edit was written against and the remote's log is a prefix of the working log, runs the policy's declared expectation set against the amended file where one exists, and only then attests. Each of those refusals is machine-readable, distinct, and reached before the attestation, so a stopped ceremony leaves the policy edit as a working-tree change and nothing else. The commit is assembled on the remote's tip without a checkout and pushed by refspec, so the human ends the ceremony on the branch they started on with the working tree they started with. (Amended APRV-203.)
|
|
522
566
|
|
|
567
|
+
A proposal MAY name bytes that are already the policy in force when it is made through a relay (a reaffirmation, amended APRV-455). It changes the attester of record and nothing else: same hash, a semantic diff of no change, and an acceptance resolved against the policy in force like any other. `approval policy amend` keeps refusing such a proposal `policy-already-attested`.
|
|
568
|
+
|
|
523
569
|
*Amends §10.3.* An attestation prompt is an ordinary channel request and inherits every rule of this section, including the computed and claimed split and the verbatim payload presentation of §10.4, where the payload is the policy text itself. Two computed fields are added for it: the semantic diff of what the proposed bytes change about class resolution, in before and after form, and the advisory saying whether those bytes load. The hash, the diff, and the advisory MUST all be derived by the runtime from the bytes; a proposing caller supplies none of them, so a proposal cannot show an approver one story and attest a different file. A prompt carrying only a hash asks a human to sign for sixty-four characters and is a conformance failure. A diff a channel cannot show whole MUST be refused rather than truncated, naming the terminal path as the repair: a prompt showing two thirds of a policy change collects a signature for the third it did not show. A prompt whose policy bytes have changed since it was rendered MUST leave the queue rather than be shown, and the answer to such a prompt MUST be refused, so the hash the approver was shown is the hash that gets attested or nothing is.
|
|
524
570
|
|
|
525
571
|
*Amends §11.* The trust boundary of this section is unchanged. Human identity is config-declared, the boundary is the local machine, and attestation proves that someone with local control signed off rather than who. What this amendment changes is where that person's act is collected: the listener holds the human identity for an attestation the way it already holds it for a grant, and the same reasoning that makes a tapped grant a human decision makes a tapped attestation one. An agent MUST NOT append `policy.updated` or `policy.declined`, in the schema and in the runtime both.
|
|
@@ -559,7 +605,7 @@ For `manual` actions, channels MUST present the full payload or a faithful rende
|
|
|
559
605
|
|
|
560
606
|
A thin MCP server exposing the same verbs as tools for clients where MCP is more ergonomic than shelling out. It shares the CLI's code paths.
|
|
561
607
|
|
|
562
|
-
What shipped is `approval mcp serve`: a foreground server speaking MCP over stdio, running as one `agent:<id>` the operator fixes when they start it. The tool surface is the agent-facing half of the verb registry, one tool per verb (`register`, `request`, `wait`, `run`, `queue`, `status`, `log_verify`, and the rest). Human-only verbs are absent by design, which is §11's argument applied to a transport: the agent is the untrusted policy and the human is the trusted overseer, an MCP client is an agent's harness, so publishing `grant` on it would hand the untrusted policy the overseer's pen.
|
|
608
|
+
What shipped is `approval mcp serve`: a foreground server speaking MCP over stdio, running as one `agent:<id>` the operator fixes when they start it. The tool surface is the agent-facing half of the verb registry, one tool per verb (`register`, `request`, `wait`, `run`, `queue`, `status`, `log_verify`, and the rest). Human-only verbs are absent by design, which is §11's argument applied to a transport: the agent is the untrusted policy and the human is the trusted overseer, an MCP client is an agent's harness, so publishing `grant` on it would hand the untrusted policy the overseer's pen. Nine agent-facing verbs are withheld as well, for transport reasons rather than authority reasons (`EXCLUDED_VERBS` in `src/mcp/server.ts`): `consume`, which is internal plumbing that `run` wraps; `payload run`, whose script path arrives as a command word no transport guard confines; `hook claude-code` / `hook cursor` / `hook codex` / `hook grok` / `hook muse` / `hook hermes`, which each read their event from the stdin this transport already owns; and `log follow`, whose unbounded foreground stream cannot be represented as one finite tool result and would occupy the serialized call queue indefinitely. A conformance reader building the tool list from this section takes it to be the registry filtered by `human_only`, minus those nine. (Amended APRV-454.) Tool descriptions and input schemas are derived from the same registry `approval instructions --schemas` prints, with `--as` deleted from every published schema, so a caller cannot name an identity; the server's own identity is appended last to every argv, so it wins even where one arrives by another route. A tool call builds an argv and invokes the function the CLI dispatches to, so a refusal is the CLI's refusal, returned as a tool result carrying `{"error":{"code","message"}}` rather than as a protocol error. The [MCP tasks extension](https://modelcontextprotocol.io) and elicitation MAY be mapped onto `awaiting` when client support stabilizes; that remains post-v1, and until then `wait` blocks and answers. A2A's `input-required` maps cleanly for agent-to-agent deployments. Under `token_delivery: sealed` (§10.4) the `wait` tool MAY return the raw execution token in its result, which retires the earlier reading that a token an agent could fetch would be a grant an agent could give itself. Fetching a minted token is not minting one: the token exists only because a human granted it, binds to the payload bytes, and is single-use, and the tool returns it only to the process that opened the request and holds the private key. What it replaces is a human copying that same value out of a terminal. `grant` remains absent from this surface, and that is the line that matters — the untrusted policy still cannot hold the overseer's pen. (Amended APRV-88, APRV-103, APRV-105.) (Amended APRV-322, pending sign-off.)
|
|
563
609
|
|
|
564
610
|
### 10.6 Experimental native Codex hook adapter (Amended APRV-313)
|
|
565
611
|
|
|
@@ -571,6 +617,18 @@ Codex has its own `codex` harness provenance. A post-execution report MUST corre
|
|
|
571
617
|
|
|
572
618
|
Operator documentation MUST distinguish direct-patch coverage from Bash refusal, native crash, timeout and malformed-response fail-open behavior, input rewriting by another hook that invalidates the approved binding, and unverified desktop and phone behavior. Doctor MUST distinguish configuration presence from trusted or demonstrated operation. This experimental adapter provides no universal enforcement or strict Claude parity. (Amended APRV-313.)
|
|
573
619
|
|
|
620
|
+
### 10.7 HTTP transport for a remote harness (optional)
|
|
621
|
+
|
|
622
|
+
A harness that runs where the log and the policy do not has nowhere for the hooks of §10.1 to write and nothing for them to read. An implementation MAY publish the agent-facing surface of §10.5 over HTTP for such a deployment. What shipped is `approval serve`.
|
|
623
|
+
|
|
624
|
+
The published verb surface MUST be §10.5's exactly: the registry filtered by `human_only`, `--as` deleted from every published schema, the server's own identity appended last to every argv, and a refusal returned as a result carrying `{"error":{"code","message"}}` rather than as a transport error. A conformance reader takes the two surfaces to publish the same names and the same schemas, and an implementation MUST NOT let this transport publish a verb the MCP one withholds for authority reasons.
|
|
625
|
+
|
|
626
|
+
It MAY additionally publish the harness hooks and `log follow`, which §10.5 withheld for transport reasons, each in the shape that transport lacked, and an archive export of the store, which §10.5 never withheld. (Amended APRV-454.) A harness hook endpoint takes the harness envelope as the request body and MUST answer the bytes the stdin form prints for that envelope, together with the exit-code semantics that form carries, for every harness the implementation speaks a protocol for; the dialects of §10.1 disagree about whether a block is in the body or in the exit status, so an implementation MUST NOT normalise one into the other. A log-follow endpoint MUST be paged rather than streamed, MUST take the exclusive `(seq, hash)` cursor of §8, MUST answer the cursor the page ends at, and MUST refuse a cursor whose hash does not bind with the integrity refusal of §8 and no records. An export endpoint MAY return the store as an archive, which MUST carry the policy file, the log and the projections, and MUST NOT carry key material, the environment source map of §5.2, or the vault.
|
|
627
|
+
|
|
628
|
+
Authority is split between two credentials read from the launch environment (§11.1 invariant 7), and the split is the reason this transport is safe to expose. One credential reaches the agent-facing verbs and the harness hook; the other reaches the log, the export and the status report. An implementation MUST NOT let the agent credential read the log, since a party that can read the verified log can read every decision made about it and the shape of the oversight it is under. In the reference runtime the agent credential reaches `instructions`, `hook classify`, `request`, `wait`, `withdraw`, `propose`, `start` and the harness hook endpoint (`AGENT_VERBS` and `scopeOf` in `src/serve/server.ts`); every other published verb, `log verify`, `log tail`, `log export`, `queue` and `status` among them, and the log-follow, export and status endpoints answer the tenant credential only. (Amended APRV-454.) Each direction MUST refuse with its own distinct machine-readable code. An implementation MUST NOT serve any path without a credential.
|
|
629
|
+
|
|
630
|
+
The server MUST bind a loopback interface unless the operator explicitly asks otherwise, and MUST NOT terminate TLS itself: the supported deployment is a proxy the operator owns in front of a loopback bind. It appends no record on its own account, holds no state a restart loses (a follow cursor belongs to the caller), and reads no `.approval/env`. (Amended APRV-421.)
|
|
631
|
+
|
|
574
632
|
## 11. Security and control model
|
|
575
633
|
|
|
576
634
|
approval.md operationalizes, at consumer scale, the structure of trusted monitoring with limited auditing from the AI-control literature ([Greenblatt et al., 2023](https://arxiv.org/abs/2312.06942)):
|
|
@@ -616,15 +674,15 @@ The following hold across every surface of the runtime. They are implicit accept
|
|
|
616
674
|
|
|
617
675
|
Invariant 6 above freezes six refusal unions as public API and pins each with a test. It does not say what any member means, and a frozen vocabulary whose triggers are folklore is a vocabulary a second implementation has to guess at. This registry states, for every member of every union, the condition under which that member fires. It is normative, and it is the source the conformance suite's `failure_class` assignments are drawn from (§13): a refusal for the wrong reason is a conformance failure, so the mapping has to live somewhere other than in the reference implementation's source. (Amended APRV-137.)
|
|
618
676
|
|
|
619
|
-
This registry covers the six gate-facing unions. A verb-local refusal union outside them is still bound by invariant 6: it is exported, frozen, and pinned by a test in the verb's own suite. (Amended APRV-215.) It also covers `channel_decision_refusal_codes`, which is not gate-facing and is registered here anyway: the refusals a decision surface makes are answers a human reads on their phone, and a vocabulary a second implementation has to guess at is exactly what this registry exists to stop. (Amended APRV-324.)
|
|
677
|
+
This registry covers the six gate-facing unions. A verb-local refusal union outside them is still bound by invariant 6: it is exported, frozen, and pinned by a test in the verb's own suite. (Amended APRV-215.) It also covers `channel_decision_refusal_codes`, which is not gate-facing and is registered here anyway: the refusals a decision surface makes are answers a human reads on their phone, and a vocabulary a second implementation has to guess at is exactly what this registry exists to stop. (Amended APRV-324.) It also covers `relay_refusal_codes` (amended APRV-455): the authenticated relay is a transport whose other end is a second implementation (an operator's control plane) that branches on these strings.
|
|
620
678
|
|
|
621
679
|
Three properties bind the whole registry. A code fires for exactly the condition named and never as a catch-all. Where two conditions could both apply, the order in which an implementation evaluates them is itself normative and is stated with the condition. And a refusal leaves the log untouched unless its row says otherwise: `budget-exceeded` appends a `budget.exceeded` record beside its refusal, `expired` materialises the `approval.expired` record a reader could already derive, and nothing else writes. (Amended APRV-137.)
|
|
622
680
|
|
|
623
|
-
**`gate_refusal_codes`** — every way `register`, `request`, `decide`, `withdraw`, `expire`, the policy-amendment ceremony, and harness-grant consumption can refuse, in definition order. (Amended APRV-137.)
|
|
681
|
+
**`gate_refusal_codes`** — every way `register`, `request`, `decide`, `withdraw`, `expire`, `wait` (`not-registered` and the log-read codes), `propose`, `start`, the policy-amendment ceremony, and harness-grant consumption can refuse, in definition order. (Amended APRV-137.) (Amended APRV-428.) (Amended APRV-454.)
|
|
624
682
|
|
|
625
683
|
| code | fires when |
|
|
626
684
|
|---|---|
|
|
627
|
-
| `policy-not-attested` | The live policy bytes do not match the latest attestation, no attestation exists, or the policy file cannot be read. Checked at intake, at grant, and at harness-grant consumption. Reject, revoke, and withdraw do not require attestation, because they withdraw authority rather than confer it. |
|
|
685
|
+
| `policy-not-attested` | The live policy bytes do not match the latest attestation, no attestation exists, or the policy file cannot be read. Checked at intake, at grant, and at harness-grant consumption. Reject, revoke, and withdraw do not require attestation, because they withdraw authority rather than confer it. Also a relay decision whose in-force policy bytes cannot be recovered from the payload store, because a relay resolves its sender only against the policy in force; nothing is appended. (Amended APRV-455.) |
|
|
628
686
|
| `policy-drift` | The policy is attested, and the hash in force differs from the hash the matching `approval.requested` pinned. Grant and harness consumption only. The pending request is void; nothing is appended. A request written before the field existed carries no hash and is decided as it always was. |
|
|
629
687
|
| `envelope-invalid` | The envelope fails `envelope.schema.json`, the task file's frontmatter carries no `approval:` key or no usable id, or an in-memory registration names an empty task id. |
|
|
630
688
|
| `task-file-unreadable` | The task file could not be read. An I/O fact, and never an accusation about its contents. |
|
|
@@ -647,7 +705,7 @@ Three properties bind the whole registry. A code fires for exactly the condition
|
|
|
647
705
|
| `already-decided` | The request already carries a terminal decision: rejected or revoked, granted where the verb is not revoke, or any settled state on the withdraw path. |
|
|
648
706
|
| `not-granted` | Revoke was attempted on a request that is awaiting a decision, or a harness grant was consumed for a key whose grant is not harness-executed. |
|
|
649
707
|
| `request-withdrawn` | A decision, or a second withdrawal, was attempted on a request its requester had already withdrawn. Nobody decided, which is what makes this distinct from `already-decided`. |
|
|
650
|
-
| `not-requester` | A withdrawal was attempted by an actor other than the one that appended the matching `approval.requested
|
|
708
|
+
| `not-requester` | A withdrawal was attempted by an actor other than the one that appended the matching `approval.requested`, or a `start` by an actor other than the one that registered the task. (Amended APRV-445.) |
|
|
651
709
|
| `expired` | The TTL lapsed, judged from the request's own timestamp whether or not an `approval.expired` record exists. Where no such record exists the runtime appends one, under a `system:` actor, and then refuses. |
|
|
652
710
|
| `not-expired` | `expire` was called on a request whose TTL has not lapsed, including every request under a policy that declares no `approval_ttl` (§5.1). |
|
|
653
711
|
| `actor-invalid` | The actor is not a well-formed `human:` or `agent:` identity, on register, request, withdraw, and harness-grant consumption. |
|
|
@@ -659,9 +717,15 @@ Three properties bind the whole registry. A code fires for exactly the condition
|
|
|
659
717
|
| `diff-too-large` | The rendered semantic diff of a proposed policy amendment is larger than a channel prompt can show whole. A refusal rather than a truncation, because a prompt showing two thirds of a policy change would collect a signature for the third it did not show. |
|
|
660
718
|
| `proposal-not-found` | No `policy.proposed` record exists at the named seq, so there is no attestation prompt to answer. |
|
|
661
719
|
| `proposal-stale` | The policy bytes changed after the attestation prompt was rendered, so the hash on the approver's screen is not the hash on disk. Distinct from `policy-drift`, which is about a pending approval routed under superseded rules. |
|
|
662
|
-
| `policy-already-attested` | An attestation was proposed for a policy file that already matches its attestation. There is no amendment to sign. |
|
|
720
|
+
| `policy-already-attested` | An attestation was proposed for a policy file that already matches its attestation. There is no amendment to sign. The exception is a reaffirmation over the edgeos relay, which re-records the same bytes with the accepting approver as attester (§10.3, APRV-455). (Amended APRV-454.) |
|
|
663
721
|
| `reaction-note-required` | A grant carries a `reaction` of `loved` or `disliked` (§5.2) and no non-blank note. Grant only, evaluated with the other checks that read nothing, and nothing is appended. Reject and revoke accept no reaction at all, which is a usage error at the verb rather than a member of this union. (Amended APRV-237.) |
|
|
664
722
|
| `append-failed` | The append itself failed, carrying the writer's own error. Its code is `head-moved` where the log grew between the read that authorized the write and the write. |
|
|
723
|
+
| `class-not-agent-requestable` | `propose` named a class that is not an exact key of the policy's `classes`, or that neither its exact key nor a declared `<prefix>.*` family opens with `agent_may_request: true` (§5.2). Evaluated after attestation and before every other question about the class, before anything is registered, and again at intake inside the retried cycle. Nothing is appended. (Amended APRV-445.) |
|
|
724
|
+
| `payload-too-large` | A `propose` or `start` payload exceeds 256 KiB of UTF-8 as sent. Refused before the bytes are parsed, hashed or stored; exit 2, because the caller must change the call. (Amended APRV-445.) |
|
|
725
|
+
| `task-is-proposal` | A plain `request` named a task `propose` registered, or a task FILE's registration claimed an id in the `propose:` namespace. A proposal is requested only through `propose`, which re-checks the operator's opening of its class. Nothing is appended. (Amended APRV-445.) |
|
|
726
|
+
| `task-not-proposal` | `start` named a task `propose` did not register. Nothing is appended. (Amended APRV-445.) |
|
|
727
|
+
| `key-class-mismatch` | A `propose` key does not begin with `<class>:` followed by an identifier. Exit 2. Nothing is registered. (Amended APRV-445.) |
|
|
728
|
+
| `contended` | `propose` exhausted its bounded restarts because every in-append re-check was refused by a log that kept moving. Exit 1; nothing more is appended and the proposal's standing is whatever the log says. Distinct from `already-decided`, which would misreport a proposal that stands granted or pending. (Amended APRV-445.) |
|
|
665
729
|
|
|
666
730
|
**`token_verify_refusal_codes`** — every way a presented token can fail verification, in definition order. Evaluated as: revoked, then lapsed by derivation, then any state that is not granted, then the parent TTL re-applied, then the harness marker, then the recorded digest, then consumption, then the presented preimage. (Amended APRV-137.)
|
|
667
731
|
|
|
@@ -734,11 +798,30 @@ Three properties bind the whole registry. A code fires for exactly the condition
|
|
|
734
798
|
|
|
735
799
|
| code | fires when |
|
|
736
800
|
|---|---|
|
|
737
|
-
| `sender-unmapped` | A channel supplied a sender and the attested policy maps it to no approver, or the policy could not be loaded at all. Evaluated before the gate's verbs, so nothing is decided and no decision record is appended; ONE `audit.decision_refused` IS appended, carrying the observed `payload.sender` and no `payload.actor`, because the runtime cannot name a person and naming the listener would be a false record. A policy that declares no `senders` for the channel does not reach this code: that is the configured-attribution path of §5.2 and it refuses nothing. |
|
|
801
|
+
| `sender-unmapped` | A channel supplied a sender and the attested policy maps it to no approver, or the policy could not be loaded at all. Evaluated before the gate's verbs, so nothing is decided and no decision record is appended; ONE `audit.decision_refused` IS appended, carrying the observed `payload.sender` and no `payload.actor`, because the runtime cannot name a person and naming the listener would be a false record. A policy that declares no `senders` for the channel does not reach this code: that is the configured-attribution path of §5.2 and it refuses nothing. On a relay, it also fires when the policy in force declares no sender for the relay's channel, because a relay has no configured identity to fall back on. (Amended APRV-455.) |
|
|
738
802
|
| `sender-ambiguous` | The policy in force maps the supplied sender to more than one approver. A policy carrying this does not load (§5.2), so the code is reachable only where a caller supplies a policy that never passed a load; the runtime refuses rather than choosing between two people, and appends the same single audit record. |
|
|
739
803
|
| `sender-key-unavailable` | The attested policy maps that channel's senders in the KEYED form of §5.2 and the deciding process holds no key, so no digest can be computed and no account on the channel can be resolved. It refuses the WHOLE channel, including entries written raw beside keyed ones: the roster's ambiguity check cannot be run over a half nobody can read, and ambiguity resolves to the stricter path (§11.1). An implementation MUST NOT fall back to comparing the observed id against the raw entries. Distinct from `sender-unmapped`, which says the policy answered and did not name this account; this says nothing about the account and everything about the process, and its repair is the key in the environment rather than an amendment. The same single `audit.decision_refused` is appended, carrying the observed id in the RAW form, because there is no key to hash it with and a record naming no account at all would leave an operator unable to say who was in front of a gate that had lost its key. (Amended APRV-370.) |
|
|
740
804
|
| `attest-requires-terminal` | An attestation answer arrived with a sender, and the policy IN FORCE cannot say whether that account may sign for this amendment: either its bytes are not recoverable (an attestation records only their digest), or the amendment changes the `senders` mapping and the policy in force maps no sender for this channel. Attestation answers alone; evaluated before the answer is recorded, so nothing is attested and nothing is declined. The repair is a terminal, which supplies no sender. It is distinct from `sender-unmapped`, which says the policy in force answered and did not name this account. |
|
|
741
805
|
|
|
806
|
+
**`relay_refusal_codes`** — every way the authenticated relay (`approval channel relay`, §10.3) can refuse a post, in evaluation order: secret, duplicate header, URL, path, method, body size, JSON, shape, sender, freshness, nonce, then the gesture. None appends to the log. (Amended APRV-455.)
|
|
807
|
+
|
|
808
|
+
| code | fires when |
|
|
809
|
+
|---|---|
|
|
810
|
+
| `relay-secret-mismatch` | No secret header, or not the launch environment's secret; checked before the path, method and body. HTTP 401. |
|
|
811
|
+
| `relay-duplicate-secret-header` | More than one secret header. HTTP 401. |
|
|
812
|
+
| `relay-malformed-request` | The request line and Host form no URL. HTTP 400. |
|
|
813
|
+
| `relay-unknown-path` | A path other than `/relay/gesture`. HTTP 404. |
|
|
814
|
+
| `relay-method-not-allowed` | Not POST. HTTP 405. |
|
|
815
|
+
| `relay-body-too-large` | Over 16 KiB. HTTP 413. |
|
|
816
|
+
| `relay-body-unreadable` | Unreadable or not JSON. HTTP 400. |
|
|
817
|
+
| `relay-body-invalid` | JSON outside the closed gesture shape, including any unknown field. HTTP 400. |
|
|
818
|
+
| `relay-sender-invalid` | `sender.channel` is not `edgeos`, or the id is outside the pinned grammar. HTTP 400. |
|
|
819
|
+
| `relay-gesture-stale` | `issued_at` more than five minutes from the relay's clock. HTTP 400. |
|
|
820
|
+
| `relay-nonce-replayed` | The nonce was already claimed within the retention. HTTP 409. |
|
|
821
|
+
| `relay-nonce-unavailable` | The ledger could not record the claim, so nothing is decided. HTTP 503. |
|
|
822
|
+
| `relay-policy-mismatch` | `propose` names a hash that is not the policy file on disk. HTTP 409. |
|
|
823
|
+
| `relay-handler-failed` | Applying the gesture threw. HTTP 500. |
|
|
824
|
+
|
|
742
825
|
**`append_error_codes`** — every way the write boundary itself can refuse an append, in definition order. Every one of them leaves the file byte-identical. (Amended APRV-137.)
|
|
743
826
|
|
|
744
827
|
| code | fires when |
|
|
@@ -749,6 +832,8 @@ Three properties bind the whole registry. A code fires for exactly the condition
|
|
|
749
832
|
| `canonicalization` | The record could not be serialized under RFC 8785. |
|
|
750
833
|
| `io` | The log directory, the file, or the write could not be created, opened, or performed. |
|
|
751
834
|
| `head-moved` | The caller supplied a compare-and-append precondition and the tail read under the lock is a different `(seq, hash)`. Every read-dependent check that authorized the write is stale, and nothing is retried by the writer. |
|
|
835
|
+
| `daemon-id-invalid` | The appending process declared a daemon id that is not a well-formed id, so no record it wrote could be attributed. An implementation MUST NOT substitute a derived id for a declared one it refused. (Amended APRV-383.) |
|
|
836
|
+
| `daemon-not-allowed` | The attested policy lists the daemon ids permitted to append to this log and the appending daemon's id is not among them. An ABSENT list is no restriction; an empty list admits none. (Amended APRV-383.) |
|
|
752
837
|
|
|
753
838
|
**`gate_window_refusal_codes`** — every way `approval gate open` and `approval gate close` can refuse, in evaluation order: the reason, then the duration, then the actor, then the terminal (where `--json` counts as no terminal), then the confirmation, then the verified read and the window's state it yields, then the append. The terminal and the typed word come before the log is read at all, so a process that cannot be asked learns nothing about the window from the refusal it gets. Every one of them appends nothing. (Amended APRV-214.)
|
|
754
839
|
|
|
@@ -776,7 +861,7 @@ Three properties bind the whole registry. A code fires for exactly the condition
|
|
|
776
861
|
|
|
777
862
|
## 13. Non-goals
|
|
778
863
|
|
|
779
|
-
No new task file format. No kanban UI. No agent framework or orchestration platform. No hosted service (local-first
|
|
864
|
+
No new task file format. No kanban UI. No agent framework or orchestration platform. No hosted service with authority over decisions. A hosted process may deliver, render, transport, operate and, once decisions are independently signed by the approver (§11, cryptographic identity), record. Below that level a hosted runtime operates under stated operator trust (`GOVERNANCE.md` discloses the hosted implementation that exists), and the local-first path remains complete without it. (Amended APRV-422.) No channel breadth beyond the four shipped (cli, web, telegram, and `edgeos`, the relay-only channel APRV-455 added). (Amended APRV-454.) No claim of scheming-robustness (§11).
|
|
780
865
|
|
|
781
866
|
Post-v1 (non-normative): `review: adversarial` as a per-class flag. Before a flagged `manual` request reaches the approver, an independent agent instance with the raw payload and no stake in the outcome writes a dissent: worst plausible reading, omissions, questions a suspicious reviewer would ask. The approver adjudicates between framings instead of consuming one. Untrusted monitoring, spent where human attention is scarcest.
|
|
782
867
|
|
package/cli.js
CHANGED
|
@@ -9,22 +9,81 @@
|
|
|
9
9
|
* The exit code is set via `process.exitCode` rather than `process.exit()`, so
|
|
10
10
|
* stdout is flushed by the normal exit path — a JSON object truncated by an
|
|
11
11
|
* early exit would be worse than no output.
|
|
12
|
+
*
|
|
13
|
+
* ONE exception to "no behaviour here" (APRV-445 refutation, S3): the Hermes
|
|
14
|
+
* hook. Hermes reads any non-zero exit with an EMPTY stdout as an ALLOW, so a
|
|
15
|
+
* failure to load the runtime at all (a missing or half-written dist/, a
|
|
16
|
+
* static import that throws) must still answer with a block. That is the only
|
|
17
|
+
* thing this file can do that the runtime cannot: it runs before the runtime
|
|
18
|
+
* exists. For `approval hook hermes` it prints `{"action":"block",...}` and
|
|
19
|
+
* exits 2 on any failure to load or run; every other invocation keeps its old
|
|
20
|
+
* behaviour exactly.
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
23
|
+
import { existsSync, writeSync } from "node:fs";
|
|
15
24
|
|
|
16
25
|
const entry = new URL("./dist/src/cli/main.js", import.meta.url);
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
// Matched the way `main` dispatches it: `--no-color` is the one global flag it
|
|
28
|
+
// strips, wherever it appears (APRV-445 recheck L-d).
|
|
29
|
+
const dispatched = argv.filter((word) => word !== "--no-color");
|
|
30
|
+
const hermesHook = dispatched[0] === "hook" && dispatched[1] === "hermes";
|
|
31
|
+
|
|
32
|
+
/** Hermes's own block directive, spelled here because the runtime may not load. */
|
|
33
|
+
function hermesBlock(message) {
|
|
34
|
+
writeSync(1, `${JSON.stringify({ action: "block", message: `hook-io: ${message}; nothing authorizes this call` })}\n`);
|
|
35
|
+
printed = true;
|
|
36
|
+
process.exitCode = 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The last guard (recheck L-d): whatever path the process takes out, a Hermes
|
|
40
|
+
// hook that leaves with a non-zero code other than 2 and nothing on stdout is
|
|
41
|
+
// an ALLOW to Hermes. Such an exit leaves as 2, with the directive when nothing
|
|
42
|
+
// was printed. Exit 0 with nothing on stdout is the post-event answer and is
|
|
43
|
+
// left alone; the adapter itself never answers a pre-event that way.
|
|
44
|
+
let printed = false;
|
|
45
|
+
if (hermesHook) {
|
|
46
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
47
|
+
process.stdout.write = (chunk, ...rest) => {
|
|
48
|
+
if (chunk !== undefined && String(chunk).length > 0) printed = true;
|
|
49
|
+
return write(chunk, ...rest);
|
|
50
|
+
};
|
|
51
|
+
process.on("exit", (code) => {
|
|
52
|
+
if (code === 0 || code === 2) return;
|
|
53
|
+
if (!printed) {
|
|
54
|
+
try {
|
|
55
|
+
writeSync(1, `${JSON.stringify({ action: "block", message: `hook-io: the hook exited ${String(code)} without a verdict; nothing authorizes this call` })}\n`);
|
|
56
|
+
} catch {
|
|
57
|
+
// stdout is gone; the exit code below is the whole verdict.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Exit 2 blocks whatever stdout says, so it is the code a Hermes hook
|
|
61
|
+
// leaves with on any non-zero path.
|
|
62
|
+
process.exitCode = 2;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
17
65
|
|
|
18
66
|
if (!existsSync(entry)) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
if (hermesHook) {
|
|
68
|
+
hermesBlock("the approval runtime is not built (dist/src/cli/main.js is missing)");
|
|
69
|
+
} else {
|
|
70
|
+
// Exit 4 = I/O error in the frozen exit-code table (src/cli/exit-codes.ts).
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
"approval: dist/src/cli/main.js is missing — run `npm run build` first.\n",
|
|
73
|
+
);
|
|
74
|
+
process.exitCode = 4;
|
|
75
|
+
}
|
|
76
|
+
} else if (hermesHook) {
|
|
77
|
+
try {
|
|
78
|
+
const { main } = await import(entry.href);
|
|
79
|
+
process.exitCode = await main(argv);
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
hermesBlock(`the hook could not run: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
82
|
+
}
|
|
24
83
|
} else {
|
|
25
84
|
const { main } = await import(entry.href);
|
|
26
85
|
// `main` is asynchronous since APRV-209: every verb is loaded on demand, and
|
|
27
86
|
// ESM has no synchronous dynamic import. The exit code still travels through
|
|
28
87
|
// `process.exitCode`, so the flush behaviour described above is unchanged.
|
|
29
|
-
process.exitCode = await main(
|
|
88
|
+
process.exitCode = await main(argv);
|
|
30
89
|
}
|
|
@@ -138,6 +138,24 @@ export interface SmtpTransportOptions {
|
|
|
138
138
|
/** Applied to every string this module returns. See the module header. */
|
|
139
139
|
redact?(text: string): string;
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The TLS `servername` (SNI) to use for `host`, or `undefined` when there is
|
|
143
|
+
* none to send (APRV-416).
|
|
144
|
+
*
|
|
145
|
+
* SNI names a virtual host, and an IP address is not one. Sending an address
|
|
146
|
+
* there has always been meaningless; Node 26 turned the long-standing
|
|
147
|
+
* deprecation into a hard error, so a session against an IP-addressed relay
|
|
148
|
+
* fails at the handshake with "Setting the TLS ServerName to an IP address is
|
|
149
|
+
* not permitted" before it reaches the certificate. Omitting it is the whole
|
|
150
|
+
* fix: verification of an IP host then rests on the certificate's IP SAN
|
|
151
|
+
* entry, which is what TLS intends for an address, and `rejectUnauthorized`
|
|
152
|
+
* keeps its meaning either way.
|
|
153
|
+
*
|
|
154
|
+
* A bracketed IPv6 literal (`[::1]`, the URL spelling) is unwrapped before the
|
|
155
|
+
* test, because it is an address in every sense that matters here and nothing
|
|
156
|
+
* that reaches this function is a hostname shaped like one.
|
|
157
|
+
*/
|
|
158
|
+
export declare function tlsServername(host: string): string | undefined;
|
|
141
159
|
/** The SMTP envelope, which is not the message's headers. */
|
|
142
160
|
export interface SmtpEnvelope {
|
|
143
161
|
/** MAIL FROM. The return path, not necessarily the From: header. */
|