Skip to content

feat(protocol): let a proof end a program that writes, evaluated on the snapshot its writes committed against - #272

Merged
jsam merged 4 commits into
mainfrom
fm/en-en7
Oct 4, 2026
Merged

jsam merged 4 commits into
mainfrom
fm/en-en7

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Intent

please continue developing the input layer engine so we make sure that the symbolic engine is ready to go live and completely production ready while we develop the GoTM uh, market.

(Context: the captain approved the neurosymbolic decision engine plan; its milestone 'Engine readiness' makes the symbolic engine production-ready for the hosted decision API. This task is item EN-7: proof pinned to the commit snapshot: a proof statement at the end of a program, evaluated on the snapshot the guard passed on, returned in the reply. M1 critical path — why: the stored proof is the proof of the state the gate checked. The CLM evaluation's run_ok workaround is the shim until then: a claim guarded on eligibility (eligible, which contains !attempt) withdraws eligibility by succeeding, so .why ?eligible(...) sent after the claim explains an absent row; the workaround proves a separate right-to-continue view, run_ok(S, T, Id) <- attempt(S, T, Id), need(S, T), tool_ready(T), after the claim instead. In the decision flow the guarded program is the token form — -il_txn(""), +il_txn("t-…") <- <guard> then ghost-anchored conditional inserts -il_ghost(0), +attempt(...) <- il_txn("t-…"), +decision(...), +decision_score(...), then -il_txn(T) <- il_txn(T), T = "t-…" — where the guard is evaluated once, every conditioned statement lands or none does, and the reply's per-statement counts say whether the claim won; the proof (for example of the candidate the claim was guarded on: the facts, the absences and the rules that made the action allowed) is stored as the decision's proof.)

What Changed

  • A .why, .why full or .why_not can now be the last statement of a program that writes. It runs after the commit, on the snapshot the writes committed against: ProgramCommit now carries a base snapshot, and if the program is re-staged after going stale, the proof uses the new base. The reply keeps one message row per statement and its per-statement counts, and puts the proof in proof_trees. If the proof fails, its statement fails and the writes stay committed.
  • program_boundary now treats proofs as their own kind of statement. Before anything runs, it rejects with unsupported a program that writes and has a proof that isn't its last statement, or that ends with a proof and also holds a query (?… or .rule). Each case gets its own error message.
  • Every proof tree now has an optional revision field naming the snapshot it was evaluated on. The core-concepts, explainability and WebSocket API guides now describe pinned proofs, including a new "Proofs of a Guarded Write" section. New handler tests and integration tests (tests/pinned_proof_tests.rs) cover the behavior; a new test hook between staging and commit is used to force a concurrent write.

Risk Assessment

⚠️ Medium: The pinned proof changes protocol semantics on the critical decision path: proofs run on the commit's base snapshot, and the reply's rows become write messages. The implementation is well-bounded and tested, including restage and concurrent writes. The round-2 .rule fix is correct and complete (Query and RuleQuery are the only statements that set query_to_execute), and only a minor truncation-signal gap remains.

Testing

I started a disposable inputlayer-server (temp data dir, local port) and ran the intent's decision flow and each boundary case over the real WebSocket API. - The token-form claim returned per-statement counts and a proof pinned to the pre-commit revision. need and tool_ready appeared as fact leaves and !attempt as a negation leaf. - A repeated claim got zero counts and an empty proof. - The same .why sent on its own afterwards returned nothing, which is the problem this change fixes. - Proof-not-last, query+proof, .rule+proof and .rel+proof were all rejected with unsupported, and nothing was applied. - A failed proof kept its writes. - .why full and .why_not explained the state before the write. - A program without writes still runs .why as before. The concurrency scenario (a restage after a conflicting commit, and writes landing during proof search) can't be timed over the socket. I ran the targeted handler and integration tests that drive it through test hooks, and they passed, but that is not a live result, so the scenario is marked untested. I then stopped the server and deleted the temp data.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Token-form guarded claim ending with .why wins and returns per-statement counts plus a proof of the candidate (fact need/tool_ready leaves, !attempt negation leaf, revision) ✅ pass live live-transcript.txt section S1
Repeating the same claim is refused (all counts 0) and returns an empty proof_trees; only one attempt/decision row exists ✅ pass live live-transcript.txt sections S2, S2b
A standalone .why after the claim explains nothing (shows why the proof has to be pinned) ✅ pass live live-transcript.txt section S3
A proof that is not the last statement of a program that writes is rejected with unsupported, and nothing is applied ✅ pass live live-transcript.txt sections S4a, S4e
A program that writes and holds a query, a .rule <name>, or an outside command (.rel) alongside an ending proof is rejected with unsupported at that statement's index ✅ pass live live-transcript.txt sections S4b, S4c, S4d, S4e
A pinned proof that fails reports 'Why error' at its own index while the program's writes stay committed ✅ pass live live-transcript.txt sections S5, S5b
.why full and .why_not can end a program that writes and explain the state before the write (escalate missing; need missing at revision 9 vs 10 after) ✅ pass live live-transcript.txt sections S6, S6b, S6c
A program that fails mid-write rolls back and returns no proof ✅ pass live live-transcript.txt sections S7, S7b
A program without writes still runs a query and a .why as before ✅ pass live live-transcript.txt section S8
A conflicting commit between staging and commit restages the program, and the proof follows the guard to the new snapshot; writes during proof search do not change the proof ⏸️ untested no The prior payload did not establish a live result for this scenario. It needs a write injected exactly between staging and commit, or during proof search, and that timing can't be reproduced over the…
Evidence: Live WebSocket transcript of all scenarios
auth: authenticated default

=== setup kg
>>> .kg create pinned_live
>>> 
<<< {
 "type": "result",
 "rows": [
  [
   "Knowledge graph 'pinned_live' created."
  ],
  [
   "Switched to knowledge graph: pinned_live"
  ]
 ],
 "errors": []
}

=== use kg
>>> .kg use pinned_live
<<< {
 "type": "result",
 "rows": [
  [
   "Switched to knowledge graph: pinned_live"
  ]
 ],
 "errors": []
}

=== pack
>>> +il_txn(t: string)
>>> +il_ghost(n: int)
>>> +attempt(s: string, t: string, id: string)
>>> +decision(id: string, s: string, t: string)
>>> +il_ghost[(0)]
>>> +need[("s1", "carrier_check"), ("s1", "say_eta")]
>>> +tool_ready[("carrier_check"), ("say_eta")]
>>> +eligible(S, T) <- need(S, T), tool_ready(T), !attempt(S, T, _)
<<< {
 "type": "result",
 "rows": [
  [
   "Schema for 'il_txn' registered with 1 columns (persistent)"
  ],
  [
   "Schema for 'il_ghost' registered with 1 columns (persistent)"
  ],
  [
   "Schema for 'attempt' registered with 3 columns (persistent)"
  ],
  [
   "Schema for 'decision' registered with 3 columns (persistent)"
  ],
  [
   "Inserted 1 fact(s) into 'il_ghost'."
  ],
  [
   "Inserted 2 fact(s) into 'need'."
  ],
  [
   "Inserted 2 fact(s) into 'tool_ready'."
  ],
  [
   "Rule 'eligible' registered."
  ]
 ],
 "errors": [],
 "statements": [
  {
   "index": 4,
   "kind": "insert",
   "inserted": 1,
   "deleted": 0
  },
  {
   "index": 5,
   "kind": "insert",
   "inserted": 2,
   "deleted": 0
  },
  {
   "index": 6,
   "kind": "insert",
   "inserted": 2,
   "deleted": 0
  }
 ]
}

=== S1 token-form claim wins, proof of the guard state
>>> -il_txn(""), +il_txn("t-1") <- eligible(S, T), S = "s1", T = "carrier_check"
>>> -il_ghost(0), +attempt("s1", "carrier_check", "t-1") <- il_txn("t-1")
>>> -il_ghost(0), +decision("t-1", "s1", "carrier_check") <- il_txn("t-1")
>>> -il_txn(T) <- il_txn(T), T = "t-1"
>>> .why ?eligible("s1", "carrier_check")
<<< {
 "type": "result",
 "rows": [
  [
   "Update: 0 deleted, 1 inserted."
  ],
  [
   "Update: 1 deleted, 1 inserted."
  ],
  [
   "Update: 0 deleted, 1 inserted."
  ],
  [
   "Conditional delete: 1 fact(s) deleted from 'il_txn'."
  ]
 ],
 "errors": [],
 "statements": [
  {
   "index": 0,
   "kind": "update",
   "inserted": 1,
   "deleted": 0
  },
  {
   "index": 1,
   "kind": "update",
   "inserted": 1,
   "deleted": 1
  },
  {
   "index": 2,
   "kind": "update",
   "inserted": 1,
   "deleted": 0
  },
  {
   "index": 3,
   "kind": "delete",
   "inserted": 0,
   "deleted": 1
  }
 ],
 "proof_trees": [
  {
   "query": "__query__(_c0, _c1) <- eligible(_c0, _c1), _c0 = \"s1\", _c1 = \"carrier_check\"",
   "revision": 6,
   "roots": [
    "eligible([\"s1\",\"carrier_check\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "negation:attempt"
   ]
  }
 ]
}

=== S1b state after claim
>>> ?attempt(S, T, Id)
>>> 
<<< {
 "type": "result",
 "rows": [
  [
   "s1",
   "carrier_check",
   "t-1"
  ]
 ],
 "errors": []
}

=== S2 same claim again is refused, empty proof
>>> -il_txn(""), +il_txn("t-2") <- eligible(S, T), S = "s1", T = "carrier_check"
>>> -il_ghost(0), +attempt("s1", "carrier_check", "t-2") <- il_txn("t-2")
>>> -il_ghost(0), +decision("t-2", "s1", "carrier_check") <- il_txn("t-2")
>>> -il_txn(T) <- il_txn(T), T = "t-2"
>>> .why ?eligible("s1", "carrier_check")
<<< {
 "type": "result",
 "rows": [
  [
   "Update: 0 deleted, 0 inserted."
  ],
  [
   "Update: 0 deleted, 0 inserted."
  ],
  [
   "Update: 0 deleted, 0 inserted."
  ],
  [
   "Conditional delete: 0 fact(s) deleted from 'il_txn'."
  ]
 ],
 "errors": [],
 "statements": [
  {
   "index": 0,
   "kind": "update",
   "inserted": 0,
   "deleted": 0
  },
  {
   "index": 1,
   "kind": "update",
   "inserted": 0,
   "deleted": 0
  },
  {
   "index": 2,
   "kind": "update",
   "inserted": 0,
   "deleted": 0
  },
  {
   "index": 3,
   "kind": "delete",
   "inserted": 0,
   "deleted": 0
  }
 ],
 "proof_trees": []
}

=== S2b only one attempt/decision
>>> ?decision(Id, S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "t-1",
   "s1",
   "carrier_check"
  ]
 ],
 "errors": []
}

