Skip to content

The policy engine and the approval lifecycle

This expands Sections 8.3, 8.4, 9, 11.2, 13, and 22 of the engineering plan, and Milestone 4. It does not replace them. Where this document adds a type, a column, or a rule, it is an addition of the kind Section 15 already sanctions when it says to create migrations for at least the tables it lists, and Section 4 sanctions when it says to create directories as the implementation reaches them.

Recorded as ADR-0005 and ADR-0006, and constrained by ADR-0017.

The policy engine is the only thing standing between a model and the world

Section 2.5 states the premise in one line: a prompt instruction is not an authorization mechanism. Section 5's eleventh dependency rule states the consequence: the policy engine must not depend on prompts or model judgment. Everything below is those two sentences made mechanical.

The reason this layer is worth more care than its size suggests is that it is the only component whose failures are silent in the direction that matters. A broken model gateway produces an error. A broken projection produces a wrong answer someone eventually notices. A policy engine that allows one action it should have gated produces a completed action, a satisfied user, and no signal at all — until the action was a payment, a deletion, or an email to a customer list.

That asymmetry drives four properties this document is written to guarantee.

  • The decision is a pure function of its inputs. Same action, same principal, same ruleset, same answer — always, and provably, because the evaluator performs no I/O and reads no clock.
  • There is exactly one place a proposed action can become an authorized one. Not one per surface. One, with an import-boundary test that says so.
  • Nothing downstream of the model can widen a decision. Tool output, memory, retrieved documents, and the model's own argument can only ever make a decision more restrictive, and most of them cannot move it at all.
  • A decision that cannot be classified is a denial. Not a default-allow, not an exception that some caller might catch.

The plan describes this engine at the level of its interface. What it does not supply — and what Milestone 4 cannot be built without — is the vocabulary the interface is written in. ProposedAction, ApprovalStatus, SideEffectClass, RiskLevel, and IdempotencyClass each appear exactly once in the plan, as the type of a field, with no definition anywhere. The next section defines them in the only way that is safe: derived from statements the plan already makes, so that each value exists because something in the plan requires it and not because it seemed useful.

The vocabulary the interface is written in

SideEffectClass

ToolSpec.side_effect (Section 8.1) is typed SideEffectClass and never defined. Section 9.2's default policy matrix is keyed on a prose column called "Action category" with sixteen rows and no referent. These are the same thing seen from two ends, and joining them is what makes the matrix executable.

class SideEffectClass(str, Enum):
    # Each comment names the Section 9.2 matrix row this value came from.
    NONE = "none"                            # Pure computation
    WORKSPACE_READ = "workspace_read"        # Read isolated workspace
    WORKSPACE_WRITE = "workspace_write"      # Write isolated workspace
    NETWORK_READ = "network_read"            # Read approved resource
    CODE_EXECUTION = "code_execution"        # Execute code
    PACKAGE_INSTALL = "package_install"      # Install packages
    SANDBOX_NETWORK = "sandbox_network"      # Enable sandbox network
    EXTERNAL_MESSAGE = "external_message"    # Send a message
    EXTERNAL_WRITE = "external_write"        # Modify external data
    EXTERNAL_DELETE = "external_delete"      # Delete external data
    FINANCIAL = "financial"                  # Spend money
    PUBLICATION = "publication"              # Publish content
    CREDENTIAL_ACCESS = "credential_access"  # Access raw credentials
    HOST_ACCESS = "host_access"              # Access host filesystem
    PRIVILEGED = "privileged"                # Privileged container op

Fifteen values for Section 9.2's first fifteen rows. The sixteenth row, "Unknown tool", is not a side-effect class and is discussed below.

The comments are load-bearing. They are the evidence that this enum was read off the plan rather than designed, and a test asserts the correspondence is total in both directions: every enum value has exactly one matrix row, and every matrix row has exactly one enum value.

RiskLevel

