ian-operator lib/operator.js Pure function, explicit profile Folio II of VII

The approval gate

Six rules, three verdicts, and one approval path that is deliberately absent from the interface the model can reach. This is the heart of the instrument; everything else in the repository exists to keep this function honest.

Article II — Of the recording of intent and the granting of approval

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:

THE GATE IS LAW: before any external send, spend/purchase, paid commitment, phone call, or people-impacting action, you must first call operator_record_action_intent. If the gate returns requires_approval/blocked you STOP. src/runner/fast-tick.mjs:247–248

The 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.

lib/operator.js:1003–1021normalizeAction
// 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.

Decision cascade of evaluateOperatorActionPolicy A normalised action enters at the top. Six rules are evaluated in order: money, irreversibility, phone calls, night-shift recipient gating, sender disclosure, and secret-shaped text. The first two set the requires-approval flag; the last four set the blocked flag. The two profile-conditional rules are drawn with dashed borders. At the bottom the flags collapse: blocked wins over requires approval, which wins over allowed, and each verdict maps to an initial intent status of blocked, pending approval, or ready. normalizeAction(action) → normalized 1 · Money type ∈ {spend, purchase, paid_commitment, subscription, vendor_payment} · |amount| ≠ 0 · money verb requiresApproval = true 2 · Irreversibility delete, publish, deploy, post_public, wire_transfer, send_bulk, overwrite, drop · metadata.irreversible requiresApproval = true 3 · Telephony phone_call, call, or channel = phone — unless calls_enabled blocked = true 4 · Night-shift recipients (profile-conditional) only if profile.recipients declares night_allowed / night_blocked blocked = true 5 · Sender disclosure (profile-conditional) only if profile.disclosure sets sender_regex + literal blocked = true 6 · Secret-shaped text had_secret_like_text — redacted, then held for review blocked = true blocked ? 'blocked' : (requiresApproval ? 'requires_approval' : 'allowed') blocked → status 'blocked' requires_approval → 'pending_approval' allowed → status 'ready'
Figure 2 — the decision cascade All six rules are evaluated; there is no early return. Reasons accumulate into an array that is stored with the intent, and the two flags collapse in a fixed precedence at the end. lib/operator.js:2222–2297

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:

  1. 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;
  2. the subject must appear in the human-approver allowlist, drawn from an environment variable or profile.policy.human_approvers;
  3. the intent must be neither blocked nor 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:

lib/operator.js:2448–2450, 2157–2159the two refusals
// 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.

Executability by verdict and status
policy_decisionstatusExecutable?
blockedanyNo, ever.
allowedreadyYes.
allowedapprovedOnly with a valid decision bound to this intent and this content.
requires_approvalapprovedOnly with a valid decision bound to this intent and this content.
requires_approvalpending_approvalNo.

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.

Two proposed actions traced through the gate — one passing, one held Two parallel sequences down four lifelines: brain, MCP face, kernel gate and executor. On the left a wiki note is recorded, stamped allowed with status ready, claimed by the executor and marked sent. On the right a paid subscription is recorded, stamped requires approval with status pending approval; the runner stops, the human is notified with granular reasons while the brain receives only a coarse verb and category, and the intent only becomes executable after an allowlisted human approves it out of band, binding a decision to the intent and its content hash. CASE A · write a wiki note CASE B · start a paid plan (amount_cents 4900) BRAINMCP FACEGATEEXECUTOR BRAINMCP FACEGATEHUMAN record_action_intent{type:'note'} normalize → evaluate allowed · no rule fired status ready · reasons [] progress row: outcome 'staged' isActionIntentExecutable true claim: ready → sending (CAS) handler tier 'reversible' files_write, mode 'create' write-if-absent status 'sent' — terminal verifier checks the declared file record_action_intent{type:'spend'} normalize → evaluate requires_approval · rule 1 status pending_approval to the brain: coarse only VERIFY — spending requires human approval to the human: granular amount, reasons, intent id once per fresh intent runner STOPS · outcome 'asked' approve-intent.mjs subject not a runner subject on the allowlist decision ← intent_id + action_hash status 'approved' + linkage audit kind 'approval' executor: tier 'spend' — no auto-purchase notifies the owner with full detail; marks the intent 'completed' as a terminal human hand-off Nothing left the machine until the executor claimed a row the kernel predicate still judged executable. The brain never learns the amount that tripped the rule, and money is never moved by machine on either path.
Figure 3 — a passing proposal and a held one Case A is the ordinary path for reversible internal work. Case B is the path every consequential act takes. The asymmetry between the two right-hand boxes at the top of case B — coarse to the model, granular to the person — is the anti-oracle property described in Article III §3.6.

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.

terminal · a granted approvalsrc/runner/approve-intent.mjs
$ 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].

terminal · liveness probeserver.js:171–181
$ 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

  1. The explanatory comment above approveActionIntent and 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.