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… - TS SDK slice 2: author & push a signed public change (#424)
The write half of the in-memory loop: a pre-registered key edits files and
pushes a signed full-tree change to the relay, read back to confirm. All
composition (change-id fold, dual signatures, bundle encode, /stow
envelope) stays in Rust/WASM (#381); TS owns only the capture-first overlay.
Prefactor:
- Move the change-id fold into loot-codec (change_id module:
compute_change_id_raw / change_signing_message / mint_change_id /
canonical_predecessors); loot-core's compute_change_id(&Change) delegates.
Behavior-preserving (loot-core 296 tests unchanged).
- loot-codec: seal_uncompressed (the wasm author path can't run zstd).
- loot-identity: public Identity::from_seed(&[u8;32]) (the flagged #383/#424
ctor, native side).
loot-wasm:
- Identity.sign + wrapEnvelope ([0x01][pubkey][sig][bundle], sig over bundle).
- ChangeBuilder: carry(unchanged) / put(edited, seals uncompressed public) /
finish() -> {envelope, changeId, versionId} — folds, signs the finalize
message, encodes the Sync frame, wraps the envelope, all in Rust.
- Golden parity (Seam 2) extended: frozen pubkey, signature, envelope bytes,
and change-id fold — green natively and under wasm-pack test --node.
sdk/:
- Capture-first overlay: edit/remove mutate an in-RAM overlay that IS the
pending change; describe names it; status/diff report it; push composes via
ChangeBuilder (carry unchanged paths, put edited, skip removed) and POSTs
the envelope to /stow, returning the durable change-id.
- Seam 1: a write behavior suite drives a real relay whose allow-list holds
the SDK key; the SDK authors the first change and reads it back through a
fresh connection (4 tests). Full SDK suite: 9 green.
Slice 2 authors PUBLIC content stored UNCOMPRESSED (valid + readable; zstd's C
won't build for wasm, and fzstd is decompress-only). Deferred: unauthorized
error mapping (slice 3), private/grant writes (slice 4).
Refs #424, #421.
2defc2f5 · dbf3dbe6…diff - Slice 4 (#426): author private (sealed) content — visibility + guards
Adds the authorship/sealing side of private content to the in-memory TS SDK,
plus same-session read-back. Cross-session grant delivery stays deferred (#383).
ECIES to wasm (single-sourced, no drift): move the key_seal composition (ECDH
over X25519 + ChaCha20-Poly1305, the "loot grant key wrap 2024" KDF, ed25519→
x25519 derivation, 80-byte wire format) into the wasm-buildable loot-codec.
loot-identity::key_seal now delegates to it, preserving its IdentityError surface
so loot-cli/loot-net callers are untouched. loot-wasm's Identity gains
x25519PublicKey / sealKeyToSelf / unsealKey.
ChangeBuilder::put now seals Restricted content: the content key is ECIES-wrapped
to the author (never rides in the bundle), so the relay stores only ciphertext.
AuthoredChange exposes the private grants (oid → wrapped key) for the SDK's RAM
keyring; read() unwraps via the wasm identity to read own content back.
SDK guard model: edit(path, bytes, { visibility }) inherits the path's current
visibility (new path → public); describe/push take { allowDemote, allowReveal }
and a visibility change without the matching guard is refused (GuardError).
list()/read() report each entry's visibility.
Seams: parity.rs freezes the deterministic x25519 pubkey + a native ECIES
wrapped-key vector and asserts native AND wasm unseal it (round-trip + wrong-
identity refusal). private.behavior.test.ts drives sealing-stores-ciphertext,
same-session read-back, keyless-reader refusal, and the guard cases.
ac8b8ef5 · dbf3dbe6…diff - CLI: machine error channel — coded CliError + RepoError::code() under --json (#430)
Give the binary a machine error channel so its error taxonomy travels as data
instead of the SDK regex-matching stderr prose. RepoError::code() (in loot-codec)
maps each of the 10 variants to a stable slug and is the source of truth. A new
CliError { code, message } (loot-cli/src/error.rs) carries it: From<RepoError>
(code from the variant), From<String>/&str (code "error"), and to_json() emitting
{"contract":N,"error":{"code","message"}}. Verbs now return Result<_, CliError>;
the dispatcher's fail() emits the JSON error object to stderr under --json and the
byte-for-byte-unchanged `loot: <message>` otherwise. CLI-level failures carry
codes: no_repo (open_repo) and unknown_flag (the flag gate).
Scope note: Workspace already stringifies RepoError internally (its methods return
Result<_, String>), so no live verb path yet propagates a typed RepoError to the
dispatcher — engine failures emit code "error" (message intact) while no_repo and
unknown_flag flow as real codes. From<RepoError> is in place and unit-tested;
threading typed engine errors through Workspace is a larger follow-up.
cargo test --workspace green.
97b9becb · dbf3dbe6…diff - Rename the Public visibility tier to Internal (ADR 0041 §2, #480)
6969626d · dbf3dbe6…diff - Publish mechanism: the `published` keyword + @world grantee (ADR 0041, #481)
a6758352 · 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 - 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 - a lineage catch-up brings the objects and keys its nodes reference, and a ferry that dies mid-projection names the oid in hex and the recovery (#778)
The filed hypothesis was that a change node can become visible before the
objects it references are durable. It is wrong: save_to already writes objects
before the graph that names them, and that order is deliberate and commented.
The real defect needs no race at all. The object store and the keyring are
loaded exactly once, at load_from, while the shared graph is deliberately
re-read mid-process by ingest_shared_lineage (the 265 catch-up primitive). So
any peer that finalizes after this process opened contributes a node whose
bytes and keys this process will never hold. A land guarantees a long window
there: it opens the workspace, runs the pre-land tests, and only then ferries,
so the ferry aborts mid-projection while holding the harbor lock.
ingest_shared_lineage now refreshes objects and custody after it re-reads the
graph, and load_from reads in the reverse of save_to write order, so a reader
never blends a newer graph with an older object scan.
Also: an Oid renders as hex instead of a 32-element decimal byte array (Debug
full width, error strings short), and a mid-ferry abort now names its recovery
with flags checked against the verbs that parse them. hex moved from loot-core
to loot-codec because RepoError is defined in loot-codec and the dependency
runs one way only; loot-core re-exports it, so every call site is unchanged.
0ba6d577 · dbf3dbe6…diff - the store spells a path one way: tree keys go slash-separated at every boundary, and a windows store loads nested on posix (#988)
d2889f60 · dbf3dbe6…diff - an empty holder list means unknown, not nobody: a pulled Restricted path stops reading as a permanent local edit, and one engine rule answers for every content comparison (#1005)
65b6946c · 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 - custody at rest seals to your own key behind an optional passphrase, unlock is a session file or an env var, and no non-interactive caller starts prompting (#1138)
c8ffee3b · 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 - the keyring and escrow share one codec so a custody decision is written once, the locked refusal has one home instead of five drifted ones, and the terminal predicates each say why they differ (#1174)
33aca9d9 · dbf3dbe6…diff - a wrong LOOT_PASSPHRASE stops shutting the locked-pull door it was never a key for, the one verb a locked repo runs names the session file it refused, and ADR 0068 stops contradicting itself about its own cryptography and its own amendments (#1231)
ddcf499d · 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 publication marker that could not be read stops being carried as an unpublished one, because the three re-seals that carry @world across a rewrite now ask a read that refuses instead of a predicate whose swallow was argued for the publish guard alone, and the refusal is a typed variant rather than prose so a corrupt seal reaches the run-loot-verify remedy a vanished one already did, while ADR 0012 table rows name their consumer and state the grep the census is the output of (#1582)
6ee38984 · dbf3dbe6…diff - a named landmark stops needing a format major to carry its message, because a landmark IS an attestation under a reserved role envelope the signature already covers — and that is also what keeps a retirement from eating the thing it retires, since the attestation log is keyed by (change, attester, role) and writing both under one role made the retirement silently REPLACE the attestation rather than record beside it (#1508)
2d06014f · 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 - loot notes lands as a SECOND WRITER ON THE ATTESTATION LANE rather than as a record type of its own, so AC3 and AC6 arrive FREE and the wire does not move: a note is an attestation carrying a reserved role, the sibling of #1508 landmark record in the namespace the TAG field was shaped to admit, so every shipped reader parses the bytes, FORMAT_MAJOR does not move and NO RELAY OR FORGE REDEPLOY IS OWED. the visibility rule is the decision and it is DERIVED rather than chosen: the attestation lane carries no key and no grant list and rides the bundle beside the change it names, so a note audience is EVERY HOLDER OF THE CHANGE including a relay holding no content key - on a wholly-Internal change that audience can already open every byte, so no text ABOUT it can tell them anything new, and that implication FAILS THE INSTANT one path is Restricted or Embargoed. so the verb REFUSES on a change recording any path sealed narrower than Internal and names the paths, while Published PASSES because it is Internal plus world, a WIDER audience. inherit was rejected on its own terms - a Change HAS NO SINGLE VISIBILITY, that is the founding sentence of the glossary, so inheriting means a key granted to the INTERSECTION of holders which goes stale on every grant and maroon - and own-visibility was rejected because it is the only option that can OVER-disclose, making the safe answer an operator choice every time on an append-only already-delivered lane. an allow-leak hatch was declined because it would consent to nothing enforceable. the gate is the unconditional FIRST STATEMENT of the write path above a match exhaustive over the three writing ops, so a fourth does not COMPILE until it has an arm, and the limit is stated rather than implied: the check is at WRITE time, a path demoted afterwards leaves the note standing, and burn is the verb for that. seventeen mutations, each red with the count read, and both risky pins carry POSITIVE CONTROLS - the version-id pin mints a real version three lines below the assertion that notes did not, and the visibility pin has a peer who provably CANNOT open the sealed object reading the note in the clear beside it, each half the control for the other. a smoke run caught a defect this change had introduced rather than inherited: a multi-line note broke the attested-by line at column zero, so the cut is made AT THE DOOR, which covers a multi-line tag message that was latent and never exercised (#1519)
0ec3069f · 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 - the attestation lane has one machine row, and it says whether the listing is raw or folded. loot manifest and loot notes show gain --porcelain/--json through loot_cli::attestation_shape, one row for a lane record spelled once over the record rather than per verb: the change and the attester key as full hex, a closed kind word (role, landmark-note, landmark-retire, note, reserved), a note generation, an open flag set (removed, unprintable) and the payload last, marked R for manifest listing every record as held and F for notes show listing the trusted live register, with folded in --json; manifest also emits its grant book as G rows ahead of them, by the absorb precedent of #1837. what a role carries is read through a new loot_codec::reserved::ReservedRecord::parse that reads the envelope tag once, and render::role_display now matches on it without a wildcard instead of trying the landmark parser and then the note parser - the door #1850 asks for, built only as far as this row needs; a scratch variant fails to compile at both matches. only the notes show leaf takes the flags, and the writing leaves refuse them naming it. the prose of both verbs is byte-identical, measured against the v0.4.20 primary binary on one fixture, where both verbs refused a machine flag with unknown_flag, so no frozen row moved. PROSE_ONLY_CEILING comes down 53 to 51 as the #1519 entry paid, ADR 0066 tiers move 28 to 30 and 52 to 50 with the #1519 amendment marked paid, ADR 0023 gains the amendment recording the rows, flags and JSON fields, and CONTEXT.md the Attestation entry. red first, four ways: every row marked R reddens the shape pin, the notes pin and the spawned pin (6 passed, 2 failed; 2 passed, 1 failed); notes show building its rows from the raw lane reddens the notes pin and the spawned pin (7 passed, 1 failed; 2 passed, 1 failed); manifest dropping its grant book reddens the manifest pin (7 passed, 1 failed); and the door misreading the note tag reddens the codec pin (4 passed, 1 failed), five cli pins (4 passed, 5 failed) and two spawned pins (1 passed, 2 failed). no migration, no wire or format byte moves, and no forge or relay byte moves, so this owes no deploy. the workspace suite is green (3911 passed over 123 binaries, 7 ignored) (#1846)
5f1a824d · dbf3dbe6…diff - the receiver wants walk reads each address off its manifest frames instead of decoding the manifest, so locally_missing_objects no longer materializes the deferred manifest of each change (#1536) to keep only its addresses. Manifest::addresses steps the frames with the non-allocating twins Manifest::walk already uses, and does so only when the deferring walk recorded counts, the condition tier_counts already rests on for frames and map entries to correspond one to one; a legacy separator manifest or an already decoded one answers from its map. the ceiling was re-taken on the landing position first: 17.96 to 17.99 ms at width 200 and depth 128, against 0.026 ms with the walk over the manifests deleted. paired --missing readings, one loot-perf-gate --features count built per arm, three interleaved rounds, the gate reading load idle at 3 to 9 percent: 17.93 to 18.29 ms down to 0.905 to 0.913 ms at width 200, and 96.13 to 96.34 ms down to 5.70 to 5.89 ms at width 800. along depth at width 200, 1.11 / 4.47 / 17.96 / 72.2 ms down to 0.050 / 0.20 / 0.86 / 3.69 ms at depths 8 / 32 / 128 / 512, still linear in depth with the slope fallen, and one walk at 200 x 128 allocates 25 times against 60,569. the gated counters do not move, object_gets 743 and object_disk_reads 200 on both arms. what it gives up is the decode memo: a later walk in the same process steps the frames again, 0.71 to 0.73 ms against 0.60 to 0.62 ms at 200 x 128. walks of the same shape it did not measure still decode. red under mutation, counts read each time: the walk put back on c.tree.values() (1 passed and 1 failed), the frame arm never taken (0 and 2), the collapse guard dropped (1 and 1), the visibility stepped as one byte (1 and 1), each restored to 2 passed. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4265 passed over 135 binaries, 10 ignored) (#2240)
0c212874 · dbf3dbe6…diff - Manifest::addresses reads the frames only after a step over them shows the stored keys strictly ascend in Path order, so a manifest whose frames are not its map, two keys naming one path or keys out of the map order, answers from the map, and a pull over a corrupt local graph file no longer asks for an address the map dropped. tier_counts keeps its backslash test, now documented as not a proof, and the a.txt and a/b ordering it cites is corrected to component order in both places. what the check costs, paired --missing readings, one loot-perf-gate --features count per arm, eight interleaved rounds, load idle at 2 to 10 percent: 0.875 to 0.924 ms without it against 2.09 to 2.18 ms with it at width 200, and 5.76 to 5.88 against 10.50 to 10.63 ms at width 800, where a decode arm read 18.75 to 19.13 and 100.0 to 100.8 ms, so about 9x against the decode where it was about 21x and 17x; the Path comparison is most of it. red under mutation, counts read each time: the check removed, compared by byte, not strict, byte equality only and path equality only (1 passed and 1 failed each), restored to 2 passed. riding along: store.rs and ADR 0075 say the ingest transaction still reads for checks of its own and that the landing decision is what reads nothing inside it, that a push with no proposal open pays 0.28 to 0.43 ms where #2177 asked for nothing measurable, and that the fallback decision rests on one repo size; finish_stage says why any refusal is answered by the address on disk; the dated test counts in format_skew_gate.rs and workflow.md say at the time; two rewraps; the calls.rs runner stubs use the file imports. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4274 passed over 135 binaries, 10 ignored) (#2273)
0a64d280 · dbf3dbe6…diff - the graph rewrite a finalize pays reads the graph file back deferred and copies each manifest stored frames where Manifest::frames proves them to be what encoding its map writes (no backslash key, keys strictly ascending in Path order, UTF-8 holder names), and falls back to the eager read otherwise, so save_to no longer decodes every path of every change in history to add one change. the new --graph-rewrite opt-in half times that whole save at 200 paths; paired, three interleaved rounds, the gate reading load idle at 1 to 4 percent: 48.18 to 48.48 ms down to 12.65 to 12.72 ms at depth 256, 183.43 to 183.49 down to 39.14 to 39.47 ms at 1024, and 750.3 to 759.4 down to 179.5 to 182.6 ms at 4096. counted by the new graph_rewrite_allocations test, the save at depth 1024 allocates 6,039 and 6,061 times on two runs against 695,198. the file written is byte-identical to the decoding rewrite, pinned over every golden and a lone y//ab key (copied), the legacy separator and the #2273 shapes (not copied, rebuilt from the eager read), and a key or holder name that is not UTF-8 (not copied, and the save still refuses it). red under mutation, counts read each time: the eager read put back (1 passed and 1 failed), the holder check dropped, the ascent check dropped and the eager fallback dropped (1 and 1 each), the counts check dropped (0 and 2), restored to 2 passed each. riding along: HUNT-PERF names the new half, and the visibility census names the moved and new bindings. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4278 passed over 137 binaries, 11 ignored) (#2280)
e7d666e0 · dbf3dbe6…diff - closure_complete, the walk the implicit-capture door takes in front of each bare mutating verb, reads each ancestor address off its manifest frames through Manifest::addresses, the spelling locally_missing_objects has used since #2240, where it decoded each ancestor manifest to keep only the addresses; a manifest whose frames are not provably its map, a legacy separator key or the #2273 shapes, still answers from the map. paired --closure readings at depth 1024, one loot-perf-gate --features count built per arm, interleaved, the gate reading load idle at 1 to 4 percent (peak 7): 166.1 to 167.9 ms down to 19.6 to 20.0 ms at width 200 over three rounds, and 382.7 to 383.2 down to 42.8 to 42.9 ms at width 400 over two, the gated counters the same on both arms. two new pins: a reopened repo answers both verdicts decoding no manifest, and over canonical, nested, legacy separator and the three #2273 shapes, with no hole and with each object removed, the deferred and the pre-decoded answers equal a walk over the decoded manifests, with a control that each collapsing shape reached the arm where frames and map disagree. red under mutation, counts read each time: the walk put back on values (0 passed and 2 failed), the ascent check dropped from Manifest::addresses (1 and 1, on the answer itself), the frame arm never taken (0 and 2), restored to 2 passed; the backslash condition dropped alone stays green on Windows, where Path reads a backslash as a separator and the ascent check catches the collapse, which the test doc records. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4280 passed over 137 binaries, 11 ignored) (#2281)
a2fe24af · dbf3dbe6…diff - LineagePool::read, the one read of the shared graph file an adopt or ferry pass catches up from, reads it through read_graph_deferred, the door #2280 added, and leaves every manifest undecoded, so an ingest no longer decodes every path of every change in history to splice nodes whose manifests it never reads; before moving any node the splice asks the new Manifest::decodes, which steps the frames for a key or holder name that is not UTF-8, and refuses the ingest naming the change when a node it would splice holds one, which the eager read refused, so the #2275 panic gains no new route. paired --ingest readings, one loot-perf-gate --features count built per arm, interleaved, the gate reading load idle at 1 to 6 percent (peak 9): 162.7 to 164.6 ms down to 12.04 to 12.39 ms at depth 1024 and 331.4 to 331.5 down to 26.9 to 27.6 ms at 2048, the check costing 0.2 to 0.7 ms at 1024; counted at depth 128 and width 200 the region allocates 1,057 times against 61,604, the read band 279 against 60,826 and the splice band 778 on both arms. the memory trade the splice comment left open is measured and taken: over a copy of this repo graph file (85.7 MB) the deferred read peaks at 171.3 MB against 308.5 and holds 87.9 against 222.2 while the pool lives, and once the pool drops a splice of ten nodes keeps the 85.7 MB buffer where the eager read kept 1.2 MB, one buffer per read that splices, for the rest of the verb. two new pins: over canonical, nested, legacy separator and the three #2273 shapes a spliced node stays undecoded and answers the eager read tree, addresses and saved tree, with tier_counts answering as the open does where two backslash-free keys name one path; and a key or holder name that is not UTF-8 refuses the splice and splices nothing. lineage_allocations now pins that the ingest count does not follow the entries. red under mutation, counts read each time: the eager read put back (0 passed and 2 failed in the engine pins, 2 and 1 in lineage_allocations), the splice check dropped (1 and 1), the holder check dropped (1 and 1), restored to 2 passed and 3 passed. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4282 passed over 136 binaries, 11 ignored) (#2282)
2350f8dd · dbf3dbe6…diff - the review-sweep fix-up over #2280, #2281 and #2282. the closure-walk pin stored x/abc after x/ab, which a random address beginning with c completes, so its stored-once control failed 5 runs in 400; it now uses y/abc and read 400 of 400 green. the spliced-node pin claimed the tree a later save writes back, but that save writes the copy the graph file already holds, since the rewrite inserts what it reads back first and ChangeGraph::insert keeps the first node for an id; it now saves into a store with no graph file, the splice comment says why, and a splice that blanks each spliced manifest goes red on that assertion (0 passed, 1 failed). decided and pinned: an ingest past a key or holder name that is not UTF-8 in a node it does not splice returns Ok and the next rewriting save refuses, kept because the open already defers such a file and stepping every pool node reads 13.8 to 14.4 ms beside a 54.4 to 55.0 ms deferred read of this repo graph file (red when the check scans the whole pool and when the save fallback is dropped, 0 passed and 1 failed each). measured with a tracking allocator over that 85.7 MB file: the rewrite union holds 88.2 MB deferred against 222.5 eager and peaks at 193.9 against 382.0, both freed when the save returns; a pool keeps one copy of the file per read that splices, 85.7 to 342.7 MB over one to four reads against 1.2 to 6.7 eager, recorded at the splice. the move-not-clone property lost its count pin to deferred manifests (a clone costs about two allocations per spliced node, 78 against 65 at the pin shape) and is pinned by the pool ids instead (red under the clone, 0 and 1). the closure walk doc names the holder-name case where it answers and the decoding walk panicked, pinned (red when the ascent check also steps holders, 0 and 1). each mutation restored to green. list-class sentences in HUNT-PERF and the loot-perf fixture rows now name what defines the set of graph file reads, and a rewrap leftover and a stale keys_ascend reference are fixed. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4285 passed over 137 binaries, 11 ignored) (#2287)
a8c863e3 · dbf3dbe6…diff - loot blame and loot log --path read the one path they ask of each change through the new GraphView::address_at, which answers off the manifest frames through the new Manifest::address_of where the frames provably are the map (no backslash key, keys strictly ascending in Path order, checked by the keys_ascend step Manifest::addresses uses) and from the decoded map otherwise, so a history walk no longer decodes every manifest on the lineage to read one entry of each, and blame no longer clones each tree; has_path keeps decoding, since its remaining production caller asks many paths of each of a few changes. the new --path-history opt-in half times address_at over every change of a re-opened chain at 200 paths, asking the path that orders last; paired, one loot-perf-gate --features count built per arm with the frame arm never taken as the before, interleaved, the gate reading load idle at 1 to 4 percent (peak 7): 34.87 to 34.97 ms down to 4.46 to 4.53 ms at depth 256, 138.8 to 139.3 down to 18.01 to 18.03 at 1024, and 279.2 to 280.6 down to 35.9 to 36.2 at 2048; counted by the new path_history_allocations test at width 200, the walk allocates 484,352 times at depth 1024 against 0. through the binary, against the landing position binary over a repo 1,025 changes deep and 203 paths wide, min of 15 over two rounds: loot blame 160.4 to 160.6 ms down to 33.7 to 33.8, loot log --path 164.8 to 165.8 down to 43.7 to 44.0, plain loot log 28.9 to 29.2 on both, and every output compared was byte identical with its exit code and stderr: blame plain, --porcelain, --json, with a selector and on an absent path, log --path on flat, nested, dotted and absent paths, and shortlog --path. new pins: over every golden, a file whose Path order is not its byte order, the legacy separator file and the three #2273 shapes, address_of answers what the eager map answers for every key and for probes that are not keys, decoding nothing exactly where the frames are the map, and it answers off the frames past a holder name that is not UTF-8; a spawned blame and log --path find a/b.txt beside a-b.txt and a.txt. red under mutation, counts read each time: the frame arm never taken (0 passed and 1 failed in the codec pin, 1 and 1 in path_history_allocations), the ascent check dropped (0 and 1, on the answer), the counts check dropped (0 and 1, on the control, since Windows reads a backslash as a separator), byte order in the stop (0 and 1 in the codec pin, 0 and 1 in the spawned pin), each restored to green. riding along: HUNT-PERF names the new half. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4289 passed over 137 binaries, 12 ignored) (#2283)
045c7e42 · dbf3dbe6…diff - the deferring manifest walk checks every stored key and Restricted holder name for UTF-8 as the eager decoder does, so a graph file holding one it refuses is refused naming the file at the open, at the adopt and ferry pool read and at the save, where the open passed it and the first read of that manifest panicked; measured first, the check shares one pass over each key with the backslash test it replaces and the open got faster, one loot-perf-gate --features count per arm, interleaved, load idle: --graph-load 9.47 to 9.54 ms before and 8.93 to 9.04 after at depth 1024, --manifest-breadth 33.9 to 35.9 against 32.3 to 32.9 at 872 paths, store_file_reads 24 on both, and over a copy of this repo graph file (93.5 MB, 1,634 nodes) the deferring decode 16.6 to 17.1 ms before and 8.8 to 9.3 after, where handing every key to the validator read 24.9 to 25.8. Manifest::decodes, the splice check of #2282 and the refusal left to the save by #2287 go, since the read now refuses what they caught. through the binary, loot log and loot status over such a file panic on 0.4.24 and refuse on the lane build. red with each piece undone, counts read each time, each restored green: the key check dropped (0 passed and 2 failed), the holder check dropped (0 and 2), a valid non-ASCII key refused (1 and 1), the refusal not naming the file (1 and 1), the open alone not naming it (0 and 1). no format constant, codec byte or migration moves. the workspace suite is green (4433 passed over 138 binaries, 13 ignored) (#2275)
23fec061 · 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.