Changes touching this path

  • ADR 0038 + CONTEXT.md: burn and the mis-seal gate - the remedy for a mis-sealed secret (#63) cc8fa9aa · dbf3dbe6…
  • Build the mis-seal gate: secret-name refusal + first-seal summary (#343) 12d2ea42 · dbf3dbe6…diff
  • Mis-seal gate covers loot edit and audited signing chokepoints (#353) e4e3a804 · dbf3dbe6…diff
  • ADR 0038: record the resolve_conflict audit + override ceremony gotcha (#369) The #353 amendment claimed seam coverage without recording the one audited signing path outside the seam: Workspace::resolve_conflict signs immediately without the mis-seal gate - safe by construction (a conflicted path exists on both parent lines, so it is never a first seal, and first-seal scoping would keep the gate silent), now stated so the amendment is not read as every-signing-path-runs-the-gate. Also records the ceremony gotcha found in #353: describe --allow-reveal alone does not complete the override - it must ride the finalizing verb (loot new --allow-reveal). Docs-only. 47cd9514 · dbf3dbe6…diff
  • Authenticate the purge lane: a purge is a signed request (#503) A wire purge carried only an oid and yielded an unauthored tombstone, and stow honored it before storing objects -- so any authenticated pusher could destroy any oid across every tenant, needing no key, only the address. ADR 0038 already called a purge event a request asking cooperating relays and peers to destroy their copy. Cooperating meant nothing: loot honored anyone. The signature now says who is asking; a per-receiver policy decides whether to honor. Peers honor registered senders and quarantine strangers; relays honor their push allowlist. Local burn is untouched and needs no key -- only propagation requires a signature, so a keyless repo burns locally and refuses to propagate, explicitly. No global entitlement predicate exists: ChangeNode.tree is a full manifest and loot duplicate copies a tree address-for-address, so authored-a-change- referencing-this-oid is satisfiable by any cloner. Both withdrawn rules have regression tests. Format 9 to 10. A v10 reader parses a legacy purge lane and drops it, so no unsigned request is honored while the rest of a v9 bundle still decodes; an older client hard-fails on v10. Destruction is structurally unreachable without verification: HonoredBurn has a private field and authorize_burn is its only constructor. Both halves are signed, with domain-separated schemes -- without tags a maroon signature re-encodes byte-for-byte as a burn tombstone at path length 31. Maroon entitlement is an exact Manifest grantor join, and a grant only records a grantor when it actually installed a key, so a crafted grant cannot plant one for content you already hold. Closes #503. 913c5cc6 · dbf3dbe6…diff
  • a forge backup is operator recovery, and a restore replays the revocation journal (#586) #586 asked whether a backup defeats a burn. It does, but not where the ticket looked, and the grilling moved the question in both directions at once. NARROWER: the #502 amendment means the forge files a content key only where `publish::published_rows` authorizes one, so an operator backup reconstitutes plaintext ONLY for content that was Published -- i.e. world-readable anyway. And `published_key` is not "the one table with no other copy": the rows come from the client's own bundle at ingest, so a re-push re-supplies them. Painful, loud, not unrecoverable. WIDER, and this is the find: burn is not the only thing a restore inverts. Three columns are monotone capability-removals that no client can push back -- `burn_tombstone`, `publication.retracted_at`, `grant_log.revoked_at`. A restore is the single operation that runs time backwards over all three. Stated once, because it is the whole ADR: A RESTORE RE-GRANTS A CAPABILITY SOMEONE DELIBERATELY TOOK AWAY. Burn is just the one with an ADR attached. So the cure is not a shorter window alone. Snapshots stay immutable at rest and the RESTORE PATH is scrubbed by construction: a key-free, content-free revocation journal, exported continuously and replayed before the forge binds a port. The window (7 days, enforced by an R2 lifecycle rule rather than by a prune script that can fail silently) then bounds media at rest only, never a running system. The dump is `age`-encrypted to a key the VPS does not hold, because `tree_entry.path` and `path_touch.path` are PLAINTEXT for every tier -- #610's barrier views protect the live database and protect a dump not at all. The journal is deliberately NOT encrypted: putting the one unrecoverable artifact behind a losable key buys nothing. R2 gets no mirror. The realistic threat to blobs is #493's own unbuilt reaper, which that ticket says of itself deletes live data unrecoverably when it drifts -- so the reaper reaps into a `reaped/` prefix under the same 7 days, and burn keeps deleting outright. Two paths remove blobs; only one may be recoverable. Weekly automated restore-verification is rejected as SELF-DEFEATING rather than expensive: automating decryption puts the private key back on the VPS. The one scoping correction: #586 said the work lands beside `setup-forge.js`. That holds for the machinery. The honesty half -- burn's third tier and the retention window advertised on `/info` -- lands in loot proper, so this is two build tickets in two repos. Decision only; nothing here is built. ADR 0046, three CONTEXT.md entries, a third tier marked into ADR 0038 in place, and forge-datastores.md's closing "still unspecified" line replaced with a plan that says out loud it is a plan until the drill's negative has been run. 79fa0586 · dbf3dbe6…diff
  • the mis-seal gate stops missing the suffix that names a secret: *.env joins .env*, and the tier it has always watched is called Internal in the four places that still said Public (#1108) 72176935 · dbf3dbe6…diff
  • the docs gate themselves: a land refuses a link that resolves to nothing, five rotted ADR cross-links are fixed, every amended ADR warns in its status, and the artifact sweep retires with its producer 586e3260 · dbf3dbe6…diff
  • a seal to a holder list that excludes its author refuses and names what nobody could ever open, --allow-lockout is the deliberate way through, and ADR 0038 records why the gate refuses rather than filing a key for the author (#1249) bbb858b0 · dbf3dbe6…diff
  • a visibility spec loot cannot read stops being dropped in silence: the parser records the malformed line and seal_gate refuses to capture over it, naming the line, the spec and the legal forms, so a typo can no longer ship the path it was written to seal at the tier a relay reads 28ff6a3e · dbf3dbe6…diff
  • a retired key no longer burns what it introduced on the forge: purge::honor_burn now asks account_of about the burner and applies the require_pusher rule with the introducer as owner, so a key with no account row burns by the byte compare as before, a live key burns as the introducer or as a key of the account that holds the introducer, and a retired key is refused, as the new RetiredKey verdict where the live rule would admit it and as NotEntitled otherwise. same_live_account becomes same_account and no longer asks liveness. the maroon arm still asks no retirement, since it is the only way to revoke a forge grant and has no account lane, and the relay and peer policies read no account tier, so none of them moves. a new server.rs test asserts that the burn verdict agrees with require_pusher over the #927 cast plus a retired stranger, and member_class.rs runs the rule against real account_key rows. before the change the in-memory reproduction went red (11 passed, 1 failed) and member_class against a throwaway cluster went red (3 passed, 1 failed). over the purge tests and the server.rs agreement tests the pins went red with the retirement check removed (13 passed, 4 failed), with the introducer compare answering first (14 passed, 3 failed), with an accountless burner refused (15 passed, 2 failed), with retirement asked before entitlement (15 passed, 2 failed) and with the tenant equality dropped (14 passed, 3 failed), and member_class went red under the first three (3 passed, 1 failed each). ADR 0038 and ADR 0077 are amended, CONTEXT.md, store.rs and server.rs name honor_burn, and the Rotate card on the account page no longer says burning is not covered and names the maroon limit instead. the site gate is green, the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (#2004) e6641d95 · dbf3dbe6…diff
  • a .lootattributes rule whose pattern ends in a separator refuses the capture instead of sealing its subtree internal, and the decision is recorded with the widening it declines. the trailing-separator subtree affordance is .lootignore dialect - build/ prunes there, and #1859 repaired it there - while an attributes pattern is compared against the whole relative path, which never ends in a separator, so docs/ restricted=connor matched NO path and every path under docs/ fell through to Internal, the ANYONE-granted tier a relay reads by design. reproduced through the spawned binary first, which is what the ticket asked: loot new sealed docs/plan.md internal at exit 0 with one first-seal-summary line as the only signal, the #1108 shape exactly, and loot explain said the rule was tried and none matched. the ticket offered two answers and they are not equally safe, so the rejected one is measured rather than argued: reading the separator as .lootignore does MOVES what a path resolves to, and first-match-wins means not always narrower - with it honoured, docs/ internal above docs/secret/** restricted=connor resolves docs/secret/keys.md internal, and a_recorded_inert_pattern_moves_no_paths_tier goes red (27 passed, 4 failed over the file). silent exactly where the mis-seal gate is blind, a first seal of a basename that is not secret-shaped; an already-recorded path would trip the demotion guard instead, loud and just as unasked-for; either way the operator file would mean something new because the binary changed. so the line is RECORDED, not re-read: Attributes::parse keeps every rule exactly as it always parsed it, so no path changes tier and ferry ingest, which reads the rules and never the faults, is untouched, and the line becomes a MalformedRule that Workspace::malformed_attrs_gate already raises RepoError::MalformedAttrs over - one variant and one slug for both faults, because the taxonomy grows where a driver must act differently (#1582) and the tree one consumer of the slug does not, so each fault renders its own remedy beside its own line rather than one sentence teaching a reader the mistake they did not make. read off the compiled glob, so the docs-backslash spelling a Windows shell completes is the same fault and is quoted in the spelling it is compared in, the one loot explain prints for that line (#1859). scoped to the trailing separator and not to a pattern that matches nothing, since a bare docs internal is a fine rule for a file named docs. VISIBILITY blast radius measured rather than accepted: every .lootattributes in the estate - this repo and its lanes, scripts, millerbyte, the test and perf-scratch repos - plus every recorded revision of this repo own, holds no trailing-separator pattern, so no position next capture refuses and no repo changes tier, and the_shipped_attributes_holds_no_rule_the_gate_refuses keeps the shipped file that way. mutations: the recording dropped reddens the unit pins, the seam pin and the spawned pin (28 passed, 3 failed; 4 passed, 1 failed; 0 passed, 1 failed, the last naming the capture that succeeded); the separator honoured instead reddens four including the no-widening pin; a docs/ line added to the shipped .lootattributes reddens its census (0 passed, 1 failed). the path compared never ending in a separator is measured too, since the argv door rebase strips one and loot explain docs/ asks about docs. ADR 0038 carries the #2030 amendment and CONTEXT.md both halves, the attributes refusal and the .lootignore entry stating its affordance does not reach the other file. no migration, and no forge, relay or site byte moves, so this owes no deploy. the workspace suite is green (3859 passed over 119 binaries, 7 ignored) (#2030) 3e849712 · dbf3dbe6…diff
  • review sweep 17 fix-up: the separator-rule census checks what its name claims now and reads every caller of a pattern door, two more callers ask policy::unix_separators instead of answering the separator by hand, and the one class of path whose protection depends on that answer is pinned rather than lucky. clean::Sieve::keep spelled the replacement itself and compared the key against the recorded store keys and against an Ignore built from operator-typed -e values, the #1859 class in a third file at a verb that DELETES what it declines to protect; ferry::ingest_commit did the same for the .lootignore and .lootattributes it reads out of a git commit, a fourth home the ticket did not name and one that is inert because git hands over /-separated paths. both ask the normalizer now, whose body is the expression each replaced, so no path moves - measured through the spawned binary before and after on a scratch repo over ten invocations, -x, -X, the default, three backslash-spelled -e values with their slash twins, and a --force run with the tree it left, whose transcripts are byte-identical. what was NOT pinned is the half that matters: the protected set holds /-separated store keys while the walk hands keep a native rel, so only a recorded path with a separator in it can catch a key nobody normalized, and the clean fixture held none - sub/recorded-deep.txt is in it now, and let key = rel.to_string() reddens four cases over that file, the removal matrix among them, having taken a recorded path as a candidate (10 passed, 4 failed), where the whole file was green under that same mutation before. the census is renamed and derived on both halves rather than trusted: the files are every .rs under this crate src, production halves only, and the doors are read off policy.rs, a function there taking rel or path as a &str, so a new door joins by existing; an offence is the pair per function rather than the spelling alone, because a backslash pass on the way to a display line or a map key is the other axis and a spelling-only census would have to name its exceptions. mutations: the hand-spelling put back reddens it naming src/clean.rs fn keep, and put back in ferry naming src/ferry.rs fn ingest_commit (0 passed, 1 failed each); the door reader blinded reddens its anchors; the needle blinded reddens the fixture (left empty, right the one site); and the floor call dropped reddens source_walk own consumer census, whose derived count line this land repastes with the new consumer. #1859 AC4 is measured rather than argued, as #2028 did: from another module of the crate, Patterns and Pattern literals are E0451 on their fields and reaching for .list is E0616, throwaway probes, and the doc says privacy is what holds the pair and that a literal inside the module is not closed by it. a_recorded_inert_pattern_moves_no_paths_tier runs its claim over the paths its own rule list mints, with the inert line and without it, over the tier and over publish-ness, and the separator honoured reddens it naming a derived probe (27 passed, 4 failed); ADR 0038 and CONTEXT.md stop citing a three-path sample for no path changes tier and rest that on the parse. ADR 0038 and view.rs stop rating the .loot/view gap oppositely: closed on the tier axis, and the cost is materialization, which view.rs holds and the ADR now points at. conformance.rs stops resting its counted pair on a comment and points at ClassReads, where every other MetadataStore method is unreachable and the trait defaults none, so a third read in AccessClass::of panics there instead of undercounting here - measured, it does (0 passed, 1 failed). propose::visible_to and Viewer are pub(crate), with the field privacy named as what holds the pair and the in-module literal named as what it does not. seven string literals in cli_smoke.rs that carried a raw newline where the file spells the escape are fixed, five more than the ticket named, from a scan of the whole file. no migration, and no forge behaviour moves - a visibility narrowing, doc prose and one test fixture - so this owes no deploy. the live loot-forge suite is green against a throwaway 17.0 cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (476 passed over nine binaries, no skip note) and the workspace suite is green (3859 passed over 120 binaries, 7 ignored) (#2033) 4720219d · dbf3dbe6…diff
  • two concurrent key rotations can no longer leave an account with no live key, which is a lockout because a key that is not live is refused the sync read, the push and the burn: the floor in site/src/server/identity/identity.ts is a count, the BEGIN it runs inside is the postgres default READ COMMITTED, and two registrations that each retire the key the other leaves live write different rows, wait for nothing, each count the other as live and both commit. registerKey now selects the key rows of that account in account_key under FOR UPDATE, ordered so two waiters take them in the same order, before it writes any of them, so the second registration waits and then counts a state that has stopped moving; and identity/db.ts stops rewrapping a refusal raised inside a transaction as a plain Error, which had been answering every one of them with the anonymous 500 rather than the 422 that carries the sentence the account page renders. the ticket preferred a database constraint and that was the right thing to prefer, but a floor of this kind says a row must EXIST, which a declarative postgres constraint does not say, and the trigger that would say it was measured failing on a throwaway cluster: with a DEFERRABLE INITIALLY DEFERRED constraint trigger counting live rows over a table shaped like account_key, two sessions each retiring the row the other left live both committed and zero live rows were left, because a deferred trigger fires at commit and still reads its own snapshot. what closes the race is a lock either way, so it is taken where it needs no migration, the forge release chain no-migration-since-v0.4.20 state stands, and this owes a site deploy and no forge deploy. red first against a throwaway postgres with the counts read: with the locking clause dropped the new concurrent test ends with the account on zero live keys and neither session refused (1 failed and 10 passed), with the refusal rewrapped the two class pins go red (2 failed and 9 passed), and with the pause keyed on a statement that never matches it refuses on zero arrivals rather than passing vacuously (1 failed and 10 passed). ADR 0038 records the decision beside the #2004 amendment whose argument rests on this invariant, and names what the lock does not cover. bash ci/local.sh is green against a postgres 18 it created (3977 passed over 122 binaries, 7 ignored, and 67 passed over the seven site live files with none skipped) and the site gate is green end to end (668 passed and 62 skipped over 61 files, 62 surfaces) with no ceiling moved (#2015) 18aca67b · dbf3dbe6…diff
  • the rename census gives each rename its own row and reads a function header as a qualifier run, so a second rename written into a function it already names arrives red: store_rename_census.rs keyed every hit as path plus enclosing fn into a set, so the twelve fs::rename sites under crates/*/src collapsed into nine rows with three of them carrying two renames each, and the property the file states, that a new rename arrives red until somebody says which end of it can be held, was already false at rows the file itself holds, while the section saying what it cannot catch did not name that limit. the key is now the file, the fn and which rename it is under that name in source order, and not the line number, because a line moves under any edit above it and the repair for that is retyping a digit in the table, which is the habit the file exists to refuse. a header is now a run of qualifier-shaped tokens and then fn, a bare word or a pub restriction or an extern abi string read as shapes rather than as the four visibility prefixes it held, so async fn, const fn, unsafe fn and extern fn key to themselves instead of to the function above them; no rename in the tree sits behind such a qualifier today, so a fixture over sample lines carries that half rather than the walk. proved as the ticket asked, with a thirteenth rename added inside a function the census already names: green before the change (2 passed and 0 failed) and red after it on the extra row (2 passed and 1 failed), and the fixture goes red under its own mutations, the ordinal dropped from the key (0 passed and 1 failed) and the visibility-prefix header reading restored (0 passed and 1 failed). sentences wider than their code are narrowed where a reader meets them: identity.ts and the ADR 0038 amendment said the property is that every write happens under the account lock, where a lock over an account with no key rows holds nothing, so two concurrent first registrations do not wait for each other and are safe for a different reason, neither retiring anything and a duplicate meeting the pubkey primary key; docs.$.tsx said a status added to DocStatus later renders no body, where it takes the written path and renders whatever is filed under that slug, blank only because the content gate refuses a body filed against a page the registry does not call written; and that gate now keys its filed set on what react renders rather than on nullishness, so a body of false or the empty string no longer counts as filed, pinned by a fixture that is red under the old reading (1 failed and 8 passed). type-program.test.ts seeds its import walk recursively, so a test moved into a subdirectory stays in the program it reads, measured at 62 files flat and 63 deep with one moved down. no migration, no wire or format byte moves, and the site half is comments and tests, so this owes no deploy. the workspace suite is green (4001 passed over 124 binaries, 7 ignored) and the site gate is green end to end (669 passed and 62 skipped over 61 files, 62 surfaces, no ceiling moved) (#2117) 0c90a730 · dbf3dbe6…diff
  • a .env in the middle of a basename is secret-shaped too, and the backup written beside a sealed credential file is refused rather than sealed Internal: #1108 widened the mis-seal gate to the .env suffix and recorded one boundary as deliberately left open, that <name>.env.<suffix> answered to neither affix, on the argument that closing it with *.env.* would swallow the committed-template convention; that was an argument and not a measurement, so this measures it first. over every git-tracked basename in every git repo directly under this estate, 25 repos and 12,732 paths on 2026-09-20, the arm newly catches one path, the scripts repo .setup.env.example, and nothing else, which is the template class #1108 feared and also the one shape the arm cannot be narrowed away from by name, since a template and a backup differ only by the suffix the operator typed and a suffix allowlist would be a hand-maintained list of exactly the kind AGENTS.md names, guarding a credential. what bounds even that catch is the gate scoping rather than the pattern, first seal and Internal-by-fallthrough only, so a path already in the anchor or named by a rule of its own never trips and the template costs one .lootattributes line, once, against a false negative that loot burn can only bound. the case is this estate again: scripts seals .setup.env by exact basename, its gitignore covers the whole family since loot#1643, and a .setup.env.bak-v0420 sits in that working tree named by neither of the two loot policy files. reproduced through the spawned binary in a throwaway repo before and after, a .setup.env.bak-v0419 sibling beside a .setup.env restricted=connor rule captured and sealed internal with no refusal on 0.4.22 and refused by name on the lane binary, with one rule reaching the sibling sealing it restricted instead. ADR 0069 carries the measurement table and ADR 0038 section 1 closes the boundary it had recorded as open. red under mutation, counts read each time: the arm removed (0 passed and 2 failed, the gate handing back the old Ok with the sibling Internal), the arm widened to a boundary-free env substring (0 passed and 2 failed, on the negative controls both here and in the suffix pin), the refusal condition stripped of its tier and fallthrough halves so the sealed original is named beside the sibling (0 passed and 1 failed), and the arm narrowed to a suffix allowlist that spares the template (0 passed and 1 failed). the scripts .lootattributes half of the ticket is untouched and stays with the operator, being a visibility rule in another repo. no migration, no wire or format byte moves and no host behaviour moves, so this owes no deploy. the workspace suite is green (4051 passed over 128 binaries, 8 ignored) (#1930) 85479732 · dbf3dbe6…diff

Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.