Changes touching this path

  • Extract loot-codec + wasm core; de-risk the in-memory SDK boundary (#423) Slice 1 (the tracer bullet) is a multi-session build; this lands its riskiest integration — the Rust→WASM crypto/codec boundary — proven end-to-end, per the #381 "verify spikes before building" mandate. Spike result (ADR 0040): zstd-sys will not build for wasm32 (needs clang for its C), while aes-gcm/blake3/ed25519/getrandom-js do. So: - Extract `loot-codec`: a no-fs, wasm-buildable crate holding the byte format, sync-bundle codec, sealed content (AES-GCM + blake3), and the leaf types (Oid/Visibility/RepoError/ChangeNode). loot-core depends on it and re-exports every item at its original path — a pure relocation, proven by the unchanged, still-green workspace (767 tests pass). - zstd is an optional loot-codec feature (default on for the native host; off for wasm). A new zstd-free `sealed::decrypt` single-sources the AES-GCM step so native `open` and the wasm core share decrypt code; public content is inflated host-side in JS. - `loot-wasm`: wasm-bindgen exports (bundle decode, decrypt, blake3 address) over a pure native-testable `core`, plus the minimal diskless identity (generate/fromSeed/publicKey) on ed25519 directly — not loot-identity, whose OpenSSH/passphrase machinery is native-only (#383). - Golden-parity harness (loot-wasm/tests/parity.rs) runs identical assertions natively and under `wasm-pack test --node`, freezing native-computed vectors; both green. Remaining for slice 1 (next sessions): the TS package, fetch transport + client-side path-scoping, host-side zstd, and the live-relay behavior suite. Refs #423, #421. ADR 0040. 491cf780 · dbf3dbe6…
  • conflicts map stores base OID + loot resolve --tool for external 3-way merge (#400, #401) a0711731 · 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
  • SDK/WASM cross-session grant delivery: pull queues, accepting applies (#508) e0bb183e · dbf3dbe6…diff
  • bundle_codec: cap the pre-allocation, not the wire count (#614) ddb0680b · dbf3dbe6…diff
  • loot-core durable decoders cap the reservation, never the count (#620) 537c4219 · dbf3dbe6…diff
  • a change records when it was authored, so a projected commit stops reading '6 years ago' on GitHub (ADR 0043) loot changes carried no timestamp, so the git bridge fabricated one: BASE_EPOCH + generation, one second per ancestor depth from a 2020 epoch. All 526 commits sat inside seven minutes of September 2020. Earlier repairs (#626's floor, the missing-generation refusal) fixed ORDERING and never touched the absolute date, which is why this kept coming back. ADR 0028 inherited the no-timestamp constraint rather than choosing it. The real reason is upstream: a version id is blake3 over authored content, and a clock inside that hash gives two peers different ids for identical content, destroying the dedup and convergence of ADR 0001/0004. So authored_at rides the label seam ADR 0029 already cut for change_id -- covered by the finalize signature (no relay can restamp it) but never folded into the version id. The wasm golden vectors prove the separation held: FROZEN_VERSION_ID, FROZEN_OBJ_ADDR and FROZEN_SIGN are byte-identical, and only the version marker and one presence byte moved. Advisory, and never an ordering input: a self-reported clock is a claim, not evidence (ADR 0025). in_order/ids_topo, buoy and path_touch.ordinal are all untouched, the forge indexes nothing on it, and the projection floors it past every git parent so ancestry holds whoever's clock is wrong. Format v11: additive for readers (a v<=10 change decodes as None and an absent timestamp adds nothing to the signed message, so every existing signature still verifies), breaking for writers, so loot-cli and loot-forge go to 0.4.0 in lockstep. Forge migration 0004 stores it as bigint, not timestamptz -- signed data must round-trip bit-exact or pullers reject the change. Only new work gets real dates. Every commit on main predates v11, main is push-fast-forward-only, and backfilling would fabricate the very claim this replaces. Perf-Baseline: reset the change body grew one presence byte, plus eight where a timestamp is present, so bundle_bytes/store_bytes/wire_bytes step once at the v11 boundary eaa56d99 · dbf3dbe6…diff
  • the trust matrix says what each reader can actually read, and /why stops claiming the relay cannot read your code (#1130) Publishes /trust as the one canonical answer to who reads what: six readers against four tiers, with a regardless-of-tier block for path names and DAG metadata, and footnotes on the two cells whose plain answer misleads — Restricted vs the git mirror rests on ADR 0028 projection omission rather than keys, and an embargo reveal is enforced by the relay clock rather than by maths (ADR 0027, written down nowhere until now). #1089 falls out of the matrix as its ticket predicted: the /why headline was false over ordinary code, since Internal is the default and a relay reads it by design. Every other surface now cites /trust instead of answering independently, which is what let /why and /docs drift into contradicting each other. ADR 0069 records that Internal stays the default deliberately — Restricted by default would make a clone readable by nobody and empty the git projection. ADR 0068 records the at-rest design for #1138: .loot/keyring is raw oid-key pairs beside the ciphertext it opens, so the unit of exposure is the whole .loot/ directory. Carries ADR 0067 from #1132, which shared this tree uncaptured. 361b2026 · dbf3dbe6…diff
  • the alpha states what it promises: a scoped set on /install, v0.5.0-alpha.N carrying it, and no format-major freeze (ADR 0066, #1128) f85705c7 · dbf3dbe6…diff
  • the format-skew refusal tells the caller the host is behind and their repo is untouched, and scopes the redeploy to whoever operates the host (#1136) 74f719c6 · dbf3dbe6…diff
  • the FORMAT_MAJOR redeploy obligation is checked at publish time, so a bump cannot strand a host through inattention (#1152) 64a989f6 · dbf3dbe6…diff
  • the format-skew rationale states the measured position: an allowlist of one refuses a stranger before any format check, so skew changes which refusal they read, and the land verdict's relay=FAILED was the detector that was always there (#1200) 8a623333 · dbf3dbe6…diff
  • a repo open stops materializing half a million manifest entries no verb asked for: ChangeNode.tree becomes a Manifest holding either the decoded map or a proved-decodable byte range into one shared Arc, so topology decodes eagerly because reachability needs it while a change's path manifest decodes only when something reads it - which is where the cost actually was, since framing the 37.9 MB graph without building the maps takes 6.8 ms against 507 ms to build them, and that 507 ms was the bulk of the 1050 ms every verb paid, including loot whoami whose whole job is to print 93 bytes out of id.pub. Deref carries the ~270 existing node.tree read sites unchanged and Deferred is module-private, so a deferred manifest cannot exist over bytes Manifest::walk has not already refused with the same framing the eager pass uses, which is what earns an infallible &TreeManifest return rather than a Result; and an unmaterialized manifest is pinned never to read as an empty one, the rule read_graph_required already states for an absent graph file. Verbs also declare what they read now: RepoNeed rides beside CustodyNeed through the already-pub(crate) open_at_clocked_needing, so no new public door is added and the sealing door custody_lock pins by string and call-site count is untouched, with whoami, conflicts and manifest narrowing to POSITION_ONLY, defaulting to EVERYTHING everywhere else, and a withheld graph or store aborting loudly rather than reading as empty. whoami 555 to 42 ms against a 40 ms control, conflicts 557 to 41, manifest 557 to 42, log -n 5 626 to 144, status 704 to 227, lanes 4069 to 717, peak RSS for whoami 148 to 7.1 MB, and twelve verbs byte-identical against main including 414 KB of loot log. Three of this ticket's premises were wrong and are corrected rather than worked around: the keyring it named as the second cost measures 1.1 ms and is left alone, read_graph_outline did not fit because it discards the parents reachability needs, and the object-store half it called the boolean that costs a gigabyte had already landed in #1545 - while the stat cache stays unbuilt, which is the whole of what status and lanes still spend (#1536) b923abb1 · dbf3dbe6…diff
  • a move becomes a recorded fact instead of a later guess, and the premise this ticket rested on was false: #98's object reuse is keyed by PATH, so a moved path is absent from the outgoing tree at its new key, falls through to put_sealed and gets a fresh address - the same object under a different key was not a fact waiting to be read off the tree, it had to be made true by extending the reuse across the move, and everything else follows from that. The rule is that a move is recorded only when the path's sealed object survives it, so a move whose content also changed in the same capture window shares no object, records nothing and is two rows, while a move and then an edit across two captures of one change composes and reads as one row saying the content also changed. Empty content and any ambiguity are refused for the same reason the whole design exists: zero bytes equal all zero bytes, and choosing between two vanished twins would make a signed fact depend on iteration order. On the boundary the render side compares nothing at all - resolve reads keys and never an address, a visibility or a byte, pinned by running it twice over trees that agree on every key and differ on every address at three unopenable tiers, with a positive control beside it so agreement is not evidence it answers nothing. The capture side compares once: it opens a vanished object with this identity's own keys, fail-closed so an unopenable object is never read or hashed, and pairs only against an addition at an identical visibility and publication tier, with the digest living for one call, never an address, never stored and never on the wire. What a relay newly learns is that two keys held equal ciphertext, which recording a move states in plaintext anyway since tree paths are plaintext at every tier, so it is inherent to the feature rather than the ADR 0004 oracle - written into that ADR rather than left in a message. Renames ride the label seam change_id and authored_at already use: covered by the finalize signature so a relay cannot rewrite a move, never folded into the version id so two peers reaching one tree by different routes still agree on its address, which is why every existing id and signature is unchanged and a v11 store reads with no move recorded. That was tested rather than argued, against a real store built by the shipped binary, where the old move still renders as a delete and an add in the same repo the new one renders as a rename, because inferring the old one would be the equality oracle arriving through the compatibility door. Three further things were wrong and are corrected rather than worked around: ADR 0019 says an additive change bumps the minor and has never described this project, since the minor is still zero and all five additive changes took the major; plan_moves own doc comment claimed the tier is checked before the digest, three lines above code that does the lookup first, when the property actually holds by the stronger route that nothing unopenable enters the index at all; and the empty-content refusal was written on both sides of the pairing, where each made the other unprovable and removing either left the test green - a duplicated guard is not belt and braces, it is two guards neither of which can be shown to be doing anything (#1539) 3c7e029b · dbf3dbe6…diff
  • a bundle stops carrying the address it can derive from the bytes beside it, and the bump is 12 to 13 rather than the 11 to 12 the ticket names, because #1539 already took 11 to 12 on main and has never shipped - every tag through v0.4.20-deploy.2 still reads 11. Reusing 12 for a second layout was rejected on measurement rather than taste: read_version can only compare numbers, so two incompatible layouts under one major is exactly the misparse the marker exists to prevent, and pinning the constant at 12 while writing the v13 layout turns bundle-codec tests red with the decoder taking the legacy arm on bytes it wrote itself. No count is given for those reds on purpose - the experiment was run twice and answered differently the second time, which is the whole reason this repo does not write a number it has not just measured. ADR 0019's own amendment says every layout change bumps the major, and two majors between releases has precedent - v8, v9 and v10 all first shipped in v0.2.0 - so a major is not a release label. The encoder stops writing the 32-byte address and the decoder derives blake3 of nonce and ciphertext for every major; the v12-and-earlier declaration is parsed for cursor correctness and DROPPED rather than checked, which is the treatment v5 gives the v4 escrow section and v10 the v9 purge lane, and dropping is the stronger choice because a checked claim still has to decide what to do on disagreement while a discarded one cannot reach any outcome, so the compat lane cannot carry the attack either. The grant_ids lever is taken but NOT as the ticket frames it: the list really is a pure function of vis plus the world marker, and rebuilding it on decode would still be wrong, because that synthesises an authorization token from an invariant nothing enforces on a hand-built SealedObject - so it becomes a two-bit flag byte carrying exactly what the pre-v13 wire could carry, with unknown bits REFUSED rather than masked, since a silently narrowed holder list is how an object stops being published with nobody told. bundle_bytes falls 45,757 to 37,857, minus 17.27 percent, and the figure reconciles to the byte: 200 addresses at 32, plus 180 Internal objects at 8, plus 20 Restricted at 3. The ticket's own baseline of 45,753 was stale by the four bytes #1539's rename count added, and its estimate for the grant half comes in at 3.28 percent rather than 3.5 because the fixture makes every tenth file Restricted, whose holder list #521 had already reduced to a bare zero count; the address half lands at 13.99 against the ticket's 14.0. This is a safety change and not only a size one, so it is pinned on both sides: a hand-built v12 frame declaring one address over bytes that hash elsewhere must arrive at its true hash, led by a positive control because refused and impossible look identical from outside and only one is the claim, and a byte-flip sweep over a real v13 bundle - deliberately not a round trip, which passes whether or not the address is derived - asserting every decode that survives yields a key equal to the hash of the bytes read beside it. Restoring the declared address executes the attack and prints it: one bit flipped at offset 11 and the object separates from its content, ac4373d5 against ad4373d5. A golden is added for the object lane because a field REORDER passes the width test and fails only the golden, and that lane had no frozen bytes at any version. The forge's shipped-under-the-wrong-address refusal is DELETED rather than left standing, which is the riskiest edit here: decoded objs has exactly one constructor and its keys are now obj.address() by construction, so the branch is unreachable from any input, and a refusal nobody can show is doing anything would read to the next maintainer as the thing keeping mislabelled bytes out of the blob store - what actually keeps them out is one layer down and pinned there. VERDICT_CONTRACT moves with the major and no machine-output column does, which is stated in the version history so a consumer pinning contract 12 knows to move and knows the columns are identical (#1384) Perf-Baseline: reset FORMAT_MAJOR 12 to 13 orphans every recorded point; bundle_bytes 45757 to 37857 is the intended -7900 from #1384 1c22e8ad · dbf3dbe6…diff
  • packed object storage lands its READ half, and the decision that could not wait for the writer is what a pack is ALLOWED TO GROUP: membership is the address first byte, order is the address order, and the reader REFUSES both violations rather than the packer promising them. a pack adds structure over addresses and that structure sits OUTSIDE the ciphertext, so a packer grouping by grant list or visibility tier would make the pack boundary BE the set of objects a reader cannot open - an authorization-set disclosure produced by a storage layout, which no encryption below it helps. the rule is safe structurally rather than by good behaviour: addresses are blake3 over nonce and ciphertext (ADR 0004), so bucketing by addr[0] is provably uncorrelated with custody and a packer CANNOT group everything one identity can open. the stronger rule, no loose object in a packed bucket, is rejected as unimplementable, since one object arriving after a repack would make every store refused; the residual packed-or-loose bit is an arrival-order fact mtime already discloses. FORMAT_MAJOR goes 13 to 14 at the READER and not at the writer, because the number should mean written by a binary that can read packs, and deferring it to 1525 leaves a window where some binaries read packs and some do not and all report 13. what the bump buys is a named refusal instead of a silent lie: pack/ is not a 64 character hex name, so a v13 walk SKIPS it and reports every packed object MISSING, referenced by change and by path, where v14 answers UnsupportedFormat found 14 supported 13. v14 is the first major in this history that moves no wire layout at all, measured rather than argued - the two frozen parity vectors moved by their marker byte and every other frozen vector beside them is byte identical. two findings beyond the criteria. packing breaks ADR 0038 burn SILENTLY: destroy_objects_loose deletes a file, cannot reach a span in a pack, and would not fail either, the already-destroyed arm swallowing it while the caller records a tombstone over LIVE ciphertext - it now refuses, and the guard asks the DISK rather than the live index, because the index version was vacuously green with burn having removed the address before the save runs. and a packed read must re-hash where a loose read need not, since a wrong offset yields a DIFFERENT VALID object under the wrong address rather than garbage. the membership pin was vacuous on its first writing too, filtering with the same function the code bucketed with, so it asserted only that the two agreed (#1524) eb753b9f · dbf3dbe6…diff
  • review sweep 7 fix-up: loot-first now reads loot --version --json with a JSON parser, so the relay push is decided on a single JSON object whose top-level contract fits a u8 and whose top-level version is a string, in any key order and beside other fields, while a duplicated field, trailing text or a looser number refuses the push. the pusher= path now percent-encodes control characters as well as percent signs and whitespace, and workflow.md says so. land-change gains the relay=REFUSED recovery (catch the primary up, cargo build --release, check the contract loot --version --json prints against the major the landed commit declares, then loot push, and record the push output on the issue), says the refused block names both majors on a mismatch and a read failure otherwise, and stops counting the relay and branch states. afk-loop agrees that a FAILED or REFUSED land stands and owes a push, gains that push as a step, and counts the ticket done once the push is recorded on the issue. format.rs, ADR 0066 and ADR 0083 note that a land carrying a FORMAT_MAJOR bump normally comes out relay=REFUSED rather than FAILED, and the --version usage line, the print_out and dispatcher comments and a verdict test name follow. both new pins went red before the change (2 passed, 2 failed), the control-character pin went red with that arm removed (1 passed, 1 failed), and a serde_json Value reader went red on the duplicated field (1 passed, 1 failed). the workspace suite is green (#1979) ce994e1d · dbf3dbe6…diff
  • review sweep 8 fix-up: the owed relay push recovery now lives once, in a new workflow.md section, paying an owed relay push, which covers relay=FAILED and relay=REFUSED in both shells with the position on every command, the build job cap and the in-flight guard, and states one done-when: the push succeeded, or its format skew is recorded on the issue as owed to the release relay redeploy. land-change and afk-loop point at it instead of carrying their drifted copies, afk-loop refers to that item and to its sections by name rather than by step number, as AGENTS.md now does too, and its when-it-goes-wrong list gains relay=FAILED. probe_pusher now says why a report was refused, with the parser message or the version check, and keeps the pre-1776 explanation for output that does not open a JSON object, and the pusher= docs say UNKNOWN means the report was not read. the new pin and the tightened pre-1776 pin went red before the change (0 passed, 2 failed), and red again with the pre-1776 arm removed (9 passed, 1 failed) and with the pre-1776 explanation given for the new pin reports (9 passed, 1 failed). LanePeek becomes a struct and a dead unwrap_or_default becomes an expect, the 4.2 s lanes figures in heads.rs and lanes.rs are labelled as history, the PositionHalf doc credits the reorder with matching the documented load order, the ObjectIndexScan doc no longer names its CLI consumer, ADR 0028 says merge_tips reads tree_at itself, and rewrap leftovers in land-change, format.rs and CONTEXT.md are joined. the workspace suite is green (#1983) 2ee76c18 · 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.