Design spec · 2026-08-22 · live verified
Looking Is the Price of Claiming
Three read-only tools, a receipt for every use, and one rule enforced in the writer: a settlement made in a turn with no receipt is refused. This is the design, the reasoning for departing from both our own "no tools" contract and Kairo's tool layer, the controls that were fired before it was trusted, and what the first live look did when it guessed wrong.
What would count as this design failing
- A tool writes anything to the store, ever. (Fence: the three tools can only read; SQL is refused before sqlite is touched if it is not
SELECT/WITHor contains a write verb; the connection is openedmode=rowithquery_onlyset.) - A settlement is accepted with no receipt in its turn. (Fence:
predict.extractrefuses it; the refusal is counted and its reason reaches telemetry.) - The model settles against a receipt that does not support the claim. Not fenced. The receipt proves a look happened; it does not grade the reading. See §Boundaries.
- A look loop runs unbounded. (Fence: max 3 looks per turn, exactly one second round, a dream's 180 s clock above all of it.)
Why "no tools" was the right fence around the wrong risk
Our model contract (docs/PULSE.md, "The model contract") says no tools. It was written against one risk: a model that can call believe() manufactures the evidence that unlocks its own convictions. That fence is correct and it stands — the only write path from the model remains the two typed lines EXPECT: and SETTLE:, validated in the store.
But "tools that write self-state" and "tools that read the world" are different risks, and the ban covered both. The cost showed up as a number: 23 open expectations, one ever settled, that one self-graded. The model was being asked "has it been observed yet?" about observables it had named — a SQL count, a file — with no way to look. Reads never touched the fence.
The three looks
| typed line | reads | caps |
|---|---|---|
LOOK: store.query <SELECT …> | the store, read-only; columns named in the menu | 20 rows, 1200 chars |
LOOK: canon.read <message_id> | one Discord canon post, by id; digits only, no path components | 1200 chars |
LOOK: frame.count [dimension] | live self-state rows per dimension; an empty dimension returns 0, not absence | — |
Same regex family as EXPECT:/SETTLE:: anchored to line start, leading markdown tolerated. One source for the syntax and the parser (tools.syntax_help), so the instruction cannot drift from the regex it describes.
Receipts
Every look becomes an observation event — look: <tool> <args> — whose meta carries the full receipt: tool, args, result, a 16-hex SHA-256 of the result, wall time, and the cause_event of the human or dream turn that asked. The turn's telemetry carries a summary (fields.receipts), the order of the menu it was shown (fields.tool_order), and fields.look_rounds.
An accepted settlement's evidence string is stamped with the receipt hashes: [receipts: store.query#1551:a956d404a77d51ad]. A settlement is now a claim plus a pointer to the observation it was made against, and both are in the same store.
The one round
The first reply's LOOK lines are run; the receipts are sent back as a single numbered message with the first exchange as history; the second reply is the one predict.extract reads. Bounded on purpose. The second round uses the same voice (sampler options) as the first.
Ordering — the voice's second hand
The menu the model sees is ordered by what is owed: the curiosity engine names the largest measured gap, and the tool that closes that kind of gap goes first (open/overdue/unverified → store.query; unread → canon.read; thin → frame.count). The order used is recorded per turn.
Departure from Kairo, cited. Kairo's pulse report discloses that "tool ordering currently reads state tokens without gating on decayed confidence — a zero-confidence field moved alpha to the first position." Ours orders from measured quantities (row counts, ages, settlement records) and never from a field's self-reported confidence, so that class of bug has no input to arrive through. This is an improvement only if the measured quantities are the right ones to order by; that is a design claim, not a measured one.
Controls fired before trusting it
| guard | known-bad input | observed |
|---|---|---|
| SQL write refusal | DELETE FROM frame, select 1; DROP TABLE frame, PRAGMA journal_mode=off, UPDATE … | each returns an error; frame_live() unchanged after all four |
| settle needs receipt | SETTLE: <id> confirmed with receipts=[] | refused, counted, reason names the rule; expectation still open |
| legacy exemption | same line with receipts=None (caller predates tools) | not refused for that reason — offline extract still works |
| look cap | four LOOK lines in one reply | three run, looks_dropped: 1 in telemetry |
| path games | canon.read ../x | error; no filesystem access |
| server round-trip | a scripted responder that looks, then settles on round two | two rounds, receipt event id in telemetry, settlement accepted |
29 checks in src/test_tools.py, plus the server-level case in src/test_foreground_run.py. The SQL-count assertion went red once before green because store.expect() writes a frame row too; the test, not the code, was wrong, and it is recorded here because a test that passes first time has not proved it can fail.
Live: the first look
Asked for the most recent observation event "word for word — you cannot know this without looking," the model emitted LOOK: store.query SELECT content FROM events ORDER BY timestamp DESC LIMIT 1. Both column names were guesses. The receipt came back no such column: content, and the model's second reply said so — it reported the error it was handed rather than inventing a row. That is the behaviour the receipt rule exists to produce.
Defect found live, in this runtime, not the model: the server's receipt summary line raised a TypeError on the first real receipt (a tuple-or-set expression that no unit test reached, because the tests exercised tools.loop and not the server's summary of it). Fixed; the server-level test above now covers it. Two turns later, with the columns named in the menu, the model ran SELECT body FROM events ORDER BY ts DESC LIMIT 1 and answered from the receipt.
Boundaries
- A receipt proves a look, not a reading. The model can still settle "confirmed" against a receipt that says the opposite. Grading the reading against the receipt is the next control, and it is not built.
- One round. A wrong first look is a spent turn. The column list in the menu is the mitigation, not a fix.
- Three tools. Nothing reads the host, the network, or any file outside the canon directory. That is the point and also the limit.
- Ordering is unmeasured. The second dissociation channel now exists; whether state-driven order changes which tool the model calls first has not been tested.
- Dreams inherit this through
/api/say. At publication no dream had yet looked and settled on its own; a watch is armed for the first one.
Implementation map
src/tools.py the three reads, run(), loop(), order(), syntax_help() src/predict.py SETTLE refused without receipts; evidence stamped with hashes src/server.py tool order from curiosity → prompt; look round between respond and extract src/identity.py renders the menu in the order supplied src/test_tools.py 29 checks · src/test_foreground_run.py server round-trip