=== S3 standalone .why after the claim (the old shim problem)
>>> .why ?eligible("s1", "carrier_check")
<<< {
 "type": "result",
 "rows": [],
 "errors": []
}

=== S4a proof not last -> unsupported
>>> .why ?eligible(S, T)
>>> +need("s9", "x")
<<< {
 "type": "result",
 "rows": [
  [
   "'.why ?eligible(S, T)' must be the last statement of a program that changes facts, rules or schemas: a proof there explains the state the program's writes committed against. Nothing was applied."
  ]
 ],
 "errors": [
  {
   "index": 0,
   "code": "unsupported",
   "message": "'.why ?eligible(S, T)' must be the last statement of a program that changes facts, rules or schemas: a proof there explains the state the program's writes committed against. Nothing was applied."
  }
 ]
}

=== S4b query + proof -> unsupported
>>> +need("s9", "x")
>>> ?need(S, T)
>>> .why ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "'?need(S, T)' cannot run in a program that changes facts, rules or schemas and ends with a proof: the program replies with its statements' messages and the proof. Nothing was applied; send the query in a separate request."
  ]
 ],
 "errors": [
  {
   "index": 1,
   "code": "unsupported",
   "message": "'?need(S, T)' cannot run in a program that changes facts, rules or schemas and ends with a proof: the program replies with its statements' messages and the proof. Nothing was applied; send the query in a separate request."
  }
 ]
}

