Article II Of intent and approval
2.1
Recording is not doing
The gate’s first premise is a separation that the rest of the system never lets collapse: an action intent is a row in a table, not an act. The tick-runner states it flatly — “the action_intent is a record, not an effect.”
Every autonomous runner is told the same thing in its system prompt, in capitals:
operator_record_action_intent. If the gate returns requires_approval/blocked you STOP.
src/runner/fast-tick.mjs:247–248The prompt is not the enforcement. The enforcement is that the runner has no other capability: its allowlist of callable tools contains only state and intent tools, so a model that ignores the instruction still cannot send anything — it can only fail to record.
2.2
Normalisation precedes judgment
Before any rule is applied, the proposed action is normalised. Types, channels, recipient types
and sender accounts are folded to lowercase snake tokens; the body is passed through the redactor;
amount_cents is coerced to a number; and a flag is set if the body ever contained
secret-shaped text.
// Slice 8.1 security MUST-FIX B: a caller-supplied approval_decision_id is IGNORED. The // approval linkage is settable ONLY by approveActionIntent (the out-of-band human path), // never at record time. idempotency_key: String(action.idempotency_key || action.idempotencyKey || '').trim(), metadata: action.metadata && typeof action.metadata === 'object' ? action.metadata : {}, had_secret_like_text: containsSecretLikeText(rawBody),
Three regular expressions define “secret-shaped”: a key/value pair whose key contains
api_key, token, secret, password,
authorization or a CSRF/dash/bot token variant; an Authorization: Bearer …
header; and the numeric-colon-alphanumeric shape of a bot token. The same three drive
redactOperatorText, which is applied to the body at normalisation and, recursively, to
every string leaf of the metadata object before it is written — “so secrets that ride in metadata do
not round-trip out via operator_state.”
2.3
The six rules
The gate is one exported function with an explicit profile argument and no I/O. It accumulates two booleans across six rules, then collapses them into a single verdict. Two of the six rules only fire when the loaded profile actually defines the taxonomy they depend on — under the bundled generic profile they are inert by construction.
2.4
Approval, not prohibition, for irreversible acts
The second rule carries an explicit note about why it produces the amber verdict and not the red
one: “This is approval, not block: a human can
green-light it, but the executor can never fire it alone.” The distinction is real —
a blocked intent can never be approved by any path, and
approveActionIntent throws on one.
Profiles may extend the irreversible set through profile.policy.irreversible_types;
the profile’s entries are token-normalised and unioned with the built-in list, so a profile can widen
the rule but never narrow it.
2.5
The row the verdict creates
recordOperatorActionIntent runs the gate, maps the verdict to an initial status, and
inserts one row. Two details in that write matter more than they first appear.
Derived idempotency
If the caller supplies no idempotency key, one is derived from the action’s salient fields —
sha1(type|title|recipient|amount_cents|body), truncated to 32 characters — before the
duplicate check, so the same action re-proposed on the same day short-circuits to the
same row and returns created:false. The runners key their notification on that
flag, so a model that re-proposes a gated spend five times produces one row and one notification
rather than five of each.
Coarse audit detail
The audit row written alongside the intent deliberately does not carry the gate’s reasons. The reasons contain amounts, recipients, thresholds and disclosure literals; the audit trail is read back to the model verbatim in the context packet. So the audit detail carries only a normative verb and a coarse category — VERIFY plus, for example, “spending requires human approval”. The granular reasons survive only in the human notification.
2.6
Approval exists only out of band
There is exactly one function that can mint an approval, and it is not reachable from the MCP
interface. approveActionIntent requires an approver subject, and applies three tests
before it writes anything:
- the subject must not match the runner pattern — the fast tick, the slow tick, the brain, the
executor, a relay, or any subject containing the token
relay; - the subject must appear in the human-approver allowlist, drawn from an environment variable or
profile.policy.human_approvers; - the intent must be neither
blockednor already terminal.
Only then does it insert a decision row bound to the intent by intent_id and to the
intent’s current content by action_hash, link that decision onto the intent, and
flip the status to approved. The insert is INSERT OR IGNORE on a slug
derived from the intent id and the hash, so approving twice is idempotent.
The two MCP-reachable mutation tools are closed by explicit throw:
// MUST-FIX A: approval may ONLY be minted by approveActionIntent (out-of-band human path). if (normalizedStatus === 'approved') { throw new Error('updateOperatorActionIntent cannot set status=approved; approval is out-of-band only via approveActionIntent (not exposed on the MCP face)'); } if (['approved', 'accepted'].includes(normalizedStatus)) { throw new Error('updateOperatorDecision cannot set status approved/accepted; approval is out-of-band only via approveActionIntent'); }
2.7
What makes an intent executable
Status alone is never sufficient. isActionIntentExecutable is the single predicate the
executor, the assertion helper and the transactional claim all consult, and it re-derives the content
hash from the live row every time it is asked.
| policy_decision | status | Executable? |
|---|---|---|
| blocked | any | No, ever. |
| allowed | ready | Yes. |
| allowed | approved | Only with a valid decision bound to this intent and this content. |
| requires_approval | approved | Only with a valid decision bound to this intent and this content. |
| requires_approval | pending_approval | No. |
The third row closes a subtle path: an allowed intent quietly stamped
approved would otherwise skip the ready-path audit. Validity means all five of: the
decision exists; its status is approved or accepted; its author is not a runner; its author is on the
allowlist; its intent_id matches; and its action_hash equals the hash
recomputed from the intent as it stands now. Editing the recipient after approval changes the hash
and voids the approval.4
2.8
Two proposals, side by side
The clearest way to read the gate is to follow one action that passes and one that does not. Both begin identically: the brain returns a JSON decision, the runner overwrites the actor field unconditionally, and the intent is recorded.
2.9
The approval instrument in use
The approval CLI is the interim human path; its header notes that a future chat handler must call the same library function with the human’s subject rather than adding a tool to the MCP face.
$ node src/runner/approve-intent.mjs 41 --as ian [2026-07-07T16:04:12.338Z] APPROVED intent 41 (email) -> status='approved' via decision 12 by 'ian' [2026-07-07T16:04:12.341Z] The intent is now executable; the separate executor-tick will act on it (dry-run unless OPERATOR_EXECUTOR_DRYRUN=0). $ node src/runner/approve-intent.mjs 41 --as operator-fast-tick [2026-07-07T16:05:02.117Z] approval REJECTED: refused: 'operator-fast-tick' is a RUNNER actor; approval must come from a human/non-runner actor $? 1
Flags are those declared in the CLI usage line: <intentId> [--as ian] [--note "…"] [--day YYYY-MM-DD].
$ curl -s http://127.0.0.1:3401/health { "ok": true, "service": "ian-operator", "version": "0.1.0", "profile_id": "generic-personal-v1", "timezone": "America/Chicago", "db_path": "…/.ian-operator/operator.db", // absolute in the real response "uptime_s": 41283 } $ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3401/mcp 401
Notes
- The explanatory comment above
approveActionIntentand the implementation beneath it disagree by one field: the code hashes strictly more of the intent than the prose describes, so an approval binds to more than the comment promises. The implementation is the stricter of the two, and §7.5 records the drift. ↩