class RiskLevel(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

Risk never selects the decision. The side-effect class does. Risk is carried on the decision and used for three things that are not authorization: ordering the pending-approval queue so a human sees the consequential item first, choosing the default approval expiry, and grouping metrics.

This restraint is deliberate. If risk could also select a decision there would be two matrices, they would disagree, and the disagreement would surface as an allow. A profile may define a rule keyed on the pair (side_effect, risk) when it genuinely needs to distinguish, but no rule may be keyed on risk alone.

IdempotencyClass

Section 8.4 states four crash-recovery behaviours. The enum is those four behaviours named, and nothing more.

class IdempotencyClass(str, Enum):
    # Each comment is Section 8.4's crash-recovery rule for that class.
    READ_ONLY = "read_only"                                # retry
    IDEMPOTENT = "idempotent"                              # retry
    CONDITIONALLY_IDEMPOTENT = "conditionally_idempotent"  # key required
    NON_IDEMPOTENT = "non_idempotent"                      # never retry

The event-log spec's recovery path already dispatches on this classification when it decides whether a tool left RUNNING by a crash may be retried or must resolve to UNCERTAIN. That dispatch is the reason the classification is denormalized onto tool_invocations below rather than resolved from a historical ToolSpec version at recovery time.

ActionKind, and actions that are not tool calls

The plan's approval object hangs off tool_invocation_id, non-nullable. But Section 30.3 requires that authoring a skill be gated by policy and approval, and the memory specs require governance on the write path. A memory write is not a tool invocation at all. Skill authoring is one — skill_manage, a capability tool, per skills.md — but its approval carries a diff rather than an argument blob, and the payload is selected by the action kind. Rather than inventing a synthetic tool invocation for the memory case — which would put rows in tool_invocations that no tool ever executed, and corrupt every metric computed over that table — the action becomes the general case and the tool call becomes the common one.

class ActionKind(str, Enum):
    TOOL_CALL = "tool_call"
    MEMORY_WRITE = "memory_write"
    SKILL_AUTHORING = "skill_authoring"
    ARTIFACT_EXPORT = "artifact_export"

tool_invocation_id becomes nullable and is populated only when the action has one behind it, which is every TOOL_CALL and every SKILL_AUTHORING; a new action_id carries the reference in every case. Nothing about the tool path changes.

ApprovalStatus

class ApprovalStatus(str, Enum):
    PENDING = "pending"
    APPROVED = "approved"
    DENIED = "denied"
    EXPIRED = "expired"
    CANCELLED = "cancelled"

Five statuses, but Section 9.3 lists three resolutions, APPROVE_ONCE, APPROVE_FOR_TASK, and DENY, and the first two both reach APPROVED. The lists differ because EXPIRED and CANCELLED are statuses reached without a resolution — by the reaper and by the cancellation edge respectively, both of which Section 9.3 assigns to the application service.

CANCELLED is kept distinct from DENIED for a reason that shows up later, in the metrics: a run cancelled by its own user reaps a pending approval, and counting that as a human denial makes the denial rate a measure of how often people change their minds rather than a measure of how often the agent proposes something unwelcome.

class ApprovalResolutionType(str, Enum):
    APPROVE_ONCE = "approve_once"
    APPROVE_FOR_TASK = "approve_for_task"
    DENY = "deny"

APPROVE_FOR_TASK (ADR-0129, accepted by the owner 2026-09-25) is valid only for a browser.act approval that carries a server-authored task-grant offer for an owner-configured site scope. It resolves the approval to APPROVED, exactly as APPROVE_ONCE does, and in the same transaction creates a thirty-minute, two-hundred-action BrowserTaskGrant bound to the session, as specified in browser-automation.md. It requires browser.grant.write in addition to approval.resolve, and its request repeats the offer's origin and path prefix. The CLI, inbound surfaces, and notification actions cannot use it. No other session-wide or permanent approval grant exists.

ProposedAction

The policy engine's primary input appears once in the entire plan, as the type of the first parameter of PolicyEngine.evaluate (Section 7). Everything the engine can decide with is exactly what this object carries, so its field list is the real specification of the engine's power.

class ProposedAction(BaseModel):
    kind: ActionKind
    action_id: UUID
    tenant_id: str
    session_id: UUID
    run_id: UUID
    step_number: int

    name: str                        # tool name, or the action name
    version: str | None              # ToolSpec.version when there is one
    summary: str                     # human sentence for the approval UI

    side_effect: SideEffectClass
    risk: RiskLevel
    idempotency: IdempotencyClass
    required_scopes: set[str]

    arguments: dict[str, Any]        # already normalized (Section 8.3)
    normalized_arguments_hash: str
    argument_trust: dict[str, TrustLevel]
    origin_trust: TrustLevel

    target: ExecutionTarget
    evaluated_at: datetime           # passed in; never read from a clock

Four fields deserve their justification.

argument_trust and origin_trust exist because the same action is not the same action twice. workspace.write_text proposed because the user asked for a file is an ordinary write. The identical call, with identical arguments, proposed because a fetched web page contained text instructing the agent to write it, is an injection succeeding. Section 11.2 already requires every context item to carry a trust label; carrying that label forward into the decision is what lets a rule say require approval when any argument derives from EXTERNAL_UNTRUSTED content — which is a mechanical defense, unlike asking the model to be careful.

target is what makes Section 9.2's "Allow only in sandbox" executable.

class ExecutionTarget(BaseModel):
    kind: str                # in_process | sandbox | device | mcp_server
    isolated: bool
    network_enabled: bool
    device_id: str | None
    server_id: str | None

Section 29 is explicit that a device is an execution target and not a policy or credential authority — "it ran on my laptop" is not an authorization. Modelling the device as a field on the input rather than as a separate evaluation path is how that stays true.

evaluated_at is passed in rather than read. A rule that needs the time — a business-hours approval window, an expiry computation — receives it as data. The moment the evaluator can call now() it stops being replayable, and the determinism gate below stops being testable.

The scope vocabulary, and the check that precedes policy

required_scopes is a field on ToolSpec and on ProposedAction, and Section 8.3 checks it at step 3, ahead of the engine. Three things were never written down: which strings are legal, how the check compares them, and where a worker gets the principal's scope set. Each is an answer an implementer would otherwise have to invent, and two of the three have a wrong version that looks right.

One vocabulary, not two

http-api-and-streaming.md enumerates the scopes its routes require and states the property that matters: the vocabulary is closed, because a scope the policy engine checks against a string no document contains is a scope that gets misspelled. It made skill.write the demonstration — enumerated there, in no row of its route table, checked here.

The rest of the tool-checked scopes belong in the same list for the same reason. That document is Milestone 5 and this check is Milestone 4, so the union is written here:

session.read      session.write
run.read          run.write        run.cancel
approval.read     approval.resolve
artifact.read     artifact.write
workspace.read    workspace.write
sandbox.execute
skill.write
demo.write
knowledge.write
browser.profile.read   browser.profile.write
browser.grant.read     browser.grant.write

Nineteen strings. Thirteen are enumerated by the API document; the five after them are the required_scopes the builtin roster declares in builtin-tools.md, and knowledge.write is the one knowledge-documents.md adds for ingestion. artifact.read and artifact.write are the pair that shows this is one namespace rather than two that happen to collide: the first gates a read route, the second gates artifact.export, and they are two actions on one resource. Since ADR-0120 the second also gates the chat attachment upload route, which adds no string to the vocabulary.

Milestone 11 extends this closed vocabulary rather than creating a second one:

schedule.read      schedule.write      schedule.cancel

The fifteen-string list remains the completed Milestone 4 baseline and the subject of its historical gate. The four browser scopes added by Milestone 10 and the three schedule scopes added by Milestone 11 make the executable platform vocabulary twenty-two strings. The three scheduling additions authorize the application-level schedule routes in scheduling.md: read covers schedule and occurrence reads, write covers create, update, pause, and resume, and cancel covers the terminal schedule transition. That was the complete Milestone 11 surface. Milestone 19 also gives the one-time model tool schedule.create exactly schedule.write; its EXTERNAL_WRITE/HIGH classification makes approval mandatory under the existing matrix. Milestone 23 gives schedule.list exactly schedule.read, schedule.update, schedule.pause, and schedule.resume exactly schedule.write, and schedule.cancel exactly schedule.cancel. The read is NONE/LOW; the three state writes are EXTERNAL_WRITE/HIGH; terminal cancellation is EXTERNAL_DELETE/HIGH. The existing matrix therefore allows the read and requires approval for every mutation. Exact matching, MCP namespace isolation, and every other rule in this section are unchanged.

Milestone 12 adds device.read, device.write, and notification.read for the device and notification routes in notifications-and-devices.md, making the executable vocabulary twenty-five strings. Milestone 17, authorized as a parallel workstream rather than the next number in sequence (ADR-0070), adds one more:

memory.read

memory.read gates the two read-only routes in memory-read-api-and-browser.md, making the executable vocabulary twenty-six strings at that stage. Milestone 13 adds run.delegate for the delegate.run control tool in subagents-and-delegation.md, and Milestone 14 surface.read and surface.write for the pairing routes in inbound-surfaces.md. Milestone 22 adds persona.read and persona.write; Milestone 26 adds email.read and email.write, which made thirty-three strings. Milestone 27 adds call.read, call.cancel, and call.delete for the call routes in bland-calling.md; Milestone 28 adds people.read and people.write; ADR-0117 adds memory.write; ADR-0119 adds settings.read and settings.write; ADR-0140 adds media.generate; ADR-0143 adds notification.write. The implemented platform vocabulary therefore contains forty-three strings.

The grammar, and the contributor a closed list cannot hold

A scope is two or more lowercase segments matching [a-z][a-z0-9_]* joined by dots, of which the last is the action. The four browser. scopes have three; the other thirty-nine have exactly two.

A closed list needs no grammar, so the grammar exists for the one contributor the list cannot enumerate. tool-system.md:1382 takes an MCP tool's required_scopes from server configuration — the operator declares them, never the server — and an operator-declared string is outside a closed set by construction. The rule is therefore that an entry is legal when it is one of the forty-three, or when its first segment is mcp and its second is the server id. mcp.files.write is legal on a tool from the files server. run.cancel on that tool is not.

The escalation this prevents is quiet, which is why it is worth naming. required_scopes reads as a restriction, so a misdeclaration looks harmless — the apparent worst case is a tool nobody can call. It is not. An operator who declares that a remote filesystem-write tool requires session.write has handed every principal that can open a session the ability to write files on that server, because that principal already holds the scope. Borrowing a platform scope for a capability the platform never granted is how a scope system stops meaning anything, and it is one configuration typo away. Registration validation rejects it.

The comparison is a subset test over exact strings

required_scopes is a set and the rule is all of them:

missing = action.required_scopes - principal.scopes

Empty passes. Non-empty raises AuthorizationError, which the pipeline converts into denied with reason_code = policy.scope.missing.

There is no hierarchy, no wildcard, and no prefix rule. run.write does not admit run.read, and mcp.files.write does not admit mcp.files.read — a shared prefix is a naming convention and not an evaluation input. This is the API document's exact-match rule applied to a set rather than to one route scope, and the argument is unchanged: a hierarchy needs an evaluation order, an evaluation order is a thing that can be subtly wrong, and the direction it is wrong in grants access nobody intended.

An empty required_scopes is a tool with no scope check. That is what the two Milestone 1 builtins carry, and builtin-tools.md gives the reason: a scope that gates arithmetic is a scope that gets granted to everyone, which teaches the scope system to be ignored.

The scope denial names the scope, and no other denial names anything

The section below argues at length that a denial must not tell the model what fired. The scope denial is the exception, and skills.md already takes it — the denial names the scope.

The distinction is that a policy rule is an evasion gradient and a scope is not. A model that learns a rule denied a write at four hundred kilobytes tries three hundred and ninety-nine. A model that learns workspace.write was missing learns nothing it can act on, because it cannot grant itself a scope; that sentence is for the human reading the transcript. The missing scopes are named. The set the principal does hold never is, because that is a map of the surface still worth probing.

Where a worker gets the scope set

The check runs on a worker and a worker has no credential. http-api-and-streaming.md turns a credential into a Principal inside a request; runtime-loop.md declares PrincipalResolver.for_run, which produces one from a Run, and nothing said from what.

The scope set is captured at submission and stamped on the run, and for_run reads the stamp. It does not read a principal table. Re-deriving the set on the worker would be wrong rather than merely slower: the runtime already fixes that a permission change takes effect on the next run and not mid-run, and that guarantee holds only if the run carries the set it was submitted under. A re-derivation makes it depend on how long the run sat in the queue, which is the kind of guarantee that passes every test and fails under load.

This is a Milestone 2 column serving a Milestone 4 check. ADR-0032 established that direction for the consent stamp and the reason transfers: a run that started before the column existed has no honest value to backfill, so the column has to precede the first run whose decisions anyone might later have to explain.

Approval resumption is the one path that deliberately ignores the stamp. The revalidation table below voids an approval when the principal's scopes are narrowed, and the runtime names resumption as its one exception to resolving a principal once. Both say the same thing: a human's consent is authority the human held at the moment they gave it, so resumption compares against the current principal.

The advertisement filter uses the same predicate at the other end. Section 11.1's second filter is principal authorization, and it is this subset test run once at session open. That is why a tool can be advertised and then denied at call time: the filter ran against the session, the check runs per action against the run's stamp, and tool-system.md fixes that the pinned set is never rewritten in between.

Roles are declared in 0.1 and resolved in the version after it

Principal.roles exists in Section 6.2, and the API document says roles are bundles that authentication resolves into a scope set. In 0.1 there is one token and one configured principal, so there is no bundle to resolve: the configured principal's scopes are configured directly, roles is populated for audit and for log lines, and nothing reads it as an authorization input. Naming the mechanism without building it is the posture the corpus already takes for multi-tenancy — the field is shaped now so that it does not have to be added later, and the resolution step arrives with the second principal.

AUTH_MODE=dev binds the full scope set, and this section is what "full" means: all forty-two, and no mcp. scope. Those exist only once a server is configured, and a development principal that silently held every scope an operator could declare would make the misdeclaration above the one class of mistake development cannot surface.

The decision, and what may change it

Restrictiveness is an ordering

ADR-0017 permits the advisory layer to make a decision "more restrictive" and never defines the comparison. Over PolicyDecisionType (Section 9.1):

rank 0   ALLOW
rank 1   ALLOW_WITH_MODIFICATIONS      narrows the action
rank 2   REQUIRE_APPROVAL              suspends the action
rank 3   DENY                          ends the action

Combination is max by rank. The ordering is total, so combination is associative and commutative, so the order in which layers are consulted cannot change the outcome — which is what makes the layering safe to reason about.

Two constraints keep the ordering honest.

  • Only the deterministic layer may produce modifications. Combining two different modified_arguments sets is undefined, and an advisory layer that could rewrite arguments would be an injection vector wearing a safety badge. Exactly one rule may modify: the highest-precedence rule that matches.
  • A hardline block is not a rank. It is evaluated first and short-circuits. No later layer sees it, so no later layer can be induced to soften it. This is the mechanical meaning of "frozen at load" in ADR-0017: not merely that the rules cannot be edited, but that nothing runs after them that could reinterpret them.

ALLOW_WITH_MODIFICATIONS and the idempotency key

The fourth decision type has a sequencing problem the plan does not mention. Section 8.4 derives the idempotency key from the normalized arguments hash, and Section 8.3 normalizes arguments before policy runs. If policy then modifies the arguments, the key that was computed no longer describes the call that will execute, and a crash-recovery lookup will match a key for an action that never happened.

The resolution: when a decision carries modified_arguments, the pipeline re-normalizes and recomputes the idempotency key from the modified arguments before execution, and persists both hashes on the invocation — the proposed one for tracing the model's intent, the effective one for idempotency. The normalized_arguments_hash on ProposedAction is always the proposed one.

Because this type has no consumer anywhere in the plan, the default profile ships zero rules that produce it. The plumbing exists, is tested, and is unused until a rule needs it. Recorded as a decision below.

The four layers

Section 9.3's closing paragraphs describe four layers. In evaluation order:

1. Hardline rules

Never-bypassable patterns, evaluated first, frozen at load, disabled by nothing.

Where they live. src/agent_core/policy/hardline.yaml, packaged inside the distribution so it is immutable in a built image. Section 22 lists "Policy rules" as Trusted; a database table is editable at runtime by anyone holding a connection string, which is not a trust boundary, so rules are files under version control and not rows.

How they are frozen. The loader reads the file once at import, validates it, computes its SHA-256, and exposes an immutable frozen structure. There is no setter, no reload path, and no environment variable that alters the set. A test asserts the module exposes no public mutator and that mutating the loaded object raises.

Why they are not behind a port. Every other collaborator in this system is a Protocol with a substitutable implementation. Hardline evaluation is a module-level pure function, deliberately. A substitutable never-bypassable rule is a contradiction: the substitution point is the bypass. This is the one place in the architecture where the ports-and-adapters rule is suspended, and the suspension is the point.

Format.

rules:
  - id: destructive_root_delete
    kind: command_regex
    applies_to: [code_execution, sandbox_network]
    pattern: '\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/(\s|$)'
    message_code: policy.hardline.destructive_command
    near_miss: 'rm -rf ./build'
  - id: credential_class
    kind: side_effect
    applies_to: [credential_access]
    message_code: policy.hardline.credential_access
    near_miss: 'read a credential *reference* from configuration'

Why every rule carries a near_miss. ADR-0017 already flags the false-block hazard. The near_miss field is a required legitimate input that the rule must not block, and the test suite asserts both directions for every rule. A rule that cannot name something similar it permits is a rule that is too broad, and the schema will not let it load. This is the discipline that keeps the hardline list from growing into the deterministic layer.

The initial set covers what ADR-0017 names — destructive commands and secret exfiltration — plus the classes Section 9.2 denies outright:

  • Root-scoped destructive commands and filesystem-destroying operations.
  • Writes to host paths outside the workspace: /etc, /usr, ~/.ssh, .git/config, .env and its variants.
  • Any action classified CREDENTIAL_ACCESS, HOST_ACCESS, or PRIVILEGED.
  • Egress to link-local metadata addresses.
  • Reading a credential-shaped value and passing it to an egress or messaging action within the same action.

2. The deterministic layer

Authoritative, per Section 9.3. This is Section 9.2's matrix, keyed on SideEffectClass, extended with the one column the prose form was missing.

side_effect Condition Decision Otherwise
NONE — Allow —
WORKSPACE_READ path inside workspace Allow Deny
WORKSPACE_WRITE path inside workspace Allow Deny
NETWORK_READ constructed provider target or operator-classified read-only MCP target Allow Deny
CODE_EXECUTION target.isolated Allow Deny
PACKAGE_INSTALL — Deny —
SANDBOX_NETWORK — Deny —
EXTERNAL_MESSAGE — Require approval —
EXTERNAL_WRITE — Require approval —
EXTERNAL_DELETE — Require approval —
FINANCIAL — Require approval —
PUBLICATION — Require approval —
CREDENTIAL_ACCESS — Deny —
HOST_ACCESS — Deny —
PRIVILEGED — Deny —

Three of Section 9.2's decision strings are not PolicyDecisionType values. They resolve as follows, without changing any outcome the plan states.

  • "Allow with restrictions" (read approved network resource) is ALLOW guarded by a predicate, with DENY when the predicate fails. The restriction was always a condition on the argument; the condition column is where it goes. web-access.md supplies the first constructed predicate: a web_provider execution target whose actual HTTPS API host is fixed by the composition root. Browser-provider reads use the same constructed-target arm. Milestone 18 adds the MCP arm: an operator-classified mcp target satisfies host_on_allowlist only when its declared side effect is NETWORK_READ and its idempotency is READ_ONLY. The stdio command remains operator-owned and the remote package confines its own destinations; no model-authored URL field satisfies either arm. The exact mcp.{server_id}.use scope remains an independent prerequisite.
  • "Allow only in sandbox" (execute code) is the same shape, with target.isolated as the predicate.
  • "Deny initially" (install packages, enable sandbox network) is DENY in the default profile. "Initially" is a statement about which profile is loaded, not about a fifth decision type — which is precisely what profiles are for.

Tool-name-keyed entries. A profile may carry an optional tool_rules section, a mapping from an exact tool name to a row of the same shape as a side-effect row, plus the boolean human_confirms_arguments. When an action's name matches, that row decides for that one tool; every other tool in the class still gets the class's decision, so the matrix stays total and nothing about the class changes. The section exists for a tool whose own mechanism already carries the control the class demands, and it is the seam a milestone uses instead of editing a matrix row. Milestone 24's device.sms.send is the first entry: iOS presents the composed message and the owner's Send tap performs the send, so the class's approval would duplicate a confirmation the platform already enforces (ADR-0081 and ADR-0083, and device-channel-and-sms.md). Hardline rules run first and are unaffected, so a credential-shaped body is still refused before anything reaches the device. Two entries for one tool name, like a missing side-effect row, are unclassifiable and deny.

ADR-0209 adds an application authorization when BROWSER_RESEARCH_ENABLED=1 (the default) and the bounded-scroll rule is present. The pure evaluator is unchanged: other actions remain REQUIRE_APPROVAL until the application verifies owner/scheduler provenance, the selected profile, current policy and observation, and supplies a live-page constraint. The isolated browser repeats ordinary-interaction checks before sending an effect. Named consequences, untrusted inputs and denials remain excluded. The authorization receipt is independent of compiled policy, like existing standing grants; setting the flag to 0 restores ordinary approvals.

ADR-0213 adds a second application authorization, for a call the owner asked for: start_call, or schedule.create with place_call=true, from the owner's own chat or schedule seed, dialing only numbers the owner typed, in a conversation with no external, knowledge or device-ingested content in any turn. It does not change the compiled policy or the engine's refusal to let a profile allow calls; AGENT_CALL_OWNER_REQUESTS_ENABLED=0 restores approvals.

ADR-0168's bounded_browser_scroll condition remains available, allowing only a validated bounded scroll through the isolated browser provider and requiring approval otherwise. It checks the exact tool name, action kind, conservative write classification, bounds and closed argument fields; optional postconditions must validate as observation predicates. No provider or page claim of harmlessness changes the decision. Hardline rules still precede it, and the YAML condition changes the content-addressed policy version.

Trust overlay. One rule applies across the table: if any argument's trust label is EXTERNAL_UNTRUSTED, or the proposing turn's origin trust is EXTERNAL_UNTRUSTED, a decision of ALLOW for a class other than NONE, WORKSPACE_READ, or NETWORK_READ is raised to REQUIRE_APPROVAL. This is a max combination like any other, so it can only tighten.

A tool-name-keyed entry adjusts both halves, and only for the tool it names. The argument half is suppressed only when the entry declares human_confirms_arguments: true — that tool shows the arguments to the human who completes the action, so re-escalating on model-authored arguments would refuse what that person is already looking at. An entry without the flag gets no suppression. The origin half is tightened: instead of testing one label, it admits only the origins the trust table below marks as able to authorize (PLATFORM, TRUSTED_CONFIGURATION, USER), so MEMORY and KNOWLEDGE escalate exactly as EXTERNAL_UNTRUSTED does.

A successful bounded-scroll check under either browser condition has its own narrow exception: page-derived references do not re-escalate that explicitly permitted gesture. ProposedAction.newest_user_trust is an optional trusted pipeline fact from the newest user message, independent of tool-result taint. Only for this matched condition, the origin overlay uses that fact (falling back to origin_trust when absent). Platform, trusted-configuration and user origins may proceed; other origins still ask. An untrusted newer user message cannot borrow an older owner's authority. Other actions and conditions ignore this field and retain both existing overlay checks. The tool remains non-idempotent and records its effect, since a scroll may invoke page scripts even though it is authorized.

What the origin half does and does not guarantee. It is defense in depth, not a containment boundary. Origin trust is scoped to the active turn: the runtime walks back only to the most recent USER-trust user message, so a turn that ingests untrusted content taints that turn and no later one. An injection delivered in one turn, followed by the owner typing anything at all, produces a USER origin on the next turn. Policy does not catch that, and a profile must not claim it does. The control that holds across turns is the tool's own mechanism: the entry is legitimate only for a tool that shows the human what it is about to do — for device.sms.send, the recipient and the body in the compose sheet, which the owner can decline. That is the property human_confirms_arguments asserts, and it is why the flag is explicit rather than implied by the entry's existence.

Unclassifiable actions. Section 9.2's "Unknown tool → Deny" row looks unreachable, because Section 8.3 resolves the tool before policy runs and an unresolvable name already fails with ToolNotFoundError. The row is not unreachable; it is more general than its name. The engine denies any action whose classification it cannot determine:

  • a side_effect value the loaded profile has no rule for;
  • an action arriving through the in-sandbox RPC bridge (Section 8.5) or a device channel (Section 29) whose name resolves to no registered tool;
  • a ProposedAction failing schema validation.

All three return DENY with reason_code = policy.unclassifiable_action. The row's intent — fail closed on the unknown — is preserved and given a reachable home.

3. The advisory layer

Optional, off by default, sequenced after Milestone 6 by Section 21.1, and authorized as Milestone 30 by ADR-0111, the policy ADR roadmap item B8 requires. The five bullets below are the requirement and bind every advisor; the paragraphs after them are the first implementation.

  • Its output is constrained to abstain, require_approval, or deny, via a schema-validated structured response. It cannot emit ALLOW and it cannot emit modifications, so by construction it cannot lower a rank.
  • It runs only when the deterministic decision is ALLOW or ALLOW_WITH_MODIFICATIONS. There is nothing for it to add to a decision that already denies or already suspends, and skipping it there keeps the expensive path off the common path.
  • It never sees the hardline list or the profile contents. Showing an injectable component the rules it is protecting is showing the injection the rules it needs to evade.
  • Its input is injection-hardened per ADR-0017: comments stripped, untrusted content XML-delimited.
  • On timeout or error it abstains. Failing open is correct here and only here: the advisory layer can only escalate, so an unavailable advisor leaves the system exactly as safe as the deterministic gate, which is authoritative by Section 9.3. Blocking on it would make an optional component load-bearing for availability, which ADR-0017 forbids in the safety direction and which is no better in this one.

The advisor is a port. PolicyAdvisor.advise(action) takes the proposed action and nothing else, and returns a verdict of abstain, require_approval, or deny; the verdict type has no allow. Because no ruleset, profile, or deterministic decision is in the signature, "never sees the rules" is a property of the interface rather than of an implementation's care. AdvisedPolicyEngine implements PolicyEngine over the deterministic engine and one advisor and combines through the max rule above. The first advisor is backed by the typed-judgment port, not the model gateway: the output was always a closed choice, abstaining on a timeout suits a call measured in tenths of a second, and this package may not reach the model gateway. ADR-0111 records that divergence from Section 21.1's dependency column. A model-gateway advisor remains possible behind the port.

What version one consults on, which is narrower than the requirement allows. Only a plain ALLOW, and only a NETWORK_READ whose target is the web or browser provider: a search query, a fetch URL, a navigation URL. That is where an allowed action still carries model-written text outward — arguments are untrusted unless the owner's message supplied them, and the trust overlay already escalates every class but NONE, WORKSPACE_READ, and NETWORK_READ. ALLOW_WITH_MODIFICATIONS is passed through untouched: escalating it would ask the owner to approve arguments the deterministic layer had not yet narrowed. Reads with no outward side, MCP reads, file writes, code execution, and the human-confirmed SMS tool are not consulted on.

It asks narrow questions and code decides. Three probabilities, each phrased positively: the outbound text addresses an AI system; the query or the URL's query carries private details of an identifiable person; the URL's path or query carries prose or an opaque encoded blob. Any one at or above its threshold escalates. No question can veto another and none asks whether the action is safe, so an answer steered downward yields exactly the advisor-off state and one steered upward costs a prompt. Thresholds are constants beside the questions, stricter when the turn's origin cannot authorize, and a hash of questions and thresholds is the advisor_version. They are not profile values: a tuning deploy would otherwise change policy_version and void unrelated pending approvals, and a vendor's model can drift regardless, so hashing them would not make an advisory decision replayable.

No shipped advisor emits deny. A denial from an injectable component would be unappealable, would reach the model as a reason code, and would void an approval the owner had just given. The verdict type keeps deny so the monotonicity gate can exercise the full cross product.

The hardening, restated for a structured state. Instructions and criteria are platform-authored and travel only in question fields. Every untrusted string is XML-entity-escaped and wrapped in an untrusted_input element inside a named state field, so content cannot close its own delimiter; HTML comments and a fetch URL's fragment are stripped. The state carries the tool name and the outbound string arguments after the approval view's redaction — sensitive keys and credential-shaped values masked, long strings truncated — with the URL split into host, path, and query in code, capped at 4 KiB. A truncated value escalates, because its tail cannot be judged. No identifier, scope, hash, trust label, rule, profile content, or deterministic decision is sent.

Once per invocation. The tool pipeline holds the deterministic engine as its recovery policy and uses it at revalidation and whenever an invocation row already exists for the idempotency key, taking the max rank with the decision persisted on that row under the same policy_version. A resumed or revalidated invocation therefore makes no advisor call, a verdict cannot flip a running invocation into a forbidden transition, and an escalation recorded before a crash survives it. Standing authorization evaluates against the deterministic engine, so a standing grant never satisfies an advisory escalation.

Observe before enforce. Observing is the environment flag AGENT_POLICY_ADVISORY_OBSERVE_ENABLED: the advisor runs, its verdict is recorded, and the deterministic decision is returned unchanged. It changes no effective rule, so it is not a profile value and does not move policy_version. Enforcing is the profile value advisory.enabled: it changes decisions, so it hashes into policy_version like every profile value, and with both set it wins. ADR-0111's amendment records why the two differ: the bundled memory-formation release evidence is bound to the compiled policy_version, so a profile change — and therefore enforcing — voids a pending approval whose re-evaluation is not ALLOW and unbinds that evidence until it is regenerated, while observing costs neither. The shipped profile is unchanged. With the layer on in either way and no judgment provider composed, composition warns once and uses the deterministic engine.

What an escalation looks like. REQUIRE_APPROVAL with reason_code = policy.advisory.escalated, an explanation naming signal identifiers and the advisor_version and no content, no modified_arguments, and the deterministic decision's policy_version. One coarse code is deliberate: policy_reason is visible through the approvals API. An abstention returns the deterministic decision byte-identical and is recorded as a metric and a structured log line by cause — timeout, provider error, missing credential, invalid response — never as a decision.

4. Human approval

The gate for consequential actions, specified in the next section.

Policy profiles and policy_version

AgentSpec.policy_profile (Section 6.1) is a str selecting a profile by name. PolicyDecision.policy_version (Section 9.1) is a str with no stated producer, format, or storage — and the context engine's ContextPlan already consumes it. This section is what makes that reference resolve.

A profile is a named, versioned, file-backed rule set at src/agent_core/policy/{name}.yaml. Version 0.1 ships one, default, which is the matrix above.

A policy_version identifies the entire evaluated ruleset — profile plus hardline set — not a single file:

{profile_name}@{profile_sha256[:12]}+h{hardline_sha256[:8]}

default@3f2a1c9d4e5b+h7c1e0a92

A content hash rather than a counter, for one reason: it cannot be forgotten. A monotonic version number requires a human to remember to increment it while editing a rule at an hour when humans do not remember things, and a stale number is worse than no number because it asserts a falsehood. Two deployments reporting the same string provably evaluated identical rules.

Profiles are loaded once at process start and frozen, exactly like the hardline set, and a policy.profile.loaded event records the load. Rules therefore change across a deploy, never within a process. This is what makes Section 9.3's step 8 — "revalidate policy after approval in case policy or arguments changed" — analyzable rather than a race: an approval can outlive a deploy, which is exactly the window step 8 exists to close.

policy_version is written to:

  • every PolicyDecision, hence tool_invocations.policy_decision;
  • the approvals row, at request time and again at revalidation;
  • ContextPlan (context engine), because the cacheable prefix advertises the tool list and its gating, so a ruleset change makes the prefix's claim about what requires approval stale;
  • the policy_profiles audit table, so a version string found on a year-old decision can be resolved to the rules that produced it.

That last table is an audit record, not a rule store. Rules stay in files.

The approval lifecycle

The eight steps, and where each one can fail

Section 9.3's sequence, with the failure mode each step must survive. The ordering is not negotiable: every step before the lease release must be durable, because after the lease is released no worker is watching.

  1. Persist the proposed tool invocation — status WAITING_FOR_APPROVAL, carrying the classification and the PolicyDecision.
  2. Persist the approval request — same transaction as step 1. A proposed invocation with no approval row is a run that waits forever.
  3. Checkpoint the run — the checkpoint records pending_approval_ids (Section 6.9), which is how the resumed worker knows what it is resuming.
  4. Transition the run to WAITING_FOR_APPROVAL — a guarded transition from RUNNING, which fails if the run was cancelled concurrently.
  5. Release the worker lease — last, and only after 1–4 committed. A crash before this point leaves the lease to expire and the run to be reclaimed and replayed from the checkpoint; a crash after it leaves a correctly parked run.
  6. Emit the approval event — approval.requested. Per the event-log spec, notification is a latency hint, so the approval UI polls from a watermark and does not depend on the notification arriving.
  7. Resume only after an authenticated resolution — via the run dispatcher, which re-queues the run.
  8. Revalidate policy — specified below.

Resolution is idempotent, and the first one wins

Section 29 requires that any authorized device may resolve an approval and that resolution be idempotent, first resolution winning. Mechanically:

UPDATE approvals
   SET status = :new_status,
       resolution = :resolution,
       resolved_at = :now,
       resolved_by = :principal_id
 WHERE id = :approval_id
   AND tenant_id = :tenant_id
   AND status = 'PENDING'
RETURNING *;

Zero rows returned means someone else resolved it first. The response then depends on whether the two callers agreed:

  • Same decision — return 200 with the stored approval. This is what idempotent means: the caller's intent holds, and a phone that retried on a flaky connection should not see an error.
  • Different decision — return 409 with the stored resolution in the body. First resolution still wins; the second caller is told, because silently discarding a human's explicit "deny" and reporting success is the one behaviour here that could mislead someone into thinking they had stopped something.

Authorization to resolve requires the approval.resolve scope and a matching tenant_id. Cross-tenant access returns not found, never forbidden — forbidden confirms the row exists, which is itself a leak, and Milestone 4 requires cross-tenant approval access to be rejected.

Self-approval — the principal who started the run resolving their own approval — is permitted by default, because the single-user deployment Section 6.2 anticipates has no one else. A profile may require a distinct resolver; the check is a rule, not a hardcoded condition.

Expiry and cancellation

Both edges belong to the application service, not the worker loop, because a parked run has no worker (Section 9.3).

The reaper runs periodically, claims approvals past expires_at with a guarded update identical in shape to the one above, marks the tool invocation DENIED, emits approval.resolved with an expiry resolution — the plan specifies this event, so no new event type is introduced — and completes or fails the run deterministically. Default expiry by risk: 24 hours for LOW and MEDIUM, 4 hours for HIGH, 1 hour for CRITICAL, capped by the run deadline when present (ADR-0209). Top-level resumption after that deadline fails visibly with deadline_exceeded. Cancellation is rechecked after awaited authorization and card presentation, before an approval is persisted; an already-expired run fails instead of entering another approval wait. An expired approval is never auto-approved, in any profile, at any risk level.

Cancellation of a QUEUED or WAITING_FOR_APPROVAL run transitions directly to CANCELLED and reaps any pending approval to CANCELLED — not DENIED, per the status discussion above.

The reaper and the cancellation path can race for the same row. Both use the same WHERE status = 'PENDING' guard, so one wins and the other is a no-op; neither needs a lock and the losing side has nothing to undo.

Revalidation after approval

Step 8 says "in case policy or arguments changed" and leaves the comparison unspecified. Four things are compared between request and resume:

Changed since the request Consequence
normalized_arguments_hash Approval void — different bytes were approved
Principal scopes narrowed Approval void — authority was revoked
AgentSpec.version Approval void — tools or profile may have changed
policy_version Re-evaluate from scratch

The first three void the approval outright, emit approval.invalidated, and deny the call. They are not re-askable: the human approved a specific action, and a changed action is a new question.

The fourth re-runs the engine. If it returns ALLOW, execution proceeds. If it returns anything else — including a second REQUIRE_APPROVAL — the call is denied with reason_code = policy.revalidation.escalated. Asking a second time would be defensible, but it admits a loop in which a ruleset that always escalates parks a run forever, and a denial the user can retry deliberately is better than a pause the system cannot leave.

The engine re-run here is the deterministic engine, the pipeline's recovery policy, even when the advisory layer is enabled. The advisor's only power is to summon the owner, and the owner has just approved these exact bytes; asking it again could only void that approval on a judgment that may differ between two calls.

Denial is a message to the model, and a message is an attack surface

Milestone 4's acceptance criterion "denial becomes a structured tool result" has no shape anywhere in the plan. It needs one for a protocol reason and a security reason.

The protocol reason: both first adapters treat a tool-use block with no corresponding tool-result as a malformed conversation. A denial that simply drops the call corrupts the next request.

The security reason: the model is a partially trusted consumer (Section 22). Telling it precisely which pattern blocked it tells it what to avoid, and a model that reformulates until it gets through has been handed a search gradient.

So the denial result is deliberately thin:

{
  "status": "denied",
  "action": "demo.external_write",
  "reason_code": "policy.matrix.external_write",
  "message": "Not performed. Approval was required and was denied.",
  "retryable": false,
  "remediation": "none"
}
  • reason_code is a stable enumerated string of the form policy.{layer}.{rule} or approval.{outcome}. Stability matters: the loop detector below keys on it.
  • message is a fixed string per reason_code. It never contains the rule text, the pattern, the profile name, or another principal's data. The full explanation from PolicyDecision goes to the audit log and the human approval UI, not into the context window.
  • remediation is one of none, request_approval, or modify_arguments — enough for the model to choose a next step, not enough to reverse-engineer the rule.
  • A test asserts the serialized result matches an allowlist of exactly these fields.

Scope failures produce this same shape. Section 8.3 checks principal scopes before policy, so a missing scope is an AuthorizationError and not a PolicyDecision — it carries no policy_version, because no policy was evaluated. The denial result is therefore produced by the pipeline rather than by the engine, and covers both origins with reason_code = policy.scope.missing in the second case.

The repeated-denial circuit breaker. A model that proposes a denied action, reads a denial, and proposes it again will do so until the run budget is gone. Because reason_code is stable and normalized_arguments_hash is computed anyway, the runtime counts identical denied proposals per run keyed on (name, normalized_arguments_hash, reason_code) and fails the run after the third, with ToolPolicyDenied (Section 13). Three, not one: a model correcting its arguments after a denial is legitimate and common, and it changes the hash.

Trust tiers and trust labels

Section 22's three tiers and Section 11.2's seven TrustLevel labels do not map onto one another anywhere, and two labels — MEMORY and KNOWLEDGE — have no tier at all. That gap matters here because Section 11.2 also states that a tool result must never redefine platform policy or change approval requirements, which is an authorization claim about a trust label.

The mapping, plus the authorization weight each label carries:

TrustLevel Section 22 tier May authorize
PLATFORM Trusted Yes — carries policy
TRUSTED_CONFIGURATION Trusted Yes — within the deployed ruleset
USER Partially trusted Within the principal's scopes only
INTERNAL_TOOL Partially trusted No
MEMORY Partially trusted No
KNOWLEDGE Untrusted for instructions No
EXTERNAL_UNTRUSTED Untrusted No

MEMORY is placed with the partially trusted content because it is agent-authored from prior conversation, which means it is at most as trustworthy as the least trustworthy content that produced it — and the memory formation spec tracks that provenance. But it cannot authorize, and that is the important half: a belief that says the user always approves deletions is a belief, not a grant. KNOWLEDGE is retrieved document content and is untrusted as instructions for the same reason a fetched web page is.

The single sentence this table encodes: content can inform a decision; only platform configuration and the principal's own scopes can authorize one. That is Section 2.5 with a schema.

Schema additions

Additions to Section 15. Nothing existing is removed; one column widens from NOT NULL to nullable, and the reason is stated in the vocabulary section above.

runs                                      -- Section 15, extended
  + principal_scopes JSONB NOT NULL       -- stamped at submission

approvals                                 -- Section 15, extended
  + tenant_id       TEXT NOT NULL         -- M4 cross-tenant rejection
  + principal_id    TEXT NOT NULL         -- who the run belongs to
  + session_id      UUID NOT NULL
  + action_kind     TEXT NOT NULL         -- ActionKind
  + action_id       UUID NOT NULL         -- the thing being approved
  ~ tool_invocation_id UUID NULL          -- was NOT NULL; see ActionKind
  + risk            TEXT NOT NULL         -- queue order, expiry default
  + policy_version  TEXT NOT NULL         -- at request time
  + revalidated_policy_version TEXT NULL  -- at resume, step 8
  + INDEX (tenant_id, status, created_at) -- agent approval list
  + INDEX (run_id)                        -- resume, cancellation reap
  + INDEX (status, expires_at)
      WHERE status = 'PENDING'            -- the reaper's only scan
  + UNIQUE INDEX (action_id)              -- one open approval per action

tool_invocations                          -- Section 15, extended
  + side_effect       TEXT NOT NULL       -- classified at decision time
  + risk              TEXT NOT NULL
  + idempotency_class TEXT NOT NULL       -- crash recovery reads this
  + origin_trust      TEXT NOT NULL
  + effective_arguments_hash TEXT NULL    -- set if policy modified args

policy_profiles                           -- audit only; rules are files
  policy_version   TEXT PRIMARY KEY       -- name@sha+hsha
  profile_name     TEXT NOT NULL
  profile_sha256   TEXT NOT NULL
  hardline_sha256  TEXT NOT NULL
  rule_count       INTEGER NOT NULL
  loaded_at        TIMESTAMPTZ NOT NULL
  loaded_by        TEXT NOT NULL          -- process or deployment id

runs.principal_scopes is the stamp the scope section specifies. It is JSONB and not TEXT[] because tool_definitions.required_scopes is already JSONB, and one schema carrying two representations of the same concept is a conversion function somebody eventually writes twice. Like export_consent it is a Milestone 2 column with a Milestone 4 reader, and its default is the empty array only for the migration; submission always writes the set explicitly, so a run with no stamp is a bug rather than an unprivileged run.

The classification columns on tool_invocations are denormalized on purpose. ToolSpec is versioned and its classification can change between releases; an audit needs what was true when the decision was made, and crash recovery must be able to dispatch on idempotency without loading a tool registry that may no longer contain that tool version.

Two new event types, added to Section 6.8's list:

approval.invalidated      -- revalidation voided an approved request
policy.profile.loaded     -- a ruleset was loaded; carries policy_version

Expiry deliberately does not get its own event: Section 9.3 already specifies that expiry emits approval.resolved, and a second event for the same state change would make every consumer count it twice.

Ports and data model

PolicyEngine (Section 7) is unchanged, including its async signature. These are additions.

The deterministic core is a synchronous pure function; the port stays async so the advisory layer can be composed behind the same interface. ADR-0111 corrects the claim this paragraph once made, that no caller changes: a deterministic engine may be evaluated twice for one invocation with no effect, and an advisor whose answer can differ between calls may not. The tool pipeline therefore takes a second, deterministic PolicyEngine as its recovery policy, used at revalidation and for an invocation that already has a row. It defaults to the primary engine, so a pipeline built with one engine behaves exactly as before.

class PolicyAdvisor(Protocol):
    async def advise(self, action: ProposedAction) -> AdvisoryVerdict: ...

AdvisoryVerdict carries the verdict, the identifiers of the signals that fired, and the advisor_version; it carries no content.

def evaluate_deterministic(
    action: ProposedAction,
    principal: Principal,
    run: Run,
    ruleset: LoadedRuleset,
) -> PolicyDecision:
    """Pure. No I/O, no clock, no database, no network."""
class LoadedRuleset(BaseModel, frozen=True):
    policy_version: str
    profile_name: str
    rules: tuple[PolicyRule, ...]
    hardline: tuple[HardlineRule, ...]

class ApprovalRepository(Protocol):
    async def create(
        self, request: ApprovalRequest
    ) -> ApprovalRequest: ...

    async def get(
        self, approval_id: UUID, principal: Principal
    ) -> ApprovalRequest: ...

    async def list_pending(
        self,
        principal: Principal,
        run_id: UUID | None = None,
        limit: int = 50,
        cursor: str | None = None,
    ) -> list[ApprovalRequest]: ...

    async def resolve(
        self,
        approval_id: UUID,
        principal: Principal,
        resolution: ApprovalResolutionType,
        reason: str | None,
    ) -> ApprovalResolutionOutcome: ...

    async def expire_due(
        self, now: datetime, limit: int
    ) -> list[ApprovalRequest]: ...

    async def cancel_for_run(self, run_id: UUID) -> int: ...

ApprovalRepository is the "Approval repository" Section 7 lists among the ports to define. resolve returns an outcome object rather than the row so the caller can distinguish applied from already resolved identically from already resolved differently without a second read.

API and CLI additions

Section 17 requires agent approval list. Section 16 defines only POST /v1/approvals/{approval_id}/resolve, so the CLI command has no endpoint to call. Two additions close that, and the CLI calls the same application service as the API per Section 17.

GET /v1/approvals?status=pending&run_id=&session_id=&limit=&cursor=
GET /v1/approvals/{approval_id}

Both are tenant-scoped from the authenticated principal and never from a query parameter — a tenant identifier accepted from the client is a cross-tenant read waiting to be discovered. Responses carry action_summary, tool_name, arguments, argument_digests, risk, expires_at, and policy_reason, and for browser.act the optional task_grant_offer, task_grant_id, and task_grant_not_covered (ADR-0129); they do not carry the rule that fired.

arguments is a redacted view, not the action: a value under a sensitive key or matching a credential shape becomes [REDACTED], and a string over 512 characters is truncated to that many characters plus a marker. Truncation alone would leave a client unable to confirm that a frozen action still matches the content it displays, so argument_digests carries the SHA-256 of every argument truncated for length, keyed by argument name. A client verifies a truncated value by digesting its own copy, which covers the whole value rather than the retained prefix. A value redacted for sensitivity is never digested: the owner has no reason to verify a credential, and its digest would be a brute-force target. The digests describe only the top-level arguments; nested truncation inside an object or array remains unverifiable.

Neither field is the integrity boundary. normalized_arguments_hash still covers the full normalized arguments of the tool invocation, and dispatch uses the invocation's arguments, never this view — a truncated view can therefore never become a truncated action.

POST /v1/approvals/{approval_id}/resolve is unchanged in shape and gains the 409-on-conflicting-resolution behaviour described above. All three routes are Milestone 5, where http-api-and-streaming.md puts every route in the API; the service methods they call and the CLI commands that call the same methods are Milestone 4.

ADR-0129 later added one optional body field, task_grant, which repeats the offered origin and path prefix and is accepted only with approve_for_task. That decision also requires browser.grant.write, checked by the application service before any read; the route's declared scope stays approval.resolve.

Failure modes and defenses

Failure What breaks Defense
A second call site authorizes an action The gate is bypassed for one surface One transition function; import-boundary test
Ruleset edited at runtime Decisions are unreplayable Files, frozen at load, hashed into policy_version
Advisory layer returns allow A model judgment overrides policy Output schema excludes it; max combination
Advisory layer times out Latency, or a stall on the common path Abstain on timeout; runs only on allow paths
Advisor is steered by text in the arguments it judges A missed escalation, or a needless prompt Escalate-only, any-signal mapping, no veto question; steered down equals advisor off
Advisory verdict differs between first evaluation and resume A forbidden status transition, or a recorded escalation lost Recovery policy is deterministic; max with the persisted decision; one advisor call per invocation
Approval approved, arguments then change A human's consent is transferred Hash compared at resume; approval.invalidated
Two devices resolve at once Double resolution, or a lost decision Guarded update; 200 if equal, 409 if not
Run cancelled with approval pending Orphaned pending row, wrong metrics Cancellation reaps to CANCELLED, not DENIED
Crash between steps 1 and 5 Run parked with no approval, or lost lease Steps 1–4 in order, lease released last
Reaper and cancel race Double transition Same PENDING guard; loser is a no-op
Model loops on a denied action Budget exhausted, no progress Circuit breaker at three identical denials
Denial text leaks the rule The model gets an evasion gradient Field allowlist; explanation never sent
Untrusted content proposes an action Injection reaches an external write Trust overlay raises ALLOW to approval
New SideEffectClass, no rule An unclassified action slips through Totality test; profile fails to load
Tool spec reclassified after a run Audit shows the wrong classification Classification denormalized at decision time
Cross-tenant approval probed Existence disclosed Not-found, never forbidden

Hard gates

Section 20's harness gates Milestone 4 on the first thirteen and Milestone 30 on the last five. Each is a hard gate: failing one blocks the milestone, not a warning.

  1. Totality. Every SideEffectClass value has exactly one rule in every loaded profile, and every Section 9.2 row maps to exactly one value. A profile missing a rule fails to load rather than defaulting. M4.
  2. Determinism. The same (action, principal, run, ruleset) evaluated one thousand times returns byte-identical output including reason_code and policy_version. The evaluator's module imports nothing from infrastructure and calls no clock. M4.
  3. Single gate. Exactly one function transitions a tool invocation from PROPOSED to AUTHORIZED, asserted by an import-boundary test, and the sandbox bridge and device channel both reach it. M4.
  4. Monotonicity. Over the full cross product of deterministic and advisory outputs, the combined rank is never below the deterministic rank. M4.
  5. Hardline immutability and precision. The loaded set cannot be mutated after import; every rule blocks its target and permits its declared near_miss. M4.
  6. Revalidation. Each of the four changes in the revalidation table produces its stated consequence, including after a worker restart. M4.
  7. Cross-tenant. Get, list, and resolve against another tenant's approval all return not-found. M4.
  8. No leakage. The serialized denial result matches the field allowlist exactly, for every reason_code. M4.
  9. Idempotent resolution. Two concurrent identical resolutions produce one state change and two successes; two concurrent differing resolutions produce one state change and one conflict. M4.
  10. Prompt is not authorization. Across the injection corpus Section 22 requires, untrusted content instructing a REQUIRE_APPROVAL action produces an approval request in every case and an execution in none. M4.
  11. Scope grammar. Every entry in the forty-two-string vocabulary and every required_scopes entry on a registered ToolSpec matches the grammar, and registration rejects an MCP tool declaring a scope that is neither in the vocabulary nor prefixed mcp.{server_id}.. M4.
  12. Scope matching is a subset test over exact strings. A principal holding one of a tool's two required scopes is denied with policy.scope.missing, the denial names the missing scope and not the held one, holding both succeeds, and holding run.write does not satisfy a requirement for run.read. M4.
  13. The scope set is the run's. Narrowing a principal's scopes after submission changes no decision in the run already submitted, and the next run that principal submits is denied. M4.
  14. The composite cannot lower a rank. Over the full cross product of deterministic decisions and advisor verdicts, evaluated through AdvisedPolicyEngine rather than the combination function alone, the result's rank is never below the deterministic rank, it never carries modifications the deterministic layer did not produce, and its policy_version is the deterministic one. Registered as gate.policy.advisory_monotonic, property. M30.
  15. The advisor runs on allow paths only, and once. It is never called after a hardline block, a DENY, a REQUIRE_APPROVAL, or an ALLOW_WITH_MODIFICATIONS, never for an action outside the consulted class, never at revalidation, and never for an invocation that already has a row; a persisted escalation survives a restart. Registered as gate.policy.advisory_allow_path_once, case. M30.
  16. An unavailable advisor abstains. A timeout, a provider error, a missing credential, and an invalid response each return the deterministic decision byte-identical and fail no run; with the layer enabled and no provider composed, composition uses the deterministic engine. Registered as gate.policy.advisory_abstains, case. M30.
  17. The advisor is blind to the rules, and what it sees is redacted. No advisor request contains a hardline pattern, path, range, or identifier, the profile's name or contents, a policy_version, or a deterministic decision; sensitive keys and credential-shaped values are masked, every untrusted string is escaped and delimited, and the advisor module imports neither the loader nor the hardline module. Registered as gate.policy.advisory_blind_redacted, structural. M30.
  18. Off and observing change nothing. With advisory.enabled false and the observe flag unset no advisor is constructed and no judgment request is made; the shipped profile compiles to the policy version the bundled release evidence is bound to; when observing, every decision is byte-identical to the deterministic decision and the policy version is unmoved while the verdict is recorded; and no shipped advisor returns deny. Registered as gate.policy.advisory_default_off, case. M30.

Tracked metrics

Extending Section 19's approval_requests_total:

  • Approval request rate by side_effect, and approval latency p50 and p95.
  • Expiry rate — a rising number means the expiry defaults are wrong or the notification path is failing, and the two are distinguishable by whether latency also rose.
  • Denial rate by reason_code, and the circuit-breaker trip rate.
  • Advisory escalation rate and advisory disagreement rate, tracked from the day the layer is enabled so its value is measurable before it is trusted. The escalation rate is escalations over consulted actions, counted in observe mode as well as enforce. The disagreement rate is the share of owner-resolved approvals with policy_reason = policy.advisory.escalated that were approved: the owner disagreeing with the advisor. Abstentions are counted by cause, with consulted-call latency and input tokens. The cause is timeout, the provider's reason code when the error carries one, or the error's class name. A host with no metrics exporter still has to answer "is the advisor running": every consultation writes one policy_advisory_consulted line carrying the tool name, the verdict, whether it was enforced, the signal identifiers, and the latency, and every abstention writes one policy_advisory_abstained line carrying the tool name and the cause. Neither line carries an argument, a probability, or an identifier.
  • Revalidation void rate by cause.

Build sequence

  1. The vocabulary: the six enums — SideEffectClass, RiskLevel, IdempotencyClass, ActionKind, ApprovalStatus, and ApprovalResolutionType — plus ProposedAction, ExecutionTarget. No logic, no callers. Everything below depends on this and nothing depends on the order of the rest.
  2. default.yaml, the loader, policy_version computation, and the totality test. The test exists before the second profile does.
  3. evaluate_deterministic as a pure function, with the determinism property test.
  4. The single gate in the Section 8.3 pipeline, scope check ahead of policy, classification persisted to tool_invocations.
  5. The denial tool result, the field allowlist test, and the circuit breaker.
  6. The hardline module, frozen, with block-and-near-miss tests per rule.
  7. approvals migration, repository, and the guarded resolution update.
  8. The eight-step pause flow, checkpoint interaction, WAITING_FOR_APPROVAL.
  9. Resume and revalidation.
  10. The reaper and the cancellation edge, including the race test.
  11. The ApprovalService read methods and the CLI commands that call them.
  12. The advisory layer, behind a flag, default off, after Milestone 6. In order: the ruleset's advisory field and the observe environment flag; the PolicyAdvisor port with its contract suite; AdvisedPolicyEngine; the argument-redaction helpers moved into the domain so this package may use them; the judgment-backed advisor; the pipeline's recovery policy; the composition wiring and the metrics.

Steps 1 through 11 are Milestone 4. Step 12 is Milestone 30, authorized by ADR-0111; it was sequenced by Section 21.1 and is not a Milestone 4 dependency. The two routes that expose step 11's read methods over HTTP are Milestone 5, with every other route: http-api-and-streaming.md places them there, and Section 21 agrees, naming the whole HTTP surface at Milestone 5 and no approval work in it. What Milestone 4 owes the routes is the service behind them, which is what "Approval API and CLI" in Section 21's Milestone 4 implement list means here — the CLI calls the application service directly, as the paragraph adding those routes already says.

Decisions

  1. SideEffectClass has fifteen values, one per Section 9.2 row, and the correspondence is asserted by a test in both directions.
  2. Risk never selects a decision; it orders the queue, sets expiry defaults, and groups metrics.
  3. IdempotencyClass has exactly the four values Section 8.4's crash-recovery bullets describe.
  4. ProposedAction carries trust labels for its arguments and its origin, so provenance is available to a rule.
  5. evaluated_at is an input, never a clock read.
  6. Restrictiveness is the total order allow, modify, approve, deny, combined by max.
  7. Only the deterministic layer may produce modified_arguments, and at most one rule may modify.
  8. When arguments are modified, the idempotency key is recomputed and both hashes are persisted.
  9. The default profile ships zero rules producing ALLOW_WITH_MODIFICATIONS.
  10. Hardline rules are packaged files, frozen at import, and deliberately not behind a port.
  11. Every hardline rule declares a near_miss it must permit, enforced by the schema and the tests.
  12. Section 9.2's three non-enum decision strings resolve as conditions and profiles, changing no stated outcome.
  13. "Unknown tool" generalizes to policy.unclassifiable_action, which is reachable through three paths.
  14. The advisory layer may only escalate, runs only on allow paths, never sees the rules, and abstains on timeout. As built under ADR-0111 it is a port whose shipped advisor never denies, is consulted at most once per invocation, and observes before it enforces.
  15. policy_version is {profile}@{sha12}+h{sha8}, a content hash rather than a counter.
  16. Profiles and hardline rules are files under version control, never database rows; policy_profiles is an audit record only.
  17. Rulesets are frozen per process and change only across a deploy, which is what makes Section 9.3's step 8 well-defined.
  18. ActionKind generalizes approval beyond tool calls; approvals.tool_invocation_id becomes nullable.
  19. ApprovalStatus has five values; CANCELLED is distinct from DENIED.
  20. Resolution is a guarded update: 200 when the second caller agrees, 409 when it does not.
  21. Cross-tenant access returns not-found rather than forbidden.
  22. Self-approval is permitted by default and is a rule, not a hardcoded condition.
  23. Expiry defaults are risk-scaled; an expired approval is never auto-approved.
  24. Revalidation voids on argument, scope, or agent-version change, and re-evaluates only on policy_version change; a second REQUIRE_APPROVAL becomes a denial.
  25. The denial tool result is a field allowlist that never carries rule text.
  26. Three identical denied proposals fail the run.
  27. Classification is denormalized onto tool_invocations at decision time.
  28. Expiry reuses approval.resolved; only approval.invalidated and policy.profile.loaded are new event types.
  29. The seven trust labels map onto Section 22's tiers, and only PLATFORM, TRUSTED_CONFIGURATION, and USER can authorize anything.
  30. GET /v1/approvals and GET /v1/approvals/{id} are added so agent approval list has an endpoint.
  31. The scope vocabulary is one closed set of forty-two dotted strings, shared by the API's route checks and by this pipeline's tool check. Authorized milestones and ADRs add exact schedule, device, notification, memory, delegation, surface, persona, email, call, People, settings, and media scopes without changing exact matching or the Milestone 4 policy baseline.
  32. An MCP tool may require only scopes whose first segment is mcp and whose second is the server id, so an operator configuring a server cannot borrow a platform scope for a remote capability.
  33. Scope matching is a subset test over exact strings — no hierarchy, no wildcard, no prefix rule — and the denial names the missing scopes and never the held ones.
  34. The principal's scope set is stamped on the run at submission; PrincipalResolver.for_run reads the stamp and never a table.
  35. Principal.roles is populated and never read as an authorization input in 0.1, because one configured principal has no bundle to resolve.

Advisory layer implementation checkpoint: 2026-09-20

Build step 12 landed. PolicyAdvisor and the verdict types, AdvisedPolicyEngine in agent_core.policy.advised, the judgment-backed advisor in agent_core.policy.judgment_advisor, the argument-redaction helpers in agent_core.domain.argument_views, the pipeline's recovery policy, the observe flag, the composition wiring, and the metrics in agent_core.observability.policy. Gates 14 through 18 bind through tests/gates/test_policy_advisory.py; the composed check approves an escalated search and finds it executed once with the vendor asked once. The layer is off in every deployment, and the thresholds are untuned initial values to be calibrated while observing.

Open questions

Does "Deny initially" mean a later profile or a later milestone? Section 9.2 marks package installation and sandbox networking "Deny initially". This document reads "initially" as in the default profile, which makes the relaxation a configuration change. The other reading is that a later milestone changes the default for everyone. The two differ in who can turn it on.

Should ALLOW_WITH_MODIFICATIONS ship in version 0.1 at all? It is defined in Section 9.1 and consumed nowhere. The plumbing is specified here because removing a declared enum value would weaken the plan, but shipping an untested path is its own risk, which the zero-rules decision is meant to bound.

Where does the configured principal's scope set live? 0.1 has one principal, so its scopes are a configured value, and this document does not say which of the three configuration layers holds it. A settings field keeps the count of configuration files at six; a file makes the second principal a data change rather than a deploy. The question becomes answerable when there is a second principal to hold.

Milestone 26 email application authority

The approved email experience adds exact email.read and email.write scopes for application resources under ADR-0092. They do not replace account-specific MCP scopes, run scopes or approval authority. Every send still resolves through the ordinary exact-arguments approval lifecycle; internal autosave grants no Gmail write. Current authority and a fresh thread read precede an atomic local frozen-action claim. Observed source or content changes invalidate approval; an external change after the read remains a Gmail race, and possibly dispatched non-idempotent sends remain uncertain without automatic retry. Email content and inferred style never become trusted policy.

ADR-0095 adds a model-free archive/move-to-Inbox command. The labelled owner gesture supplies expiring consent to one exact account-bound INBOX change. It does not lower the Gmail write approval floor: after validating that consent, the application records approval.requested, uses ordinary APPROVE_ONCE resolution, and resumes the same frozen invocation with normal revalidation. Current authority, source revision, expiry, worker fencing, and distinct-resolver policy remain enforced. An uncertain write is not automatically repeated.

Milestone 31 unsubscribe approval floor

ADR-0112 adds one tool-name arm to the floor ADR-0071 and ADR-0097 established for mutating email servers and telephone calls. email.unsubscribe is EXTERNAL_WRITE, which the default matrix already resolves to REQUIRE_APPROVAL (policy-and-approvals.md:613); the arm holds that decision under every profile, so no profile can turn an unsubscribe request into a standing allow. It can only tighten. An owner gesture in Email mode satisfies the approval through the exact-match consent consumer ADR-0095 introduced, never through a standing authorization, and a Chat proposal parks in the ordinary queue.

ADR-0143 adds the exact notification.write scope for the bounded notification sync route. It authorizes terminal-result receipts and reconciliation, never approval resolution, question input, device actions or mutation of run state.

An authenticated owner's complete instruction to follow one resolved X account is single-action consent. The application records its target on the owner message, validates ownership and current website access, and consumes it once before a matching dispatch. It applies only to a top-level interactive owner run, within thirty minutes and before any subsequent owner message supersedes it. Scheduled text, delegated work, inbound-surface content, memory and website text cannot supply that consent. Policy DENY decisions remain final.

When policy returns REQUIRE_APPROVAL for the exact Follow action, this recorded consent satisfies it without a second prompt. Authorization is audited as owner_action; the isolated runtime rechecks a constrained profile and control, and completion requires positive Following evidence on that profile. Changed targets and unrelated writes retain their normal approval rules. A consumed or uncertain request cannot authorize another click. Verified connection/sign-in resumes the same bounded request; it does not create general browser authority.