Changes touching this path
- 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… - Slice 1 review fixes: prove path-scoping, reusable suite, SDK docs (#423)
Addresses the code-review findings on de6e281:
- Add sdk/README.md — states plainly that loot hides CONTENT, not path
names or tree shape ("don't put secrets in path names"), documents usage
and the build:wasm step. Closes the missing-docs AC.
- Make path-scoping observable: the behavior suite now seeds TWO public
files and asserts at the wire level that a scoped /fetch returns the
requested object's bytes but NOT the sibling's — while the sibling's
address is still visible (structure is public, content is scoped).
- Make the read suite backend-parameterized: assertions live in
runReadContract(makeRepo) so #422's physical openRepo reuses them verbatim.
- Honest streaming: ReadStream now documents that a sealed object is one
AES-GCM unit yielded as a single chunk (not chunked streaming), which the
iterable shape grows into for later slices.
Left as-is: currentTree resolves heads in TS — that's the #381-sanctioned
thin store-orchestration (only crypto/codec must stay Rust), not drift.
Refs #423.
f736b628 · 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 - SDK slice 3 (#425): typed error model on the write path
Map relay/loot failures to typed LootError subclasses so callers branch
on `code` instead of string-matching:
- push(): a 401 from /stow (loot-net handle_stow -> unwrap_envelope maps a
non-allow-listed key to BadSignature/UNAUTHORIZED) now throws AuthError
(code "unauthorized") carrying the offending signing pubkey. The SDK signs
every envelope in WASM, so a 401 means the key isn't enrolled. No pre-check,
auto-enroll, or downgrade (#383) -- attempt and report. Other non-2xx and
fetch failures stay TransportError.
- Add ConflictError (code "conflict") to errors.ts and export it. It is NOT
thrown: /stow is append-only and accepts concurrent forks without rejecting a
moved parent (loot-core stow_accumulates_concurrent_forks_without_conflict),
and capture-first push re-snapshots heads at push time so it never builds on a
stale parent. Clean detection needs an optimistic-concurrency model slice 2's
overlay doesn't carry -- deferred, documented, not faked.
- AuthError gains an optional `pubkey` (hex) for the allow-list case.
Seam 1: push-errors.behavior.test.ts drives a real relay seeded with a foreign
key -> AuthError(unauthorized) with pubkey; an unreachable relay -> TransportError.
tsc clean; vitest 12/12 (2 new + 10 existing).
565a0638 · 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 - SDK: extract deep WorkingOverlay both LootRepo backends compose (#429)
Both LootRepo adapters shared only the interface — the capture-first
pending-change behaviour (Pending type, overlay/message/guard fields, the
added/modified/removed ternary, the two push preconditions, the guard union)
was copied into RelayRepo and PhysicalRepo. Extract it once into a pure,
synchronous WorkingOverlay<P>: RelayRepo composes WorkingOverlay<Uint8Array>,
PhysicalRepo composes WorkingOverlay<string> (abs path). status/diff become
one-liners over classify + message; push walks entries() to compose its own
change. Relay's client-side visibility resolution + GuardError enforcement and
the private keyring stay in the relay adapter.
classify(committed) is pure and unit-tested with hand-built inputs (13 tests,
no relay, no binary). CONTEXT.md gains the SDK-tier Working overlay entry
linking [[Working change]]. SDK suite 45 green (32 pre-existing + 13 new).
82ee8e52 · dbf3dbe6…diff - SDK: restore OverlayEntry as a discriminated union (#429 follow-up)
The #429 extraction flattened repo.ts's tight put|remove union into a single
shape with optional payload, which regressed the fold site to a non-null
assertion (builder.put(path, pending.payload!, …)). Make OverlayEntry<P> a
discriminated union again so the push loop narrows on kind and reads payload
without the `!`. Pure type change; tsc clean, 45 SDK tests green.
4e54101b · dbf3dbe6…diff - SDK: inject transport/runner seams + map binary error codes (#432, #433, #434)
Three architecture-review deepenings that make the two LootRepo adapters'
decision logic testable without a live relay or the real binary, and replace
stderr regex-scraping with the binary's coded error channel.
#432 — RelayTransport seam. A narrow dumb-pipe (`post`) + default
HttpRelayTransport, injected via `connectRelay(url, id, { transport })`. All
interpretation stays adapter-side: response classification (401→AuthError+pubkey,
non-2xx/connection-fail→TransportError) is a pure helper, and the push
visibility-resolution + GuardError enforcement is extracted to a pure
`resolvePushVisibilities`. New relay.unit.test.ts proves error classification,
path-scoping, decode, and compose/guard against a fake transport — the WASM core
exposes no bundle encoder, so decode/path-scoping replay golden `/fetch` bytes
captured from a real relay (test/fixtures, regenerate with gen-relay-fixtures.mjs
after a format bump). Relay integration trimmed to the read + write round-trip
smokes (read.behavior/write.behavior); push-errors.behavior deleted.
#433 — LootRunner seam. `run` (buffered, never throws on non-zero) + `spawn`
(streaming) + default SubprocessRunner, injected via `openRepo(path, { runner })`.
Physical error-mapping, arg composition, and pull streaming are unit-tested
against a fake runner (physical.unit.test.ts); physical integration trimmed to
the read + write round-trip smoke.
#434 — map binary error codes → LootErrorCode. physical.ts reads `error.code`
from the binary's `{"error":{"code","message"}}` under --json and maps it in one
place (demotion/mis_seal/seal_wip→guard; unsupported_format/no_repo/unknown_flag
→setup; not_found→not-found; else generic); all stderr prose regexes dropped.
`run`/`pull` append --json, so the CLI's `new`/`describe` verbs now accept --json
(not --porcelain — no consumer) to emit coded failures. loot's engine has no
conflict-family slug (it accumulates forks rather than rejecting), so conflict
stays deferred/generic — ConflictError remains exported for when a slug lands.
e75ba364 · 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 - the two servers share one auth preamble, and a forge handler reaches the router only through the crossing (#865)
a10e310f · dbf3dbe6…diff - the browser SDK reads a relay at the heads generation and refuses a host that cannot give it one: connectRelay list() and read() open with a fetch carrying depth one, the heads nodes and nothing older with the whole manifest on each, and the object round is bounded the same way rather than paying the history a second time, since a want is answered by address across the cut, so a read is two bounded rounds where it was one unbounded and one that repeated it, against the 53 MB per call the map measured on this repo; the host is asked once per repo, on GET /info, which the transport seam gains as an optional get so a lane that never asks stays honest, and a host whose answer does not say fetch_depth, or answers with a non-2xx, or not JSON, or through a transport with no get, is read as one that did not say and is refused as a SetupError naming the host and the version line it reported, before any bytes are asked for, unless the caller passed unboundedRead, which accepts the cost on that host and asks a capable one for the bound all the same; a probe that could not reach the host is a TransportError and is asked again next time. heads() names the set the bounded read answered with, on a RelayLootRepo the connect door now returns, and several heads fold as they did, a later head write winning a shared path; push, status and grant ride the same bounded round because the parents a change builds on and the tree it carries are the heads own, which is all they ever read. pinned on a fake transport with the golden bundles, the request bytes bounded on the metadata and the object rounds, the old host refused by name on list and on read with nothing posted, the opt-in against it and not against a capable host, the probe asked once across list and read, the three did-not-say shapes each refused and each opted into, the unreachable probe re-asked, and heads() one; and over a spawned relay with two generations, the request four bytes longer than the unbounded one ending in one, the answer one node whose parent is the base change a whole fetch shows, every round of a read one node, the head named and then two after a fork the second session pushes off a replayed metadata answer, listed as the union, and the field stripped off the real /info refused by name and paid for on opt-in. README states the cost and the option, CONTEXT and ADR 0089 say the browser SDK refuses rather than is to. red under mutation, counts read each time: the depth never sent (21 passed and 5 failed), every host assumed to cut (21 passed and 5 failed), unboundedRead ignored (22 passed and 4 failed), the probe asked on every read (18 passed and 1 failed), a failed probe remembered (17 passed and 2 failed), heads() naming nothing (23 passed and 3 failed), the refusal not naming the host (24 passed and 2 failed), a transport without get reading as capable (18 passed and 1 failed), a 404 on /info reading as capable (18 passed and 1 failed), an error on /info remembered as a refusal (18 passed and 1 failed), an explicit false read as absent (18 passed and 1 failed), a JSON non-object left unclassified (18 passed and 1 failed), the head derivation skipping the parented filter (6 passed and 1 failed), and the object round unbounded (24 passed and 2 failed). no rust, no migration, no wire or format byte moves, and no host behaviour moves, so this owes no deploy beyond the one #2123 already owes the release; the SDK suite is green in the lane (126 passed over 11 files, against release binaries and sdk/wasm built in the lane) and the workspace suite is untouched by a TypeScript change and was green at the base (4017 passed over 126 binaries, 7 ignored, at 5a007435) (#2124)
8eb8e8cd · dbf3dbe6…diff - the browser sdk transport stops waiting on a silent host indefinitely, because fetch has no timeout of its own and in a browser nothing stands in for one: HttpRelayTransport now aborts through a watchdog that waits for an answer to begin for its idle budget plus the upload at the uplink floor, then re-arms on each chunk of the body, so an answer that keeps arriving is not cut for its size and one that stops fails once the idle budget passes with no bytes, the same policy #2079 gave the native client, with TRANSFER_IDLE_MS mirroring wire::TRANSFER_IDLE and each side now naming the copy on the other. a budget that runs out throws a TransportError naming which wait it was and how long, and the adapter passes that through instead of relabelling it as an unreachable host, one rule in errors.ts asked by every catch around a transport call. TWO OF THE THREE ITEMS ARE REFUTED RATHER THAN BUILT: the forge blocking clients the ticket named both sit inside cfg(test) modules, in the tree today and at the rev the ticket was filed on, and the forge talks to its object store only through loot_s3::S3Client, which already names its own 60 second timeout; and the upload side header wait was fixed under #2079 by the byte-derived transfer budget, whose refusal already names the answer budget. the frozen-tab limit is written down rather than fixed, since a timer on the page loop measures the page clock and not peer silence, which is #2079 in a browser. red under mutation, counts read each time: the re-arm moved outside the read loop failed the steady-answer pin (6 passed and 1 failed), the answer wait ignoring the bytes failed two (5 passed and 2 failed), the watchdog verdict dropped failed all three budget pins on a bare AbortError (4 passed and 3 failed), and the adapter relabelling every throw failed the pass-through pin (0 passed and 1 failed), each restored. the sdk suite is green against release binaries built in the lane (137 passed over 13 files), no migration, no wire or format byte moves and no host behaviour moves, so this owes no deploy. the workspace suite is green (4179 passed over 133 binaries, 9 ignored) (#2070)
f6348ba0 · 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 - a relay with a push allowlist now gates its reads on it too: every relay read route was unauthenticated while every bundle carries the key for every Internal object (ADR 0011), so an allowlisted relay.millerbyte.com served this private repo to a stranger key in plaintext (#2388). a route is a read when its answer comes from the store, and the store now sits behind Hold, whose only doors are a gated read and a gated write, with the router built from one match over Route that has no wildcard, so a later route cannot answer from the store ungated; the reads today are /negotiate, /offer, /fetch, /wants and /haves, /info stays the one open probe, and the grant lane is unchanged. with no list a relay stays open and unsigned, and loot serve and loot-relayd now warn at startup that anyone who can reach it can clone everything, Internal content included. /info advertises authenticated_reads and a client signs its reads iff it is advertised, so a new client reads an old relay unsigned and an old client gets a 401 naming the upgrade; loot seek records the posture and the SDK signs its reads the same way. no format constant moves. ADR 0011 and 0015 amendments, CONTEXT.md, the forge spec, sdk README and the site cli, guides and concepts pages say it. red under mutation, counts read: gate never on (the stranger clone succeeds), gate admitting any valid key, the 401 without the upgrade wording, /haves filed as open, /info not advertising, client never signs, client signing whenever a list exists, the startup warning dropped, the SDK never signing; each restored to green. workspace suite 4539 passed over 141 binaries, 13 ignored; SDK 145 of 145; site gate green (777). owes a release before loot serve users get it; the live relay stays stopped (#2389)
f6b5ecc7 · 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.