Files
troupe-standup/recoveries/prose-anchor-hardening.md
T

15 KiB
Raw Blame History

Q26 — Harden the prose-anchor convention: the two silent token classes die loudly

Status: DONE — hardened prototype built, all five Q25 legs re-run green, two negative test sets (7 malformed tokens, 5 outside-panel sentinels) each dying with its own message, the contributor.display_name fifth slot measured without building it. Merlin, 2026-10-11 ~00:20–00:24 UTC. Scratch tree /root/crucible/q26/, every artifact re-runnable from the scripts named in §9. Nothing published, nothing applied to the shared build tree; blocks.json was read-only throughout.


0. Reading rule (unchanged from Q25, restated so the population is re-derivable)

  • Prose panels = the fields render_entry emits as the archive's own prose and routes through expand_prose: body where body_kind == "summary", plain_summary.text, body_provenance, uncertainty. The body_kind == "quote" branch renders body through esc() inside a blockquote — third-party quoted text, not a panel.
  • Token sentinel = any occurrence of the literal {{ or }} anywhere in an entry.
  • Malformed token = a {{x:...}} in a panel that TOKEN_RE (\{\{x:([A-Za-z0-9_]{1,15})\}\}) does not match.
  • Cited handle (validator registry) = every contributor.handle value plus the first path segment of any https://x.com/… or https://twitter.com/… URL anywhere in blocks.json, minus reserved account paths.

1. What was hardened, and why

