Compare commits

...

9 Commits

Author SHA1 Message Date
Tim Lingo d9653b1a22 feat(gate): make a state_get with no producer a build error, not a silence
Retires the defect class behind #129. The engine's state store returns "" for a
key nothing writes — no error, no warning, no log. That is how the agentic
path's crisis-escalation input scored 0 on every real conversation for two days
after ff421d3 moved conversation history behind conv_hist_key(session_id) and
left one consumer reading the old "conv_history" bucket by hand.

scripts/verify-state-keys.sh is the gate; scripts/state-key-audit.py is the El
reader behind it. Two checks:

  DEAD-READ    a state_get whose key resolves to something no state_set in the
               tree produces.
  HAND-ROLLED  a literal that belongs to a namespace a helper owns, accessed
               without the helper. This is #129's actual shape, and DEAD-READ
               alone does NOT catch it: the dead handle_chat() still writes
               "conv_history" through conv_hist_key(""). Stating that plainly
               because a gate that only appears to work is worse than none.

WHY IT DOES NOT CRY WOLF. Keys are usually computed, so a literal-matching
script would flood and be switched off in a day. The resolver handles
concatenation (matched on the static prefix), helper functions (resolved to
their possible returns, with guard conditions folded so conv_hist_key("") does
not falsely claim to produce the session_hist_ namespace), keys built into a
local, and keys arriving as a parameter (resolved through the call sites).
278 of 278 sites on this tree resolve: UNRESOLVED 0, FINDINGS 0. Unresolvable
keys would be listed and would NOT fail the build.

TWO-LEG PROOF, one variable — agentic_safety_screen's single line:
  pre-fix  scripts/verify-state-keys.sh --root <scratch>
           chat.el:2536 state_get("conv_history")
             conv_hist_key() owns this key namespace (EXACT 'conv_history')
           FAIL: 1 state-key finding(s)                        exit 1
  as-is    scripts/verify-state-keys.sh
           FINDINGS (0) ... PASS                               exit 0

INDEPENDENT CONFIRMATION: run read-only against
origin/feat/soul-openai-tools-v2, which carries the same defect on its own, the
gate reported chat.el:2937 — the exact line 43d0449's message had named by
hand, with no prior knowledge. Against origin/fix/129-on-openai-tools: PASS.

PRODUCER-MOVED CONTROLS: renaming the sole writer of an EXACT key (soul_model)
orphans 3 readers across 3 files; renaming the sole writer of a PREFIX
namespace (agent_workspace_root_*) orphans 3 readers, including when the
producer moves to a NARROWER namespace — a case an earlier, more permissive
prefix rule let through. That rule is now directional, with the reason written
next to it.

FOUND ON ITS FIRST RUN, unprompted: soul.el's state_set("soul_identity", ...)
was deleted 2026-05-13 in b163fa6 (a commit about awareness/ISE writes) and five
readers in chat.el were left behind — build_system_prompt, the vision handler,
the agentic system prompt and two council handlers have prefixed "" for ~3
months. studio.el:57 emits "principal":"" and never had a producer. Both are
recorded in state-key-baseline.txt with dates and causes so the gate can be
turned on today; they are DEBT, not false positives, and every run prints them.

Baseline signatures carry no line number (an unrelated edit must not un-mute an
accepted finding) but do carry a count, so a GROWTH in a baselined finding still
fails the build.

Engine behaviour unchanged: this commit adds scripts only, no .el is touched.
CI is deliberately NOT wired here — .gitea/workflows/ci.yaml has changes in
flight from someone else, and turning the gate on would immediately red
feat/soul-openai-tools-v2 (correctly). That flip should be deliberate.

