Changes touching this path

  • 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…
  • Forge publish ingest: file the published key the bundle already carries (#499) 417aaf83 · dbf3dbe6…diff
  • Give the forge an operator channel, with one redaction rule (#522) f9ca9f91 · dbf3dbe6…diff
  • Sweep the forge blob tier for bytes that no longer address to their oid (#523) 3a1109bd · dbf3dbe6…diff
  • Forge storage drivers: Postgres metadata tier, S3 blob tier, and one conformance suite both must pass (#516) f65970e6 · dbf3dbe6…diff
  • the forge grows an operator door list, so its first public ingress refuses all but listed keys with a typed 403 (#690) 0541e431 · dbf3dbe6…diff
  • a forge advertises its retention window on /info, and burn prints one honest bound per host it disclosed to (#814) 652f4023 · dbf3dbe6…diff
  • the two servers share one auth preamble, and a forge handler reaches the router only through the crossing (#865) a10e310f · dbf3dbe6…diff
  • a long-lived container holds no credential: the forge and the site seed their environment from a file the container env only points to - ADR 0059, the loot half of (#1034) 27df4dc9 · dbf3dbe6…diff
  • the forge's five hand-kept wrapper lists stop being lists that could drift and become one macro stamp per adapter, so a case reaches every adapter by construction rather than because somebody remembered: each suite is a callback macro handing its names to the adapter's own arm, and one repetition emits both the tests and the const the roll call compares against, which is the stronger form of the ticket's ask - making the list unforgeable beats parsing for evidence it drifted, and the parse the ticket specified would have had to survive three Case::new calls that are not cases, two spellings of the path, and calls sitting in helpers. no source walker was needed or added, because everything here already lives under cfg(test) so test code is the subject rather than the thing to exclude. one text parse survives and only one side of it is text, since a case can still be written and named by no suite and rust has no reflection over a module's functions while this repo cannot run deny warnings: it reads column-zero pub(crate) fn in its own file only - and its premise is true by construction rather than by care, because the checker had to move inside an indented module or it named itself, which is not a thing anyone designed but what the guard did on its first run, red, on its own name. both halves were proved able to fail: deleting a case from a suite leaves every roll-call test green and the total quietly falling from 278 to 275 with only the parse catching it, and an adapter stamping a subset names the adapter, the count, the suite and the case. the limit is stated narrowly on purpose - every case is asked of every adapter that takes a suite, never that any adapter answered, since 56 of 66 pg cases and 7 of 12 s3 cases skip green with no credentials in CI - and a second limit nobody asked for is written down too, that nothing counts implementors, so a new store whose module never invokes the suite is asked nothing and this mechanism stays silent about it (#1655) 66876d46 · dbf3dbe6…diff
  • the forge learns what a proposal IS, with no verb and no route: migration 0011 adds one table keyed by the tip's durable change id and scoped per repo, with no server-minted id, no base column since the declared base is CHECKED rather than stored in generation_expected's own shape, no extent table because a stack is derived and cannot be declared, and no index because both reads are answered by the primary key. The extent is the walk from the tip along parents to the landed frontier, and landed means the closure of ref_head over parents rather than everything the repo holds - a /stow having written repo_change is deliberately NOT landed, which is the difference between the two admission refusals doing their job and failing open. Refusal one is that the walk must terminate at landed changes, and it is an anti-abuse boundary rather than tidiness: an unrooted stack lets any party donate arbitrary unrelated history into someone else's namespace, unbounded, on a host whose quota producer still has none. Refusal two is that every change in the STACK and not merely the tip carries change_id, author and signature, all three of which are NULLable today because a legacy or unauthored change has none - no durable id is unidentifiable, no author is unattributable, no signature is not a contribution under ADR 0041. Each refusal was proved by deleting it and watching the donated or unsigned row be ADMITTED, and a third breakage is worth recording because it does not fail open: swapping the repo-scoped graph lookup for the global one still refuses eventually, but only after the walk has read another tenant's change_node row and it then blames the wrong thing. The ref_head pin is the decision itself and is asserted at three levels - a before-and-after on the declared head set with controls proving the proposal really is on file and its tip really could have been a head, a check that the migration text never names ref_head guarded by a non-vacuity read of its own prose, and a database-level count over a cluster only the shipped code ever wrote, where proposal tips that are a head of their own repo is zero. The reaper was CHECKED rather than assumed and the answer is the ticket's most important output: #493 is decided and not built, and its live set as specified roots on ref_head plus live publications plus un-acked grant blobs - so an OPEN proposal, which is outside ref_head by construction because that is the decision, is reachable from none of them and would be swept, deleting a contributor's offered work while the maintainer was reading it. ADR 0075's terminal sentence is right and its open half needs proposal.tip as a root, argued once in an amendment and filed as #1720. Visibility is a pure predicate and a concealed proposal reads as ABSENT rather than forbidden; no view was added at all, so the invariant that no forge_read view names grant_log or repo_member survives untouched and the reader holds nothing on the base table. The third viewer, the presenting proposer, is a deliberate widening over ADR 0075's letter of two and is recorded as an amendment rather than a comment: the presenter supplied the whole stack so existence discloses nothing they do not hold, and excluding them makes ADR 0075's own named feature - carrying a colleague's signed change - produce a row its creator cannot see (#1649) bc73a1dd · dbf3dbe6…diff
  • propose::list reads the tip authors its gate needs in one proposal_tip_authors read, taken only when a row cannot be answered without one, where it read change once per row; the read is scoped by the repo proposal rows rather than repo_change, since a tip is held to change_node alone and the Postgres key-arm pin in tests/proposal_read.rs went red on the repo_change join first tried. ingest asks propose::landed_by_declaration, the one landed closure run over the stored graph overlaid with the push, where it carried a second walk of its own, and a new conformance case hands the ingest transaction a decision that disagrees with the graph both ways and holds both stores to applying it and deriving nothing. counted rather than timed through a new call-logging store double, calls.rs: a listing makes the same reads at 1 row as at 40, a push makes the same reads at 1 open proposal as at 40 with the proposal rows and the graph each read once and the transaction last, and a push with no open proposal reads no graph; a push that lands a proposal is answered in the same bytes as the same push to a forge holding none. measured on a throwaway Postgres 18 over a 1,000-change repo with one-change pushes landing nothing, three interleaved passes of 45 pushes per arm: per-pass medians 1.80 to 1.90 ms with the decision removed, 2.18 to 2.23 ms with it and nothing open, 4.52 to 5.94 ms with 1 to 100 open and flat in the count, against a knowingly wrong arm reading the graph per open proposal at 24.7 to 37.1 ms for 10 and 232 to 360 ms for 100, so the recorded fallback is not taken and ADR 0075 now says so. the proposal read still carries no pipeline verdict; that half of this ticket is left. red under mutation, counts read each time: the list reading change per row (0 passed and 1 failed), each store deriving closes from the head set (0 and 1 on memory and on Postgres, while the existing close case stayed at 1 passed), landed following no parent (0 and 1 on the ingest walk pin), the graph read per open proposal, read with none open and a read after the transaction (0 and 1 each), a landing push answered in other bytes (0 and 1), the tip authors answering every held change (0 and 1), and the Postgres read joined through repo_change (0 and 1 in conformance and in proposal_read), restored to 8, 4 and 1 passed. no migration and no format byte moves; the new read is forge SQL, so the list change reaches the live forge with its next deploy. bash ci/local.sh is green against Postgres 18 (4273 passed over 135 binaries, 10 ignored) (#2235) 3cffe4b6 · dbf3dbe6…diff
  • the kinds trailer on /ingest and /propose and the job creation it drives, with the wake-up sender; the runner-deposit reap is not built. loot_net::forge::HeadDeclaration and ProposalClaim gain kinds: a push declares the kinds of the on main steps of its live heads and a proposal the kinds of the on change steps of its tip, read by pipeline::declared_kinds from the .lootpipeline each version holds, as one trailing byte in Kind::ALL order that is not written when nothing is declared, so a payload declaring nothing is byte-identical to the one an older client signs; a decoder reads the byte iff bytes remain, and a bit this build cannot name is dropped. FORMAT_MINOR is not bumped, per the 2026-09-22 comment on the ticket that overrides step 1 of its body (a minor bump makes persist_codec rewrite every store graph file, the #2180 finding), and adding_the_kinds_moves_neither_format_constant pins both constants; a new client payload decodes on a frozen copy of the old decoder and an old client payload decodes here as declaring nothing. /ingest makes a main job per declared head and declared kind, less those on file, inside the ref-moving transaction (IngestTxn::jobs on both stores, with a conformance case run on memory and on Postgres), and /propose makes a change job per declared kind for the tip once the proposal row stands; member is the author of the version classified by AccessClass::of and approved is a verifying pipeline/approve from a key require_pusher admits, both read once at creation. after the commit each live runner whose row covers a created job kind and carries a URL is woken through jobs::Wake, whose HttpWake calls loot_net::runners::send_wake: the URL checked as registration checks it, the send refused if any resolved address is one registration would refuse (refused_wake_address, which both ask), the checked address pinned, no redirect followed, the request bounded by WAKE_TIMEOUT_SECS. the ticket said the site TypeScript client learns the field, and no code under site or sdk encodes an /ingest or /propose payload, so nothing there changed. the reap is recorded as unbuilt in the ADR 0091 #2159 amendment: grant_inbox is keyed by recipient alone while runner, ref_head, proposal and job are bound to one repo under migration 0017, so a reap run per repo cannot see an address live in another repo the same runner key serves, and how to scope it is a decision left to the reap ticket; nothing writes a runner deposit before #2130. measured: the trailer is one byte when a kind is declared and none otherwise, and ingest::job_cost pins that a push declaring nothing makes no call for jobs and that job creation reads per declared head, not per kind. red under mutation, counts read each time, each 0 passed and 1 failed on a filter selecting its one pin unless said otherwise: the trailer written before the heads, the trailer read unconditionally, the trailer always written, FORMAT_MINOR at 1, every kind made rather than the declared ones (through the push route, the proposal handler and the binary end to end, each), a wake-up sent to every row with a URL, a wake-up sent before the commit, member never set, approved by any attester, the memory ingest dropping jobs, the memory ingest overwriting them, the Postgres ingest dropping them (through bash ci/local.sh, 461 passed and 1 failed in loot-forge), only the first resolved address checked, the binary declaring the kinds of every trigger, the push declaring nothing, approved read once per kind, and the no-kind early return removed; each restored to green. no migration, no format byte and no published wording moves; the forge binary changes, so a forge makes jobs only once it is redeployed, and a forge older than this still accepts a client that declares kinds. the workspace suite is green under bash ci/local.sh against Postgres 18 (4345 passed over 137 binaries, 12 ignored) (#2159) 09cefc35 · dbf3dbe6…diff
  • the reap of runner deposits ADR 0091 section 4 owed is built, which closes the last half of this ticket: a runner mailbox is now PER REPO (migration 0026 runner_inbox, keyed repo_id and runner_pubkey and blob_address under 0017 binding), because the question that stopped the first attempt was that grant_inbox is keyed by recipient alone while liveness is recorded per repo, so a reap for one repo could drop a key another repo jobs still need and the standing walk, deduped by its own ledger, would never deposit it again. the operator chose scoping the mailbox over a reader that crosses repos or refusing one runner key in two repos, and the third option turned out not to be enforceable where it would have had to be anyway, since runner is keyed repo_id and pubkey and repo-bound so a forge serving one repo cannot see that a key is a runner elsewhere; a global unique index could, at the cost of making its refusal an existence oracle about another tenant repo. it is a second table rather than a repo column on grant_inbox because row security is per table and grant_inbox mailbox is the caller own verified envelope pubkey, which is what closes the read-anyone-mailbox hole, while an owner filing a key for a runner they registered is a different fact through a different door. WHAT IS LIVE IS THREE ROOTS, and a change ships its whole tree so a root manifest entries ARE its objects and there is no ancestor walk: the declared heads, open proposal tips, and the versions of UNFINISHED JOBS - the third is not redundant, since a push moves the heads and a withdrawal closes a proposal while a job made for that version still stands, and a pin holds it alone. history is deliberately not a root, because any change references it is true of every superseded object forever, so a reap rooted there keeps everything and does nothing, which is the failure section 4 describes. the door is loot-forge --reap-runner-deposits --repo owner-hex/repo, which lists and destroys nothing until --apply, the shape reap-grants has, because the row is the only copy of that key the runner gets; it names its repo and reaps no other, refuses --dev, and refuses a missing or malformed --repo. the decision of what is dead is one implementation over the trait and the store write is pure, the split ingest keeps. red under mutation, counts read each time: ten mutations through the policy and the reference store each went red at 1 passed and 1 failed with the pg stamp skipping, each restored to 2 passed - the three roots removed one at a time, a terminal proposal and a finished job counted as roots, a listing that destroys, history as the root, the mailbox read across repos, a deposit that is not idempotent, and a drop that ignores which runner it is for; the history mutation was TOO WEAK on its first attempt and is recorded as such, since an empty version list emptied the live set rather than widening it and so duplicated the heads mutation, and re-done as rooting at every change the repo holds it goes red one line further down at the assertion it is for. on a throwaway Postgres 18 the two mutations only the driver SQL can carry went red at 0 passed and 1 failed after an unmutated green arm, the DELETE no longer naming the runner and the INSERT no longer idempotent, each restored. bash ci/local.sh is green against Postgres 18 (4565 passed over 142 binaries, 13 ignored) with all three new conformance cases running on the real database, and the forge suite is green after the sweep (638 passed). NOT EXERCISED AGAINST A REAL DEPOSIT: nothing writes a runner deposit until loot runner add, so every row this has run against is one a test filed, and the first run over a mailbox a push filled is owed to that ticket. migration 0026 rides the forge binary so the forge owes a deploy, and nothing is broken until then because no code path writes the table yet (#2159) a4cabdfd · 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.