=== S4c .rule query + proof -> unsupported (review fix)
>>> +need("s9", "x")
>>> .rule eligible
>>> .why ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "'.rule eligible' cannot run in a program that changes facts, rules or schemas and ends with a proof: the program replies with its statements' messages and the proof. Nothing was applied; send the query in a separate request."
  ]
 ],
 "errors": [
  {
   "index": 1,
   "code": "unsupported",
   "message": "'.rule eligible' cannot run in a program that changes facts, rules or schemas and ends with a proof: the program replies with its statements' messages and the proof. Nothing was applied; send the query in a separate request."
  }
 ]
}

=== S4d outside command + proof -> unsupported
>>> +need("s9", "x")
>>> .rel
>>> .why ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "'.rel' cannot run in a program that changes facts, rules or schemas: those changes commit together as one transaction, and this command cannot join it. Nothing was applied; send the command in a separate request."
  ]
 ],
 "errors": [
  {
   "index": 1,
   "code": "unsupported",
   "message": "'.rel' cannot run in a program that changes facts, rules or schemas: those changes commit together as one transaction, and this command cannot join it. Nothing was applied; send the command in a separate request."
  }
 ]
}

=== S4e nothing applied by rejected programs
>>> ?need("s9", T)
<<< {
 "type": "result",
 "rows": [],
 "errors": []
}