Rung reached: RUNS — the gate executes (0.15s), discriminates on four
independent test pairs, and its verdicts are quoted above. Not wired to CI, and
no engine binary was built from this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:11:26 -05:00
Tim Lingo f1f52bcb2f merge: Stage 1 structural audit as a real route (#142/#91)
Neuron Soul CI / build (push) Failing after 47s
Neuron Soul CI / deploy (push) Has been skipped
The runtime-vs-owner divergence check. Its absence let a ~24,000-node loss run for
weeks with every boot reporting green, which is most of why the last two days were
spent rediscovering by hand what this route would have said.

Follows the spec rather than inventing a metric: CGI provisional
05-detailed-description.md Stage 1 calls for an annotated characterization of the
graph's structure, so the route returns findings with score null BY DESIGN. A number
here would be a fabrication dressed as rigour.

Rebased across 27 commits of drift. The rebase merged cleanly at source level and
that was misleading — dist/soul.c held one side's code and not the other, because
git resolved the amalgam as an ordinary file. The stamp gate from 9fd8c11 caught it
on its first real use. Without it this would have landed an engine containing the
audit but none of the 08-09 engine work, or the reverse: neuron#133 again.

Verified before merging, not after:
  stamp OK                    dist/soul.c matches the sources (1,204,442 bytes, 1,259 bodies)
  builds from committed input 920,776 bytes
  interface                   108 -> 110 routes, nothing removed
  the route answers           stage 1, annotated_characterization, findings present

Closes #142. Refs #91.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:03:10 -05:00
Neuron aad988ecbf chore: regenerate dist/soul.c after rebasing onto main
The rebase merged cleanly at source level but left dist/soul.c holding one side's
code and not the other — main's regenerated amalgam vs this branch's. The stamp
gate added in 9fd8c11 caught it on its first real use:

  FAIL: dist/soul.c is STALE. It does not match the current .el sources.
  Sources that changed: neuron-api.el, routes.el

Without that gate this branch would have looked clean and shipped an engine
containing the structural audit but none of the 08-09 engine work, or the reverse.
That is exactly neuron#133, which once hid five merged fixes including a P0.

Regenerated from the rebased sources: 1,204,442 bytes, 1,259 inlined bodies.

Verified after: stamp OK; builds from its own committed input (920,776 bytes);
interface 108 -> 110 routes with nothing removed, adding /api/neuron/audit/structural;
and the route answers — stage 1, assessment_kind 'annotated_characterization',
score null by design, with findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:01:25 -05:00
Tim Lingo 7b86e6f72c feat(soul): Stage 1 structural audit as a real route — an annotated characterization, not a score (#91)
`runStructuralAudit` has been an advertised MCP tool with nothing behind it: the
dispatcher GET'd /session/begin and returned that unrelated session digest under
an audit tool's name. Meanwhile the failure the audit would have caught ran
silently for about three weeks — the soul reporting 103,089 nodes while the
engram, which OWNS persistence, held ~79,900, a crash discarding the difference,
and every boot reporting green throughout, because nothing in the system ever
compared the two sides.

WHAT THE PATENT SPECIFIES, AND HOW IT SHAPED THIS
  CGI provisional, 05-detailed-description.md, "Stage 1: Structural audit 430".
  Two clauses did the design work. First the four things the module evaluates:
  the density and typed distribution of causal edges; value/execution-record
  consistency; the richness and connectivity of the self-model; and wonder-
  manifest authenticity. Second, and decisively: it "produces a coherence
  assessment 432 — NOT A BINARY SCORE but an annotated characterization of the
  graph's structural properties."

  So every finding carries its numbers AND a plain-language note saying what
  they mean and how they were obtained. There is no pass/fail and no composite
  health figure, and `"score":null` is emitted explicitly so a reader cannot
  mistake its absence for an omission.

WHAT IS IN STAGE 1 (four findings)
  owner_runtime_divergence   — the motivating case. Runtime counts vs the owner's
      own GET /api/stats, the delta, and the trend against the previous audit, so
      a second call answers "is the gap growing?" rather than restating it.
  self_model_connectivity    — the three identity pillars plus the self root:
      present, content length, one-hop degree. This RETIRES the Claude-side vitals
      identity block, which lived outside the system it was checking and went on
      reporting green while the memory-philosophy pillar was absent from the live
      graph. Asking the running soul is the designed mechanism; a shell probe was
      the fourth patch on the same hole.
  typed_edge_distribution    — exact counts against the claim-10 vocabulary, plus
      density, plus a separate count of LOWERCASE near-misses ("causes" vs
      "Causes"): "the vocabulary is unused" and "the vocabulary is misspelled by
      the write paths" are different defects with different fixes.
  orphans_and_dangling_edges — the tool's own long-standing promise.

WHAT IS DEFERRED, AND WHY IT IS DATA RATHER THAN A COMMENT
  Value/execution-record consistency and wonder-manifest authenticity ship as a
  `deferred` array that MEASURES the populations they would need (Prediction and
  WonderQuestion nodes) and reports those counts as the reason. Both are ~0 today
  — WonderQuestion because of a known write/read node-type mismatch. Asserting
  value coherence or a pull-weight correlation on an empty population would be a
  fabricated result, which is worse than a stated gap.

MEASUREMENT HONESTY: EXACT WHERE CHEAP, SAMPLED WHERE NOT, ALWAYS LABELLED
  Counts, edge typing and self-model connectivity are exact. Orphan and dangling
  rates are sampled, because engram_find_node_index is a linear scan — an
  exhaustive dangling check is O(nodes x edges), ~2.2e9 string compares at today's
  scale. Samples are UNIFORM across the whole population (str_index_of_all gives
  every edge offset in one pass, so any index is O(1); json_array_get would have
  been O(n^2)), never head-of-list, and each figure ships with its own sampled /
  population / exhaustive fields. ?edge_sample= and ?node_sample= at population
  size run either check exhaustively. The real fix is an id index in the runtime.

ONE BUG THIS FOUND IN ITSELF, CAUGHT IN TEST
  http_get does not return "" when the owner is unreachable — it returns a JSON
  error object. Testing only for "" made a DEAD owner read as reachable with
  node_count 0, so the audit reported 100% divergence and named it data loss.
  Reachability is now proved by the presence of the node_count field, and the
  owner's raw reply is attached. A confident wrong answer is exactly what this
  route exists to stop.

  Edge findings need relation labels and the runtime has no edge-enumeration
  builtin, so they use the same scratch export GET /api/graph/edges already uses
  (engram_save to TMPDIR, never the owner's canonical file — #117). That is a
  large write on a large graph, so this is a manual route, not a timer; ?edges=0
  skips it.

  neuron-api.el:900-1273  handler + helpers
  routes.el:567,752       GET and POST /api/neuron/audit/structural
  mcp-wrapper/src/main.el:113,682  tool description + dispatch off /session/begin
  dist/soul.c             regenerated (1255 bodies)

Rung: E2E-VERIFIED. Soul built from this branch (gen-soul-amalgam + cc-brain,
921,192 bytes, 16 warnings, 0 errors), booted on throwaway ports 7893/7896/7897
with throwaway HOMEs against a stub owner on 7894. Three scenarios pass: owner
reachable (runtime 62 vs owner 42, delta 20 / 32.2%, trend flat on the second
call; 12/20 edges claim-10 typed, 3 lowercase near-misses; 50/62 orphans, 3/20
dangling — every figure matches the fixture by construction), owner unreachable
(reported as a finding with the raw reply, not a crash), and file mode (owner
"none", divergence undefined). Reached end-to-end through the MCP tool via a
locally built wrapper. verify-soul-contract.sh: GATE PASS, 27/27 routes +
immutability. No process left running; live :7770 and :8742 untouched (GET only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:54:42 -05:00
Tim Lingo bf521974af merge: semantic retrieval, self-seeded context, connections on write, and one build lineage
Neuron Soul CI / build (push) Failing after 11m54s
Neuron Soul CI / deploy (push) Failing after 14m43s
Consolidates the 2026-08-09 engine work. Each piece was measured in an isolated lab
against the real corpus, gated, and verified on the operator machine before landing.

WHAT IT CONTAINS

  Semantic retrieval + the #146 delete fix, shipped together on purpose. The
  semantic leg was once wired into engram_search_json, which seven sites use as a
  keyed read and then delete every result from; one runs at every boot. Measured
  cost when live: ~234 real records destroyed per startup, identity among them.
  Retrieval must never reach production without the engram_recall_json split.

  Self-seeded compiled context. The design: 'Every compilation query begins at the
  self-model node and traverses outward.' Ours seeded from a hardcoded string, so
  compiled context held 0 identity records. Now 18, bounded the same way every other
  list in that handler is — the unbounded version is what set self_neighbors to []
  after it closed the socket on every call.

  Connections on write. CCR claim 29 requires linking candidates with typed edges
  'rather than appending as unlinked content'. Unlinked append was the only
  behaviour we had: 5% of nodes connected, no edge created by any write in 19 days.

  dist/soul.c drift becomes a build failure (#133), and deploys now build from that
  same committed input rather than a scratch amalgam — one lineage, provenance
  recorded, enforced by a gate in the deployer.

MEASURED, all on the real corpus or the live machine

  rephrased-question recall     0/43 -> 20/43 (bar was >=6 queries; nonsense controls held 10/10)
  identity in compiled context  0 -> 18
  connections per memory        0 -> ~2 on write; 2,828 backfilled into history
  no data loss across boots     44,418 / 44,418 / 44,418 over three restarts

FOUR DEFECTS FOUND BY MEASURING RATHER THAN READING, each fixed here

  an inert test arm (both arms byte-identical but for a float rounding artifact);
  an association hook wired into a path the request never takes (zero edges across
  four writes); an identity exclusion matching lowercase 'self' that let a memory
  link into the identity graph, whose verification shared the blind spot and printed
  PASS; a telemetry filter matching hyphenated 'state-event' that missed
  'INTERNAL STATE EVENT'. Common shape: a filter tested a proxy for the property it
  cared about. Assert the property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:40:56 -05:00
Neuron 5743568bf1 chore(engine): deploys build from the committed input, not a second lineage
Until now deploys were built by build-soul.sh from a scratch amalgam while CI
compiled the committed dist/soul.c. Two lineages — 'what runs' and 'what the repo
says builds' were different artifacts. That is #133/#111 in another costume, and it
is how a round-9.1 brain shipped matching no committed source at all.

build-soul-from-dist.sh asserts dist/soul.c matches the .el sources, compiles it
with CI's own flags (-O2 -DHAVE_CURL -rdynamic; the CI comment explains -rdynamic —
without it the runtime cannot resolve its HTTP handler by name and the binary serves
nothing on every route), and writes a .provenance sidecar recording the dist/soul.c
hash, the stamp hash and the commit.

deploy_binary.sh gains GATE 0: refuse any soul without provenance, or built from a
dist/soul.c other than the one in the repo now. The override
(NEURON_DEPLOY_UNSTAMPED=i-accept-two-lineages) exists deliberately — a gate with no
escape hatch gets bypassed by disabling the gate, which is worse than one that
announces itself.

Verified before enforcing, so the sanctioned path is not a broken one:
  interface parity with the deployed binary   108 routes in, 108 out
  boots and serves                            2s
  carries today's work                        24 self-neighbours, 17 identity records
  GATE 0 refuses an unstamped binary          exit 10
  GATE 0 accepts the stamped one, deployed    durability PASS

Production now runs 4845db3d, built from dist/soul.c at 9fd8c11. After the swap:
identity in context 17, connections still form on write, semantic recall intact,
mind and store at delta 0.

Refs #133, #111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:38:01 -05:00
Neuron 9fd8c11670 chore(engine): make dist/soul.c drift a build failure instead of a silent ship (#133)
#133 regenerated the amalgam once and said so itself: 'Nothing in the tree
regenerates this file. Only a human running the recipe. It lags in batches, never
per-change, and it will drift again.'

It drifted again. Every binary deployed on 2026-08-09 was built by build-soul.sh
from a scratch amalgam that never touches dist/soul.c, so the committed build
input fell 2,761 bytes behind the sources by a different route than #133 describes.

Auto-regeneration is not available: the CI workflow records that elc needs 24GB+
of virtual memory and would OOM the runner. So the build cannot regenerate the
file. It can refuse to compile a stale one, for free and with no compiler.

tools/soulc-stamp.sh records a content fingerprint of every .el source at the
moment the amalgam is generated. --check recomputes and compares; divergence exits
1 and names the changed files and the recipe. Wired into CI ahead of the compile.

dist/soul.c regenerated from current sources: 1,176,361 -> 1,179,122 bytes, 1,247
inlined bodies (gate wants >=1200), and verified to compile clean at 903,552 bytes.

Demonstrated to FAIL on the bad input, per postmortem 0004's rule that a gate which
only passes on good input proves nothing:
  fresh stamp        -> OK,   exit 0
  one .el modified   -> FAIL, exit 1, names memory.el
  source restored    -> OK,   exit 0

Refs #133, #111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:56 -05:00
Neuron be0f9d1afe feat(soul): memories form connections when written
The design rejects what this system did: promotion must link candidates 'using
typed semantic edges rather than appending as unlinked content' (CCR claim 29).
Unlinked append was the only behaviour we had. Measured 2026-08-09: 14,214 edges
over 80,936 nodes, 5% of nodes connected to anything, and no edge created by any
write since 2026-07-19 across 27,000+ new nodes. A memory with no connections is
unreachable by spreading activation, so retrieval degrades to literal matching.

On write, a memory is now linked to its top related existing memories.

Bounds, each bought with a specific failure:
  - max 3 edges per memory (link_memories.py's cap: precision over spray)
  - never link to identity. The existing policy is explicit that 'memories must
    not pollute the self traversal by similarity; only an explicit citation may
    touch identity'.
  - never link telemetry (state-event, soul-response, boot_count, loop-outcome,
    search-result): ~97% of daily write volume. Linking it would add thousands of
    noise edges a day and re-flatten the graph in the name of connecting it.
  - fail-soft: a failed association never fails the write
  - associate only AFTER durability, so no edge points at a node that did not
    persist — that is the dangling-edge defect the 08-09 cleanup removed 830 of

Two defects found by measuring rather than reading, both fixed here:
  1. Hooking mem_store alone produced ZERO edges across four real writes. The HTTP
     memory route writes via wt_node directly; mem_store serves only the awareness
     telemetry paths we refuse to link.
  2. A lowercase-only identity check let a memory link to 'Self — Values
     (grounded)' — the exact pollution the policy forbids. My verification shared
     the blind spot and printed PASS. Now uses str_lower.

Measured, lab, same corpus: ~1-2 edges per memory; identity edges unchanged at
475; zero identity leaks post-fix including an adversarial batch of six memories
written about values. Edges route through wt_edge so they reach the owner.

Rung: E2E-VERIFIED in an isolated lab. Not deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:00:04 -05:00
Neuron 1742d0b575 feat(soul): seed compiled context from the self node, bounded
The design is explicit — 'Every compilation query begins at the self-model node
and traverses outward... structural reachability from the self-model node is a
precondition for any node to appear in compiled context.' (will-anderson
patents/drafts/engram-claims.md, Self-Seeded Activation. DRAFT, not a filed
provisional — cited as such.)

Measured before: compiled context held 0-1 identity records out of 10, because
compilation seeds from a hardcoded text string and never from the self. Even an
explicit 'my values identity who I am' query returned a boot counter and
state-events.

This restores the designed behaviour without repeating the failure that set
self_neighbors to [] originally: that was an unbounded ~90KB neighbour dump which
closed the socket on every call. Same bound as every other list in the handler.

Cap is 24 rather than 8 because the self root's first 8 neighbours are tag nodes
('neuron', 'tier:note', 'disposition:experimental', 'imprint', 'traversal') that
crowd out the substantive identity records behind them. The root has 34
neighbours of which 23 are identity.

Measured after, same corpus, binary as the only variable:
  identity records in compiled context  0 -> 17
  response size                         10,806 -> 18,985 bytes
  interface gate                        108 routes in, 108 out, none removed

Rung: E2E-VERIFIED in an isolated lab. Not deployed — this is the identity layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:45:12 -05:00
14 changed files with 2300 additions and 130 deletions
+10
View File
@@ -63,6 +63,16 @@ jobs:
cp vendor/el-runtime/v1.0.0-20260501/el_runtime.h /opt/el/runtime/el_runtime.h
echo "El runtime PINNED to v1.0.0-20260501: $(ls /opt/el/runtime/)"
# neuron#133: CI compiles dist/soul.c, NOT the .el sources. On 2026-08-07 a
# build off main would have shipped an engine with none of five merged fixes,
# including a P0 safety fix, while main's source read as correct. The runner
# cannot regenerate the amalgam (elc needs 24GB+ virtual memory), but it can
# refuse to compile a stale one. Fails loudly with the recipe in the message.
- name: Verify dist/soul.c matches the sources
run: |
chmod +x tools/soulc-stamp.sh
./tools/soulc-stamp.sh --check
- name: Build neuron soul binary
run: |
RUNTIME=/opt/el/runtime
Generated Vendored
+431 -127
View File
File diff suppressed because it is too large Load Diff
Generated Vendored
+18
View File
@@ -0,0 +1,18 @@
# soul.c.stamp — fingerprint of the .el sources dist/soul.c was generated from.
# Written by tools/soulc-stamp.sh --write. Do not hand-edit.
# generated_amalgam_sha256 63e30030bee5e87fa082a84cda5c1226896f49da6076101fcd6b9530ea7caf49
# generated_amalgam_bytes 1204442
f8597e10546654bce3fbbe40461b2da59d0e06dbf1b038d1d362d24f949e3911 awareness.el
b6f3d14ca0c26017a2d617399a6d3754dabb0905e4d5f52eb75d25c4ad18d3c5 chat.el
42288c212cbf72fb1e8ecbd4d9900e4e9ee1cfa475b7974295c7637f1bf2939f elp-input.el
b3f77f49d6086932c38bd17fe7a5eaf8bce25685f6fc3e1750f05729c6b49b9e imprint.el
fba8ffdb9ba72bca5b09ca1c93a520edc52f3f4d8aec2c7585fe9b17e06420b2 manifest.el
550a72e234ae8cec1f33e02108fd365353f45edd88513da90b792e79b6c0e5f0 memory.el
5ec07ec9785b02abe32f3ff7acf2d1f9f7e07c0967fac97e6eff17d7110b5c84 neuron-api.el
03c47c451e0e87f2c252cadb4b765867943962a804f548dd53adeef0520912c8 persist.el
a6d69f3fc55233d9d3300160fd46a1551f2064bcd0fb84e2c9e432f636a72476 routes.el
c28e36952ec56525963a0bdf29455ab097d3b0c5653d19c25fbb005e1069a1f7 safety.el
fd3ab91d0ae0ea26639e21bef2f8f94054dc4b02eae68b19e3fe689d2769aad4 sessions.el
0f1cf43904a98a5a646cce5a07e0e96162ced662692fbc13357d9b67d9a8ac3d soul.el
30337940905171a9645b0929f0a412ce6b3dccb1246495070c553bca0bbae6cd stewardship.el
e105dc5990e6adbf39db9dc0462cd8bcf6e6c3dfd03709059227ecfad2bbab29 studio.el
+11 -2
View File
@@ -110,7 +110,7 @@ tool("beginSession", "Initialize session: surface recent high-importance memorie
"," + tool("linkCausal", "Create a causal edge (cause -> effect).") +
"," + tool("restructureCausalGraph", "Re-balance the causal subgraph after new evidence.") +
"," + tool("rebuildGraph", "Rebuild graph indices from the on-disk snapshot.") +
"," + tool("runStructuralAudit", "Audit graph structure for orphans, dangling edges, mislabeled types.") +
"," + tool("runStructuralAudit", "Stage 1 structural audit: owner-vs-runtime divergence, orphans and dangling edges, typed-edge distribution, self-model connectivity. Returns an annotated characterization, not a score.") +
// Backlog + work
"," + tool("planWork", "Create a backlog item.") +
"," + tool("reviewBacklog", "Browse work items.") +
@@ -680,7 +680,16 @@ fn dispatch_tool_call(tool_name: String, args: String) -> String {
return mcp_json_result(resp)
}
if str_eq(tool_name, "runStructuralAudit") {
let resp: String = http_get(neuron_url() + "/session/begin")
// Was: GET /session/begin an unrelated session digest returned under an
// audit tool name, i.e. the tool advertised a check that did not exist.
// Now points at the real Stage 1 route (neuron-api.el
// handle_api_structural_audit). Sample caps ride the query string; the
// defaults keep a manual audit to a couple of seconds.
let e_s: Int = json_get_int(args, "edge_sample")
let n_s: Int = json_get_int(args, "node_sample")
let qs: String = "?edge_sample=" + int_to_str(if e_s > 0 { e_s } else { 3000 })
+ "&node_sample=" + int_to_str(if n_s > 0 { n_s } else { 300 })
let resp: String = http_get(neuron_url() + "/audit/structural" + qs)
return mcp_json_result(resp)
}
+83
View File
@@ -4,6 +4,86 @@ fn tier_working() -> String { return "Working" }
fn tier_episodic() -> String { return "Episodic" }
fn tier_canonical() -> String { return "Canonical" }
// Association on write
// DESIGN: "promotion integrates candidate nodes by linking them to existing nodes
// using typed semantic edges RATHER THAN APPENDING AS UNLINKED CONTENT" (CCR
// claim 29). Unlinked append is the explicitly rejected behaviour and it is the
// only behaviour this system had. Measured 2026-08-09 on Tim's graph: 14,214 edges
// across 80,936 nodes, 5% of nodes connected to anything, and NO edge created by
// any write since 2026-07-19 while 27,000+ nodes were added. A memory that forms
// no connections cannot be reached by spreading activation, so retrieval silently
// degrades to literal matching.
//
// BOUNDS, each one bought with a specific failure:
// * max 3 edges per memory link_memories.py's cap, precision over spray
// * never link to identity (self/*, Value): the existing policy is explicit that
// "memories must not pollute the self traversal by similarity; only an explicit
// citation may touch identity". Similarity is not citation.
// * never link telemetry (state-event, soul-response, boot_count, loop-outcome):
// these are ~97% of daily write volume (1,020 vs 31 real memories on 08-08).
// Linking them would add ~3,000 noise edges a day and re-flatten the graph in
// the name of connecting it.
// * fail-soft: a failed association never fails the write.
// Edges go through wt_edge so they reach the owner and survive restart.
fn mem_assoc_skip_label(label: String) -> Bool {
if str_contains(label, "state-event") { return true }
if str_contains(label, "soul-response") { return true }
if str_contains(label, "soul-outbox") { return true }
if str_contains(label, "boot_count") { return true }
if str_contains(label, "loop-outcome") { return true }
if str_contains(label, "search-result") { return true }
return false
}
// A candidate is linkable only if it is a real, distinct, non-identity node.
fn mem_assoc_ok(cand_id: String, cand_label: String, self_id: String) -> Bool {
if str_eq(cand_id, "") { return false }
if str_eq(cand_id, self_id) { return false }
// CASE MATTERS measured 2026-08-09. A lowercase-only check let a memory link
// to "Self — Values (grounded)", i.e. it polluted the self traversal, which is
// the one thing this policy exists to prevent. My verification had the same
// blind spot and printed PASS. Check every casing the graph actually uses, and
// exclude identity node TYPES as well as labels.
let lab: String = str_lower(cand_label)
if str_starts_with(lab, "self") { return false }
if str_starts_with(lab, "value") { return false }
if str_contains(lab, "values") { return false }
if str_contains(lab, "identity") { return false }
if mem_assoc_skip_label(cand_label) { return false }
return true
}
// One slot of the association. Manual unroll rather than a loop: EL's codegen
// mis-emits accumulating while-loops (documented at soul.el:212, which unrolled
// three affective slots for the same reason).
fn mem_assoc_slot(results: String, idx: Int, new_id: String) -> Void {
if idx >= json_array_len(results) { return }
let cand: String = json_array_get(results, idx)
let cid: String = json_get(cand, "id")
let clabel: String = json_get(cand, "label")
let ctype: String = json_get(cand, "node_type")
if str_eq(ctype, "Value") { return }
if str_eq(ctype, "DharmaSelf") { return }
if str_eq(ctype, "Safety") { return }
if mem_assoc_ok(cid, clabel, new_id) {
wt_edge(new_id, cid, el_from_float(0.5), "related")
}
}
// mem_associate connect a freshly written memory to what it is about.
fn mem_associate(new_id: String, content: String, label: String) -> Void {
if str_eq(new_id, "") { return }
if mem_assoc_skip_label(label) { return }
// Ask the graph what this memory resembles. Now that the store carries
// meaning-vectors this is semantic, not merely lexical.
let probe: String = str_slice(content, 0, 400)
let results: String = engram_recall_json(probe, 4)
if str_eq(results, "") { return }
mem_assoc_slot(results, 0, new_id)
mem_assoc_slot(results, 1, new_id)
mem_assoc_slot(results, 2, new_id)
}
fn mem_store(content: String, label: String, tags: String) -> String {
let id: String = wt_node(
content,
@@ -31,6 +111,9 @@ fn mem_store(content: String, label: String, tags: String) -> String {
// rather than claiming a save that did not happen. The id is still returned:
// the local write DID succeed, and the queued delta will be retried.
let durable: Bool = wt_commit(id)
// Associate AFTER the node is durable: an edge to a node that did not persist
// is a dangling edge, which is the defect the 2026-08-09 cleanup removed 830 of.
mem_associate(id, content, label)
if durable {
println("[memory] write persisted at owner: " + id + " label=" + label)
} else {
+492 -1
View File
@@ -304,10 +304,35 @@ fn handle_api_begin_session(body: String) -> String {
let state_events: String = api_compact_node_array(state_events_raw, 5, 500)
let recent_raw: String = engram_scan_nodes_json(10, 0)
let recent: String = api_compact_node_array(recent_raw, 10, 240)
// SELF-SEEDED SLICE (2026-08-09). The design is explicit: "Every compilation
// query begins at the self-model node and traverses outward... structural
// reachability from the self-model node is a precondition for any node to
// appear in compiled context" (will-anderson patents/drafts/engram-claims.md,
// Self-Seeded Activation; DRAFT, not a filed provisional — cite it as such).
//
// Measured 2026-08-09 before this change: compiled context contained 0-1
// identity records out of 10, because compilation seeds from a hardcoded
// TEXT STRING, never from the self. Even an explicit "my values identity who
// I am" query returned a boot counter and state-events.
//
// This restores the designed behaviour WITHOUT repeating the failure that got
// self_neighbors set to [] in the first place: that was an UNBOUNDED ~90KB
// neighbour dump which closed the socket on every call. Same bound as every
// other list here cap 8, 240-char snippets. The self root has 34 direct
// neighbours of which 23 are identity records, so depth 1 is dense enough to
// be worth seeding and small enough to stay cheap.
let self_raw: String = engram_neighbors_json("kn-efeb4a5b-5aff-4759-8a97-7233099be6ee", 1, "both")
// Cap 24, not 8: measured 2026-08-09, the self root's first 8 neighbours are
// TAG nodes ("neuron", "tier:note", "disposition:experimental", "imprint",
// "traversal") which crowd out the substantive identity records behind them.
// The root has 34 neighbours of which 23 are identity; 24 captures them while
// staying bounded. Cost measured at ~+4KB on a ~12KB response, nowhere near
// the ~90KB unbounded dump that closed sockets and got this set to [].
let self_slice: String = api_compact_node_array(self_raw, 24, 240)
return "{\"stats\":" + stats
+ ",\"recent\":" + recent
+ ",\"activated\":" + activated
+ ",\"self_neighbors\":[]"
+ ",\"self_neighbors\":" + self_slice
+ ",\"recent_state_events\":" + state_events + "}"
}
@@ -355,6 +380,13 @@ fn handle_api_remember(body: String) -> String {
sal, sal, el_from_float(0.9),
"Episodic", final_tags)
if !api_persisted(id) { return api_not_persisted(id) }
// Associate on write (2026-08-09). THIS CALL MUST BE HERE, not only in mem_store.
// The HTTP memory route writes via wt_node directly; mem_store serves only the
// awareness paths (soul-response, search-result, activation-result) which are
// exactly the telemetry we refuse to link. Hooking mem_store alone produced
// ZERO edges across four real writes measured, not assumed, which is the only
// reason it was caught before shipping.
mem_associate(id, content, "memory:remembered")
return "{\"id\":\"" + id + "\",\"ok\":true}"
}
@@ -894,3 +926,462 @@ fn handle_api_consolidate(body: String) -> String {
}
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
}
// Stage 1: structural audit
//
// WHAT THIS IMPLEMENTS
// The CGI provisional, 05-detailed-description.md, "Stage 1: Structural audit
// 430". Verbatim, the audit module evaluates: the density and typed
// distribution of causal edges; the consistency between value nodes and
// execution-record neighborhoods; the richness and connectivity of the
// self-model; and the authenticity of open-question nodes in the wonder
// manifest. It "produces a coherence assessment 432 — NOT A BINARY SCORE but
// an annotated characterization of the graph's structural properties".
//
// That last clause is the whole shape of this handler. Every finding carries
// its own numbers AND a plain-language note saying what the numbers mean and
// how they were obtained. There is no pass/fail, no percentage-of-health, no
// composite score, and `"score":null` is emitted explicitly so a downstream
// reader cannot mistake its absence for an omission.
//
// WHY IT EXISTS NOW, AND WHY THE FIRST FINDING IS THE ONE IT IS
// `runStructuralAudit` has been an advertised MCP tool with nothing behind it:
// the dispatcher GET'd /session/begin and returned that blob (mcp-wrapper/src/
// main.el). Meanwhile the failure the audit would have caught ran silently for
// about three weeks the soul reported 103,089 nodes while the engram, which
// OWNS persistence, held ~79,900; a crash discarded the difference. Every boot
// reported green throughout, because nothing in the system ever compared the
// two sides. So finding 1 is owner-versus-runtime divergence: it is the check
// whose absence cost real memory, and it is cheap and exact.
//
// WHAT IS DELIBERATELY NOT HERE (stage 1b, see the `deferred` array in the
// response): value/execution-record consistency and wonder-manifest
// authenticity. Both need node types that barely exist in this graph today
// the response MEASURES those populations and reports the counts as the reason,
// rather than asserting a deferral without evidence.
//
// MEASUREMENT HONESTY: EXACT WHERE CHEAP, SAMPLED WHERE NOT, ALWAYS LABELLED
// Counts, edge typing and self-model connectivity are exact. Orphan rate and
// dangling-edge rate are SAMPLED, because the engram runtime has no node-id
// index `engram_find_node_index` is a linear scan over every node, so an
// exhaustive dangling check is O(nodes x edges) (~2.2e9 string compares at
// today's scale, tens of seconds inside one request). The samples are UNIFORM
// across the whole population, not head-of-list, and every sampled figure is
// emitted with its own `sampled` / `population` fields plus an extrapolation
// labelled as such. Raise `?edge_sample=` / `?node_sample=` to the population
// size to run either check exhaustively and pay the time. The real fix is an
// id index in the runtime; that is the engram repo's, not this handler's.
// audit_pct1 one-decimal percentage as a bare JSON number, sign-safe.
// Integer math only: EL has no fixed-precision formatter, and float_to_str
// would put an unbounded mantissa in the response.
fn audit_pct1(num: Int, den: Int) -> String {
if den <= 0 { return "null" }
let neg: Bool = num < 0
let a: Int = if neg { 0 - num } else { num }
let tenths: Int = (a * 1000) / den
let whole: Int = tenths / 10
let frac: Int = tenths - (whole * 10)
let sign: String = if neg { "-" } else { "" }
return sign + int_to_str(whole) + "." + int_to_str(frac)
}
// audit_finding the one envelope every finding uses: name, the measurements,
// and the annotation. Keeping it in one place is what stops the characterization
// from degenerating into a bag of numbers with no reading attached.
fn audit_finding(name: String, measured: String, note: String) -> String {
return "{\"finding\":\"" + name + "\""
+ ",\"measured\":{" + measured + "}"
+ ",\"note\":\"" + api_json_escape(note) + "\"}"
}
// audit_str_at read the quoted string value starting at byte `start`.
// Slices a bounded window rather than the tail of the (multi-MB) edges array, so
// this is O(window) per call instead of O(remaining input).
fn audit_str_at(s: String, start: Int, maxlen: Int) -> String {
let n: Int = str_len(s)
if start < 0 || start >= n { return "" }
let end_guess: Int = start + maxlen
let stop: Int = if end_guess > n { n } else { end_guess }
let win: String = str_slice(s, start, stop)
let q: Int = str_index_of(win, "\"")
if q < 0 { return "" }
return str_slice(win, 0, q)
}
// audit_rel_count exact count of edges carrying `rel`, by scanning the emitted
// edge array for the literal `"relation":"<rel>"`. engram_emit_edge_json writes
// metadata ESCAPED as a string, so no nested object can contain that literal and
// the count cannot be inflated by edge payloads.
fn audit_rel_count(edges: String, rel: String) -> Int {
return str_count(edges, "\"relation\":\"" + rel + "\"")
}
// audit_owner_stats ask the persistence OWNER for its own counts.
// Returns "" when there is no HTTP owner configured or the owner is unreachable;
// both are reported as findings, never as a failure of the audit.
fn audit_owner_stats(url: String) -> String {
if str_eq(url, "") { return "" }
return http_get(url + "/api/stats")
}
// audit_divergence FINDING 1. Runtime (this soul's in-process graph) versus
// the persistence owner's own count. Trend is measured against the previous
// audit recorded in soul state, so a second call answers "is the gap growing?"
// rather than just restating it.
fn audit_divergence() -> String {
let rt_nodes: Int = engram_node_count()
let rt_edges: Int = engram_edge_count()
let url: String = wt_engram_url()
if str_eq(url, "") {
return audit_finding("owner_runtime_divergence",
"\"runtime_nodes\":" + int_to_str(rt_nodes)
+ ",\"runtime_edges\":" + int_to_str(rt_edges)
+ ",\"owner\":\"none\",\"owner_reachable\":false",
"No HTTP persistence owner is configured, so this soul IS the owner "
+ "(file mode) and divergence is not defined. This check only has "
+ "meaning when ENGRAM_URL points at a separate engram that owns the "
+ "canonical store.")
}
let stats: String = audit_owner_stats(url)
// REACHABILITY IS PROVED BY THE PAYLOAD, NOT BY A NON-EMPTY REPLY.
// http_get does not return "" on a connection failure it returns a JSON
// error object ({"error":"Failed to connect to ... Couldn't connect to
// server"}). Testing only for "" made a DEAD owner read as reachable with
// node_count 0, i.e. the audit would have reported a 100% divergence and
// named it as data loss. That false positive is worse than no check at all:
// it is precisely the kind of confident wrong answer this route exists to
// stop. Require the field the contract promises.
let owner_nc_raw: String = json_get_raw(stats, "node_count")
if str_eq(stats, "") || str_eq(owner_nc_raw, "") {
return audit_finding("owner_runtime_divergence",
"\"runtime_nodes\":" + int_to_str(rt_nodes)
+ ",\"runtime_edges\":" + int_to_str(rt_edges)
+ ",\"owner\":\"" + api_json_escape(url) + "\",\"owner_reachable\":false"
+ ",\"owner_reply\":\"" + api_json_escape(api_utf8_trunc(stats, 200)) + "\"",
"The persistence owner at " + url + " did not return a node_count "
+ "from GET /api/stats. Divergence is UNKNOWN, NOT ZERO — an owner "
+ "that cannot be read is exactly the condition under which the "
+ "runtime's own count means least, and reporting 0 for the owner "
+ "would manufacture a total-loss reading out of a network error. "
+ "Reported as a finding rather than raised as an error so the rest "
+ "of the audit still returns; the owner's raw reply is in "
+ "owner_reply.")
}
let ow_nodes: Int = json_get_int(stats, "node_count")
let ow_edges: Int = json_get_int(stats, "edge_count")
let d_nodes: Int = rt_nodes - ow_nodes
let d_edges: Int = rt_edges - ow_edges
// Trend against the previous audit in this soul's state.
let prev_raw: String = state_get("audit_prev_node_delta")
let prev: Int = str_to_int(prev_raw)
let abs_now: Int = if d_nodes < 0 { 0 - d_nodes } else { d_nodes }
let abs_prev: Int = if prev < 0 { 0 - prev } else { prev }
let trend: String = if str_eq(prev_raw, "") {
"no_prior_audit"
} else {
if abs_now > abs_prev { "growing" } else {
if abs_now < abs_prev { "shrinking" } else { "flat" }
}
}
state_set("audit_prev_node_delta", int_to_str(d_nodes))
state_set("audit_prev_ts", int_to_str(time_now()))
let note_head: String = if d_nodes == 0 {
"Runtime and owner agree on node count."
} else {
"Runtime holds " + int_to_str(d_nodes) + " nodes (" + audit_pct1(d_nodes, rt_nodes)
+ "% of its own graph) that the persistence owner does not report. Nodes "
+ "that exist only in runtime memory do not survive a restart."
}
return audit_finding("owner_runtime_divergence",
"\"runtime_nodes\":" + int_to_str(rt_nodes)
+ ",\"runtime_edges\":" + int_to_str(rt_edges)
+ ",\"owner\":\"" + api_json_escape(url) + "\",\"owner_reachable\":true"
+ ",\"owner_nodes\":" + int_to_str(ow_nodes)
+ ",\"owner_edges\":" + int_to_str(ow_edges)
+ ",\"node_delta\":" + int_to_str(d_nodes)
+ ",\"edge_delta\":" + int_to_str(d_edges)
+ ",\"node_delta_pct_of_runtime\":" + audit_pct1(d_nodes, rt_nodes)
+ ",\"trend_vs_previous_audit\":\"" + trend + "\""
+ ",\"previous_node_delta\":" + (if str_eq(prev_raw, "") { "null" } else { int_to_str(prev) }),
note_head + " Trend against the previous audit recorded in this soul's "
+ "state: " + trend + ". This is the comparison whose absence let a "
+ "~24,000-node loss run for weeks with every boot reporting green.")
}
// audit_edge_typing FINDING 2. Density plus the typed distribution the patent
// asks for, against the claim-10 relation vocabulary. Exact: str_count over the
// emitted edge array, one linear pass per relation.
fn audit_edge_typing(edges: String, total_edges: Int, node_total: Int) -> String {
let c_sup: Int = audit_rel_count(edges, "Supersedes")
let c_cau: Int = audit_rel_count(edges, "Causes")
let c_con: Int = audit_rel_count(edges, "Contains")
let c_ref: Int = audit_rel_count(edges, "References")
let c_ctr: Int = audit_rel_count(edges, "Contradicts")
let c_exe: Int = audit_rel_count(edges, "Exemplifies")
let c_act: Int = audit_rel_count(edges, "Activates")
let c_tmp: Int = audit_rel_count(edges, "TemporallyPrecedes")
let typed: Int = c_sup + c_cau + c_con + c_ref + c_ctr + c_exe + c_act + c_tmp
// Lowercase near-misses: the same eight concepts written by the ad-hoc write
// paths (linkEntities defaults to "associates", linkCausal to "causes").
// Counted separately because "the vocabulary is unused" and "the vocabulary
// is used in the wrong case" are different defects with different fixes.
let l_sup: Int = audit_rel_count(edges, "supersedes")
let l_cau: Int = audit_rel_count(edges, "causes")
let l_con: Int = audit_rel_count(edges, "contains")
let l_ref: Int = audit_rel_count(edges, "references")
let l_ctr: Int = audit_rel_count(edges, "contradicts")
let l_exe: Int = audit_rel_count(edges, "exemplifies")
let l_act: Int = audit_rel_count(edges, "activates")
let l_tmp: Int = audit_rel_count(edges, "temporallyPrecedes")
let near: Int = l_sup + l_cau + l_con + l_ref + l_ctr + l_exe + l_act + l_tmp
let untyped: Int = total_edges - typed
return audit_finding("typed_edge_distribution",
"\"total_edges\":" + int_to_str(total_edges)
+ ",\"total_nodes\":" + int_to_str(node_total)
// Density per 100 nodes, not per node: EL has no fixed-precision float
// formatter, and "0.3 edges per node" rounded to an integer is a lie.
+ ",\"edges_per_100_nodes\":" + audit_pct1(total_edges, node_total)
+ ",\"claim10_typed\":" + int_to_str(typed)
+ ",\"claim10_typed_pct\":" + audit_pct1(typed, total_edges)
+ ",\"outside_claim10_vocabulary\":" + int_to_str(untyped)
+ ",\"lowercase_near_miss\":" + int_to_str(near)
+ ",\"by_relation\":{"
+ "\"Supersedes\":" + int_to_str(c_sup)
+ ",\"Causes\":" + int_to_str(c_cau)
+ ",\"Contains\":" + int_to_str(c_con)
+ ",\"References\":" + int_to_str(c_ref)
+ ",\"Contradicts\":" + int_to_str(c_ctr)
+ ",\"Exemplifies\":" + int_to_str(c_exe)
+ ",\"Activates\":" + int_to_str(c_act)
+ ",\"TemporallyPrecedes\":" + int_to_str(c_tmp) + "}",
"Only " + int_to_str(typed) + " of " + int_to_str(total_edges)
+ " edges use the claim-10 causal vocabulary; the remainder are ad-hoc "
+ "relation strings, which is why the graph's causal claims cannot yet "
+ "be checked for internal consistency — an untyped edge asserts "
+ "association, not causation. " + int_to_str(near) + " edges use a "
+ "lowercase spelling of a claim-10 relation: those are near-misses the "
+ "write paths could be corrected to emit, not genuinely foreign types.")
}
// audit_orphans_dangling FINDING 3. Both figures are SAMPLED; see the header
// for why exhaustive is O(nodes x edges) on this runtime.
//
// An "orphan" here is a node with zero RESOLVABLE edges: engram_neighbors_json
// drops any edge whose other endpoint does not resolve to a node, so a node
// whose only edges are dangling reads as an orphan. That is the right reading
// such a node is unreachable by traversal but it is stated rather than hidden.
fn audit_orphans_dangling(edges: String, total_edges: Int, node_total: Int,
edge_cap: Int, node_cap: Int) -> String {
// orphan sample: uniform stride over the node store
let n_take: Int = if node_total < node_cap { node_total } else { node_cap }
let n_stride: Int = if n_take > 0 { node_total / n_take } else { 1 }
let n_stride = if n_stride < 1 { 1 } else { n_stride }
let orphans: Int = 0
let n_checked: Int = 0
let j: Int = 0
while j < n_take {
let one: String = engram_scan_nodes_json(1, j * n_stride)
let nid: String = json_get(json_array_get(one, 0), "id")
if !str_eq(nid, "") {
let nbrs: String = engram_neighbors_json(nid, 1, "both")
let deg: Int = json_array_len(nbrs)
let orphans = if deg == 0 { orphans + 1 } else { orphans }
let n_checked = n_checked + 1
}
let j = j + 1
}
// dangling sample: uniform stride over the edge array
// str_index_of_all gives every edge's field offsets in ONE linear pass, so
// any index can be read in O(1). json_array_get would have been O(i) per
// element and O(n^2) over the array.
let from_pos: [Int] = str_index_of_all(edges, "\"from_id\":\"")
let to_pos: [Int] = str_index_of_all(edges, "\"to_id\":\"")
let nf: Int = len(from_pos)
let nt: Int = len(to_pos)
let ne: Int = if nf < nt { nf } else { nt }
let e_take: Int = if ne < edge_cap { ne } else { edge_cap }
let e_stride: Int = if e_take > 0 { ne / e_take } else { 1 }
let e_stride = if e_stride < 1 { 1 } else { e_stride }
let dangling: Int = 0
let e_checked: Int = 0
let i: Int = 0
while i < ne && e_checked < e_take {
let fid: String = audit_str_at(edges, get(from_pos, i) + 11, 96)
let tid: String = audit_str_at(edges, get(to_pos, i) + 9, 96)
let f_gone: Bool = str_eq(engram_get_node_json(fid), "{}")
let t_gone: Bool = if f_gone { true } else { str_eq(engram_get_node_json(tid), "{}") }
let dangling = if f_gone || t_gone { dangling + 1 } else { dangling }
let e_checked = e_checked + 1
let i = i + e_stride
}
let orphan_est: Int = if n_checked > 0 { (orphans * node_total) / n_checked } else { 0 }
let dangle_est: Int = if e_checked > 0 { (dangling * total_edges) / e_checked } else { 0 }
let exhaustive_n: String = if n_checked >= node_total { "true" } else { "false" }
let exhaustive_e: String = if e_checked >= ne { "true" } else { "false" }
return audit_finding("orphans_and_dangling_edges",
"\"nodes_population\":" + int_to_str(node_total)
+ ",\"nodes_sampled\":" + int_to_str(n_checked)
+ ",\"nodes_sample_exhaustive\":" + exhaustive_n
+ ",\"orphans_in_sample\":" + int_to_str(orphans)
+ ",\"orphan_rate_pct\":" + audit_pct1(orphans, n_checked)
+ ",\"orphans_extrapolated\":" + int_to_str(orphan_est)
+ ",\"edges_population\":" + int_to_str(total_edges)
+ ",\"edges_sampled\":" + int_to_str(e_checked)
+ ",\"edges_sample_exhaustive\":" + exhaustive_e
+ ",\"dangling_in_sample\":" + int_to_str(dangling)
+ ",\"dangling_rate_pct\":" + audit_pct1(dangling, e_checked)
+ ",\"dangling_extrapolated\":" + int_to_str(dangle_est),
"Orphan = zero RESOLVABLE edges, so a node whose only edges dangle counts "
+ "as an orphan; either way it is unreachable by traversal. Dangling = an "
+ "edge with an endpoint id that resolves to no node. Both are uniform "
+ "stride samples over the whole population, not the head of the list; "
+ "the extrapolations are estimates and are labelled as such. Pass "
+ "?node_sample= / ?edge_sample= at or above the population size to run "
+ "either check exhaustively. A high orphan rate is a characterization, "
+ "not a verdict: an accumulating store legitimately holds unlinked "
+ "material. It becomes a defect when the write paths were SUPPOSED to "
+ "link and did not.")
}
// audit_pillar one self-model pillar: present, how much content, how connected.
fn audit_pillar(key: String, id: String) -> String {
let node: String = engram_get_node_json(id)
let present: Bool = !str_eq(node, "{}") && !str_eq(node, "")
if !present {
return "\"" + key + "\":{\"id\":\"" + id + "\",\"present\":false"
+ ",\"content_length\":0,\"degree\":0}"
}
let content: String = json_get(node, "content")
let deg: Int = json_array_len(engram_neighbors_json(id, 1, "both"))
return "\"" + key + "\":{\"id\":\"" + id + "\",\"present\":true"
+ ",\"label\":\"" + api_json_escape(json_get(node, "label")) + "\""
+ ",\"tier\":\"" + api_json_escape(json_get(node, "tier")) + "\""
+ ",\"content_length\":" + int_to_str(str_len(content))
+ ",\"degree\":" + int_to_str(deg) + "}"
}
// audit_self_model FINDING 4. "the richness and connectivity of the
// self-model ... is it connected to behavioral evidence?"
//
// This finding RETIRES the Claude-side vitals identity block. That check lived
// outside the system it was checking a shell script grepping a snapshot so
// it could only ever report on a file, and it went on reporting green while the
// memory-philosophy pillar was absent from the live graph for about three weeks.
// Asking the running soul about its own three pillars is the designed mechanism;
// a shell probe was the fourth patch on the same hole.
fn audit_self_model() -> String {
let dna: String = audit_pillar("intellectual_dna", "kn-5adecd7e-d6db-4576-87fe-6ef8a935cea6")
let val: String = audit_pillar("values_hub", "kn-5b606390-a52d-4ca2-8e0e-eba141d13440")
let phi: String = audit_pillar("memory_philosophy", "kn-dcfe04b3-3702-4cac-b6f0-ecb4db837eee")
let root: String = audit_pillar("self_root", "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee")
return audit_finding("self_model_connectivity",
"\"pillars\":{" + dna + "," + val + "," + phi + "," + root + "}",
"The three identity pillars plus the self root. `degree` counts nodes "
+ "reachable in one hop in either direction — the self-model's connection "
+ "to the rest of the graph. present:false on any pillar is the condition "
+ "that ran undetected for weeks; content_length distinguishes a pillar "
+ "that is present from one that is present but hollowed out. The patent "
+ "also asks whether the self-model makes ACCURATE PREDICTIONS about the "
+ "system's own behavior; that half needs Prediction nodes and is deferred "
+ "with the rest of stage 1b below.")
}
// audit_deferred what stage 1 does NOT yet evaluate, with the measured reason.
// Emitted as data, not as a comment, so a reader of the assessment sees the gap
// and its evidence rather than inferring completeness from silence.
fn audit_deferred() -> String {
let preds: Int = json_array_len(api_or_empty(engram_scan_nodes_by_type_json("Prediction", 50, 0)))
let wonders: Int = json_array_len(api_or_empty(engram_scan_nodes_by_type_json("WonderQuestion", 50, 0)))
return "[{\"deferred\":\"value_execution_record_consistency\""
+ ",\"stage\":\"1b\""
+ ",\"measured\":{\"prediction_nodes_found\":" + int_to_str(preds) + "}"
+ ",\"reason\":\"" + api_json_escape(
"The patent asks whether the execution history SUPPORTS the stated "
+ "values or shows systematic conflict. That requires execution "
+ "records tied to value nodes and predictions to score them against. "
+ "Prediction nodes found (capped at 50): " + int_to_str(preds)
+ ". Asserting value/execution coherence on that population would be "
+ "a fabricated result, which is worse than a stated gap.") + "\"}"
+ ",{\"deferred\":\"wonder_manifest_authenticity\""
+ ",\"stage\":\"1b\""
+ ",\"measured\":{\"wonder_question_nodes_found\":" + int_to_str(wonders) + "}"
+ ",\"reason\":\"" + api_json_escape(
"The patent asks whether pull weights CORRELATE WITH GENUINE "
+ "PREDICTION UNCERTAINTY or are uniform/externally assigned — a "
+ "correlation between two populations. WonderQuestion nodes readable "
+ "by type (capped at 50): " + int_to_str(wonders) + ", against "
+ int_to_str(preds) + " Prediction nodes. There is a known write/read "
+ "node-type mismatch on the wonder path; until that is fixed and both "
+ "populations exist, any correlation reported here would be noise.") + "\"}]"
}
// handle_api_structural_audit Stage 1. Returns the coherence assessment 432:
// an annotated characterization, explicitly NOT a score.
//
// COST NOTE: the edge findings need the relation labels, and the runtime exposes
// no edge-enumeration builtin. The only way to see them is the same one
// GET /api/graph/edges already uses engram_save to a SCRATCH path (never the
// owner's canonical file; see routes.el, neuron#117) and read the array back.
// On a large graph that is a multi-hundred-MB write, so this is a manual audit
// route, not something to put on a timer. Pass ?edges=0 to skip both edge
// findings and get the divergence + self-model readings cheaply.
fn handle_api_structural_audit(method: String, path: String, body: String) -> String {
let node_total: Int = engram_node_count()
let edge_total: Int = engram_edge_count()
let want_edges: Bool = !str_eq(api_query_param(path, "edges"), "0")
let edge_cap: Int = api_query_int(path, "edge_sample", 3000)
let node_cap: Int = api_query_int(path, "node_sample", 300)
let divergence: String = audit_divergence()
let self_model: String = audit_self_model()
let edge_part: String = if want_edges {
// Scratch export only. state_get("soul_snapshot_path") is deliberately
// NOT used: in HTTP-engram mode the soul is not the persistence owner and
// must never write the canonical file, not even on a read path.
let scratch_dir: String = env("TMPDIR")
let scratch_base: String = if str_eq(scratch_dir, "") { "/tmp" } else { scratch_dir }
let snap_path: String = scratch_base + "/soul-audit-export-" + state_get("soul_cgi_id") + ".json"
// engram_save returns Int (1 ok / 0 fail); str_eq on it SIGSEGVs (#150).
let saved: Int = engram_save(snap_path)
if saved == 0 {
"," + audit_finding("typed_edge_distribution", "\"available\":false",
"Could not export the graph to " + snap_path + " for edge analysis, "
+ "so edge typing and the dangling-edge sample were not run. "
+ "Reported as a gap, not as zero findings.")
} else {
// wt_read, not fs_read: fs_read leaves a thread-local length hint that
// the NEXT HTTP response would use as its Content-Length, appending
// adjacent heap bytes to the reply (see persist.el wt_read).
let snap: String = wt_read(snap_path)
let edges_raw: String = json_get_raw(snap, "edges")
let edges: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
"," + audit_edge_typing(edges, edge_total, node_total)
+ "," + audit_orphans_dangling(edges, edge_total, node_total, edge_cap, node_cap)
}
} else {
""
}
return "{\"audit\":\"structural\",\"stage\":1"
+ ",\"spec\":\"CGI provisional 05-detailed-description.md, Stage 1: Structural audit 430\""
+ ",\"assessment\":\"coherence_assessment_432\""
+ ",\"assessment_kind\":\"annotated_characterization\""
+ ",\"score\":null"
+ ",\"score_note\":\"By design. The specification calls for an annotated characterization of the graph's structural properties, not a binary score. Read the findings.\""
+ ",\"cgi_id\":\"" + api_json_escape(state_get("soul_cgi_id")) + "\""
+ ",\"ts_ms\":" + int_to_str(time_now())
+ ",\"findings\":[" + divergence + "," + self_model + edge_part + "]"
+ ",\"deferred\":" + audit_deferred() + "}"
}
+1
View File
@@ -37,3 +37,4 @@ extern fn handle_api_memory_update(body: String) -> String
extern fn handle_api_cultivate(body: String) -> String
extern fn handle_api_list_typed(node_type: String, path: String, body: String) -> String
extern fn handle_api_consolidate(body: String) -> String
extern fn handle_api_structural_audit(method: String, path: String, body: String) -> String
+13
View File
@@ -567,6 +567,13 @@ fn route_dispatch(method: String, path: String, body: String) -> String {
if str_starts_with(clean, "/api/neuron/graph") {
return handle_api_inspect_graph(method, path, body)
}
// Stage 1 structural audit (CGI provisional, "Structural audit 430").
// GET because it is a read of the graph's own structure; the query string
// carries the sample caps (?edge_sample=, ?node_sample=, ?edges=0), so
// str_starts_with rather than str_eq.
if str_starts_with(clean, "/api/neuron/audit/structural") {
return handle_api_structural_audit(method, path, body)
}
if str_starts_with(clean, "/api/neuron/list/") {
// Offset 17 = len("/api/neuron/list/"). Was 16, which left a leading "/" on node_type
// ("/BacklogItem"), so engram_scan_nodes_by_type_json matched nothing list/<type>
@@ -748,6 +755,12 @@ fn route_dispatch(method: String, path: String, body: String) -> String {
if str_eq(clean, "/api/neuron/graph/link") {
return handle_api_link_entities(body)
}
// POST accepted too: same handler, so a JSON-RPC-shaped caller that only
// speaks POST reaches the identical audit. Options still come from the
// query string the handler reads no body fields.
if str_eq(clean, "/api/neuron/audit/structural") {
return handle_api_structural_audit(method, path, body)
}
if str_eq(clean, "/api/neuron/memory") {
return handle_api_remember(body)
}
+937
View File
@@ -0,0 +1,937 @@
#!/usr/bin/env python3
"""state-key-audit.py — the analyzer behind scripts/verify-state-keys.sh.
Read that script's header for WHY this exists (issue #129). This file is the
HOW: a small El reader that resolves the key expression at every state_get /
state_set site, including keys that are computed.
WHAT IT PARSES
El as this engine writes it: `fn f(a: T, b: T) -> T { ... }`, `let x: T = e`,
`return e`, `if c { a } else { b }` as an expression, `+` concatenation,
`"..."` with backslash escapes, `//` line comments. No block comments, no
const/match/struct exist in this dialect (verified over the whole tree).
KEY PATTERNS the only two things a key expression can resolve to
EXACT "soul_model" the whole key is known
PREFIX "session_hist_" a known head, then runtime text
(plus UNRESOLVED, which is a report line and never a failure)
RESOLUTION resolve_expr() returns a SET of patterns; unions are how branches,
multiple returns, and multiple bindings of one name are represented.
literal "k" -> {EXACT k}
concat A + B -> fold left; all-static -> EXACT,
static head + dynamic tail -> PREFIX
if-expression if c {A} else {B} -> resolve(A) | resolve(B), except that
str_eq(X,"") with X statically ""
folds to the taken branch only
call f(args) -> union over f's return expressions,
with f's params bound to THIS call
site's actual argument expressions
local var let k = e; state_get(k)-> union over every `let k =` in the
enclosing function
parameter fn g(k) { state_get(k) }-> union over the argument at that
position across every call site of g
anything else json_get(...), env(...)-> UNRESOLVED
Recursion is depth- and cycle-guarded; a guard trip yields UNRESOLVED, never a
failure.
COVERAGE a read is satisfied when some write can produce the same key:
read EXACT k <- write EXACT k, or write PREFIX p where k starts with p
read PREFIX p <- write EXACT k where k starts with p, or write PREFIX q
where p and q are prefixes of each other
Deliberately permissive at the boundaries: a gate that cries wolf gets deleted.
"""
import os
import re
import sys
MAX_DEPTH = 12
# ── patterns ────────────────────────────────────────────────────────────────
EXACT = "exact"
PREFIX = "prefix"
def pat_exact(s):
return (EXACT, s)
def pat_prefix(s):
# A prefix with no static text at all carries no information; that is the
# UNRESOLVED case, not a pattern.
return (PREFIX, s) if s else None
def covers(write, read):
"""Can a write of pattern `write` produce a key that `read` reads?
The prefix rule is DIRECTIONAL, and that direction is the whole point. A
write namespace that is the same or BROADER than the read namespace covers
it (write "rl:" covers read "rl:x"). A write namespace that is NARROWER does
NOT (write "session_histv2_" does not cover read "session_hist_") being
permissive there re-opens the exact hole this gate exists to close: rename
the producer, leave the readers, stay green. Verified with a control run
that renames sessions.el's writer and leaves its four readers behind."""
wk, wv = write
rk, rv = read
if rk == EXACT:
return rv == wv if wk == EXACT else rv.startswith(wv)
# read is a PREFIX: some key starting with rv is read
if wk == EXACT:
return wv.startswith(rv) # that one written key is in range
return rv.startswith(wv) # write namespace same-or-broader
# ── lexer ───────────────────────────────────────────────────────────────────
TOK_STR, TOK_IDENT, TOK_PUNCT, TOK_NUM = "str", "ident", "punct", "num"
IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
NUM_RE = re.compile(r"[0-9]+(\.[0-9]+)?")
class Tok:
__slots__ = ("kind", "val", "line")
def __init__(self, kind, val, line):
self.kind, self.val, self.line = kind, val, line
def __repr__(self):
return "%s(%r)@%d" % (self.kind, self.val, self.line)
def lex(src):
toks, i, n, line = [], 0, len(src), 1
while i < n:
c = src[i]
if c == "\n":
line += 1
i += 1
continue
if c in " \t\r":
i += 1
continue
if c == "/" and i + 1 < n and src[i + 1] == "/":
while i < n and src[i] != "\n":
i += 1
continue
if c == '"':
j, buf = i + 1, []
while j < n:
if src[j] == "\\" and j + 1 < n:
esc = src[j + 1]
buf.append({"n": "\n", "t": "\t", "r": "\r"}.get(esc, esc))
j += 2
continue
if src[j] == '"':
break
if src[j] == "\n":
line += 1
buf.append(src[j])
j += 1
toks.append(Tok(TOK_STR, "".join(buf), line))
i = j + 1
continue
m = IDENT_RE.match(src, i)
if m:
toks.append(Tok(TOK_IDENT, m.group(0), line))
i = m.end()
continue
m = NUM_RE.match(src, i)
if m:
toks.append(Tok(TOK_NUM, m.group(0), line))
i = m.end()
continue
toks.append(Tok(TOK_PUNCT, c, line))
i += 1
return toks
def match_close(toks, i, open_ch, close_ch):
"""toks[i] is open_ch; return index of its matching close_ch."""
depth = 0
while i < len(toks):
if toks[i].kind == TOK_PUNCT:
if toks[i].val == open_ch:
depth += 1
elif toks[i].val == close_ch:
depth -= 1
if depth == 0:
return i
i += 1
return len(toks) - 1
# ── program model ───────────────────────────────────────────────────────────
class Func:
def __init__(self, name, path, line, params, toks, start, end):
self.name, self.path, self.line = name, path, line
self.params = params # [param name]
self.toks = toks # the whole file's token list
self.start, self.end = start, end # body token range, exclusive of braces
self.lets = None # name -> [expr token ranges], lazily built
class Site:
def __init__(self, kind, path, line, func, arg_range, text):
self.kind = kind # "get" | "set"
self.path, self.line = path, line
self.func = func
self.arg_range = arg_range
self.text = text # source text of the key expression
self.pats = set()
self.unresolved = False
self.literal = None # set when the key expression is a bare literal
class Program:
def __init__(self):
self.files = {} # path -> toks
self.funcs = {} # name -> [Func] (El allows no overloads, but be safe)
self.toplevel = [] # [Func] one per file, params=[]
self.sites = [] # [Site]
self.calls = {} # callee name -> [(Func caller, [arg ranges])]
# -- loading ------------------------------------------------------------
def load(self, path, rel):
with open(path, "r", encoding="utf-8", errors="replace") as fh:
src = fh.read()
toks = lex(src)
self.files[rel] = toks
self._scan_funcs(rel, toks)
def _scan_funcs(self, rel, toks):
covered = []
i = 0
while i < len(toks):
t = toks[i]
if t.kind == TOK_IDENT and t.val == "fn" and i + 2 < len(toks) \
and toks[i + 1].kind == TOK_IDENT and toks[i + 2].val == "(":
name = toks[i + 1].val
pclose = match_close(toks, i + 2, "(", ")")
params = self._params(toks, i + 3, pclose)
bopen = pclose + 1
while bopen < len(toks) and toks[bopen].val != "{":
bopen += 1
bclose = match_close(toks, bopen, "{", "}")
f = Func(name, rel, t.line, params, toks, bopen + 1, bclose)
self.funcs.setdefault(name, []).append(f)
covered.append((i, bclose))
i = bclose + 1
continue
i += 1
# everything outside a fn is the file's top-level "function"
tl = Func("<toplevel:%s>" % rel, rel, 1, [], toks, 0, len(toks))
tl.covered = covered
self.toplevel.append(tl)
@staticmethod
def _params(toks, i, end):
"""`a: T, b: T` -> ['a','b'] (top-level commas only)."""
names, depth, expect = [], 0, True
while i < end:
t = toks[i]
if t.kind == TOK_PUNCT and t.val in "([{":
depth += 1
elif t.kind == TOK_PUNCT and t.val in ")]}":
depth -= 1
elif depth == 0 and t.kind == TOK_PUNCT and t.val == ",":
expect = True
elif depth == 0 and expect and t.kind == TOK_IDENT:
names.append(t.val)
expect = False
i += 1
return names
def func_at(self, rel, tok_index):
for f in self.funcs_in(rel):
if f.start <= tok_index < f.end:
return f
for f in self.toplevel:
if f.path == rel:
return f
return None
def funcs_in(self, rel):
for fl in self.funcs.values():
for f in fl:
if f.path == rel:
yield f
# -- indexing -----------------------------------------------------------
def index(self):
for rel, toks in self.files.items():
i = 0
while i < len(toks):
t = toks[i]
if t.kind == TOK_IDENT and i + 1 < len(toks) and toks[i + 1].val == "(" \
and t.val not in KEYWORDS \
and not (i > 0 and toks[i - 1].kind == TOK_IDENT
and toks[i - 1].val == "fn"):
# ^ the `fn f(a: T)` declaration is not a call site; counting
# it as one makes every parameter resolve to its own name
# and reports the whole function UNRESOLVED.
close = match_close(toks, i + 1, "(", ")")
args = split_args(toks, i + 2, close)
self.calls.setdefault(t.val, []).append(
(self.func_at(rel, i), args, rel, t.line))
if t.val in ("state_get", "state_set") and args:
self.sites.append(Site(
"get" if t.val == "state_get" else "set",
rel, t.line, self.func_at(rel, i), args[0],
render(toks, *args[0])))
i += 1
# -- resolution ---------------------------------------------------------
def lets_of(self, f):
if f.lets is not None:
return f.lets
f.lets = {}
toks = f.toks
skip = getattr(f, "covered", [])
i = f.start
while i < f.end:
if any(a <= i <= b for a, b in skip):
i = max(b for a, b in skip if a <= i <= b) + 1
continue
t = toks[i]
if t.kind == TOK_IDENT and t.val == "let" and i + 1 < f.end \
and toks[i + 1].kind == TOK_IDENT:
name = toks[i + 1].val
j = i + 2
if j < f.end and toks[j].val == ":": # skip the type
while j < f.end and toks[j].val != "=":
j += 1
if j < f.end and toks[j].val == "=":
s = j + 1
e = stmt_end(toks, s, f.end)
f.lets.setdefault(name, []).append((s, e))
i = e
continue
i += 1
return f.lets
def returns_of(self, ctx, depth=0, seen=None):
"""The value expressions of a function, in the context it was CALLED in.
Context-sensitive on purpose. `conv_hist_key` is written as a guard:
if str_eq(session_id, "") { return "conv_history" }
return "session_hist_" + session_id
Collecting both returns flat would make state_set(conv_hist_key("")) the
dead handle_chat() write claim to produce the session_hist_ namespace
too. That is a producer this engine does not actually have, and claiming
it would let the gate stay green if sessions.el's real writer vanished:
a masking hole in the exact namespace #129 lives in. So a guard whose
condition folds is honoured, and the branch not taken is dropped."""
out = []
self._values(ctx.toks, ctx.start, ctx.end, ctx, depth,
seen if seen is not None else set(), out)
return out
def _values(self, toks, s, e, ctx, depth, seen, out):
"""Append the value expressions of a statement sequence.
Returns True when the sequence definitely returns (rest unreachable)."""
if depth > MAX_DEPTH:
return False
i = s
while i < e:
t = toks[i]
if t.kind == TOK_IDENT and t.val == "return":
j = stmt_end(toks, i + 1, e)
if j > i + 1:
out.append((i + 1, j))
return True
if t.kind == TOK_IDENT and t.val == "let":
i = stmt_end(toks, i + 2, e)
continue
if t.kind == TOK_IDENT and t.val == "if":
i = self._if_stmt(toks, i, e, ctx, depth, seen, out)
if i is True:
return True
continue
if t.kind == TOK_PUNCT and t.val in "([{":
i = match_close(toks, i, t.val,
{"(": ")", "[": "]", "{": "}"}[t.val]) + 1
continue
en = stmt_end(toks, i, e)
if en <= i:
i += 1
continue
if en >= e: # trailing expression = the value
out.append((i, en))
i = en
return False
def _if_stmt(self, toks, i, e, ctx, depth, seen, out):
"""Walk one if / else-if / else chain. Returns the next index, or True
if the chain definitely returns on every reachable branch."""
bopen = i + 1
while bopen < e and toks[bopen].val != "{":
bopen += 1
if bopen >= e:
return e
bclose = match_close(toks, bopen, "{", "}")
fold = self._fold_cond(toks, i + 1, bopen, ctx, depth, seen)
j = bclose + 1
else_s = else_e = None
if j < e and toks[j].kind == TOK_IDENT and toks[j].val == "else":
if j + 1 < e and toks[j + 1].val == "{":
ec = match_close(toks, j + 1, "{", "}")
else_s, else_e = j + 2, ec
j = ec + 1
else: # `else if ...` — the rest of the chain
else_s = j + 1
else_e = stmt_end(toks, j + 1, e)
j = else_e
then_ret = else_ret = False
if fold is not False:
then_ret = self._values(toks, bopen + 1, bclose, ctx, depth + 1, seen, out)
if fold is not True and else_s is not None:
else_ret = self._values(toks, else_s, else_e, ctx, depth + 1, seen, out)
if fold is True and then_ret:
return True
if fold is False and else_s is not None and else_ret:
return True
if fold is None and else_s is not None and then_ret and else_ret:
return True
return j
def resolve(self, rng, func, depth=0, seen=None):
"""-> (set of patterns, unresolved_flag)"""
if seen is None:
seen = set()
if depth > MAX_DEPTH:
return set(), True
return self._expr(func.toks, rng[0], rng[1], func, depth, seen)
# -- expression walker --------------------------------------------------
def _expr(self, toks, s, e, func, depth, seen):
parts, cur, d = [], s, 0
i = s
while i < e: # split on top-level '+'
v = toks[i].val
if toks[i].kind == TOK_PUNCT and v in "([{":
d += 1
elif toks[i].kind == TOK_PUNCT and v in ")]}":
d -= 1
elif d == 0 and toks[i].kind == TOK_PUNCT and v == "+" and i > s:
parts.append((cur, i))
cur = i + 1
i += 1
parts.append((cur, e))
if len(parts) == 1:
return self._primary(toks, s, e, func, depth, seen)
# concatenation: keep folding while every operand so far is EXACT
head, unres = "", False
static = True
for (ps, pe) in parts:
pats, u = self._primary(toks, ps, pe, func, depth, seen)
exacts = {p[1] for p in pats if p[0] == EXACT}
if static and len(exacts) == 1 and not u and len(pats) == 1:
head += exacts.pop()
continue
if static and pats and all(p[0] == EXACT for p in pats) and len(pats) > 1:
# a branchy static operand: keep the shared head only
static = False
head += os.path.commonprefix(sorted({p[1] for p in pats}))
break
static = False
# first non-static operand: everything after it is runtime text
if (ps, pe) == parts[0]:
for p in pats:
if p[0] == PREFIX:
head = p[1]
break
if not head:
unres = True
break
if static:
return {pat_exact(head)}, False
p = pat_prefix(head)
return ({p} if p else set()), (unres or not p)
def _primary(self, toks, s, e, func, depth, seen):
while s < e and toks[s].kind == TOK_PUNCT and toks[s].val == "(" \
and match_close(toks, s, "(", ")") == e - 1:
s, e = s + 1, e - 1
if s >= e:
return set(), True
t = toks[s]
if t.kind == TOK_STR and e == s + 1:
return {pat_exact(t.val)}, False
if t.kind == TOK_IDENT and t.val == "if":
return self._if_expr(toks, s, e, func, depth, seen)
if t.kind == TOK_IDENT and s + 1 < e and toks[s + 1].val == "(":
close = match_close(toks, s + 1, "(", ")")
if close == e - 1:
return self._call(toks, t.val, split_args(toks, s + 2, close),
func, depth, seen)
if t.kind == TOK_IDENT and e == s + 1:
return self._var(t.val, func, depth, seen)
return set(), True
def _if_expr(self, toks, s, e, func, depth, seen):
bopen = s + 1
while bopen < e and toks[bopen].val != "{":
bopen += 1
cond = (s + 1, bopen)
bclose = match_close(toks, bopen, "{", "}")
then_rng = block_tail(toks, bopen + 1, bclose) or (bopen + 1, bclose)
else_rng = None
j = bclose + 1
if j < e and toks[j].kind == TOK_IDENT and toks[j].val == "else":
if j + 1 < e and toks[j + 1].val == "{":
ec = match_close(toks, j + 1, "{", "}")
else_rng = block_tail(toks, j + 2, ec) or (j + 2, ec)
else:
else_rng = (j + 1, e) # `else if ...`
taken = self._fold_cond(toks, cond[0], cond[1], func, depth, seen)
rngs = []
if taken is not False:
rngs.append(then_rng)
if taken is not True and else_rng:
rngs.append(else_rng)
pats, unres = set(), False
for r in rngs:
p, u = self._expr(toks, r[0], r[1], func, depth + 1, seen)
pats |= p
unres = unres or u
return pats, unres
def _fold_cond(self, toks, s, e, func, depth, seen):
"""Constant-fold `str_eq(X, "")` / `!str_eq(X, "")` so a helper called with
a literal (conv_hist_key("")) yields only the branch it really takes.
Returns True / False / None(unknown)."""
neg = False
if s < e and toks[s].kind == TOK_PUNCT and toks[s].val == "!":
neg, s = True, s + 1
if not (s < e and toks[s].kind == TOK_IDENT and toks[s].val == "str_eq"
and s + 1 < e and toks[s + 1].val == "("):
return None
close = match_close(toks, s + 1, "(", ")")
if close != e - 1:
return None
args = split_args(toks, s + 2, close)
if len(args) != 2:
return None
va, ua = self._expr(toks, args[0][0], args[0][1], func, depth + 1, seen)
vb, ub = self._expr(toks, args[1][0], args[1][1], func, depth + 1, seen)
if ua or ub or len(va) != 1 or len(vb) != 1:
return None
(ka, sa), (kb, sb) = va.pop(), vb.pop()
if ka != EXACT or kb != EXACT:
return None
r = (sa == sb)
return (not r) if neg else r
def _call(self, toks, name, args, func, depth, seen):
cands = self.funcs.get(name)
if not cands:
return set(), True # builtin: json_get, env, ...
pats, unres = set(), False
for callee in cands:
key = ("fn", callee.path, callee.name, tuple(args))
if key in seen:
unres = True
continue
seen = seen | {key}
# bind the callee's params to THIS call site's argument expressions
binding = {}
for idx, pname in enumerate(callee.params):
if idx < len(args):
binding[pname] = (args[idx], func)
callee_ctx = _Bound(callee, binding)
for r in self.returns_of(callee_ctx, depth + 1, seen):
p, u = self._expr(callee.toks, r[0], r[1], callee_ctx,
depth + 1, seen)
pats |= p
unres = unres or u
return pats, unres
def _var(self, name, func, depth, seen):
real = func.func if isinstance(func, _Bound) else func
# 1. a parameter bound by the call site we came through
if isinstance(func, _Bound) and name in func.binding:
rng, caller_ctx = func.binding[name]
return self._expr(caller_ctx.toks, rng[0], rng[1], caller_ctx,
depth + 1, seen)
# 2. a local `let` in the enclosing function
lets = self.lets_of(real)
if name in lets:
key = ("let", real.path, real.name, name)
if key in seen:
return set(), True
seen = seen | {key}
pats, unres = set(), False
for rng in lets[name]:
p, u = self._expr(real.toks, rng[0], rng[1], real, depth + 1, seen)
pats |= p
unres = unres or u
return pats, unres
# 3. an unbound parameter -> look at every call site of the enclosing fn
if name in real.params:
key = ("param", real.path, real.name, name)
if key in seen:
return set(), True
seen = seen | {key}
idx = real.params.index(name)
pats, unres = set(), False
sites = self.calls.get(real.name, [])
if not sites:
return set(), True
for caller, args, _rel, _line in sites:
if caller is None or idx >= len(args):
unres = True
continue
p, u = self._expr(caller.toks, args[idx][0], args[idx][1],
caller, depth + 1, seen)
pats |= p
unres = unres or u
return pats, unres
# 4. a file-level / cross-file top-level `let`
for tl in self.toplevel:
lets = self.lets_of(tl)
if name in lets:
key = ("let", tl.path, tl.name, name)
if key in seen:
return set(), True
seen2 = seen | {key}
pats, unres = set(), False
for rng in lets[name]:
p, u = self._expr(tl.toks, rng[0], rng[1], tl, depth + 1, seen2)
pats |= p
unres = unres or u
return pats, unres
return set(), True
class _Bound:
"""A callee view that also knows what its params were called with."""
def __init__(self, func, binding):
self.func, self.binding = func, binding
self.toks, self.start, self.end = func.toks, func.start, func.end
self.params, self.path, self.name = func.params, func.path, func.name
def __getattr__(self, k):
return getattr(self.func, k)
# ── token helpers ───────────────────────────────────────────────────────────
def split_args(toks, s, e):
out, cur, d = [], s, 0
i = s
while i < e:
v = toks[i].val
if toks[i].kind == TOK_PUNCT and v in "([{":
d += 1
elif toks[i].kind == TOK_PUNCT and v in ")]}":
d -= 1
elif d == 0 and toks[i].kind == TOK_PUNCT and v == ",":
out.append((cur, i))
cur = i + 1
i += 1
if cur < e:
out.append((cur, e))
return out
STMT_START = {"let", "return", "if", "while", "for"}
KEYWORDS = {"if", "while", "for", "return", "fn", "let", "else", "match"}
def stmt_end(toks, s, limit):
"""End of the expression starting at s: the next top-level statement
boundary. El has no semicolons, so a newline that starts a new statement
ends this one."""
d, i = 0, s
while i < limit:
t = toks[i]
if t.kind == TOK_PUNCT and t.val in "([":
d += 1
elif t.kind == TOK_PUNCT and t.val in ")]":
d -= 1
if d < 0:
return i
elif t.kind == TOK_PUNCT and t.val == "{":
# a brace at depth 0 belongs to this expression only when it is an
# if/else block that is part of it
d += 1
elif t.kind == TOK_PUNCT and t.val == "}":
d -= 1
if d < 0:
return i
elif d == 0 and t.kind == TOK_PUNCT and t.val == ",":
return i
elif d == 0 and i > s and t.kind == TOK_IDENT and t.val in STMT_START:
if t.val == "if" and toks[i - 1].kind == TOK_IDENT and toks[i - 1].val == "else":
i += 1
continue
return i
elif d == 0 and i > s and t.kind == TOK_IDENT and t.val == "fn":
return i
i += 1
return limit
def block_tail(toks, s, e):
"""The trailing expression of a block, if the block ends in one."""
i, last = s, None
while i < e:
t = toks[i]
if t.kind == TOK_IDENT and t.val in ("let", "return"):
i = stmt_end(toks, i + 1, e)
last = None
continue
if t.kind == TOK_PUNCT and t.val in "([{":
i = match_close(toks, i, t.val, {"(": ")", "[": "]", "{": "}"}[t.val]) + 1
continue
st = i
en = stmt_end(toks, i, e)
if en <= st:
i = st + 1
continue
last = (st, en)
i = en
return last
def render(toks, s, e):
out = []
for t in toks[s:e]:
out.append('"%s"' % t.val if t.kind == TOK_STR else t.val)
return " ".join(out)
# ── the gate ────────────────────────────────────────────────────────────────
def collect(root, include_tests):
files = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames
if d not in ("dist", "vendor", ".git", "node_modules")]
rel_dir = os.path.relpath(dirpath, root)
if not include_tests and rel_dir.split(os.sep)[0] == "tests":
continue
for fn in sorted(filenames):
if fn.endswith(".el"):
rel = os.path.normpath(os.path.join(rel_dir, fn))
files.append((os.path.join(dirpath, fn), rel))
return sorted(files, key=lambda x: x[1])
def is_bare_literal(prog, site):
toks = prog.files[site.path]
s, e = site.arg_range
return e == s + 1 and toks[s].kind == TOK_STR
def read_decl(path):
"""A declaration file: one entry per line, `# ...` comments stripped."""
out = []
if not path or not os.path.exists(path):
return out
with open(path) as fh:
for ln in fh:
ln = ln.split("#", 1)[0].strip()
if ln:
out.append(ln)
return out
def opt(argv, name, default=None):
for i, a in enumerate(argv):
if a == name and i + 1 < len(argv):
return argv[i + 1]
return default
def main(argv):
root = os.path.abspath(argv[1]) if len(argv) > 1 and not argv[1].startswith("-") else "."
include_tests = "--include-tests" in argv
verbose = "--verbose" in argv
baseline_path = opt(argv, "--baseline")
external_path = opt(argv, "--external")
prog = Program()
for path, rel in collect(root, include_tests):
prog.load(path, rel)
prog.index()
for site in prog.sites:
pats, unres = prog.resolve(site.arg_range, site.func)
site.pats, site.unresolved = {p for p in pats if p}, unres
if is_bare_literal(prog, site):
site.literal = prog.files[site.path][site.arg_range[0]].val
writes = [s for s in prog.sites if s.kind == "set"]
reads = [s for s in prog.sites if s.kind == "get"]
write_pats = set()
for w in writes:
write_pats |= w.pats
# Declared host-set keys: written by something outside the El tree (an
# operator, the installer, a host process). Each entry must carry a reason.
external = []
for ln in read_decl(external_path):
parts = ln.split(None, 1)
if len(parts) != 2 or parts[0] not in (EXACT, PREFIX):
print("bad --external line (want `exact|prefix <key>`): %r" % ln,
file=sys.stderr)
return 2
external.append((parts[0], parts[1]))
write_pats |= set(external)
# F1 — a read of a key no write in the tree produces.
f1 = []
for r in reads:
for p in sorted(r.pats):
if not any(covers(w, p) for w in write_pats):
f1.append((r, p))
# F2 — a key namespace owned by a helper, accessed by a hand-rolled literal.
# This is the #129 shape: the producer moved behind conv_hist_key() and
# one consumer kept spelling the old key out by hand.
owners = {} # helper fn name -> its value set
for s in prog.sites:
toks = prog.files[s.path]
a, b = s.arg_range
if toks[a].kind == TOK_IDENT and a + 1 < b and toks[a + 1].val == "(" \
and match_close(toks, a + 1, "(", ")") == b - 1 \
and toks[a].val in prog.funcs:
name = toks[a].val
if name not in owners:
vals = set()
for callee in prog.funcs[name]:
# No call context here on purpose: the OWNED namespace is
# every key the helper can ever produce, over all call sites.
for rng in prog.returns_of(callee):
p, _ = prog._expr(callee.toks, rng[0], rng[1], callee, 0, set())
vals |= {x for x in p if x}
owners[name] = vals
f2 = []
for s in prog.sites:
if s.literal is None:
continue
for owner, vals in sorted(owners.items()):
for v in sorted(vals):
if covers(v, pat_exact(s.literal)):
f2.append((s, owner, v))
break
else:
continue
break
unresolved = [s for s in prog.sites if s.unresolved or not s.pats]
# Baseline signatures carry NO line number on purpose: an unrelated edit that
# shifts a line must not un-mute an accepted finding (that is crying wolf),
# but a GROWTH in count must not hide either. So a baseline entry is
# `<file> <CODE> <detail> [xN]` and only the first N matches are muted.
baseline, bad_baseline = {}, []
for ln in read_decl(baseline_path):
n, key = 1, ln
parts = ln.rsplit(" x", 1)
if len(parts) == 2 and parts[1].isdigit():
key, n = parts[0].strip(), int(parts[1])
baseline[key] = n
def sig(path, code, detail):
return "%s %s %s" % (path, code, detail)
findings = []
for r, p in f1:
findings.append((sig(r.path, "DEAD-READ", "%s:%s" % p), r.line,
" %s:%d state_get(%s)\n resolves to %s %r — no state_set in the tree produces it"
% (r.path, r.line, r.text, p[0].upper(), p[1])))
for s, owner, v in f2:
findings.append((sig(s.path, "HAND-ROLLED", "%s<-%s()" % (s.literal, owner)), s.line,
" %s:%d state_%s(\"%s\")\n %s() owns this key namespace (%s %r) — go through the helper, "
"or a rename orphans this site silently" % (s.path, s.line, s.kind, s.literal, owner, v[0].upper(), v[1])))
findings.sort(key=lambda f: (f[0], f[1]))
live, muted, budget = [], [], dict(baseline)
for f in findings:
if budget.get(f[0], 0) > 0:
budget[f[0]] -= 1
muted.append(f)
else:
live.append(f)
stale = sorted(k for k, v in budget.items() if v > 0)
print("── state-key audit ─────────────────────────────────────────────")
print("scanned %d .el files%s" % (len(prog.files),
"" if include_tests else " (tests/ excluded)"))
print("sites %d state_set, %d state_get" % (len(writes), len(reads)))
print("keys %d distinct write patterns" % len(write_pats))
print("")
if verbose:
print("WRITE PATTERNS")
for k, v in sorted(write_pats):
print(" %-6s %s" % (k, v))
print("")
if external:
print("DECLARED HOST-SET (%d) — %s" % (len(external), external_path))
for k, v in sorted(external):
print(" %-6s %s" % (k, v))
print("")
print("UNRESOLVED (%d) — reported, never fails the build" % len(unresolved))
if not unresolved:
print(" (none)")
for s in sorted(unresolved, key=lambda x: (x.path, x.line)):
print(" %s:%d state_%s(%s)%s"
% (s.path, s.line, s.kind, s.text,
" [partial: %s]" % ", ".join("%s %r" % p for p in sorted(s.pats))
if s.pats else ""))
print("")
if muted:
print("BASELINED (%d) — pre-existing debt accepted in %s. NOT clean; fix these."
% (len(muted), baseline_path))
for sg, line, _ in muted:
print(" %s (line %d)" % (sg, line))
print("")
if stale:
print("STALE BASELINE (%d) — entries that no longer match anything; delete them:"
% len(stale))
for sg in stale:
print(" %s" % sg)
print("")
print("FINDINGS (%d)" % len(live))
if not live:
print(" (none)")
for _, _, body in live:
print(body)
print("")
if live:
print("FAIL: %d state-key finding(s). See scripts/verify-state-keys.sh "
"for why this gate exists (issue #129)." % len(live))
return 1
print("PASS: every resolvable state_get key has a producer, and no key "
"namespace is spelled two ways.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+28
View File
@@ -0,0 +1,28 @@
# state-key-baseline.txt — findings that already existed when this gate landed
# (2026-08-07). Each one is a REAL defect of the #129 class, not a false
# positive. They are muted only so the gate can be turned on today instead of
# being deferred until the debt is paid; every run still prints them under
# BASELINED with the word "debt".
#
# THIS FILE SHOULD ONLY EVER SHRINK. Adding a line means you are shipping a
# known silent-"" read. If you must, date it and say why in the comment.
#
# format: <file> <CODE> <detail> [xN] # N = how many sites are accepted
# No line numbers on purpose: an unrelated edit must not un-mute an accepted
# finding, but a GROWTH in count is NOT muted — the extra site fails the build.
#
chat.el DEAD-READ exact:soul_identity x5
# ^ soul.el used to run `state_set("soul_identity", soul_identity)`. It was
# deleted on 2026-05-13 in b163fa6 ("feat(awareness): route ISE writes to HTTP
# Engram ..."), a commit about something else entirely, and the five readers in
# chat.el were left behind. Since that date build_system_prompt (737), the
# vision handler (1745), the agentic system prompt (2620), the council
# transcript handler (3425) and 3480 have all been prefixing "" — exactly the
# #129 shape, found by this gate on its first run. Sites: 737, 1745, 2620,
# 3425, 3480. Fix = restore the boot-time write or delete the reads; not done
# here because this branch must not change engine behaviour.
studio.el DEAD-READ exact:soul_principal x1
# ^ studio.el:57 dharma_registry() emits "principal":"" on every call — no
# producer has ever existed in the tree's history (git log -S finds none).
# Never-wired rather than orphaned, same silent-"" result.
+16
View File
@@ -0,0 +1,16 @@
# state-key-external.txt — state keys the engine READS but deliberately never
# WRITES, because a host outside the El tree sets them (an operator, the
# installer, a deployment env). Read scripts/verify-state-keys.sh for why this
# list has to exist and why it has to stay short.
#
# THE RULE FOR ADDING A LINE: the read site must already treat "" as a defined
# default (`if str_eq(x, "") { <default> }`) AND the source must say so in a
# comment. "I could not find the writer" is NOT a reason — that is the #129
# defect, and it belongs in state-key-baseline.txt with a date, not here.
#
# format: exact|prefix <key> # why, and where the source says so
#
exact soul_rate_limit # routes.el:59-61 — "configurable via soul state key ... Falls back to 60 req/min if not set."
exact web_search_tool_version # chat.el:1884-1910 — version lives in state "so a future bump is a config write, not a recompile"; defaults to web_search_20250305
exact platform_auth # stewardship.el:92 — host-set capability flag; fail-CLOSED (anything but "true" denies the platform tool)
exact security_research_authorized # awareness.el:991-996 — state override for env SECURITY_RESEARCH_TOKEN; fail-closed, defaults false
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# verify-state-keys.sh — the state-key gate. Retires a defect class at build time.
#
# ── WHY THIS EXISTS. DO NOT DELETE IT AS NOISE. ──────────────────────────────
#
# The engine keeps runtime values in a key-value store: state_set("k", v) writes,
# state_get("k") reads. A read of a key that NOTHING writes returns an empty
# string. Silently. No error, no warning, no log line. The El compiler cannot see
# it, no test sees it, and the product keeps running — just with a hole in it.
#
# That is how issue #129 happened. ff421d3 (2026-08-05) correctly moved
# conversation history to a per-session key behind conv_hist_key(session_id). One
# consumer did not move with it: the agentic path's L1 safety screen kept reading
# the old anonymous "conv_history" bucket. The desktop app always mints a session
# id, so history was always written under session_hist_<id> and that read always
# returned "". The half of the crisis score that receives history is the
# ESCALATION half — the one that exists for distress building across several
# turns, where no single message trips the bell on its own. It scored 0 on every
# real conversation for two days, and nothing failed.
#
# The line that broke carried a comment describing this exact bug being fixed
# once already, under issue #9. A comment is not a gate. This is the gate.
#
# ── WHAT IT CHECKS ──────────────────────────────────────────────────────────
#
# DEAD-READ a state_get whose key resolves to something no state_set in the
# tree produces. The direct form of the class.
#
# HAND-ROLLED a state_get/state_set that spells out a literal belonging to a
# key namespace a helper function owns (e.g. "conv_history", owned
# by conv_hist_key()). This is #129's actual shape: the producer
# moved behind the helper and one consumer kept the old spelling
# by hand. DEAD-READ alone does NOT catch #129, because the dead
# handle_chat() still writes that key through the helper — so this
# second check is the one that earns the gate its keep.
#
# ── WHY IT DOES NOT CRY WOLF ────────────────────────────────────────────────
#
# Keys are usually COMPUTED, not literal, so a naive grep would flood and get
# switched off within a day. scripts/state-key-audit.py resolves computed keys:
# string concatenation (matched on the static prefix), helper functions (resolved
# to their possible return values), keys built into a local variable, and keys
# arriving as a function parameter (resolved through the call sites). Where a key
# genuinely cannot be resolved it is printed under UNRESOLVED and does NOT fail
# the build — visible, never silently ignored. Keep that list short.
#
# On this tree it resolves 278 of 278 sites: UNRESOLVED is 0 and FINDINGS is 0.
#
# Two declaration files, both of which should only ever shrink:
# scripts/state-key-external.txt keys a host outside the El tree writes
# scripts/state-key-baseline.txt findings that predate the gate (real debt)
#
# ── PROVEN TO DISCRIMINATE (2026-08-07) ─────────────────────────────────────
#
# 1. Synthetic: a scratch copy of this tree with agentic_safety_screen reverted
# to the pre-fix state_get("conv_history") — ONE line, nothing else — FAILS
# with `chat.el:2536 ... conv_hist_key() owns this key namespace`. The tree
# as shipped PASSES. One variable, opposite verdicts.
# 2. Independent: run read-only against origin/feat/soul-openai-tools-v2, which
# carries the same defect on its own, the gate reported chat.el:2937 — the
# exact line 43d0449's commit message had named by hand. Against that
# branch's fix (origin/fix/129-on-openai-tools) it passes.
# 3. Producer-moved controls: renaming the sole writer of an EXACT key
# (soul_model) orphans 3 readers across 3 files; renaming the sole writer of
# a PREFIX namespace (agent_workspace_root_*) orphans 3 readers — including
# when the producer moves to a NARROWER namespace, which an earlier,
# sloppier prefix rule let through.
#
# It also found, on its first run, a defect nobody was looking for: soul.el's
# `state_set("soul_identity", ...)` was deleted on 2026-05-13 in b163fa6 (a
# commit about awareness/ISE writes) and five readers in chat.el were left
# behind — the system prompt, the vision handler, the agentic prompt and the
# council handler have been prefixing "" ever since. See state-key-baseline.txt.
#
# ── SAFETY ──────────────────────────────────────────────────────────────────
# Pure static read of .el sources. Starts nothing, opens no port, touches no
# daemon, and never reads or writes ~/.neuron.
#
# ── USAGE ───────────────────────────────────────────────────────────────────
# scripts/verify-state-keys.sh gate the repo (honours baseline)
# scripts/verify-state-keys.sh --strict ignore the baseline: show the debt
# scripts/verify-state-keys.sh --verbose also dump every write pattern
# scripts/verify-state-keys.sh --root DIR audit a different tree
# exit 0 = clean; 1 = finding(s); 2 = the gate itself could not run.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
STRICT=0
PASS_THROUGH=()
while [ $# -gt 0 ]; do
case "$1" in
--strict) STRICT=1; shift ;;
--root) ROOT="${2:?--root needs a directory}"; shift 2 ;;
-h|--help) awk 'NR>1 && /^#/ {print; next} NR>1 {exit}' "${BASH_SOURCE[0]}"; exit 0 ;;
*) PASS_THROUGH+=("$1"); shift ;;
esac
done
command -v python3 >/dev/null 2>&1 || {
echo "[state-keys] CANNOT RUN: python3 not found" >&2; exit 2; }
[ -d "$ROOT" ] || { echo "[state-keys] CANNOT RUN: no such tree: $ROOT" >&2; exit 2; }
AUDIT="$SCRIPT_DIR/state-key-audit.py"
[ -f "$AUDIT" ] || { echo "[state-keys] CANNOT RUN: missing $AUDIT" >&2; exit 2; }
ARGS=("$ROOT" "--external" "$SCRIPT_DIR/state-key-external.txt")
[ "$STRICT" -eq 0 ] && ARGS+=("--baseline" "$SCRIPT_DIR/state-key-baseline.txt")
[ ${#PASS_THROUGH[@]} -gt 0 ] && ARGS+=("${PASS_THROUGH[@]}")
python3 "$AUDIT" "${ARGS[@]}"
RC=$?
if [ "$RC" -gt 1 ]; then
echo "[state-keys] CANNOT RUN: the audit itself failed (exit $RC)" >&2
exit 2
fi
exit "$RC"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# build-soul-from-dist.sh — build a deployable soul from the SAME input CI compiles.
#
# THE PROBLEM THIS CLOSES: until now, deploys were built by build-soul.sh, which
# compiles a scratch amalgam and never touches dist/soul.c. CI compiles dist/soul.c.
# Two lineages. On 2026-08-09 the committed input fell 2,761 bytes behind the sources
# while three binaries built the other way were installed on the operator machine —
# so "what runs" and "what the repo says builds" were different artifacts again,
# which is the whole of #133 and #111 wearing new clothes.
#
# This builds from dist/soul.c with CI's own flags, after asserting that dist/soul.c
# actually matches the .el sources, and writes a provenance sidecar so a deployer can
# refuse anything of unknown origin.
#
# -rdynamic and -DHAVE_CURL are copied from .gitea/workflows/ci.yaml deliberately.
# The CI comment explains -rdynamic: without it the runtime cannot resolve its HTTP
# handler by name via dlsym and the binary serves nothing on every route.
#
# usage: build-soul-from-dist.sh <out-binary>
set -u
OUT="${1:?usage: build-soul-from-dist.sh <out-binary>}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="$ROOT/vendor/el-runtime/v1.0.0-20260501"
cd "$ROOT" || exit 2
echo "[build-from-dist] GATE: does dist/soul.c match the sources?"
if ! ./tools/soulc-stamp.sh --check; then
echo "[build-from-dist] REFUSING — the build input is stale. Regenerate and stamp first." >&2
exit 9
fi
[ -f "$RUNTIME/el_runtime.c" ] || { echo "pinned runtime missing at $RUNTIME" >&2; exit 2; }
echo "[build-from-dist] compiling dist/soul.c with CI's flags"
cc -O2 -DHAVE_CURL -rdynamic \
-I"$RUNTIME" \
dist/soul.c \
"$RUNTIME/el_runtime.c" \
-lcurl -lpthread -lm \
-o "$OUT" || { echo "[build-from-dist] COMPILE FAILED" >&2; exit 3; }
# Provenance sidecar: what a deployer checks before installing anything.
SRC_SHA="$(shasum -a 256 dist/soul.c | awk '{print $1}')"
STAMP_SHA="$(shasum -a 256 dist/soul.c.stamp | awk '{print $1}')"
COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
DIRTY="clean"; [ -n "$(git status --porcelain -- '*.el' dist/soul.c 2>/dev/null)" ] && DIRTY="DIRTY"
cat > "$OUT.provenance" <<EOF
{"built_from":"dist/soul.c",
"dist_soul_c_sha256":"$SRC_SHA",
"stamp_sha256":"$STAMP_SHA",
"git_commit":"$COMMIT",
"worktree":"$DIRTY",
"runtime":"vendor/el-runtime/v1.0.0-20260501",
"flags":"-O2 -DHAVE_CURL -rdynamic"}
EOF
echo "[build-from-dist] OK -> $OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)"
echo "[build-from-dist] provenance -> $OUT.provenance (commit ${COMMIT:0:8}, worktree $DIRTY)"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# soulc-stamp.sh — make it impossible for dist/soul.c to drift from the sources
# in silence.
#
# THE PROBLEM (neuron#133, and its own words): "Nothing in the tree regenerates
# this file. Only a human running the recipe. It lags in batches, never
# per-change, and it will drift again."
#
# It drifted. On 2026-08-07 a CI or GKE build off main would have shipped an
# engine with NONE of five merged fixes — including a P0 safety fix — while
# main's source read as correct. CI compiles dist/soul.c, not the .el files, so
# the source being right is not the same as the build being right.
#
# WHY A STAMP AND NOT AUTO-REGENERATION: the CI workflow says elc cannot run on
# the runner ("elb on Linux would OOM the runner (elc uses 24GB+ virtual memory
# on a 16GB host)"). So the build cannot regenerate the file itself. What it CAN
# do, for free and with no compiler, is refuse to compile a stale one.
#
# The stamp records a fingerprint of every .el source that feeds the amalgam at
# the moment it was generated. --check recomputes and compares. Divergence is a
# build failure with the recipe in the message, not a silent ship.
#
# soulc-stamp.sh --write after regenerating dist/soul.c (records the fingerprint)
# soulc-stamp.sh --check in CI, before the compile (fails on drift)
set -u
MODE="${1:---check}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAMP="$ROOT/dist/soul.c.stamp"
AMALGAM="$ROOT/dist/soul.c"
# Every .el at the repo root is an input to the amalgam. Sorted so the hash is
# order-independent; content-only so timestamps and checkouts do not perturb it.
fingerprint() {
(
cd "$ROOT" || exit 1
for f in $(ls -1 *.el 2>/dev/null | sort); do
printf '%s %s\n' "$(shasum -a 256 "$f" | awk '{print $1}')" "$f"
done
)
}
case "$MODE" in
--write)
[ -f "$AMALGAM" ] || { echo "no dist/soul.c to stamp — regenerate it first" >&2; exit 2; }
{
echo "# soul.c.stamp — fingerprint of the .el sources dist/soul.c was generated from."
echo "# Written by tools/soulc-stamp.sh --write. Do not hand-edit."
echo "# generated_amalgam_sha256 $(shasum -a 256 "$AMALGAM" | awk '{print $1}')"
echo "# generated_amalgam_bytes $(wc -c < "$AMALGAM" | tr -d ' ')"
fingerprint
} > "$STAMP"
echo "stamped $(fingerprint | wc -l | tr -d ' ') sources -> dist/soul.c.stamp"
;;
--check)
if [ ! -f "$STAMP" ]; then
echo "FAIL: dist/soul.c.stamp is missing — the build input is unverifiable." >&2
echo " Regenerate the amalgam, then: tools/soulc-stamp.sh --write" >&2
exit 1
fi
RECORDED="$(grep -v '^#' "$STAMP")"
CURRENT="$(fingerprint)"
if [ "$RECORDED" = "$CURRENT" ]; then
echo "soulc-stamp: OK — dist/soul.c matches the .el sources"
exit 0
fi
echo "FAIL: dist/soul.c is STALE. It does not match the current .el sources." >&2
echo "" >&2
echo "CI compiles dist/soul.c, not the .el files. Shipping this means shipping" >&2
echo "an engine that does not contain the merged source. That is neuron#133," >&2
echo "which once hid five merged fixes including a P0 safety fix." >&2
echo "" >&2
echo "Sources that changed since the amalgam was generated:" >&2
diff <(printf '%s\n' "$RECORDED") <(printf '%s\n' "$CURRENT") \
| grep -E '^[<>]' | awk '{print " " $1 " " $3}' | sort -u >&2
echo "" >&2
echo "Fix: regenerate the amalgam, then tools/soulc-stamp.sh --write" >&2
exit 1
;;
*)
echo "usage: soulc-stamp.sh [--check|--write]" >&2; exit 2 ;;
esac