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… - Give the forge an operator channel, with one redaction rule (#522)
f9ca9f91 · dbf3dbe6…diff - Forge storage drivers: Postgres metadata tier, S3 blob tier, and one conformance suite both must pass (#516)
f65970e6 · dbf3dbe6…diff - loot-forge drains on SIGTERM and SIGINT instead of waiting out SIGKILL (#657)
0485a488 · dbf3dbe6…diff - forge: the migration ledger records a statement checksum and refuses silent divergence (#720)
1f851cda · 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 - 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 allowlist parsers get one home in loot-net, which un-forks six copies and restores the OpenSSH form loot-relayd had silently narrowed back to hex a day after #1086 widened it (#1167)
a702c5dd · dbf3dbe6…diff - the argv door stops being per-binary and becomes one module in loot-net that every binary asks, and flag_value is deleted rather than widened - zero definitions remain workspace-wide, which turns the class is closed from a claim into a deletion. the recorded reasoning this ticket pointed at refused widening a shared reader, which is a different act: it argued a reader must not be taught a new spelling because that changes parsing everywhere at once, and its own third bullet diagnoses the hole as this binary having no gate, so it points at giving the binary the gate and leaving one reader. per-server spec tables were rejected as a second door, the thing this class exists to prevent, and a new crate was rejected because every binary already has loot-net in its graph and the module is pure std, so the odd thing about the home is its name and not its coupling. the destructive half is demonstrated against real binaries and seeded stores rather than argued: a before binary built by reverting only the readers reaps a real blob under reap-grants --older-than 30 --dir store --addr --apply, because --addr is never consulted in reap mode so nothing else in the line looks wrong, and it forges the window under --addr --older-than 1 --older-than 3650 --apply, reading one day where the operator wrote 3650 - after, the first refuses with nothing has been destroyed and the second keeps the grant. the forge enters migrate mode, the superuser DDL path, under --addr --migrate and now treats it as an address. this ticket's own headline argv is corrected rather than repeated: --dir --apply is destructive only under the s3 backend, since on fs both binaries stop at is_relay, and the fs-reachable variant is the one it did not name. loot-first's private reader is in scope and gone too, because land declares two valued flags and --allow-publish --pr 12 --pr 9 landed the PR nobody typed. an early forge pin stayed green through the revert, since the bug was never in the traversal but in what main asked, so the call site was extracted and re-proved red; and two further destructive readers are reported and not fixed, one of which takes a size operand as its scratch directory and recursively deletes under it (#1628)
089f8dda · dbf3dbe6…diff - the door's own docs stop naming the crate it left, and the count this ticket asked to derive is deleted instead - because deriving it the way the ticket suggested would have confirmed the stale number rather than contradicting it. the ticket said loot-cli's census is spelled one way and the others another, so a grep would undercount; loot-first shares loot-cli's spelling, so grepping the others name returns exactly three, which is the wrong number already written on the line. a derived four is right today and the property is right permanently, so the sentence now says every other crate that ships a binary keeps its census in its own crate and the roll call derives the set rather than listing it. the roll call sentence is rewritten as a mechanism rather than a new pointer, since a glob pub use cannot carry a cfg(test) mod: no amount of following crate::flags or loot_net::flags reaches the roll call, and a reader who tries finds nothing with no way to tell whether it moved or never existed. a third stale path the ticket did not name is repointed too, a rustdoc link into the shim, because correcting two of three is the trap this ticket itself cites - and two more staleness sites in the door's own file go with them, a doc counting four binary crates and a fifth, and an expect message still naming the crate the door left. the new pin derives the door's crate from the single is_flag definition and holds two shapes to it, that a re-export names its target in its own header and that every backticked fully-qualified path into the module names the door's crate, with backticked the discriminator between guidance and a call through a re-export both moves deliberately left working. four reverts were each proved red, and a fifth mutation exposed the pin's own vacuity - dropping the shim detector's comment guard left it green because the doc had not spelled the shape it reads, so the shape is spelled and the control now fails. this ticket's premise that both moves left stale docs is also wrong: #1628's header was correct when written and the staleness is entirely #1682's (#1685)
fa895fab · dbf3dbe6…diff - the shorthand whose NAME states the path axis while its SHAPE states the arity axis is DELETED, so the terse spelling is now the one that REFUSES - which was this ticket whole thesis, that the wrong declaration was cheaper to write than the right one and that is why the silent-drop class kept recurring. every site that meant it now types the open constructor out, and the only shorthand left is the one that takes nothing. option A beat option B on BOTH axes, measured rather than preferred: B would have changed the constructor signature, so EVERY open call site owed a reason string - including the path-taking and the genuinely variadic ones - and the two dozen dispatch verbs would each have written the SAME sentence, which is boilerplate that teaches nothing and is itself a hand-maintained population. so A has the smaller blast radius AND the stronger property. the blast radius is ZERO BEHAVIOURAL, because the retired constant was literally that expression: no verb declared arity, no slot kind and no refusal moved - thirty-five declaration sites, eleven imports and about twenty-five prose sites, with the workspace check clean and no new warnings. the exemption list was ALREADY down to its two legitimate names before this began, since #1569 narrowed the other twenty-one hours earlier, so nothing was added to it or taken from it, and the #545 refusal that earns those two their place is untouched by construction - pinned rather than incidental, because the mutation that hands one of them the no-arguments declaration reddens all three censuses. the rename then exposed two more counts standing beside sets that MOVE, and both are fixed rather than carried: a fixture doc claiming all FOUR verbs it exists to serve are exactly this shape, where there are FIVE production attachers and NONE of them is that shape, and a line naming the four verbs that used the retired constant. both now state the rule and count nothing. the new guard refuses BINDING the zero-slot open claim to a name, which is the single edit that would undo this, while deliberately NOT refusing a leaf that spells the claim out at its own spec - the two told apart by what PRECEDES the constructor, with both run through the predicate before its answer is read. its limits are in its own header. and the control that mattered is the second: with the comment-strip removed AND the predicate control disabled, the tree scan names the flags file itself, over the retired declaration QUOTED INSIDE THE SURVIVING CONSTANT OWN DOC - so the strip is load-bearing rather than decorative. the first and third mutations are each other discrimination, one reddening only the tree arm and the other only the binding-versus-spelling arm, and the fourth proves the floor fires at zero files rather than agreeing silently (#1675)
123fdbd4 · dbf3dbe6…diff - the whole-word reading gets one home and a use this walk cannot follow is refused rather than skipped: #2126 landed census_text so that a .rs-text reading more than one census needs and none of them owns has one home, and the very doc saying the source_walk whole-word reader was shared rather than copied per census had a copy of it sitting in the temp-root census next door, drifted already, one asking char::is_alphanumeric and the other an ASCII byte test, so the sentence was false the day it was written. the reading moves into census_text as whole_word_matches, the offsets a word stands at as a whole identifier, with names_whole_word derived from it rather than written beside it, so the caller that wants the answer and the caller that wants the places cannot come to disagree about where a word begins, and the boundary is the Rust one and not the ASCII one, since a boundary that reads too narrowly lets a longer identifier answer as a whole word, which is a census reporting an offence that is not one. the copy the ticket found was not the only one: loot-first, loot-forge and loot-relayd each held the same closure inside the bare-flag census of its own crate, and each reads the shared file now through the same cross-package path attribute the other callers use, so one edit reddens every consumer of it. the docs stop naming callers and say instead what decides where a reading lives, which is the census_text admission rule, and the seam a reader arrives from now carries why the import reading stays in source_walk: it is keyed to that module own name and to what helpers_named can find afterwards, so it is not a flat question. that import reading also stops enumerating what it refuses, since the enumeration was already stale: a use item naming the module is the module under its own name, or names taken out of it, and everything else falls through to refuse_import, which is how use crate::source_walk as sw, outside both branches and skipped in silence, the under-count #1946 was filed on surviving the ticket that closed it, becomes a refusal without being named. the narrowing census in main.rs stops re-deciding which narrowing is in force and asks the door, since taking the first declared narrowing whose flag a shape requires and taking the narrowest part on a shape requiring two narrowing flags of different counts, latent while no verb writes that shape and now unreachable because the rule has one home, pinned where it lives. the forwarder that discards a door result is declined with the reason at sole_statement: a door too many is a site too many and the offenders are asserted empty, so it arrives red naming the call, while reading the discard would shrink the door set, which is the direction that loses a site in silence. one more of the same class was found beside the rest: code_mask said the two censuses that share this in the present tense, and it now speaks in the past about the two walks it replaced. red under mutation, counts read each time: the shared boundary widened to admit every character reddened all five consumers from the one edit (loot-cli lib 5 passed and 2 failed, loot-cli temp_root_census 1 passed and 2 failed, loot-first 0 passed and 1 failed, loot-forge 0 passed and 1 failed, loot-relayd 0 passed and 1 failed), the alias refusal put back to the silent skip (0 passed and 1 failed), the plain module import refused as well, which is the other direction (0 passed and 1 failed), and the door narrowing rule flipped from narrowest to widest (loot-core 0 passed and 1 failed, with the CLI census still green, which is the point of the move). no migration, no wire or format byte moves, and nothing outside test support and a doc comment moves, so this owes no deploy. the workspace suite is green (4049 passed over 129 binaries, 8 ignored) (#2148)
cb7f057f · 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.