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 1: in-memory public read against a real relay (#423) Completes the slice-1 tracer bullet on top of the landed WASM core: an in-memory agent connects to a relay and reads a public file byte-for-byte with no .loot/ on disk. The single passing read() exercises the whole stack — WASM codec + fetch transport + client-side path-scoping + AES decrypt + host-side zstd inflate. - loot-wasm: add encode_fetch_request so the /fetch wire framing (version marker + have/wants) stays single-sourced with the binary; frozen-vector parity added (native + wasm-pack test --node). - sdk/ (@millerbyte/loot-sdk, ESM): connectRelay(url, identity) -> LootRepo with list() and streaming read()/.bytes(). Transport speaks the relay HTTP wire via fetch(); loot-net never crosses. Client-side path-scoping (#380): a metadata fetch resolves path->oid, a scoped fetch pulls just that object's bytes (both have=[], since the relay gathers objects+keys from the changes not in `have`). zstd inflate is host-side (fzstd) — the wasm core has no zstd. Typed errors (LootError/TransportError/NotFoundError/AuthError, #382). - Seam 1: a vitest suite drives the real `loot serve` relay, seeded via the real `loot` CLI (init -> author -> new -> push); 4 tests green (list, byte- for-byte read, streaming, NotFound). The wasm pkg is built by `npm run build:wasm` (gitignored, regenerated from crates/loot-wasm). Deferred to later slices: write path, private/grant read, physical backend. Refs #423, #421. db75b0b7 · dbf3dbe6…diff
  • 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 2 review fixes: dedup envelope framing, rename, precondition errors (#424) Addresses the code-review findings on 8e70ef8: - loot-wasm: extract `envelope_bytes` — the `/stow` framing is now written once and shared by `Identity::wrap_envelope` and `ChangeBuilder::finish` (was duplicated). Parity vectors unchanged (byte-identical), confirming the dedup is behavior-preserving. - Rename `set_parent`/`setParent` -> `add_parent`/`addParent`: it appends (push loops over merge heads), so "add" matches the accumulate semantics. - sdk push() preconditions (no describe / nothing pending) now throw a plain Error, not `LootError("conflict", …)` — "conflict" means a real same-path bounce, not a caller usage bug. - Seam 1: assert the landed change carries its describe message on the wire (AC#5's "the message is visible"), since the v1 surface has no `log` verb. - Honest comment on carry's visibility collapse (public-only in slice 2; grant-id preservation is slice 4). Green: native + `wasm-pack test --node` parity; 10 SDK tests. Refs #424. 285b6cb5 · dbf3dbe6…diff
  • @ TS SDK slice 5: streaming pull of new changes (#427) Add `pull(): AsyncIterable<Uint8Array>` to `RelayRepo`: POST the sessions 6b3e1b47 · 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
  • Rename the Public visibility tier to Internal (ADR 0041 §2, #480) 6969626d · dbf3dbe6…diff
  • Publish: surface the `published` visibility token across CLI/porcelain/JSON/WASM/SDK (#481 refinement 1) 082ad335 · dbf3dbe6…diff
  • SDK/WASM cross-session grant delivery: pull queues, accepting applies (#508) e0bb183e · dbf3dbe6…diff
  • Sweep the rust-1.96 clippy debt; document the land-holds-the-binary hazard (#667, #681) 26cfbaa9 · 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 2020 date has a note where it will be found: the wasm authored_at gap names its own diagnosis (#762) 2d9ebf2b · 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 fetch gains a depth the host honours: the request carries a trailing depth after its wants, written only above zero so a request at zero is byte for byte what every client sent before, and an old host decoder returns after the wants and never sees it, which is what makes it safe to send to any host; the relay and the forge confine the change lane to the nodes within that many generations of their live heads, one being the heads and nothing older, intersected with the delta past have and, on the forge, after the entitlement gate, so a caller refused metadata in a full bundle is refused it at any depth and a node the caller holds is never re-sent, while a want is answered by address whatever the depth, the lane walking the cut changes as it walks the held ones; the seeds are live heads and not childless ids, the forge reading the ref declared set a client computed with its retire applied and the engine excluding any version a node names as a predecessor, since on a host that ingests amends a superseded sibling stays childless and would otherwise seed a generation of its own, 213 such tips against 31 real on this repo; /info advertises it as fetch_depth, false when absent, so a caller that needs the bound refuses a host without it and a caller that can afford the fallback proceeds. the receiver still cuts and that stays the rule, ADR 0089 amended rather than overturned: pull_metadata_via and the depth round of a bounded pull ask the host for the depth they will keep, FromTips as its n and a deepen as zero since the frontier is this position own, and applies IngestDepth to whatever arrives, so a host that predates the field sends the history and the position is the same one generation deep, which the test relay pins both ways by playing a host that honours the depth and one that does not. the WASM core encodes the same bytes, frozen in the parity suite against the native vector at depth one. decided by a grill of six questions on 2026-09-20 and recorded on the ticket and in the ADR: bounded per invocation regardless of persistence, a depth on the existing fetch rather than a listing endpoint, counted from the host heads, search kept on the receiving side over bodies fetched by address, the host cut an optimisation the client never depends on, and the SDK and the cache cap to follow. this is the wire half of the browser SDK stopping its 53 MB stateless read and of the serverless runner per-job pull; measured against the live relay in the landing comment once the hosts are deployed. pinned on the same shapes at each host so the two walks are held to one answer, a chain at depths one, two and zero and past a have, a fork whose two heads are both seeds, a superseded sibling that never is, and depth zero byte-identical to the one-pass walk this walk replaced, the forge with a reader the gate refuses getting nothing at any depth; in loot-net on the field decoded both ways with an old body as zero, an odd remainder refused, a pre-field info as false and one frozen vector both encoders pin; over the wire on a spawned relay and a spawned forge each answering one node of two at depth one and advertising the cut, with a want riding across the cut; and in the CLI cache refresh asking depth one. a clone at a depth asks the host too, so its declined count is 0 under a host that cut, since nothing behind the cut arrives to be declined, and the frontier width is what says the position is bounded, which the shallow suite now pins against a host that honours the depth and one that predates it; the count stays exact on a deepen, which asks the host for no cut because the frontier it deepens from is this position own. red under mutation, counts read each time: the engine ignoring the depth (0 passed and 1 failed), the engine cut counting one generation too many (0 passed and 1 failed), the depth never written (1 passed and 1 failed), the depth never read (1 passed and 1 failed), the relay not advertising the cut (1 passed and 1 failed), the relay handler dropping the depth (0 passed and 1 failed), the forge ignoring the depth (0 passed and 1 failed), the forge cutting before the gate (0 passed and 1 failed), the forge not advertising the cut (0 passed and 1 failed), the cache refresh asking no depth (0 passed and 1 failed), the receiver stopping its own cut when the host cuts (0 passed and 1 failed), the wasm framing never writing the depth (0 passed and 1 failed), the forge handler dropping the depth (0 passed and 1 failed), the engine seeding from a superseded version (0 passed and 1 failed), the forge seeding from every childless id declared or not (0 passed and 1 failed), an odd remainder read as no depth (1 passed and 1 failed), and the wants lane skipping the cut changes (0 passed and 1 failed). no migration and no format major move: a trailing field the old side never reads is a minor move, and /info default false is the whole compatibility story; the relay and the forge owe a deploy, which the release cut carries. the workspace suite is green (4017 passed over 126 binaries, 7 ignored) (#2123) e3ddfdce · dbf3dbe6…diff
  • the browser SDK head derivation skips a superseded version: the WASM core ChangeView, the JSON WasmBundle.changesJson renders, gains predecessors as hex ids, an empty list when a change supersedes none, and snapshot() in sdk/src/repo.ts drops any id some returned change names as a predecessor before folding the heads, the engine within_depth_of_heads rule, so heads() and the parents a push builds on no longer name a superseded version under unboundedRead against a host that sends one. the golden relay bundles are regenerated with the first change pushed, amended and pushed again, so the meta bundle holds a superseded version nothing names as a parent; the amend is a loot squash rather than the loot edit the ticket named, because an edit finalize records the superseded version as a parent as well as a predecessor, measured, which the old derivation already skipped. pinned in relay.unit.test.ts: the golden holds that shape, and heads() names the amend alone while list() does not show draft.md, the path only the superseded version holds; the shared parity check_bundle pins the empty list. red first against the old golden (20 passed and 2 failed). red under mutation, counts read each time: the predecessor filter dropped from snapshot (20 passed and 2 failed), the core rendering no predecessors (19 passed and 3 failed), the field left out of the JSON (parity 0 passed and 1 failed), each restored to green. no format byte and no migration moves and no host changes, so this owes no deploy. the SDK suite is green in the lane (139 passed over 13 files), the site gate is green (678 passed and 62 skipped over 62 files, 62 surfaces) with no ceiling moved, and the workspace suite is green (4323 passed over 137 binaries, 12 ignored) (#2137) a9fd3357 · dbf3dbe6…diff
  • the review-sweep fix-up over #2295, #2096 and #2137. the discovering layout door takes its caller body need: workspace::ambient_store_dot, which fixed BodyNeed::Unread and so would have admitted a body-reading verb that found its store through it to a body-deferred store, is now discovered_store_dot(start, bodies), passing the need through to resolve_store_dot, and loot doctor declares Unread at its call. ambient_local_config keeps a fixed Unread, since it hands back the config path and lane id rather than the store, and its doc says so. pinned in workspace.rs: a Held caller is refused naming the state from the root and from a subdirectory, an Unread caller still finds the store, and the same repo before the record is the control; red first with the need ignored (0 passed and 1 failed), red under mutation with the door fixed at Held (0 and 1), restored to green. the refusal text, the doctor bodies line and the ADR 0093 section 3 heading now say a verb that declares neither that it fetches first nor that it reads none is refused, since Unread passes. set sentences replaced by the property in workspace.rs, CONTEXT.md and ADR 0093: a door that finds a store without reaching BodyNeed::admit is unguarded and whether that is safe is a question for that door, is_relay is recorded as why a relay store needs no guard, and a verb behind an admit door meets the refusal unless a need other than Held is named there. the site marker pin: the hash now covers every module the page reaches by a relative import, so RETENTION_DAYS on /privacy moves it, and the privacy pin is re-pinned with its date unchanged; the scan reads markers with comments stripped, so a comment saying last updated no longer makes a file unreadable; the header defines a dated marker as the words last updated and says why the known-issues Last reviewed date is left to its own pins. red first against the old test, counts read: RETENTION_DAYS moved to 31 stayed green (9 passed), a terms comment saying last updated went red (2 failed and 6 passed); with the change the first is red (1 failed and 10 passed) and the second green (11 passed). red under mutation: the import reach dropped from the pin (1 failed and 10 passed), comment stripping dropped from the marker read (1 and 10), the import walk reading nothing (2 and 9), each restored to green. stale sentences fixed: HUNT-PERF names every other read the open makes rather than the reads in the body of assemble, the in_progress verb is the verb that wrote the record rather than a STOPPING member, the SDK read test no longer calls its shape the one where the head derivation is load-bearing, and repo.ts and the ChangeView doc name the superseded-head half of within_depth_of_heads and say its working-change branch is not mirrored. no migration, no format byte and no published wording moves, so this owes no deploy. the site gate is green (680 passed and 62 skipped over 62 files, 62 surfaces) with no ceiling moved, the sdk build:wasm and type-check are green, and the workspace suite is green (4324 passed over 137 binaries, 12 ignored) (#2299) d7d5ef94 · 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.