=== S5 failed proof keeps writes
>>> +need("s1", "escalate")
>>> .why ?need(S, T), X > 1
<<< {
 "type": "result",
 "rows": [
  [
   "Inserted 1 fact(s) into 'need'."
  ],
  [
   "Why error: Query execution failed: Unsafe rule: Atom { relation: \"__query__\", args: [Variable(\"S\"), Variable(\"T\"), Variable(\"X\")] }. Variables [\"X\"] in head do not appear in positive body atoms."
  ]
 ],
 "errors": [
  {
   "index": 1,
   "code": "validation",
   "message": "Why error: Query execution failed: Unsafe rule: Atom { relation: \"__query__\", args: [Variable(\"S\"), Variable(\"T\"), Variable(\"X\")] }. Variables [\"X\"] in head do not appear in positive body atoms."
  }
 ],
 "statements": [
  {
   "index": 0,
   "kind": "insert",
   "inserted": 1,
   "deleted": 0
  }
 ]
}

=== S5b write committed
>>> ?need("s1", "escalate")
<<< {
 "type": "result",
 "rows": [
  [
   "s1",
   "escalate"
  ]
 ],
 "errors": []
}

=== S6 .why full ends a write
>>> +tool_ready("escalate")
>>> .why full ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "Inserted 1 fact(s) into 'tool_ready'."
  ]
 ],
 "errors": [],
 "statements": [
  {
   "index": 0,
   "kind": "insert",
   "inserted": 1,
   "deleted": 0
  }
 ],
 "proof_trees": [
  {
   "query": "__query__(S, T) <- eligible(S, T)",
   "revision": 8,
   "roots": [
    "eligible([\"s1\",\"say_eta\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "negation:attempt"
   ]
  }
 ]
}

=== S6b .why_not ends a write, sees pre-write state
>>> +need("s2", "say_eta")
>>> .why_not eligible("s2", "say_eta")
<<< {
 "type": "result",
 "rows": [
  [
   "Inserted 1 fact(s) into 'need'."
  ]
 ],
 "errors": [],
 "statements": [
  {
   "index": 0,
   "kind": "insert",
   "inserted": 1,
   "deleted": 0
  }
 ],
 "proof_trees": [
  {
   "query": ".why_not eligible(\"s2\", \"say_eta\")",
   "revision": 9,
   "roots": [
    "eligible([\"s2\",\"say_eta\"])"
   ],
   "leaves": [
    "why_not:eligible",
    "why_not:eligible",
    "why_not:need"
   ]
  }
 ]
}

