# 47 — The commitment kernel Status: **all phases shipped and wired; strands and the control plane shipped with resolver version 2; the tier-1 reader was rewritten and the runtime wiring corrected in resolver version 4** (the reviews that led to them are summarised under *What the review changed* and *What the second review changed*). | Phase | Delivers | Governs the turn through | |---|---|---| | I0 | kernel (`vak-intent`): seven axes, cascade, authority, narrowing lattice, **strands** | `Core::resolve_turn_intent_with_escalation` | | I1 | commitment ledger (`vak-commit`): lifecycle, satisfaction lattice, projection, portfolio scheduler | `commitments::plan_episodes` then `begin_episodes` — one commitment per thread | | I2 | admission, intent ledger entry, `[intent]`/`[commitment]` config, `vak intent explain` | `IntentRecord` (reading, strands, engagement, note) | | I3 | route demand, per-commitment budget, prompt projection, **context profile** | `plan_route_ladder(demand)`, `CoreSpendGate::narrow_run_cap`; `ContextProfile` reaches the working-set planner through `ReadingKey.context` (`minimal`: no relevance retrieval, only the two most recent turns at `Full`), the tail (`working`/`full`: the workspace delta since the session began, logged as a `workspace_delta` activity first) and the note (`full`: `commitments::prompt_projection`) | | I4 | capability slicing (progressive disclosure) | stage-4 exclusion for a confident reading; the tool surface (core vs. deferred) for every reading | | I5 | envelopes: `vak grant` / `vak revoke`; a live grant narrows the strands that serve its commitment and pre-authorizes, one gate at a time, the actions it covers; revocation honoured on read | `vak_intent::apply_envelopes` → `intent::permission_mode` → `cfg.mode` and `spend_ceiling_usd` → the run cap; `intent::envelope_check` → `AgentConfig::envelope_check` | | I6 | episodes bracketing durable turns; upkeep tick — schedule wakes, predicate wakes, escalation policies, explicit expiry; **`Defer`** | `intent::DeferringApprover` parks an unanswerable gate in the inbox and suspends the commitment | | I7 | server endpoints, admin portfolio, composer strip, the `commitments` capability, per-channel autonomy ceiling, delivery cadence/urgency | the gateway reads `posture.delivery` from the turn's intent entry | | I8 | misread evidence: escalation-as-measurement, restatement, per-cell accuracy | scoped to the turn's own tool calls; `vak intent show` reports weak cells | | **I9** | **tiers 2/3**: a `Classify` dispatch on a weak reading — spend-gated, watchdogged, fail-open | `[intent] escalate = local \| cloud`, `classify_model` | | **I10** | **control plane**: authority from the channel, explicit commands, no text heuristics | `ControlSource`, `parse_command`, `evaluate_intervention` | Every part is switchable off with `[intent] enabled = false` and `[commitment] enabled = false`, which together reproduce the runtime's pre-kernel behaviour exactly: `DomainSet::All` means *everything*, and only a disabled kernel produces it. ## Problem vak decides a great deal before a turn runs — provider and model, permission mode, capability packet, budget, prompt layers — and made every one of those decisions **without any model of what the user was trying to do**. Three findings from the code as it stood: 1. **The only intent classifier was a keyword hack.** `is_managed_work_request` lowercased the prompt, looked for one of thirteen English verbs, and called it multi-step if it contained two markers from `[" and ", " then ", "first", …]`. That boolean was the entire difference between `WorkMode::Direct` and `WorkMode::Managed`, and it fired on "explain what this and that mean". 2. **Demand-driven routing was wired but fed zeros.** `score_demand` / `order_ladder_v2` is a good ordering function. Its only caller passed `estimated_input_tokens: 0, structured_output: false, reasoning_required: false, evidence_required: false`. Every session scored identical demand, so objective selection was constant and the router inert. 3. **The capability packet was all-or-nothing.** Every tool, skill, and MCP server was advertised on every turn, whether the user said "hi" or "migrate the billing schema". Underneath those three is one structural gap. vak's unit of identity was the session and its completion signal was "the model stopped talking". Both assumptions break as soon as work outlives a conversation: there is nowhere to put a month-long obligation, and no way to say whether it was ever met. The stop-gate, the premature-completion guard, and the doom-loop detector are all patches on an unfalsifiable signal. ## Design Three things, deliberately not conflated: | | Source | Wrong-ness costs | |---|---|---| | **Reading** | inferred from the request | a worse turn | | **Authority** | granted by a human | a safety incident | | **Engagement** | derived from both | bounded by the narrowing invariant | A resolution bug can change what vak *thinks the work is*. It must never change *what it is allowed to do about it*. ### Reading: seven axes, not a taxonomy vak runs everything from "hi" to a multi-month programme, so a subject-matter taxonomy is both endless and useless — "research" says nothing about what the runtime should do. The axes are behavioural and orthogonal, and each drives one subsystem. | Axis | Values | Drives | |---|---|---| | `act` | `converse` `answer` `locate` `analyze` `author` `modify` `operate` `verify` `orchestrate` `govern` | which admitted tools are loaded, output shape, stop profile | | `horizon` | `immediate` `turn` `session` `durable` | managed admission, commitment promotion, context profile | | `stakes` | `inert` `reversible` `costly` `irreversible` | approval ceiling, checkpoint-before, delivery urgency | | `evidence` | `none` `cited` `verified` `audited` | **minimum satisfaction strength to close** | | `clarity` | `clear` `underspecified` `ambiguous` | ask vs. state-an-assumption | | `modality` | `text` `image` `audio` `video` `screen` `data` `stream` | ladder filtering, delivery format | | `attendance` | `interactive` `supervised` `unattended` | HIL mode, delivery cadence, gate answerability | No axis collapses into another. A long task can be closely watched and a ten-second automation unattended, so `horizon` is not `attendance`. Work can be irreversible yet need only an assertion, or inert yet need citations, so `stakes` is not `evidence`. Subject matter survives only as an open-vocabulary `domains` tag, used for skill affinity and telemetry and **never** for control flow — so adding a domain can never change what the runtime is allowed to do. `Author` / `Modify` / `Operate` is the safety-relevant split and means the same thing for prose, a spreadsheet, and a production deploy. Only `Modify` / `Operate` / `Govern` / `Verify` demand an execution or file-modification receipt before honestly claiming done (`Act::requires_execution`); `Author` is deliberately absent from that set — producing prose, code, or a plan is proven by the response itself, and demanding a shell or file receipt for a poem is the bug the split exists to avoid. A request that *names* a file deliverable still needs one regardless of act (`OutcomeSpec:: requires_execution`, which reads the request text `Act::requires_execution` does not have). Interaction rule: **ambiguity only interrupts when the stakes are high.** `ambiguous + inert` proceeds on a stated assumption; `ambiguous + irreversible` asks. #### Axis algebra Two shapes, and conflating them was a real bug during implementation: * **Categorical** (`act`, `clarity`): rival explanations. Confidence comes from the winner's lead over the runner-up. * **Ordered** (`stakes`, `horizon`, `evidence`): levels on a scale. A signal saying `reversible` does not argue against `irreversible`, it agrees with it more weakly. Scoring these by argmax made corroborating evidence *reduce* confidence — a dirty working tree voting `reversible` dragged "deploy the billing service to production" below the acceptance floor and lost the irreversible reading entirely. Ordered axes now take the highest level with real support, which is also the cautious direction on every one of them. Stakes words ("production", "force push", `rm -rf`, `git reset --hard`) and environment-derived signals (a dirty tree) apply to effectful acts and to a request whose verb the reader does not recognise — "force push to the production branch" is no less dangerous for using a verb the lexicon lacks. They do not apply to a request recognised as asking, finding or analysing: a repository mid-edit does not make answering a question risky, and "why did the production deploy fail?" changes nothing. Stakes come from what an action does, never from what it is about ("customer", "payment", "live" are topics). #### Confidence is per-axis A single scalar conflates independent questions. Capability slicing depends only on `act`; commitment promotion only on `horizon`. Gating both on the weakest axis meant an unsignalled horizon — most requests, since few say how long they will take — suppressed slicing the act reading was certain about. For the slice specifically, the question is not "which single act won" but "does the slice cover this request". Acts within half the winner's weight join the slice, and confidence is how much of the total act evidence that set accounts for. "Fix the failing test" is a `modify` *and* a `verify`; resolving the tie by argmax would remove half of what it needs. ### Strands: a request is several pieces of work A request is rarely one thing. "Explain the parser, then refactor it, and also check whether the nightly job ran" is three pieces of work with three readings, and the runtime has to know that: the tool surface must cover all three, the stop rule must be the strictest of the three, and the nightly-job question may well be a thread the user opened two turns ago. So a turn resolves to a list of **strands** (`crate::strand`). Each carries its own reading and engagement, its **relation** to the strands beside it (`Independent`, `Sequential { after }`, `Dependent { on }`) and its **lineage** to threads from earlier turns (`New`, `Continues`, `Corrects`, `Replaces`). The turn's engagement is `Engagement::compose` over the strands: everything meets — the strictest strand governs approval, permission, spend and the stop rule — except the domain requirement, which is the **union**, because a turn that is "search the web, then run the tests" needs both toolsets. The composite reading kept on `Intent::reading` is the most consequential strand widened by the others, for everything that wants one answer (the misread ledger, the session index, a commitment's spec). Segmentation is tier 1, deterministic. Pasted material is set aside first — fenced code, runs of two or more lines that do not read as a request (a log, a CSV, a stack trace), and a single very long raw line — so a 300-line log is one part to read about, not 301 requests; it reaches the reading only as "there is pasted material". The instruction is then split at sentence boundaries, enumerated items, and a short list of sequencing (`then`, `after that`, `finally`) and addition (`also`, `additionally`) markers, matched as whole words. Plain "and" is not a boundary. Each clause gets a role — imperative, question, request, social, or statement — and a part is a clause that asks for work with the context clauses around it: "the deploy failed, fix it" is one part, and "the deploy failed" alone asks for nothing beyond an answer. A statement's words never vote for an act ("the build **fails** on CI" reports; it does not ask to verify), a question's verbs other than its head count half, a greeting or thanks is social, and a request aimed at the requester ("alert me when…", "remind me to…") is an answer to them rather than an operation on the world. There is no cap on the number of parts, and no signal is taken from length. Every strand of a several-part request is read at least as a `turn` horizon: "one reply, no tools" cannot describe a part of something larger. Strand ids are `{turn_id}.{index}`, where the host mints the turn id (a UUIDv7) once per turn; a preview that persists nothing gets positional ids. Threads, and the commitments keyed by them, are therefore unique across turns, sessions and workspaces — positional ids collided on the second turn. Cross-turn lineage: a strand continues an open thread when it shares the act and either points at something ("it", "that") or shares a content word. It is `New` otherwise — a wrong `New` costs a duplicate thread, a wrong `Continues` merges unrelated work, so the tie goes to `New`. `Corrects` and `Replaces` are **never inferred**: only an explicit `/goal fix …` or `/goal replace …` produces them, because a wrongly inferred replacement discards work. A replacing strand starts a thread of its own. Commitments follow threads (`CommitmentSpec.thread_id`), decided before the turn writes anything (`commitments::plan_episodes`): a strand on a thread with an open commitment works on it whatever its own horizon reads as ("now also check staging" is part of the weekly job it continues); a strand that replaces a thread with an open commitment opens the successor and supersedes it; any other strand opens one only when it is itself durable and confidently read. The model-visible note lists the parts in order so the model knows there are *k* things and which are still open. ### Resolution cascade Cheapest first, stopping once confidence clears the bar. | Tier | Cost | Reproducible | |---|---|---| | 0 — **declared**: CLI flags, flow intent, pinned channel policy, inherited worker engagement | free | yes | | 1 — **signals**: lexical, structural, deictic, workspace, session, surface, attachment | free | yes | | 2 — **local model**: strict JSON via Ollama | ~free | no | | 3 — **cloud model**: only below the tier-1 threshold | metered | no | Tiers 0–1 are pure functions of recorded inputs, so a decision that narrowed a turn can be reconstructed months later. Tiers 2–3 record the model id and prompt digest and are marked `reproducible: false` — the same honesty the routing ledger applies to `Settlement::Unknown`. Never claim replay fidelity you do not have. The kernel decides **whether** a paid tier is warranted; `vak-core` performs it (`Core::resolve_turn_intent_with_escalation`), because a dispatch is a dispatch: `WorkPurpose::Classify`, a work receipt on the session, spend-gate admission under `max_classify_usd`, a watchdog under the run's cancellation token, and **fail-open** to the free-tier reading with the reason recorded in `escalation_note` (a parsed answer that set nothing is kept there too, bounded to its first 400 characters). A classifier outage must never block work. `escalate = "local"` means a model on this machine and runs **only** on the keyless `ollama` provider — with `classify_model` (an `ollama/` prefix is accepted; a prefix naming another configured provider is refused rather than read as a model name) or the effective model when the route is already ollama — so a local setting can never send the request text off the machine. `escalate = "cloud"` runs on the effective provider, or on `provider/model` when `classify_model` names one; any paid provider needs a known price to be held to, and an unpriced one is refused. The recorded tier names where the model actually ran (`local-model` on ollama, `cloud-model` otherwise), not which setting asked for it. The prompt is built by the kernel (`classification_prompt`) — one JSON object per strand, each part shown on one line and capped at 280 characters, pasted material left out — so its digest is the kernel's and one long message cannot turn a cheap classification into an expensive one. The output budget grows with the number of parts (`classification_budget`: 100 + 120 per part, at most 1600 tokens), because a fixed budget truncated the answer for requests with more than a few parts and a truncated array parses as nothing. `parse_classifications` reads the first JSON value in the answer, as an array or a single object, and ignores unknown values; a domain outside the vocabulary is dropped. The request asks the model **not** to think (`ChatRequest.think = Some(false)`, Ollama `think`): measured live on `gemma4:e2b-mlx`, the default spent its whole output budget in the thinking channel and returned no JSON; without thinking it answers in 0.8–4 s. The watchdog is `classify_timeout_secs` (default 10). When the model splits the request into a different number of parts than the segmenter did — it did, for one strand containing "then", about half the time — the objects fold on the cautious side (highest level per ordered axis, union of domains, lowest confidence) and apply to every strand rather than being discarded. Measured live (six runs, local Ollama): every run settled `local-model` with five axes set, 0.8–3.9 s, and a cold model overrunning the watchdog failed open with the reason in `escalation_note`. `escalate = "cloud"` is privileged: a project config that sets it is stripped unless trusted, which is why an untrusted workspace never spends credentials classifying. A classifier may raise stakes or evidence freely; it may not lower either below what the free tiers concluded, nor lower stakes below what the act it chose implies. Authority-bearing limits are met with the free tier's, so a classifier can change what a turn *reaches for* but never what it is *allowed to do*. A classifier that states no confidence is provisional: it may raise a floor, it may not remove a tool. What a weak reading gets, with the kernel on, is the **orienting engagement**: the general posture and the orientation floor (`filesystem`, `memory`) as its domain requirement — an explicit decision, never a collapsed top element — made at least as careful as any risk the text stated. Stakes above the ordinary and an evidence standard are only ever read from words the request contains, so a weak part keeps them: a reading too weak to narrow capability is never too weak to raise caution, and "force push to the production branch" asks first whether or not its verb was understood. `DomainSet::All` keeps its one meaning, everything, and only a disabled kernel produces it (design 68 Principle 6: when a decision cannot be made confidently, send less and give the model a way to ask for more). No reading, confident or not, removes a capability: admission is policy only (docs/design/41-capability-registry.md), and the reading decides which admitted tools are loaded versus deferred behind `find_tools`. ### Authority: the autonomy spectrum Autonomy is **delegated by a human**; attendance is **observed by the runtime**. Conflating them is why agents nag when you wanted autonomy and barrel ahead when nobody is watching. | Autonomy | Meaning | Approval ceiling by stakes | |---|---|---| | `manual` | propose only | `ask` at every level | | `assisted` | act on reversible things; ask for costly or irreversible | `auto-approve` for inert/reversible, `ask` from costly up | | `delegated` | act inside a declared envelope; escalate outside it | `ask` at the turn; each gated action a live envelope covers proceeds | | `autonomous` | act freely within the permission mode; report afterwards | stakes alone: `approve-safe` at costly, `ask` at irreversible | The table is the code (`Authority::approval_ceiling`) and a test pins it. An **envelope** is pre-authorization *within existing authority* — never a grant of new authority. It belongs to a commitment, so it is not part of the turn's `Authority`: which commitment a strand works on is only known once the request is read. It reaches a turn twice. First, `apply_envelopes` narrows the strands that serve its commitment: its `permission_ceiling` lowers their permission ceiling (reaching the mode through the same `PermissionMode::capped_by` a gateway channel override uses) and its spend limit lowers their spend ceiling; under `delegated` autonomy such a strand works in the `Envelope` HIL mode, unless it is irreversible or deferred. Second, at the approval gate: for a delegated turn with nothing irreversible in it, `intent::envelope_check` lets a gated action through when a live envelope covers it — its tool is in the tool scope, if there is one, and every path it names is inside the path scope, made workspace-relative, with no `..`, absolute or foreign-root path coverable. The grant is read from the ledger at every check, so a revocation, an expiry, a closed commitment or a spent limit applies at the very next gate. An ask raised by an operator's rule or by the circuit breaker is never one a grant stands in for. `intent.autonomy`, `intent.escalate = "cloud"`, and switching the kernel or its posture off (`intent.enabled = false`, `intent.posture = false`) are **privileged config**, stripped for an untrusted project. A cloned repository must not grant itself the right to act without asking, spend the user's credentials classifying, or remove the approval floor the kernel raises for irreversible work. Irreversible work reaches a human whatever was delegated. A grant to act without asking is not a grant to act without anyone ever knowing. ### Human-in-the-loop: four modes Chosen by `attendance × stakes × autonomy`. | Mode | When | Behaviour | |---|---|---| | **Interrupt** | interactive, high stakes | block and ask now | | **Envelope** | delegated, working on an enveloped commitment | proceed inside the grant, escalate outside | | **Review** | reversible and checkpointed | do it, show the diff, offer reversal | | **Defer** | unattended, needs a human | suspend into the inbox | **Defer** is the new one. An unattended surface used to fail closed unconditionally — correct for a one-shot turn, wrong for month-long work, which should wait rather than fail. Deferring needs somewhere to park the question, so it is only offered when the horizon opens a commitment; a one-shot unattended turn still fails closed exactly as before. It covers irreversible work too: an irreversible step with nobody present reaches the inbox, not a gate that can only time out. Wiring: `intent::DeferringApprover` wraps the run's approver when `gate_fallback` is `Defer`; a gate the inner approver cannot answer becomes an `ApprovalPending` inbox entry and a `Suspended { Human }` event on the commitment, and the turn still fails closed — nothing happens without the answer, but the work survives. Every deferred question carries an escalation policy. `AssumeConservative` is refused above `costly`: assuming a default for an irreversible action because nobody replied is precisely the autonomy this system exists to prevent. ### The commitment `horizon = durable` — work that outlives the session: a recurrence, a watch, "every day" — promotes a resolution into a durable **commitment** in its own append-only ledger, independent of any session. Multi-step work inside one session runs managed, under a plan, and opens no commitment; below that, intents resolve and die inside the turn — a simple prompt pays nothing. A commitment is held by the Agent that took it on: its ledger is in the Agent's home (docs/design/64-agent-owned-platform.md), the `commitments` capability reads that ledger, and each commitment records the conversation audience that asked for it, so an Agent serving several chats never shows one chat what another asked of it. The upkeep tick (`commitments::maintain_all`) sweeps every Agent's portfolio, and a predicate is checked in the workspace its commitment belongs to — confined to it: the check runs in the server process, outside any sandbox, so a criterion that names an absolute path, a `..` step or a symlink out of the tree is not checked at all. ``` Proposed → Active ⇄ Suspended ⇄ Blocked → Satisfying → Closed{verdict} ↘ Superseded / Abandoned / Expired ↗ ``` `Verdict::Unknown` is mandatory, not a nicety. Without it the only way to tidy an untracked commitment is to assert an outcome nobody verified. #### The satisfaction lattice — how we know it is done ``` Asserted < Cited < Observed < Attested ``` Strength comes from **how a criterion was established**, not from anyone's confidence: | Criterion kind | Strength | |---|---| | `Shell`, `FileExists`, `FileContains`, `ToolSucceeded`, `FlowCompleted` | `Observed` — the runtime ran it | | `ExternalReceipt` | `Attested` — a third party vouched | | `Semantic` | `Asserted` — only the model's judgement | **Closure invariant:** a commitment may not close `fulfilled` below the strength its `evidence` axis demands, and the ledger refuses the event at append time. A ledger that can record a lie is not an audit trail — so the strength a closure records is recomputed from the criteria at append time, whatever the caller claimed, and the check-and-append runs under an OS file lock that a crashed writer releases with its process. The model may **propose** criteria; it may never **mark one passed**. Same separation of powers as permission-before-dispatch. A failed or undetermined check carries no evidentiary weight regardless of kind — only a pass earns it. Failure verdicts are deliberately unconstrained: recording `failed`, `abandoned`, or an honest `unknown` must always be possible, or the ledger could not tell the truth about work that went wrong. Criterion and evidence vocabulary is reused from `vak-session` (`CriterionKind`, `CriterionResult`, `EvidenceRef`, `WorkCriterion`, `WorkOwner`) rather than redefined. #### Progress versus motion Every episode ends with a typed advancement: `Advanced` (a criterion moved), `Learned` (uncertainty reduced — legitimate progress), `Blocked`, or `Stalled` (spent budget, moved nothing, learned nothing). Consecutive stalls trip a breaker at commitment scale. `Learned` exists so genuine exploration is not punished, which is how naive progress metrics fail. #### Suspension Every wake condition maps onto a mechanism vak already has, which is why this is rewiring rather than new infrastructure: | Condition | Mechanism | |---|---| | `Human` | `vak-core/src/inbox.rs` + gateway approval forwarding | | `Schedule` | `vak-core/src/tasks.rs` cron engine + scheduler catch-up | | `Predicate` | `TaskDef.script` watchdog — **zero tokens while the predicate stays false** | | `Commitment` | dependency edge | | `External` | `vak-delivery` outbox / webhook | #### Long-horizon mechanics * **Re-admission.** A commitment resuming after weeks re-resolves its engagement against current reality and records the drift. Never silently inherit a stale contract. * **Lifetime economics.** A lifetime budget and relevance TTL, not just per-run caps. Budget exhaustion *holds* the work rather than failing it — a human can raise the ceiling, and everything established is still good. Expiry produces an explicit `expired` verdict, never a silent deletion. * **Supersession.** "Forget the migration, just add the index" supersedes with a lineage link. Without this you get the zombie task graveyard that kills every list-based agent. * **Portfolio scheduler.** Deterministic and inspectable: a total order over stakes, deadline proximity, staleness, progress, review-due, and a user pin that dominates every computed factor. Every priority decomposes into named components. An opaque scheduler in a system whose thesis is auditability would be the one place you could not ask "why did it do that". ### Delivery posture `DeliveryPosture` decides *when* a packet goes out, never what it says: the semantic contract, the renderer and the outbox are untouched. Two rules override the cadence, and both are about not losing something that matters — an `Interrupt` urgency always sends, because an irreversible step's confirmation must not sit in a digest until morning; and an approval always sends, because a held gate is a stopped run and batching it would turn a question into a hang. ### Per-channel autonomy `ChannelPolicy.autonomy_ceiling` joins the existing restrictive-only chain. A chat may cap delegation below what the workspace granted and may never raise it: a Telegram chat gets to say "propose only, in here", and never "act freely" on a workspace whose operator did not. `vak-config` ranks the names without depending on the kernel; a test in `vak-core`, which sees both, pins the two rankings equal. ### Control plane: authority from the channel, never from the text A running turn can be steered, paused, cancelled, re-planned or approved. Three sources, typed by the transport, never parsed from a body (`ControlSource`): | Kind | Human | Agent | System | |---|---|---|---| | status | ✓ | ✓ | ✓ | | resume | ✓ | own children only | ✓ | | pause / cancel | ✓ | own children only | ✗ | | steer | ✓ (any free text) | typed message only | ✗ | | replan / add / drop / prioritize | ✓ → new revision | requires human | ✗ | | approve / reject a gate | ✓ | ✗ | ✗ | | goal corrects / replaces | ✓ explicit only | ✗ | ✗ | **Human free text is always steering.** Control from a human is only an explicit command (`parse_command`): a leading slash command — `/stop`, `/cancel`, `/pause`, `/resume`, `/status`, `/replan …`, `/add …`, `/drop …`, `/prioritize …`, `/goal replace …`, `/goal fix …`, `/approve `, `/reject ` — or a whole message that is exactly `stop`, `cancel`, `pause`, `resume` or `status`. "Stop using semicolons in the output" steers the running loop between steps; before this it cancelled the run. Goal relation follows the same rule (`goal_relation`): only an explicit command corrects or replaces the active goal, and a correction keeps what was added to the goal while a replacement discards it. A message after `/stop` starts a new goal rather than adding to cancelled work, and a message added to a paused goal resumes it, because the turn carrying it runs. Only a goal a person stated (`/goal …`) is shown to the model as the primary objective; otherwise the objective is merely the conversation's first message, and "hi" is not one. The message that starts a turn and one steered into a running turn take the same path (`next_goal_update`), so revisions count every update and are never reused. The earlier text classifiers (`classify_intervention`, `classify_goal_update`) are gone: they read "replace the deprecated API call" as a replacement of the goal. "Own children only" is a fact about the target, not the caller: an agent may control a session only when that session's own header names the agent as the one that dispatched it (`InterventionRequest::target_parent_session_id`). When the parentage is unknown, the answer is no. ### Did we read it right? Misclassification becomes **measurable**. Typed misread signals: the user rephrases immediately, corrects, aborts, overrides the intent chip — and strongest, **escalation**, where the engagement sliced a tool out and the model then asked for it. That is a measured misread, not a guess, and it is a direct benefit of doing the slicing at all. These fold into the routing evidence ledger with the same epistemics (success / failure / **unknown**, Laplace shrinkage, 30-day TTL). Escalation is measured against the turn's *own* tool calls (scanning the whole chain recorded a tool used three turns ago as an escalation against today's reading); a request restated verbatim right after the previous turn is recorded as `Restated` against the previous reading. A deferred tool used after a reading too weak to decide what was loaded is recorded but counts as unknown (`MisreadRow::sliced`): that reading loaded only the orientation floor and expected discovery. Cells are kept per resolver version, and only the running lexicon is judged. The loop closes on a person: `vak intent show` lists the running lexicon's cells that something contradicted and whose accuracy has fallen below 0.75 over at least five observations, with the capabilities the model asked for. ## Invariants 1. **Intent narrows, never widens.** An engagement's `Limits` may lower a budget, lower a permission ceiling, or *raise* an approval floor. Never grant, extend, raise a cap, or lower a floor. A reading decides which admitted tools are loaded, never what is possible: it does not shorten the route ladder, cap the turn budget, or limit delegation — those caps only ever removed capacity from requests the reader got wrong. `Limits` is a meet semilattice whose top element reproduces pre-kernel behaviour; `meet` is the only composition operator offered and there is deliberately no `join`. `Limits.required_domains` is typed as a bounded semilattice `DomainSet` ($\bot = \text{Empty} \le \text{Only}(names) \le \top = \text{All}$); disjoint domain intersections collapse strictly to $\bot$ (`Empty`) rather than widening to unconstrained $\top$, guaranteeing $\text{meet}(a, b) \sqsubseteq a$ across all inputs. 2. **Intent never gates the permission engine.** Permission is evaluated exactly as before; intent may only add an approval requirement. Nothing in the kernel can authorize anything. 3. **An envelope is pre-authorization within existing authority**, never a grant, and never for irreversible work or an operator's ask rule. It is read at every gate, so a revocation applies to the very next one. 4. **The runtime evaluates satisfaction; the model never does.** 5. **A commitment may not close above its evidence class.** 6. **Model-visible means logged.** The intent note gets its own entry type carrying the exact contributed text; the projection reads those bytes rather than re-deriving them, so a replay reproduces the prompt even if the derivation rules have since changed. 7. **Reproducible, or declared not to be.** `RESOLVER_VERSION` and the tier-1 lexicon move together: a test pins a digest of every table tier 1 reads (and the segmentation vocabulary) to the version, so a lexicon change that forgets the bump fails CI instead of silently invalidating every ledger row that claims `reproducible: true`. Nothing in `resolve` or `derive` reads a clock; time enters only where a grant's liveness is judged (`apply_envelopes`), as a parameter. 8. **Uncertainty resolves to the orienting engagement**, never to anything narrower, and never drops risk the text stated. Being unsure must never silently remove a tool or lower a floor. 9. **Commitments close explicitly**, with a verdict and evidence. 10. **Unsupported modality fails typed, never silently degrades.** Dropping an image because the serving model is text-only is the "everything worked as designed and the outcome was a lie" failure `reach` exists to prevent. 11. **Workers inherit, narrowed.** ## What this replaces A consolidation, not an addition: | Removed / demoted | Becomes | |---|---| | `is_managed_work_request` | **deleted**; managed-ness follows from `horizon` | | `WorkMode::Auto`'s keyword branch | the reading | | Session-scoped `WorkContract` as the only durable work state | commitment-scoped (staged) | | Stop policy's completion role | narrows to "did this episode terminate cleanly" | | Doom-loop heuristics | the stall breaker's measurement (staged) | | Per-call approval as the only HIL | one of four modes | ## Layout ``` crates/vak-intent axes, signals, cascade, authority, the narrowing lattice. No vak dependencies: a pure decision layer, unit-testable without a network, a model, or a config file. crates/vak-commit durable commitments, the satisfaction lattice, the append-only ledger and its projection, the portfolio scheduler. Reuses vak-session's criterion vocabulary. vak-core/intent.rs the seam: gathers facts, runs the cascade, projects the engagement onto runtime knobs. Every function takes a baseline and returns something no wider. ``` ## Configuration ```toml [intent] enabled = true # false reproduces pre-kernel behaviour exactly (privileged at false) accept_confidence = 0.75 # bar for deciding which tools are loaded provisional_confidence = 0.45 slice_capabilities = true # false loads every admitted tool on every turn posture = true # the approval ceiling (stakes × autonomy) may lower the mode (privileged at false) escalate = "none" # none | local (ollama only) | cloud (privileged at "cloud") classify_model = "…" # model, or provider/model, for the classifier max_classify_usd = 0.01 classify_timeout_secs = 10 # watchdog; an overrun fails open to the free-tier reading autonomy = "assisted" # privileged [route] modality_hints = ["vision", "-vl"] # legs able to serve non-text input; none ⇒ every leg [commitment] enabled = true lifetime_budget_usd = 25.0 stall_limit = 3 review_every_hours = 24 default_ttl_days = 30 ``` ## Surfaces * `vak intent explain ""` — the reading, every signal with the weight it carried, the engagement diff against doing nothing, and the exact text the model would additionally be told. Costs nothing, dispatches nothing. * `vak intent show` — the resolved policy. * `vak commit list|show|close|supersede|attest` — the portfolio, ordered by the same scheduler the runtime uses. `close --verdict fulfilled` surfaces the closure invariant to a person. * `GET /intent/explain`, `GET /intent/policy`, `GET /commitments`, `GET /commitments/{id}`, `POST /commitments/{id}/close` (409 on a refused closure — the request was well-formed; the evidence simply does not support the claim). ## What the review changed A deep review of the kernel (resolver version 1) found, and this version fixes: * `DomainSet::All` meant "everything" in the kernel and "nothing declared" in the runtime, so `enabled = false` did not reproduce pre-kernel behaviour. `All` now has one meaning; weak readings get the explicit orienting engagement. * Horizon phrases matched substrings (`then` inside *authentication*), and a lone sub-floor vote won an ordered axis at 0.76 confidence — "fix the authentication bug" opened a durable commitment. Phrases match whole words; sub-floor votes abstain. * Stakes words applied to questions ("where does this config live" read as irreversible, capped approval at `ask`, and told the model to confirm an irreversible step). They apply only to effectful acts. * `assisted` capped every stakes level at `approve-safe`, indistinguishable from `autonomous` on costly work. The table above is now the code. * Tiers 2/3 were configuration without a call site; they are wired (I9). * Modality, permission and spend ceilings, HIL `Defer`, delivery posture, the stop and context profiles and the checkpoint decision were computed and never consumed; they are wired. The session's first checkpoint is always taken (it is the baseline the workspace delta is measured against); later ones only when the reading expects an effect. * `Engagement::meet` was asymmetric and could reduce caution; the stop gate reasoned from requirement *prose*; the control plane granted authority to whatever body said `"human"`; goal and intervention classifiers were the keyword hacks this design set out to remove. All replaced. * `RESOLVER_VERSION` stayed at 1 through eleven lexicon changes. Pinned. * The misread ledger scanned the whole session for escalations. Scoped. ## What the second review changed A second review (resolver version 3, 2026-09-26) measured the reader on real inputs and followed every derived value to where the runtime used it. Resolver version 4 and this change fix what it found. **The reader.** Tier 1 read every line of a message as a request. A pasted 53 KB CSV took 105 s to resolve and a 300-line log became 301 strands, one of them `irreversible`, with a 26 KB note and a 527 KB intent record. With pasted material set aside (see *Strands*), measured in a release build: the CSV resolves in 14 ms, the log is one `answer` strand with no note, and a 20 KB log's intent record is 2.5 KB. Statements no longer vote ("the deploy failed" reported, it did not ask to operate), questions no longer read as their verbs ("what happens when I press ctrl-c" is an answer), greetings and thanks are social, topic words no longer raise stakes, "make sure" raises the evidence standard only next to something checkable, horizon phrases count only in a clause that asks for work, and no length signal remains. The `live-data` domain — "ask for a value observed this turn" — is set only for a request that seeks a fact (answer, locate, analyse) with a temporal word that is not about something local or a time: "the current price of copper" is live data; "the current directory" and "refactor the current parser" are not. Destructive requests with an unknown verb keep their stakes (see *Axis algebra*). A live run found ordinary instructions the verb table lacked — "every day, append the date to log.txt" opened no commitment because "append" was unknown — so common instruction verbs were added (append, insert, replace, rewrite, merge, commit, revert, move, upgrade, calculate, translate, push, upload, compile, lint and others), weighted down where the word is often a noun. "Run" and "execute" stay out on purpose: "run the tests" is a check, and reading "run" as an operation would make it irreversible. **The wiring.** Envelopes never reached a turn: the grant was looked up by the session's managed-work contract id, which is not a commitment id. The `commitments` capability read a different ledger from the one turns wrote, so the model never saw its own commitments. Strand ids were positional, so the second durable request of a session attached to the first one's commitment. Every one is fixed as described above. The reading's caps on the route ladder, the turn budget and delegation are removed (invariant 1). The freshness check (docs/design/68-context-engine.md §7) is satisfied by any observation of current state — reading the file, running the command — not only by a web retrieval; the stop gate counts an integration's tool call and a delegated task as execution; and a worker is held to the outcome of its own prompt, clamped to what a read-only worker can do, not to its parent's whole request. **The edges.** Configured thresholds and caps written as `nan` or `inf` (valid TOML) fall back to their defaults, because every comparison against NaN is false. Runtime scaffolding is never stripped from inside a fenced code block. The commitment and misread ledgers skip a corrupt line instead of stopping at it. **The live run** (local `gemma4:e2b-mlx`, 2026-09-26) confirmed the fixes above end to end — a force push under `auto-approve` reached an approval gate while an `ls` under the same settings did not; a grant let gated calls through with no approver, and its revocation stopped the very next one; a deferred gate parked its question and suspended the commitment; the model read its own commitments — and found five more, all fixed here: * A card that failed validation counted as an unresolved tool failure, so the stop gate sent complete, correct prose answers back to repair a card the small model could not build, until the turn failed. A card is a presentation of the answer: a failed one is simply not shown, and whether a card was needed is the presentation check's question. * The stop gate's generic rule demanded a verification command after every code edit, overriding the reading: "explain calc.py, then add a subtract function" (an authoring stop profile) was sent to run a check its surface could not run. With a reading present, a re-run is owed only when the reading holds the work to a check or the request's words ask for one. * The multi-part note labelled parts by act ("Part 2: author"), and the model copied the label into its answer. The note now states order and dependency as plain sentences. * The `commitments` capability needed an approver to read the Agent's own portfolio, so "what are you working on?" failed on an unattended surface; it is a read tool now, like `session_search`. And "right now" beside the agent's own state ("what commitments are you holding right now?") read as a live-data question; the agent's own state is local. * The verb table lacked common instructions (see *The reader*). On the `main` line, resolver version 4 separately fixed the sense of `live`: the verb "reside" ("we live in the city") carries no recency or stakes, while "a live score" asks for a current value and "go live" describes a launch. The version 5 resolver combines that reading with the clause-aware reader described above. One finding is left as a decision rather than a fix: under `full-access` the permission engine allows every call without asking, so the approval floor the kernel raises for irreversible work has nothing to gate there. A force push in a `full-access` session still runs unasked. ## Verification `cargo test -p vak-intent -p vak-commit` covers axis algebra, the cascade, the lifecycle, and the property test that no engagement derived from any reading in the reachable space (10 acts × 4 horizons × 4 stakes × 4 evidence × 3 clarity × 3 attendance × 4 autonomy × 2 slice settings) ever widens the baseline. `crates/vak-session/tests/intent_entries.rs` proves the projection: the note reaches the model immediately before the turn it governs, only the newest one applies, a silent engagement costs zero tokens, replay reproduces the recorded bytes, and notes stay out of the compaction packet.