Changes touching this path

  • 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…
  • Forge service skeleton: /ingest, the sync surface, and CLI forge remotes (#502) A new `loot-forge` crate and binary, sibling to the relay and never a mutation of it: `loot-net` is a dependency of `loot-cli`, so a storage driver in the relay would ship inside the released CLI binary (ADR 0041:53-57). The ban runs one way -- the forge may use the other crates; none of them may gain a driver. Every endpoint is envelope-authenticated, where a relay authenticates only /stow. The one declared exception is GET /info: a client reads the capability probe before it knows how to authenticate. POST /ingest = envelope_sign(bundle || head_declaration), one signature over both, so a genuine bundle can never be replayed under someone else's head claim. CAS first; nothing is written before the generation compare. A lost CAS answers with a 409 BODY carrying the current generation and head set -- behind, not bounced -- so the client re-carries with no extra round trip. /stow keeps relay parity and moves no ref, and consumes no generation either, since a client reads the generation once and then stows N batches. It DEFERS a change whose objects have not all arrived; /ingest REFUSES one, because a declared head whose objects are missing is a repo no pull can complete. That asymmetry is what keeping /stow is for. Bundles are filtered PER OBJECT by the caller's access class, closing the leak the ticket names: bundle_impl ships a key for every ANYONE-granted object, i.e. every Internal object. Change metadata rides the second #494 axis and gates the whole walk -- a ChangeNode carries the full tree, so shipping one while withholding its bytes still discloses every path, address, message and author. Validation before any write: author signatures, and a tree whose visibility contradicts its objects is rejected. vis_tag is derived from the SealedObject and never trusted from the tree, which is what makes content-addressed upserts safe. Forge purge policy: burn iff burner == object.introduced_by, recorded at first arrival as introducing.author else the envelope pubkey; maroon read off grant_log, global and exact. Both are judged before any object is stored and outside the CAS transaction. Repos are pubkey-addressed at /k/<pubkey>/<repo>, auto-created on first push, and may_push holds iff the signer owns the namespace. resolve_remote is untouched -- still no default host. CORS from day one. Storage sits behind three narrow traits with in-process reference implementations the whole suite runs against; the Postgres DDL they mirror is checked in at docs/sql/forge-schema.sql and its driver is #516. CLI: /info capability detection, the live head declaration off the Liveness view, and a refusal to push a lane -- v1 ingests the primary position only. verify_authored_change moves to loot-codec so the forge runs the identical gate without linking the fs-hardwired engine. Recorded in ADR 0041, the spec and CONTEXT.md: the forge stores no content key but a published one, because filing the ANYONE key lane would let the server read every Internal object, and the keystone is that only Published is server-readable. The cost is stated rather than hidden -- a fresh clone of your own Internal content from a forge is ciphertext you cannot open until #488 lands. Closes #502. 300e06ff · 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 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
  • 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

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.