=== S6c .why_not afterwards
>>> .why_not eligible("s2", "say_eta")
<<< {
 "type": "result",
 "rows": [
  [
   "eligible(\"s2\", \"say_eta\") was NOT derived:"
  ],
  [
   ""
  ],
  [
   "  Rule: eligible (clause 0)"
  ],
  [
   "    eligible(S, T) <- need(S, T), tool_ready(T), !attempt(S, T, _)"
  ]
 ],
 "errors": [],
 "proof_trees": [
  {
   "query": ".why_not eligible(\"s2\", \"say_eta\")",
   "revision": 10,
   "roots": [
    "eligible([\"s2\",\"say_eta\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "why_not:eligible",
    "why_not:eligible"
   ]
  }
 ]
}

=== S7 failed program returns no proof and rolls back
>>> +need("s3", "say_eta")
>>> +attempt(1, 2)
>>> .why ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "Insert rejected for 'attempt': Insert rejected for 'attempt': batch of 1 tuples had type/arity errors (rolled back: none of the program's 2 write statements 0-1 was applied)"
  ]
 ],
 "errors": [
  {
   "index": 1,
   "code": "validation",
   "message": "Insert rejected for 'attempt': Insert rejected for 'attempt': batch of 1 tuples had type/arity errors (rolled back: none of the program's 2 write statements 0-1 was applied)"
  }
 ]
}

=== S7b rolled back
>>> ?need("s3", T)
<<< {
 "type": "result",
 "rows": [],
 "errors": []
}