Q25 left two classes failing silently, both reproduced in the control plane's 2026-10-10 verification of Q25:

  1. A malformed token passes the build and prints raw braces. expand_prose returns esc(text) unless the text contains the literal {{x:, and the validator only ever inspects what TOKEN_RE matched. So {{x:saylor}, {{x:}}, {{ x:saylor }}, {{y:saylor}} and a 16+ character handle all emitted Built 123 pages from 121 entries, exit 0, with the literal on the page.
  2. A token outside the four panels prints literally — the quote branch (Q25's own limit 3), a title, an excerpts[].text, a contributor field, an index coverage.gaps string. Q25's validator could not see any of these.

The fix is a pre-write scan, check_tokens(b, slug) plus check_gaps(data), run for every entry before a single byte is written (the scan sits above the OUT cleanup in build(), so a die() can never leave a half-emitted tree — the Q25 prototype's scan point would have):

  • any {{ or }} in a field the panels do not render → die;
  • any sentinel left in a panel after TOKEN_RE.sub("", obj) → die;
  • the panel set is computed per entry: body is a panel only when body_kind == "summary", so the quote branch is caught by construction rather than by a special case.

Two further changes ride along, both byte-neutral with no token present:

  • render_genesis now routes body_provenance and uncertainty through expand_prose instead of esc(). The genesis page previously could not expand a token those panels carried even though block-5's own page did — a third silent class, found while writing the positive control, not in Q25's list.
  • coverage.gaps strings are checked (the index renders them through esc()).

2. The diff

/root/crucible/q26/DIFF.txt — diff -u build_blocks_proto.py build_blocks_hard.py: +70 / −6 lines, 5,817 B. DIFF-PRISTINE.txt — against the shared tree's build_blocks.py (sha256 97fedb5dd5e85d2b10071e8e4904b3a9b1573072149e722fbece93eaf0774672): +118 / −11 lines, 9,949 B.

The hardened prototype is build_blocks_hard.py, 27,878 B / sha256 d45cc28f7d69afcdab4eccb16e9e0cc264e4317e059e17f1ba53eb23f792cb60 (this host, 2026-10-11T00:22Z). The Q25 prototype, copied unchanged into this tree, verified byte-identical first: sha256 5af80b78961b4f55ca1ac436c19074d275b9a255569de07156a527de39d8de35.

3. The five Q25 legs, re-run on the hardened prototype (Q26-VERIFY.txt)

Leg 1 — no-token build is byte-identical. Unmodified blocks.json (278,459 B / sha256 63c9cee6103a6824c90c71440fa983d2f5592e380f9c3fb40f7ad366ae41723d, unchanged from Q25's record):

$ python3 build_blocks_hard.py
Built 123 pages from 121 entries (121 cleared, 0 research-only)
tree digest: 91ecf6bf939a2a499983066a2331bb953eab8a4ca85c66a8a19f42cb1b28f7a8
baseline   : 91ecf6bf939a2a499983066a2331bb953eab8a4ca85c66a8a19f42cb1b28f7a8
125 files; BYTE-IDENTICAL to baseline
$ diff -rq <scratch>/site-ledger /root/stackchain-legends-build/site-ledger
(no output — live tree identical)

Leg 2 — the registry is unchanged: 8 handles. @anthonydessauer @arizonanhodl @guiamadogomes @happyclowntime @rockachopa @satskeeper_ @saylor @yoshishisatoshi (REGISTRY.txt, identical to Q25's).

Leg 3 — the uncited-handle negative test still fails closed, same message:

$ cd negtest && python3 build_blocks_hard.py     # {{x:NotACitedHandle}} in uncertainty
exit 1
BUILD FAILED: block-25:uncertainty: prose-anchor token names handle @NotACitedHandle which no entry cites

Leg 4 — the token demo is unchanged: 6 deltas (H1–H4 as tokens) → 3 of 125 emitted files.

$ cd tokentest && python3 build_blocks_hard.py
exit 0  Built 123 pages from 121 entries (121 cleared, 0 research-only)
emitted files: 125 -> 125; changed: 3
  event-2022-07-18-the-5-invitation-becomes-a-chain/index.html
  magazine-002/index.html
  magazine-005/index.html
emitted: event-2022-07-18…  https://x.com/AriZonanHODL  @AriZonanHODL   (x2: body + plain English)
emitted: event-2022-07-18…  https://x.com/satskeeper_  @satskeeper_    (x2)
emitted: magazine-002       https://x.com/AriZonanHODL  @AriZonanHODL
emitted: magazine-005       https://x.com/AriZonanHODL  @AriZonanHODL

Leg 5 — the diff itself (§2). All five legs: green.

4. The malformed-token set — 7 fixtures, each in its own tree, each exit 1

Each fixture appends one token to block-25's uncertainty (a panel rendered on every entry). Raw stderr in MALFORMED.txt:

fixture token exit first stderr line
open brace {{x:saylor} 1 BUILD FAILED: block-25:uncertainty: malformed prose-anchor token — sentinel {{ left after token expansion; tokens must read exactly {{x:HANDLE}} with a 1-15 character handle
empty handle {{x:}} 1 same, sentinel {{
whitespace padded {{ x:saylor }} 1 same, sentinel {{
wrong namespace {{y:saylor}} 1 same, sentinel {{
handle over 15 chars {{x:abcdefghijklmnopq}} 1 same, sentinel {{
lone close brace }} 1 same, sentinel }}
close then open }}{{x:saylor}} 1 same, sentinel }}

All seven now die. Under the Q25 prototype all seven built and shipped the literal.

5. The outside-panel sentinel set — 5 fixtures, each exit 1

Each places a valid {{x:saylor}} where no panel renders it (OUTSIDE-PANEL.txt):

fixture field exit message
quote branch block-6:body (body_kind: quote) 1 token sentinel {{ in a field that is not a prose panel (panels: body_provenance, plain_summary.text, uncertainty)
title magazine-001:title 1 … (panels: body, body_provenance, plain_summary.text, uncertainty)
excerpt text magazine-001:excerpts[0].text 1 same
contributor display_name magazine-035:contributor.display_name 1 same
index coverage gap coverage.gaps[4] 1 same

Note the quote-branch message prints the panel set without body — the per-entry body_kind rule, visible in the error itself. This fixture is the one Q25's limit 3 named; it was the only silent class that survived the first pass of the hardening and the reason the panel set is computed per entry.

6. Positive control — a cited token expands on the entry page and the genesis page

{{x:AriZonanHODL}} appended to block-5's body_provenance (positive/):

$ cd positive && python3 build_blocks_hard.py
exit 0  Built 123 pages from 121 entries (121 cleared, 0 research-only)
block-5 page anchors: https://x.com/AriZonanHODL -> @AriZonanHODL  (x2: panel + contributor slot)
genesis page anchors: https://x.com/AriZonanHODL -> @AriZonanHODL
changed files: 2  [block-5/index.html, genesis/index.html]

The genesis-page anchor is the §1 wiring change proven: without it the same token would have expanded on block-5 and printed raw braces on genesis/. A negative test with no positive control proves nothing, so both are recorded.

7. The contributor.display_name fifth slot, measured without building it

Measured from blocks.json and from this run's scratch build (byte-identical to the live site-ledger), per entry, with Q24's staged delta applied in its own scratch tree (DISPLAY-NAME-MEASURE.txt):

entry Q24 clause display_name today handle today live page today after Q24's delta named accounts in registry
magazine-035 G1 @BTC_Freeborn and @AnthonyDessauer null @BTC_Freeborn and @AnthonyDessauer — anchors: NONE BTC_Freeborn and Anthony Dessauer (@BTC_Freeborn) — anchor x.com/BTC_Freeborn; sha256 871638b8… → 565e6f68… @BTC_Freeborn no; @AnthonyDessauer yes
magazine-056 G2 Red Tail Hawk (@RedTailHawk1923) null text only — anchors: NONE Red Tail Hawk (@RedTailHawk1923) — anchor x.com/RedTailHawk1923; sha256 33b55f8f… → 8094e862… @RedTailHawk1923 no
magazine-070 I2 @MidyReyes null text only — anchors: NONE MidyReyes (@MidyReyes) — anchor x.com/MidyReyes; sha256 6227e199… → c994e055… @MidyReyes no

All three live-page digests reproduce Q24's fix-pack figures to the nibble (871638b8abb5…, 33b55f8f61fe…, 6227e199522f…), so the live tree is still the tree Q24 measured. Recommendation, per entry:

  • magazine-056 (G2), magazine-070 (I2): the handle-only fix is the right instrument and it is complete. One account named, one handle slot; the build already anchors contributor.handle (line 310), and the delta is what admits the handle into the registry. The convention cannot help — display_name is not a panel, and §5 proves a token there now dies at build time.
  • magazine-035 (G1): handle-only anchors @BTC_Freeborn and leaves @AnthonyDessauer unanchored, because contributor has exactly one handle slot while the byline names two accounts — and @AnthonyDessauer is in the registry, so a token for it would be admitted today. A wording-only fix (strip both @s, as Q24 stages) loses the link on both. The only instrument that anchors both is display_name as a fifth panel carrying {{x:AnthonyDessauer}} beside the handle slot — a build change, which is Alexander's, not a wizard's.

Which of Q24's deltas the hardened convention can carry as links: H1–H4 only — 4 of the 31. The hardening changes no clause's carrier; it makes the four token carriers safe to publish. Everything else is a structured field the build already anchors, a note where the handle is uncited or dead, or a wording call.

8. Findings

  1. The first hardening pass missed the quote branch, and the error message now shows why it had to. A path-based panel set ("body is a panel") admits a token in a quote-kind entry's body, where render_entry renders it through esc(). The panel set is per entry and depends on body_kind; the quote-branch error prints the reduced set, so the rule is visible in the failure.
  2. The genesis page was a third silent class, not in Q25's list. render_genesis routed body_provenance and uncertainty through esc(), so a token those panels carry would expand on the entry's own page and print literally on genesis/. Routing them through expand_prose is byte-neutral with no token present (proved by §3 leg 1) and closes the class.
  3. The scan must run before the output tree is touched. A die() inside the write loop leaves a half-emitted site-ledger behind, which on a shared tree is exactly the state a reader cannot distinguish from a partial publish. The checks now sit above the OUT cleanup.
  4. A negative test without a positive control proves nothing — Q25's own incident note. Leg 6 carries both: a cited token builds, expands, and moves exactly the two files it should.
  5. The registry is the binding constraint, unchanged. 8 handles of the 23 the census found printed in prose; the hardening widens nothing, it only makes the four carriers safe.
  6. {{/}} still collide with nothing in blocks.json (0 occurrences), so the sentinel scan is silent on today's data — the no-token build proves it.

9. Limits

  1. Prototype only. build_blocks_hard.py lives at /root/crucible/q26/ and was never copied into the shared build tree. Verified at close: the shared tree's build_blocks.py is still sha256 97fedb5dd5e85d2b10071e8e4904b3a9b1573072149e722fbece93eaf0774672, its site-ledger byte-identical to this run's scratch build (125 files each, diff -rq silent), and find /root/stackchain-legends-build -newermt 2026-10-10 18:00 -type f returns nothing. The live archive is unchanged: index https://alexanderwhitestone.com/stackchain/blocks/ 200 / 34,488 B / sha256 4f764ef4dfb9c08b2654ff0783550d20ad1bb7a31cb0c32070974a5fe451557e at 2026-10-11T00:23:25Z. Publishing a build change is Alexander's, as Q13 states.
  2. 7 + 5 fixtures are the test set, not a census of typos. The malformed set is the item's own list plus a lone }} and a close-then-open; the outside-panel set is one fixture per field class. A sentinel in a field no entry has today cannot be enumerated from today's data.
  3. display_name was measured, not built. No fifth panel was implemented; the recommendation is a decision input, not a staged change.
  4. Nothing re-tests Q25's census. The 46-row population, the 25 X fetches and their liveness verdicts are Q25's, cited not re-run.
  5. X byte sizes are dated readings (per-request CSP nonce); none is load-bearing here — no X page was fetched this run.

10. Scripts (all re-runnable, /root/crucible/q26/)

verify26.py (all eight legs) · display_name.py (the fifth-slot measurement) · clean.py (scratch reset) · build_blocks_hard.py (the hardened prototype) · build_blocks_proto.py, build_blocks.py, blocks.json (inputs, unmodified).

Artifacts: DIFF.txt (+70/−6 vs the Q25 prototype) · DIFF-PRISTINE.txt (+118/−11 vs the shared build script) · NOTOKEN-BUILD.txt (digest 91ecf6bf…, 125 files, live-tree diff silent) · REGISTRY.txt (8) · NEGTEST.txt (exit 1) · TOKEN-DIFF.txt, TOKEN-PAGES.txt (3 of 125 files, 6 emitted anchors) · MALFORMED.txt (7 fixtures) · OUTSIDE-PANEL.txt (5 fixtures) · POSITIVE.txt (2 changed files) · DISPLAY-NAME-MEASURE.txt · Q26-VERIFY.txt / Q26-VERIFY-run.txt (full run log) · scratch trees negtest/, tokentest/, positive/, mal-*/, out-*/, dn-*/.