f87976b54d6a1e30081f2f01c09123e1d439e467
20
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
a306e2dc6d |
feat(u7): the rail, the filters and the grid keyboard — the ratified three
ROADMAP's U7 row names four components. Three of them -- a sticky rail, filters, and grid keyboard -- are already ratified there and are implemented here. The fourth, replacing directory sections with filename-derived groups, is a scope DEPARTURE the operator has not ruled on and is deliberately not built; test_no_group_rail_is_shipped_yet fails the moment somebody builds it anyway, so it cannot arrive by accident while he is away. Filters are links carrying a query parameter, resolved server-side, so the gallery keeps working with JavaScript off -- U3 already cost the verbatim path its no-JS operation and said so, and the gallery is the surface the operator actually reviews on. An unknown filter falls back to `all` rather than indexing a dict by a value that arrives from an operator-editable URL. `unanswered` means HAS AN OPEN PICK, the U4 hold predicate that already exists. The other reading is a real and different question and stays open on the contract rather than being guessed at. Filtering is a VIEW and never reorders. The grid renders `sorted(rel)` with non-matching items removed, so "the third one" means the same thing with a filter on as with it off, and the zoom ring is untouched by any filter -- a ring that changed with the grid would make `next` depend on how the operator arrived, which is the misfiled-judgment failure invariant 6 exists for. ⚠ The first version of that invariant's test was VACUOUS and the mutation run caught it: it compared each filtered view against the unfiltered RESPONSE, so a reversing mutation reversed both sides and it stayed green under the exact change it forbade. Rewritten against an independent truth -- U1 INV-3 says the order IS sorted(rel) -- and re-verified RED. Written an hour after the entry describing this exact failure class, which is worth recording. 611 -> 623 tests. |
||
|
|
b50f41bb36 |
docs(u7): a PROPOSED contract for the last unit — scope departs from ROADMAP on measured grounds
Not approved and not implemented. Frontmatter status says so, the body says so twice, and the one scope-direction call in it is named as the operator's. ROADMAP's U7 row is sections, rail, filters, grid keyboard. The measurement recorded in persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md kills the first component -- zero of eleven gallery booths have a subdirectory, and the only two booths that do are reports -- and supplies a replacement: stripping a trailing digit-run from the filename stem yields 5 to 16 sensible groups on four of the five large galleries. The degenerate fifth is carried as a first-class case rather than an edge: one group must render NO rail, because a navigation affordance that cannot navigate is worse than none. Closes ROADMAP's outstanding U7 ordering question: groups order by the position of their first member in sorted(rel), so the rail reads in the same direction as the grid. Grouping and filtering are views and never reorder -- INV-2 exists because sorting by (group, rel) looks right and silently changes what 'the third one' means, which is the misfiled-judgment failure invariant 6 was written for. Blast radius checked before writing: Item gains one field beside the existing section, build_gallery carries it, and image_chain is explicitly unchanged. |
||
|
|
e702be4e1a |
fix: a wrong-shaped answer no longer 500s the gallery and the marks page
Pre-existing, measured at
|
||
|
|
8cb21193dc |
fix(u6): fold the cold bug-hunt panel — a div in a span, a symlink split, and an append outside its lock
/heid-bug-hunt panel 01M35CRRK2RTVWWF1BN09AFQG3, diff-scoped against
|
||
|
|
32e3ed65e1 |
fix(u6): fold the cold contract panel — the import selection gap, and a document arguing with itself
/heid-contract-review panel 01M35BWCJ806MT75NA630Y4WFH. The headline arrived from all four arms independently and it is a missing feature, not a wording problem. `bench import --apply` registered every candidate, while the same contract says roughly 14 of 35 are reference bookmarks that must stay on the board. There was no selection mechanism between the dry-run report and the write -- so the write path did the exact thing this unit's rationale calls impossible, tell a bench from a bookmark by its URL, silently, to rows that belong where they are. The report existed precisely because the decision is not mechanizable. `--apply` now takes the ids the operator names; a bare `--apply` is refused and an unknown id is refused, both writing nothing. Two solo findings, both real: - A successful registration could push the registry past the size its own reader refuses, so the LAST bench added would make every other bench invisible while reporting success. The writer now respects the reader's cap. - The credential ban covered bench URLs and not `booth link`, the door this unit did not touch -- and the board renders on an unauthenticated LAN surface. A password can no longer reach it through either door. A small deliberate widening, named rather than smuggled. Cap semantics were readable three ways (refuse / clip-for-display / truncate-and-store) with a different build behind each, 4-of-4. Now stated per field: name and owner truncate, url and state are refused at the write and are DAMAGE at the read. url is not a display budget -- INV-7 promises the click goes to the posted address byte for byte, and a clipped URL keeps that promise in the type system while breaking it in the browser. The code had been clipping it; fixed. Two passages disagreed about one character: INV-7's specimen named "a trailing slash on a non-empty path" as something normalization changes, while the rule list keeps it and INV-6 makes the two spellings two benches. The rule list is right; the specimen was wrong. Found by 3-of-4. Also: INV-6's component list was illustrative where it had to be exhaustive and was short scheme and port; "writes nothing" appeared twice with different lists; the dead marker's predicate was readable two ways with 221 rows riding on it; and INV-2's falsifier read as though three callers agreeing pinned something, when three callers of one wrong predicate agree perfectly -- the table's expected values are the real check and now say so. 597 -> 604 tests. |
||
|
|
8a7af3eb08 |
fix(u6): fold the cold code-review panel — four-arm convergence on three surface clauses
/heid-code-review panel 01M35CK8YKEKMV7T15JXEF6A8N, verdict NOT drift-zero. Three findings arrived from all four arms independently, and they share a shape: a contract clause written as prose and never converted into an assertion. That is the lens working. - The panel dropped the added date the contract promised to show. - `bench ls` printed no ids, and the URL it printed was truncated to 52 columns so the line was not pasteable into `bench state|rm`. The test's docstring claimed it printed ids and asserted nothing of the kind. - `bench import` printed the description instead of the raw URL beside each normalized id, hiding the collapse the clause exists to expose. - An IPv6 literal lost its brackets: http://[::1]:8080/a normalized to http://::1:8080/a, a broken identity that no re-post can match. Bracketed literals are re-wrapped; an unbracketed one is refused rather than guessed. - A deeply-nested JSON RecursionError escaped read_benches' except pair. The byte cap does not help -- 200k open brackets is 200 KB. - An empty board hid the whole benches panel, registration form included. - The link refusal classified by captured-text emptiness, which bash can erase; it now answers with a B:/N sentinel so no name reads as "not a booth". INV-4's tie-break falsifier could not fail: _write_all serializes with sort_keys=True, so both insertion orders came back already id-sorted and removing the tie-break left the test green. It now calls order_benches directly. Same class as the five vacuous U4 falsifiers, found by a cold reader rather than by us. Also from the arms' per-invariant vacuity pass: INV-6 had no vector pinning a non-default port as part of the identity; INV-3 asserted only that links/ was absent; INV-8's hashed sequence omitted a read verb; INV-9's AST walk is defeated by a string import. All closed. Contract amended where the code was right: `updated` means last mutation, the id cap is write-only because the id is the locator controls post back, INV-8's file list includes the lock sidecar it always mandated. Every line number is out of the prose -- the panel found two already stale. 565 -> 593 tests. Nothing declined. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
c015a917ee |
fix(manifest): fold in both cross-frontier panels — and a live hole in v0.2.2
Two four-arm artifact-only rounds landed together: the contract paraphrase (against the pre-seam-review capture) and the code-vs-contract conformance review (against the amended one), correctly firewalled from each other. The conformance round found ZERO drift in the strict sense — the code is a clause-for-clause implementation of the contract — and the weight of both rounds landed one layer down, in what green tests structurally cannot report. Full triage in persistent-memory.d/. A LIVE HOLE IN RELEASED CODE, FOUND ON THE SIBLING MODULE v0.2.2 adopted the RecursionError finding from the bug-hunt round and closed half of it: `_hydrate_safe` guards hydration, but `json.loads` runs above it in `_read_raw`, whose catch list covers neither RecursionError nor MemoryError. A 400 KB file of nothing but brackets in any ONE booth therefore still returned 500 for `/` and `/healthz` across every booth on the service. Confirmed by running it before believing it. Both modules now bound the read by `stat` before touching the bytes and catch both classes anyway, so raising a bound later cannot quietly re-open the hole. The strict half of the marks asymmetry refuses everything the lenient half tolerates, or a file that reads as "no marks" gets replaced by a write that believed it. THE WHY-WIPE `booth new x --why "..."` then `booth add x out/*.png` erased the sentence the first command existed to record. Omitted flags meant empty strings and empty strings overwrote. Two arms predicted it from the contract's wording alone; every test here passed --why on both calls and so could not see it. Omitted now means unchanged and an explicit --why "" still clears — the shell carries the distinction by leaving the variable UNSET, not empty. --title WAS WRITE-ONLY Stored, flag-surfaced, rendered nowhere. 4/4, and independently top-ranked by every arm of the paraphrase round. It lands on the booth page heading with the directory name beside it, because the directory name is the identity the operator navigates by and refers to positionally. THREE TESTS THAT COULD NOT FAIL - test_the_write_is_atomic asserted no *.tmp survived, which a plain write_text passes. It asserts the inode changes now. (The first replacement was ALSO vacuous — it spied on os.open, which Path.write_text reaches through io.open in C and never touches. Recorded in the test, because writing a second vacuous test while fixing the first is exactly the failure this round is about.) - The INV-3 preservation test passed against an implementation that regenerated `created` every time, because _now() is whole-second resolution and back-to-back writes share a stamp. Seeded from 2019 now. - test_announcing_is_activity passed whether or not _newest_mtime counted the manifest, because writing it bumps the directory mtime either way. The directory's clock is put back, leaving the file as the only thing that can keep the booth alive. ALSO - The title fallback skipped the normalizer the explicit value gets; a directory name may legally carry a newline and run to 255 bytes. - Every writer derived the same .booth.json.tmp. Marks are protected from that by their flock; the manifest has none, so uniqueness stands in. - test_stdlib_only was blind to relative imports in all four modules. - INV-1 had no guard at all; INV-5 named two different promises; the negative render states were asserted on the index only. Contract amended throughout: the 4 GB case is a stat-checked bound rather than a return constraint, every field of an error-carrying record has a stated value, INV-1 no longer contradicts INV-3, repo-wide rules are named in words instead of by a colliding number, and touches admits the macro partial the implementation added. 329 tests. |
||
|
|
fac83de8f4 |
docs(u5): name the verbatim-booth boundary as U3's, not a gap
Five live booths serve the author's HTML raw and the Booth owns no header there to put a provenance line into — it reaches those pages through six regexes injected into arbitrary markup, which is the defect U3 exists to fix. Their index cards carry provenance like everything else. Written down so the boundary reads as a boundary rather than as something this unit forgot. Verified on pewpew-ui-brief. |
||
|
|
ac35f2441f |
docs(u5): state what the contract deliberately leaves out
The out-of-scope block is load-bearing for the cross-frontier review gates — without negative constraints their signal-to-noise drops sharply, and both /heid-code-review and /heid-bug-hunt refuse to fire without one. Written for the reviewer, but it is the same list the roadmap gate produced: the what-landed feed is parked for v1.1, nothing enforces that a booth must announce itself (rsync is a documented path and never runs the CLI), and the manifest describes rather than decides — U4 owns lifetime. |
||
|
|
109190b0d6 |
docs(u5): contract for self-announcing booths, plus its seam review
Blast-radius pass first (graphify explain list_booths + grep over every mkdir and every dotfile skip), then the contract, then a caller-side seam review against the real sibling module surfaces. The seam review earned its place again: the contract asserted that the upload path's filename dedupe set must gain MANIFEST_FILE or an uploaded file could collide with the manifest. safe_upload_name strips leading dots, so that collision is unreachable — and the UPLOAD_MARKER entry already sitting in that set has never been able to matter either. A scope item the contract reasoned its way into and the sibling refutes. Operator calls settled 2026-09-22: flags on the existing new/add verbs rather than a second announce verb; unannounced booths get a quiet marker rather than nothing; U5 adds provenance only and does not add a second index ordering keyed on announcement time (parked for v1.1). Cold contract panel dispatched to heid before this landed; its findings fold in before any code ships. |
||
|
|
5e41108cd3 |
fix(marks): a write over a damaged mark file was wiping the booth's judgment
Three defects and a missing test, all surfaced by the cross-frontier contract panel dispatched before implementation and triaged after it (heid, four arms, artifact-only, thread 01M33VSNFER4N1554G0Y0VC9C8). v0.2.0 was already tagged and announced to fifteen handles when they landed, which is the argument for running the gate at all. DATA LOSS. `marks_for` is deliberately lenient — an unparseable `.marks.json` reads as "no marks" so a review page still loads. The write path inherited that leniency through the same reader, so one flag click appended a single entry to an empty list and atomically replaced the file: every mark in the booth gone, silently, from a click. Reproduced first, then fixed. The fix is an asymmetry, not a retreat from leniency. Reads stay lenient; writes go strict through `_read_raw_strict`, which distinguishes bytes-present-but- unreadable from absent and valid-but-empty, and raises `MarksCorrupt`. The damaged bytes are left on disk. Routes answer 409 rather than 500 — the service is fine and the request was well-formed, the state on disk is not — and the body says what to do, because the alternative the operator reaches for otherwise is deleting the file, which is the thing being protected. The CLI says it in one line instead of a traceback. A PICK COULD NOT TARGET AN ITEM. `Mark.target` carried one, `marks_for_target` retrieved by it, and the panel already rendered "on <item>" — but `declare_pick` had no parameter for it, so no session could produce one. A question about one artifact is the whole point of the 2026-09-09 inline-placement ruling; the door was simply missing. THE IMPORTER STRANDED AN ANSWER. A stem already present as a mark was skipped wholesale. If a session had re-declared that stem through marks while the operator's choice sat in the legacy sidecar, that choice was lost permanently — reads are forbidden from looking at sidecars. The declaration is still skipped (idempotence holds) but a legacy answer is now adopted when the existing mark is an unanswered pick, and an answer made through marks is never overwritten. INV-3 NAMED A SURFACE NOTHING TESTED. All four arms converged on it: the rule protects gallery tile, zoom view and doc view; the falsifiable check covered one. The doc view was implemented and untested, so shipping it unmarked would have passed. Three tests now, one per surface. The contract carries the full triage, including two findings accepted and NOT closed: INV-2's and INV-5's checks comply in letter — openness can be re-derived without spelling the grepped pattern, and importlib inside a function defeats the AST walk. Both describe a future careless change, and the honest statement is that these checks raise the cost of drifting rather than making it impossible. Recorded rather than papered over. Also pins the three prose ambiguities the panel found, normatively and once each: what counts as open, the three distinct broken-declaration cases, and INV-6, which had named a helper that does not exist and forbidden the calls that helper must make. 253 tests. |
||
|
|
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.
|
||
|
|
ce598b3cf6 |
feat(items): one item record, so an annotation survives the zoom
The operator reported that zoomed-in images lose their annotations. That was
never a rendering bug. Three functions independently walked a booth and derived
overlapping subsets of the same facts -- build_gallery (kind, caption, blur,
doc), booth_view_file (kind, doc, image ring) and list_booths (kind counts,
cover) -- and the zoom route's subset was the smallest. Caption resolution lived
inside build_gallery's loop and nowhere else, so there was no code path by which
a caption could reach the zoom template. It was never sent.
booth/items.py is now the one truth: booth_items() returns the full record --
rel, kind, section, caption, blur, doc kind, size -- and the gallery, the zoom
view, the doc view and the index all read it. Patching view.html would have
fixed the symptom for images and left the next surface starting from the same
missing truth.
Two things fall out of the consolidation:
- the index and the booth page now agree on what an item IS. list_booths
counted every non-dot file, so an A/B pair with two caption sidecars read
as 4 items on the index and showed 2 tiles when you opened it.
- "section" (the item's subfolder) is computed and carried but nothing renders
it yet. That is deliberate: it is U7's whole input, and shipping the field
now makes U7 a template change rather than a resolver change.
Doc bodies are NOT rendered by the resolver -- the index touches every booth on
every page load, and rendering every markdown file in every booth would be the
price of that convenience. render_doc_body is a separate step for the one
surface that inlines them; an invariant test monkeypatches it to raise and
loads the index.
Verified beyond the suite, because this repo has shipped two dead controls that
every test passed: the caption was measured in a real browser at 1280x41 px,
visible, with elementFromPoint at its centre returning the caption itself.
layout-probe reports all controls hittable across index, gallery, zoom and doc.
192 tests pass (173 before, 19 new).
Contract: docs/contracts/u1_item_record.contract.md
|