=== S8 no-write program: .why still standalone
>>> ?eligible(S, T)
>>> .why ?eligible(S, T)
<<< {
 "type": "result",
 "rows": [
  [
   "s1",
   "escalate"
  ],
  [
   "s1",
   "say_eta"
  ],
  [
   "s2",
   "say_eta"
  ]
 ],
 "errors": [],
 "proof_trees": [
  {
   "query": "__query__(S, T) <- eligible(S, T)",
   "revision": 10,
   "roots": [
    "eligible([\"s1\",\"escalate\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "negation:attempt"
   ]
  },
  {
   "query": "__query__(S, T) <- eligible(S, T)",
   "revision": 10,
   "roots": [
    "eligible([\"s1\",\"say_eta\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "negation:attempt"
   ]
  },
  {
   "query": "__query__(S, T) <- eligible(S, T)",
   "revision": 10,
   "roots": [
    "eligible([\"s2\",\"say_eta\"])"
   ],
   "leaves": [
    "fact:need",
    "fact:tool_ready",
    "negation:attempt"
   ]
  }
 ]
}
Evidence: WebSocket driver script
// Drives a live inputlayer-server over its WebSocket API.
const url = process.argv[2];
const ws = new WebSocket(url);
let n = 0; const pending = new Map();
ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
const send = (o) => new Promise((res) => { const id = String(++n); pending.set(id, res); ws.send(JSON.stringify({ ...o, id })); });
const exec = async (label, program) => {
  const r = await send({ type: 'execute', program });
  const brief = { type: r.type, code: r.code, message: r.message, rows: r.rows, errors: r.errors, statements: r.statements,
    proof_trees: r.proof_trees?.map(t => ({ query: t.query, revision: t.revision,
      roots: t.roots?.map(id => { const c = t.nodes[id].conclusion; return `${c.pred}(${JSON.stringify(c.args)})`; }),
      leaves: Object.values(t.nodes).filter(x => x.kind !== 'rule' && x.kind !== 'Rule').map(x => `${x.kind}:${x.conclusion.pred}`).sort() })) };
  console.log(`\n=== ${label}\n>>> ${program.replace(/\n/g, '\n>>> ')}\n<<< ${JSON.stringify(brief, null, 1)}`);
  return r;
};
await new Promise(r => ws.onopen = r);
const auth = await send({ type: 'login', username: 'admin', password: 'testpass-123' });
console.log('auth:', auth.type, auth.knowledge_graph);
await exec('setup kg', '.kg create pinned_live\n');
await exec('use kg', '.kg use pinned_live');
await exec('pack', `+il_txn(t: string)
+il_ghost(n: int)
+attempt(s: string, t: string, id: string)
+decision(id: string, s: string, t: string)
+il_ghost[(0)]
+need[("s1", "carrier_check"), ("s1", "say_eta")]
+tool_ready[("carrier_check"), ("say_eta")]
+eligible(S, T) <- need(S, T), tool_ready(T), !attempt(S, T, _)`);
const decide = (tok, t) => `-il_txn(""), +il_txn("${tok}") <- eligible(S, T), S = "s1", T = "${t}"
-il_ghost(0), +attempt("s1", "${t}", "${tok}") <- il_txn("${tok}")
-il_ghost(0), +decision("${tok}", "s1", "${t}") <- il_txn("${tok}")
-il_txn(T) <- il_txn(T), T = "${tok}"
.why ?eligible("s1", "${t}")`;
await exec('S1 token-form claim wins, proof of the guard state', decide('t-1', 'carrier_check'));
await exec('S1b state after claim', '?attempt(S, T, Id)\n');
await exec('S2 same claim again is refused, empty proof', decide('t-2', 'carrier_check'));
await exec('S2b only one attempt/decision', '?decision(Id, S, T)');
await exec('S3 standalone .why after the claim (the old shim problem)', '.why ?eligible("s1", "carrier_check")');
await exec('S4a proof not last -> unsupported', '.why ?eligible(S, T)\n+need("s9", "x")');
await exec('S4b query + proof -> unsupported', '+need("s9", "x")\n?need(S, T)\n.why ?eligible(S, T)');
await exec('S4c .rule query + proof -> unsupported (review fix)', '+need("s9", "x")\n.rule eligible\n.why ?eligible(S, T)');
await exec('S4d outside command + proof -> unsupported', '+need("s9", "x")\n.rel\n.why ?eligible(S, T)');
await exec('S4e nothing applied by rejected programs', '?need("s9", T)');
await exec('S5 failed proof keeps writes', '+need("s1", "escalate")\n.why ?need(S, T), X > 1');
await exec('S5b write committed', '?need("s1", "escalate")');
await exec('S6 .why full ends a write', '+tool_ready("escalate")\n.why full ?eligible(S, T)');
await exec('S6b .why_not ends a write, sees pre-write state', '+need("s2", "say_eta")\n.why_not eligible("s2", "say_eta")');
await exec('S6c .why_not afterwards', '.why_not eligible("s2", "say_eta")');
await exec('S7 failed program returns no proof and rolls back', '+need("s3", "say_eta")\n+attempt(1, 2)\n.why ?eligible(S, T)');
await exec('S7b rolled back', '?need("s3", T)');
await exec('S8 no-write program: .why still standalone', '?eligible(S, T)\n.why ?eligible(S, T)');
ws.close();
Evidence: Targeted pinned-proof / boundary test results
test protocol::handler::program_boundary::tests::a_proof_in_a_program_that_writes_must_end_it ... ok
test protocol::handler::program_boundary::tests::programs_without_writes_are_not_checked ... ok
test protocol::handler::program_boundary::tests::a_program_that_writes_holds_a_query_or_a_proof_not_both ... ok
test protocol::handler::program_boundary::tests::a_proof_may_end_a_program_that_writes ... ok
test protocol::handler::program_boundary::tests::outside_commands_cannot_share_a_program_with_writes ... ok
test protocol::handler::program_boundary::tests::writes_join_with_request_local_statements ... ok
test protocol::handler::pinned_proof_tests::a_proof_that_does_not_end_the_program_rejects_it ... ok
test protocol::handler::pinned_proof_tests::a_failed_program_returns_no_proof ... ok
test protocol::handler::pinned_proof_tests::a_failed_proof_fails_its_statement_and_keeps_the_writes ... ok
test protocol::handler::pinned_proof_tests::the_documented_example_runs ... ok
test protocol::handler::pinned_proof_tests::a_won_claim_returns_the_proof_of_the_state_its_guard_passed_on ... ok
test protocol::handler::pinned_proof_tests::why_full_and_why_not_can_end_a_program_that_writes ... ok
test protocol::handler::pinned_proof_tests::writes_after_the_commit_do_not_change_the_proof ... ok
test protocol::handler::pinned_proof_tests::a_refused_claim_returns_an_empty_proof ... ok
test protocol::handler::pinned_proof_tests::the_proof_revision_is_the_snapshot_before_the_program_changes ... ok
test protocol::handler::pinned_proof_tests::the_proof_follows_the_guard_through_a_restage ... ok
test protocol::handler::proof_snapshot_tests::why_not_explains_the_captured_snapshot_despite_concurrent_writes ... ok
test protocol::handler::proof_snapshot_tests::why_explains_the_captured_snapshot_despite_concurrent_writes ... ok
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 3029 filtered out; finished in 0.44s
test a_decision_returns_the_proof_of_the_candidate_it_claimed ... ok
test racing_decisions_one_wins_and_only_the_winner_has_a_proof ... ok
test the_proof_rejects_a_program_it_cannot_end_and_survives_restart ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ src/protocol/handler/program_boundary.rs:144 - The QueryWithProof check only rejects Statement::Query. .rule &lt;name&gt; (MetaCommand::RuleQuery) has role Local, so it passes the boundary check, but the handler turns it into a query (query_to_execute = Some(...), handler.rs:2878-2880). Example: +r(1)\n.rule foo\n.why ?r(X) is accepted, commits, and then takes the pinned-proof early return (handler.rs:3355-3380), so the .rule foo query never runs and the reply gives no error for it. The rule-query result is lost without any signal. This contradicts the documented contract that a program cannot hold both a query and a proof and is rejected with unsupported before anything runs. Fix: make the check at program_boundary.rs:142-147 also match Statement::Meta(MetaCommand::RuleQuery(_)), i.e. every statement that sets query_to_execute. .rel &lt;name&gt; is the only other statement that sets it, and it is already rejected as Outside.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • ℹ️ src/protocol/handler.rs:3318 - The pinned-proof reply keeps only proof_trees and timing_breakdown from explained. It drops explained.truncated, the row cap that ProofSnapshot::why records through last_result_truncated(). When a pinned .why matches more tuples than the result row cap, the reply carries a capped set of proof trees but reports truncated: false (the default from messages_result), so the client cannot tell the proof is incomplete. This is unlikely for a single-candidate guard proof, but it can happen with a broad .why ?eligible(S, T). Fix: carry explained.truncated into the returned QueryResult.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
A token-form guarded claim ending with .why ?candidate(...) wins and its reply carries 4 message rows plus a proof (offer fact, tool_ready fact, !attempt absence, revision) ✅ pass live transcript_main.txt section S1
A .why sent as a separate request after the claim explains nothing because the claim withdrew the row (the problem this change fixes) ✅ pass live transcript_main.txt section S2 (rows: [])
A refused claim on the same candidate reports 0 inserted for every statement and proof_trees: [] ✅ pass live transcript_main.txt S3; transcript_restart.txt
Adversarial: a proof that is not the program's last statement is rejected with unsupported at its index and nothing is applied ✅ pass live transcript_main.txt S4 + S6b (tool_ready still [a,c])
Adversarial: a ?query in a program that writes and ends with a proof is rejected with unsupported and nothing is applied ✅ pass live transcript_main.txt S5 + S6b
Adversarial (review fix): a .rule &lt;name&gt; query in a program that writes and ends with a proof is rejected with unsupported and nothing is applied ✅ pass live transcript_main.txt S6 + S6b
A proof that fails reports its own Why error at its index while the program's write stays committed ✅ pass live transcript_main.txt S7 + S7b (q present)
.why_not ending a write is evaluated on the snapshot before the insert ('No matching tuples in offer'); .why full ending a claim returns a proof ✅ pass live transcript_main.txt S8, S8b
The explainability guide's 'Proofs of a Guarded Write' example runs verbatim and returns 'Update: 0 deleted, 1 inserted.' plus one proof tree ✅ pass live transcript_docs.txt
8 connections sending the same guarded claim at once: exactly one wins with a proof, the other 7 are refused with no proof, and there is one attempt and one decision ✅ pass live transcript_race.txt
Decisions written by pinned-proof programs survive a server restart, and a re-claim afterwards is refused with an empty proof ✅ pass live transcript_restart.txt
  • Built cargo build --bin inputlayer-server and ran it in a throwaway data dir on 127.0.0.1:18731 with a bootstrap admin password
  • Drove the server over WebSocket with node drive.mjs 18731 &lt;pw&gt; &lt; steps_main.json (decision pack, token-form claim + .why, separate .why after the claim, refused re-claim, proof not last, ?query+proof, .rule+proof, failed proof, .why_not and .why full ending a write, decisions check, a program without writes)
  • node drive.mjs ... &lt; steps_docs.json: ran the explainability.mdx 'Proofs of a Guarded Write' example verbatim on a fresh KG
  • node race.mjs 18731 &lt;pw&gt;: 8 authenticated connections sent the same guarded claim at the same time
  • Restarted the server on the same data dir and ran steps_restart.json (decisions persisted; re-claim refused with an empty proof)
  • cargo test --test pinned_proof_tests
  • cargo test --lib -- pinned_proof program_boundary

✅ No issues found.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Token-form guarded claim ending with .why wins and returns per-statement counts plus a proof of the candidate (fact need/tool_ready leaves, !attempt negation leaf, revision) ✅ pass live live-transcript.txt section S1
Repeating the same claim is refused (all counts 0) and returns an empty proof_trees; only one attempt/decision row exists ✅ pass live live-transcript.txt sections S2, S2b
A standalone .why after the claim explains nothing (shows why the proof has to be pinned) ✅ pass live live-transcript.txt section S3
A proof that is not the last statement of a program that writes is rejected with unsupported, and nothing is applied ✅ pass live live-transcript.txt sections S4a, S4e
A program that writes and holds a query, a .rule <name>, or an outside command (.rel) alongside an ending proof is rejected with unsupported at that statement's index ✅ pass live live-transcript.txt sections S4b, S4c, S4d, S4e
A pinned proof that fails reports 'Why error' at its own index while the program's writes stay committed ✅ pass live live-transcript.txt sections S5, S5b
.why full and .why_not can end a program that writes and explain the state before the write (escalate missing; need missing at revision 9 vs 10 after) ✅ pass live live-transcript.txt sections S6, S6b, S6c
A program that fails mid-write rolls back and returns no proof ✅ pass live live-transcript.txt sections S7, S7b
A program without writes still runs a query and a .why as before ✅ pass live live-transcript.txt section S8
A conflicting commit between staging and commit restages the program, and the proof follows the guard to the new snapshot; writes during proof search do not change the proof ⏸️ untested no The prior payload did not establish a live result for this scenario. It needs a write injected exactly between staging and commit, or during proof search, and that timing can't be reproduced over the…
  • Built cargo build --bin inputlayer-server and ran it on 127.0.0.1:18917 with a temp data dir and a throwaway admin password
  • node drive.mjs ws://127.0.0.1:18917/ws: sent 20 programs over the live WebSocket API (login, kg create, decision pack, token-form claims, boundary rejections, failed proof, .why full, .why_not, rollback, no-write regression)
  • cargo test --lib -- pinned_proof_tests program_boundary proof_snapshot_tests (includes the Commit/ProofSearch hook tests for a restage and for writes after the commit)
  • cargo test --test pinned_proof_tests (decision with proof, racing decisions, rejection survives restart)
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

@jsam jsam added this to the Engine production readiness milestone Oct 4, 2026
@jsam jsam changed the title feat(engine): let a proof end a program that writes, evaluated on the snapshot its writes committed against feat(protocol): let a proof end a program that writes, evaluated on the snapshot its writes committed against Oct 4, 2026
jsam added 4 commits October 4, 2026 19:09
… snapshot its writes committed against

A claim guarded on a candidate withdraws the candidate by succeeding, so a
`.why` sent after the claim explains a row that is gone. A `.why`,
`.why full` or `.why_not` may now be the last statement of a program that
writes. It runs after the commit on the snapshot the commit applied to: the
knowledge graph just before the program's changes, on which the program's
reads were validated to still hold, so it explains the state the guard
passed on. A restage moves the proof with the guard; later writes never
change it.

The reply keeps the statements' message rows and carries the proof in
`proof_trees`; each proof tree now names the snapshot `revision` it was
evaluated on. A proof that is not the last statement, or one in a program
that also holds a query, is rejected with `unsupported` before anything
runs. Programs without writes run `.why` as before.
@jsam
jsam marked this pull request as ready for review October 4, 2026 19:22
@jsam
jsam merged commit 25058f1 into main Oct 4, 2026
21 checks passed
@jsam
jsam deleted the fm/en-en7 branch October 4, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant