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 - loot push: send the change delta once per push, not once per 32-object batch (#680)
The defect, receiver-counted (#633) by the CLI tier (#635): a push of the
2000-file fixture put 14.4 MB on the wire for ~0.5 MB of bundle, because
every 32-object batch re-carried the full change delta — O(batches x delta)
where O(delta) is available, the same shape #98/#99 fixed one level up.
bundle_wanted_batched now builds the first bundle exactly as before (change
delta, attestations, purge lane, first object batch) and every follow-up as
object-only: the batch ciphertext plus the public keys for exactly those
objects. Keys stay per-batch because a receiver files a riding key only for
an object stored from the same bundle; everything else lands with bundle
one, ahead of all object bytes (the sequential send in cmd_push is
load-bearing).
Compatibility class: no format change in either direction. A Sync frame
with changes=0 has been a valid encoding since v1 (the golden fixtures pin
it) and stow stores objects without requiring a change in the same bundle,
so the deployed relay ingests the new shape on its existing path — and an
old client re-sending the delta every batch stays accepted (pinned by a
forge test). bundle_impl is untouched, so single-bundle products —
bundle_bytes, the fetch server, the metadata-only /ingest bundle — are
byte-identical.
Resume after interruption still works and is tested: an interrupted push
leaves the relay with an incomplete closure, negotiation_have (#217)
refuses to claim such heads, and the re-run re-offers the delta in its own
first bundle — once per attempt, never once per batch.
Forge: /stow defers the change at batch one and no longer resolves it
alone; the closing /ingest (whole delta, all keys, unchanged) does. An
object arriving in a change-less follow-up takes introduced_by from the
envelope pusher — same key as the author on a self-push. Documented at
loot-forge::ingest and in the ADR 0024 amendment.
Measured with loot-perf-cli from this lane against a loopback relay,
small_files/2000, reps 2, idle box: wire_bytes 14392934 -> 600236 bytes
(-95.8 percent), bit-exact story unchanged (0 discarded batches).
1f59fa97 · dbf3dbe6…diff - the forge stops shipping keys for retracted publications, and publication finally has a producer (#709)
ddcf4de9 · dbf3dbe6…diff - an ingest stops asking the store once per tree entry: the arriving delta's visibilities load in one query, so a first push's head declaration returns instead of hanging (#1072)
68b86ccc · dbf3dbe6…diff - a forge pull stops probing Postgres and R2 once per (change x entry): /offer and the bundle walk resolve entitlement in two batched queries, and the counting double finally points at them (#1154)
b28ca59e · dbf3dbe6…diff - a forge pull reads the delta's manifests instead of every manifest in the repo twice, so deciding there is nothing to send stops costing the whole repo, and an incremental pull stops re-paying it once per 32-object batch (#1195)
c78dd8f5 · 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 - 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 - may_propose is born beside may_push and provably does not widen it: the write rule stays exactly as narrow as it was, and the pin is not a sentence but #927's whole matrix - five signers across an accounted and an accountless namespace - run through require_pusher with the contribution door SHUT and again with it OPEN, demanding identical rendered answers including the message text. That covers the rule's entire reachable input space with respect to this change, because require_pusher reads signer, owner_pubkey and account_of and nothing else, and the break that proves it is the conflation a careless implementation would actually write: consulting the door at the top of require_pusher collapses the whole open column to ok, which IS a stranger moving the owner's ref, and it is quoted in the test's failure rather than described. The door is two halves in order, the repo's own accepts_proposals and then a LIVE account_key row, returning Result rather than bool so the refusal sentence is the product; retirement bites for #927's reason, since a rotation retires a key so a leaked one stops writing and a proposal is a write. The account tier is READ and never written - account_tier.rs survives completely untouched, and rather than assert that, its scan was proved to reach the new files by planting an UPDATE of tenant_blob in the migration and watching it name the file. The setting is migration 0012's boolean defaulting to false with no backfill and no wire caller, so it ships CLOSED on every repo, and that is recorded in the ADR amendment so nobody later reads a shut door as a bug. It cannot be derived, proved four ways rather than argued: a behavioural case over both stores that makes the four tempting facts true - the repo has a tip, holds changes, is metadata-public, publishes a path, each asserted by its own control first - and then re-reads the flag and finds it still false; a DDL check that 0012 contains no UPDATE, GENERATED, TRIGGER, SELECT or CASE WHEN and names none of the tables an inference would read, guarded by a vacuity check and the inverse read that its prose still names them; the DEFAULT plus absent backfill; and the upgrade shape run against a REAL database, where a repo that is both metadata-public and carries a live publication comes out of 0012 shut. Two things are recorded because they were caught rather than avoided. An existence-oracle guard initially passed WITH the oracle planted, because the fixture repo had never been ingested to and so was the same state as an absent namespace to every trait method - it needed a real tip plus a control asserting the two states differ before the break went red, and the reason is written beside the helper. And account_of has THREE consumers rather than the two a pre-existing header credited it with, having missed purge::same_live_account, untrue since #927 - so all three sites now NAME the consumers instead of counting them. The Postgres run earned its keep independently: a conformance case omitting put_object before publish is tolerated by the in-memory tables and rejected by a real foreign key, so memory alone would have shipped it (#1650)
8403defb · dbf3dbe6…diff - the sync surface learns membership, which is ADR 0077's other half: AccessClass::of stops being a byte compare and becomes owner-or-member, with the owner compare staying FIRST because a repo can have an owner with no account at all - repo carries no tenant_id and a push creates no tenant - and the membership arm resolving through a LIVE account_key row, since a rotation retires a key precisely so a leaked one stops working and reading a collaborator's sealed metadata is what a leaked key would be used for. The read is a new trait method with no writing sibling, and liveness stays in the CALLER so the two stores cannot disagree about #927's rule. Two breaks prove it and the interesting one is the small break: resolving membership before the owner compare moves exactly ONE cell of twenty-one, which is the point rather than a weakness, because a matrix without an accountless owner in it would not have moved at all. The dangerous shape is the other one - resolving from account_key alone with no repo_member join, which makes every account a member of every repo - and it turns eight of twenty-one green including two whole columns; the same break is red in SQL against a live cluster, and the unjoined query is run BESIDE the shipped one inside the test and asserted to admit the repo it should not. The write rules are pinned by #1650's method rather than described: the whole matrix of five signers across both door positions, run with membership rows planted for every account and again with none, demanding identical rendered answers including the message text, with a control that those same rows really do move the read class - and folding membership into require_pusher flips the contributor cell from a 403 that names another account to ok, which IS a stranger moving the owner's ref. The may_ship_key pin found something that strengthens ADR 0077's sixth decision: widening the class ships no key that was not already world-readable, and a member gets the Internal object's BYTES with the key set asserted as exactly the published oid rather than as an absence - but making may_ship_key answer true left the case GREEN, because verify_publication independently refuses a non-world object, so the lane is closed THREE times and not twice and the pin cannot be broken by widening Member in any spelling. A gap the ticket and the ADR both missed is pinned rather than fixed: a rotated owner's OTHER live key reads Anon on their own repo while require_pusher admits it, which this change did not cause since a byte compare answered the same way, but the new matrix makes it visible - the remedy needs no new predicate, only a repo_member row for the owner's own account. The claim that there is exactly one read method here was a live count in nine places and staled the moment a second arrived, so all nine become names and no number is written anywhere (#1728)
1fc1f6a2 · 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 bundle stops carrying the address it can derive from the bytes beside it, and the bump is 12 to 13 rather than the 11 to 12 the ticket names, because #1539 already took 11 to 12 on main and has never shipped - every tag through v0.4.20-deploy.2 still reads 11. Reusing 12 for a second layout was rejected on measurement rather than taste: read_version can only compare numbers, so two incompatible layouts under one major is exactly the misparse the marker exists to prevent, and pinning the constant at 12 while writing the v13 layout turns bundle-codec tests red with the decoder taking the legacy arm on bytes it wrote itself. No count is given for those reds on purpose - the experiment was run twice and answered differently the second time, which is the whole reason this repo does not write a number it has not just measured. ADR 0019's own amendment says every layout change bumps the major, and two majors between releases has precedent - v8, v9 and v10 all first shipped in v0.2.0 - so a major is not a release label. The encoder stops writing the 32-byte address and the decoder derives blake3 of nonce and ciphertext for every major; the v12-and-earlier declaration is parsed for cursor correctness and DROPPED rather than checked, which is the treatment v5 gives the v4 escrow section and v10 the v9 purge lane, and dropping is the stronger choice because a checked claim still has to decide what to do on disagreement while a discarded one cannot reach any outcome, so the compat lane cannot carry the attack either. The grant_ids lever is taken but NOT as the ticket frames it: the list really is a pure function of vis plus the world marker, and rebuilding it on decode would still be wrong, because that synthesises an authorization token from an invariant nothing enforces on a hand-built SealedObject - so it becomes a two-bit flag byte carrying exactly what the pre-v13 wire could carry, with unknown bits REFUSED rather than masked, since a silently narrowed holder list is how an object stops being published with nobody told. bundle_bytes falls 45,757 to 37,857, minus 17.27 percent, and the figure reconciles to the byte: 200 addresses at 32, plus 180 Internal objects at 8, plus 20 Restricted at 3. The ticket's own baseline of 45,753 was stale by the four bytes #1539's rename count added, and its estimate for the grant half comes in at 3.28 percent rather than 3.5 because the fixture makes every tenth file Restricted, whose holder list #521 had already reduced to a bare zero count; the address half lands at 13.99 against the ticket's 14.0. This is a safety change and not only a size one, so it is pinned on both sides: a hand-built v12 frame declaring one address over bytes that hash elsewhere must arrive at its true hash, led by a positive control because refused and impossible look identical from outside and only one is the claim, and a byte-flip sweep over a real v13 bundle - deliberately not a round trip, which passes whether or not the address is derived - asserting every decode that survives yields a key equal to the hash of the bytes read beside it. Restoring the declared address executes the attack and prints it: one bit flipped at offset 11 and the object separates from its content, ac4373d5 against ad4373d5. A golden is added for the object lane because a field REORDER passes the width test and fails only the golden, and that lane had no frozen bytes at any version. The forge's shipped-under-the-wrong-address refusal is DELETED rather than left standing, which is the riskiest edit here: decoded objs has exactly one constructor and its keys are now obj.address() by construction, so the branch is unreachable from any input, and a refusal nobody can show is doing anything would read to the next maintainer as the thing keeping mislabelled bytes out of the blob store - what actually keeps them out is one layer down and pinned there. VERDICT_CONTRACT moves with the major and no machine-output column does, which is stated in the version history so a consumer pinning contract 12 knows to move and knows the columns are identical (#1384)
Perf-Baseline: reset FORMAT_MAJOR 12 to 13 orphans every recorded point; bundle_bytes 45757 to 37857 is the intended -7900 from #1384
1c22e8ad · dbf3dbe6…diff - propose refusal 5 and its write can no longer be separated by another writer: record_proposal takes the row its caller read and writes only if that is still the row on file, compared as a whole row, answering false otherwise, and admit reads again and decides again when it does, so a caller who loses the race meets refusal 5 against the winners row and the rule stays in admit rather than the store. the reference store compares under its lock, and the postgres driver compares and writes in one bound transaction, reading with FOR UPDATE and creating with ON CONFLICT DO NOTHING; no migration. two conformance cases hold a second writer between the authorizing read and the write, one with two concurrent proposers and one with the author withdrawing, and both went red on the old code on both stores, with two proposes admitted where one is right and the withdrawal undone; a third pins the compare itself on both stores, and a postgres case holds the row from another session inside the call and went red with FOR UPDATE removed. removing either store compare or ignoring the answer in admit also turned cases red. ADR 0075 gains a 1747 amendment and CONTEXT.md says the race is closed. the loot-forge suite is green against a throwaway cluster with the CI env block, and the rest of the workspace is green bare (#1747)
14232fd5 · dbf3dbe6…diff - withdraw and its close can no longer be separated by another writer: close_proposal takes the row its caller read and closes only if that is still the row on file, compared as a whole row, answering false otherwise, and withdraw reads again and decides again when it does, as admit has since 1747, so an author whose tip a carrier moved in between meets the author check against the moved row. the reference store compares under its lock and the postgres driver reads the whole row with FOR UPDATE in the transaction that already read its state; no migration. a conformance case holds a carrier amendment between the read and the close, and it went red on the old code on both stores (0 passed, 2 failed) with the withdrawal closing a row whose tip another key wrote; a second pins the compare on both stores, and a postgres case holds the row from another session inside the call and went red with FOR UPDATE removed. ignoring the answer in withdraw, or removing either store compare, also turned cases red. binding_census now opens a fn item only outside another fn body, found by counting braces outside the string and char literals and comments it recognises, and fails a file whose count does not close; a planted pg/planted.rs method holding a nested fn before unbound SQL went red under the new census (2 passed, 1 failed) and stayed green under the old one (1 passed, 0 failed), and a fixture case with unbalanced braces in literals went red on the old census and on the new one with its string or char handling removed. the visibility fixture now covers pub(super), pub(self), pub(in path) and private methods, the stale upsert prose and the statement count in pg/meta.rs are fixed, the misplaced memory.rs comment moves to its arm, CONTEXT.md points at the matrix doc instead of a column count and adds the 1955 caveat, the evidence entry calls the 1956 mechanism suspected, the rewrap leftovers in model.rs, ADR 0078 and conformance.rs are fixed, and ADR 0075 and CONTEXT.md record the withdraw follow-up. the loot-forge suite is green against a throwaway cluster with the CI env block and bare, and the rest of the workspace is green bare (#1959)
e3d4cd50 · dbf3dbe6…diff - loot apply-patch refuses a patch that deletes a path and also renames it away: refuse_shared_paths now counts every path a stanza removes, as a deletion or as a rename old path, and refuses both rows, where the run used to move the file, remove the old path once and discard the second NotFound, and a row it refuses that was already refused keeps its first reason with the shared one appended. a rename old path that another stanza writes stays allowed, as #1995 pins, and ADR 0082 says why and where git apply differs; the rename writer now reports every removal error. the offer_cost double counts account_of, and the two constant-cost cases pin it at the one read Reader::new makes since #1955. the apply_strict comment and ADR 0082 no longer say an unmarked kept last line claims nothing: format-patch writes one when only the new side ends with a newline, so it stays unchecked, and a hunk that adds no line loses that newline. plan_one shares one helper for the rename and add verdicts, the ClassReads, ingest_cost and offer_cost doubles and Reader::new have docs true to the code, no shared base double is added since the conformance double delegates every method, and the CONTEXT.md, ADR 0077 and ADR 0082 rewrap leftovers are fixed. before the change the new rename test went red (12 passed, 1 failed), as it did with deletions uncounted, with the deletion refusal removed and with the rename refusal removed (12 passed, 1 failed each); the kept-reason test went red with the first reason overwritten (8 passed, 1 failed); the account pins went red with a per-object account_of read in readable_rows (11 passed, 2 failed), which the old double let pass (13 passed), and with the owner arm reading nothing (11 passed, 2 failed). the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (#2006)
f16115c2 · dbf3dbe6…diff - a retired key no longer burns what it introduced on the forge: purge::honor_burn now asks account_of about the burner and applies the require_pusher rule with the introducer as owner, so a key with no account row burns by the byte compare as before, a live key burns as the introducer or as a key of the account that holds the introducer, and a retired key is refused, as the new RetiredKey verdict where the live rule would admit it and as NotEntitled otherwise. same_live_account becomes same_account and no longer asks liveness. the maroon arm still asks no retirement, since it is the only way to revoke a forge grant and has no account lane, and the relay and peer policies read no account tier, so none of them moves. a new server.rs test asserts that the burn verdict agrees with require_pusher over the #927 cast plus a retired stranger, and member_class.rs runs the rule against real account_key rows. before the change the in-memory reproduction went red (11 passed, 1 failed) and member_class against a throwaway cluster went red (3 passed, 1 failed). over the purge tests and the server.rs agreement tests the pins went red with the retirement check removed (13 passed, 4 failed), with the introducer compare answering first (14 passed, 3 failed), with an accountless burner refused (15 passed, 2 failed), with retirement asked before entitlement (15 passed, 2 failed) and with the tenant equality dropped (14 passed, 3 failed), and member_class went red under the first three (3 passed, 1 failed each). ADR 0038 and ADR 0077 are amended, CONTEXT.md, store.rs and server.rs name honor_burn, and the Rotate card on the account page no longer says burning is not covered and names the maroon limit instead. the site gate is green, the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (#2004)
e6641d95 · dbf3dbe6…diff - loot apply-patch no longer keeps the old name of a rename onto a hard link: #2007 renamed the old path onto the new one whenever the two were one file, which rename(2) does nothing for between two hard links, so the old name stayed and the run exited 0. the writer now renames only when the two paths are one directory entry, which on windows is two paths with one canonical path and on unix two names of one inode that differ only in case and are not both listed in their directories, and otherwise writes the new name and removes the old one by name, and an old path is kept only when a row writes that directory entry, so a rename from a hard link of a path another stanza writes removes the old name too. a stanza whose path or rename old path is or passes through a symbolic link is now refused, checked component by component with symlink_metadata, since a write through a link lands at its target and the file identities follow links. PathKeys and PathKey replace Names and Alias, refuse_shared_paths asserts one row per stanza, the removed-twice refusal words are one const, honor_burn says it is pub(crate) only for the server.rs agreement test, and the purge.rs, ingest.rs and ADR 0082 rewrap leftovers are fixed. the Rotate card on the account page now says a leaked key stops pushing and proposing rather than writing, that the retired-key refusal covers burns, and that this forge does not check retirement on every signed request, naming withdrawing an authored proposal beside revoking a grant as examples, and its JSX comment states the forge-before-site deploy order ADR 0038 gives. the new hard-link rename tests pass on windows, where the old code already passed them, and their unix red-first has not run here; with the unix entry rule compiled on windows the rename tests stay green (20 passed) and go red with the new name read as listed (18 passed, 2 failed), the windows rule answering two entries goes red (18 passed, 2 failed), and the symbolic-link test, which uses a junction here since a file link needs a privilege, goes red with the refusal disabled (19 passed, 1 failed) and with only the last component checked (19 passed, 1 failed). the unix code type-checks for x86_64-unknown-linux-gnu in a scratch crate, ADR 0082 section 4 and CONTEXT.md state the entry rule and the link refusal, the site gate is green and the workspace suite is green (#2014)
ab00ecd2 · dbf3dbe6…diff - a prose pass narrows claims that read wider than the code, and LineDelta::rendered goes crate-private. ADR 0007, CONTEXT.md and the object_and_key_at doc say the reveal gate is asked at every read through the grant key door, not at every read, since grant_sealed keeps its escrow fallback by design; the Refit enum count, the family list in CONTEXT.md and the refit.rs header, and a stale claim that every planner runs the draft check, now point at the enum and the wildcard-free minted_edges match instead of a count; role_display gets back the blank doc line rustdoc folded into the last bullet, verified in the built HTML, and a record type added to ReservedRecord replaces a third record type; the Landmark entry records the one-line rendering from #1519; the revset named door and the hunkpick PATCH constant state the exceptions a reader finds (grep writing its own refusal, restore spelling -p out under the main.rs census); the ChangeGraph insert doc stops saying the callers do not insert parents-first, names DagRepo::apply_sync for an apply_bundle DagRepo does not have, and says why the ordered ingest_shared_lineage splice stays on insert; ADR 0023 now says what #1870 wrote in the delta_shape header, that the reveal_at split is about the field and not the number, which embargoed@ can carry. nineteen assertion messages lose the run of spaces a rewrap left inside the literal. LineDelta::rendered and Rendered become pub(crate), with a compile_fail probe and a positive control: the probe was red before the narrowing (2 passed, 1 failed), and making the method pub again reddens it (3 passed, 1 failed). items 2, 8 and 14 need no fix here: the #1515 raise is real because #1516 lowered the ceiling between the two raises, #1968 dropped the width narrative, and #1860 withdrew the figures. cargo doc warnings are unchanged for loot-core, loot-codec and loot-cli. no migration, no wire or format byte moves, and no forge or relay byte moves, so this owes no deploy. the workspace suite is green (3913 passed over 123 binaries, 7 ignored) (#1848)
eb966bdb · dbf3dbe6…diff - the forge learns runners and jobs (#2157, ADR 0091, map #2099, first of three slices of #2129): migration 0018 adds the runner and job tables, both repo-scoped, so both join 0014 row-security set and take 0017 forge_is_bound_to_one_repo policy verbatim in shape, and tests/rls.rs ENABLED_TABLES names them, which is the set equality that forces the decision rather than a list nobody maintains. A runner is its OWN keypair under NO account and that is the security property expressed as schema: require_pusher and the proposal door both resolve a signer through account_key, so a runner key is refused at push and propose BY ABSENCE, where the same key on the owner account would have been a full pusher since require_pusher admits any live key of the account; the row is written from an envelope the namespace owner signed, which is a proof needing no browser session, so tests/account_tier.rs is green UNAMENDED. A job identity is the triple version, trigger, kind, so creation is idempotent by the primary key; member and approved are computed in at creation so a later membership change cannot re-authorize queued work; a claim is claimed_by plus a lease the CLAIMANT names, exclusive by FOR UPDATE SKIP LOCKED in the driver and one lock across find-and-write in the reference store; an expired lease returns the job by a READ-TIME predicate, so there is no reaper to fall behind; a verdict is accepted whatever the lease says, because the runner did the work and a bookkeeping deadline must not discard it; a job is born unclaimed and unfinished and a creation carrying either is refused, since those are the two writes that have to be exclusive. Trigger and Kind move from loot-cli to loot_net::pipeline, the one crate the forge and the CLI both already depend on, re-exported so every path reads unchanged: the forge stores what the CLI writes, and two copies of one vocabulary would be a list to keep in step. Capabilities is exactly Kind as a set, and a bit this build cannot name is REFUSED rather than dropped, because reading a newer runner row as covering less would hand it jobs it cannot do. Five conformance cases join the roll call, so both stores answer one contract and neither can name a subset. Measured: bash ci/local.sh green end to end against Postgres 18, 129 test binaries, 0 failed and 0 SKIPPED, which is what proves the driver, the migration and both policies rather than only the reference store; the claim exclusivity and the lease expiry are proved there on real Postgres, and an EXPLAIN plan under contention is NOT measured here and is owed to #2158 where the poll load is. Three refusals found by the run rather than by thought: the destination census flagged a fixture wake-up URL, allowlisted with the reason that it is a value the store round-trips and never an address anything dials, which took the allowlist to ten and moved ADR 0074 spelled count; the shipped-predicate pin read migration 17 alone, so it now reads the migration that CREATED each table rather than recording which came from where; and the binding fixture seeds a runner and a job row per repo, without which its controls were zero and the refusal below them would have passed for the wrong reason. Five pins each broken once by a named mutation, red at 1 failed then green at 0 failed with the count above zero: the claimable predicate widened, the lease made to hold forever, retirement re-dated, the born-claim refusal disabled, and an unknown capability bit accepted. No routes, no wire and no verb here; those are #2158 and #2159. No FORMAT_MAJOR move (#2157)
ea4d34a5 · dbf3dbe6…diff - a forge push declares the delta past the heads the forge itself names rather than the whole history: declare_forge_heads built its bundle with have = &[] while the RefState it had just read for the CAS generation carried heads it read no other field of, so every push re-encoded and re-POSTed every finalized change in the repo, each with its whole manifest (#288), however little had moved. this is push own #728 move made on the route a forge declares heads on, and the forge side needed nothing built: prepare resolves against already-stowed state and the completeness refusal is over the changes in the bundle, so a change the /stow batches defer is one the push is introducing, which nothing the forge declares reaches, and it still rides. a declared head this position does not hold expands to nothing because the closure walk filters its seeds, so it subtracts nothing and whatever only it would have covered is sent as before, which is the safe direction since /ingest writes content-addressed change rows. WHAT IS GIVEN UP IS NAMED RATHER THAN LEFT TO BE FOUND LATER: the ANYONE key lane no longer re-presents a key for every published object in the history on every push, so a publication row missing for history already ingested no longer heals on the next push of anything; the steady state is untouched, published_key being global and append-only and publication being per oid and repo with a terminal retraction, both written by the ingest that first carries the change. measured twice and both readings live in the tree rather than out of repo: on the real binary against a real forge the /ingest request body of a push moving one change over a 7-change history of 16 paths reads 8880 B before and 2104 B after, and on the bundle at two depths over 24 paths the empty have reads 12039 B over 8 changes and 22615 B over 16 while the scoped arm reads 2817 B over 1 change at both, so the before figure grows with the history where the after one does not. red under mutation, counts read each time: the helper made to ignore its have failed both pins (30 passed and 1 failed in the sync unit tests, 11 passed and 1 failed in forge_push), the call site made to pass the empty have again failed only the wire pin as designed (31 passed and 0 failed in the unit tests, 11 passed and 1 failed in forge_push, reading 8880 B against the 7906 B of the first push), and the deep fixture made no deeper than the shallow one failed the growth control (30 passed and 1 failed, 12039 B then 12039 B), each restored to 31 and 12 passed with 0 failed. the now-false prose is corrected where it stood: RemoteSync haves and declared_heads, the loot-forge ingest and publish module docs, the publish re-verification comment, two forge test comments, the ADR 0024 amendment and the CONTEXT Ingest entry. no migration, no wire or format byte moves and no host behaviour moves, but what a client sends at /ingest moves, so this rides the next release and owes no deploy. the workspace suite is green (4142 passed over 132 binaries, 8 ignored) (#2222)
22beae44 · dbf3dbe6…diff - landed becomes a state something actually writes, and the walk that decides it had to see the push's own changes: ADR 0075 declares three terminal proposal states and until now close_proposal's only non-test caller was propose::withdraw, so landed - the one that is nobody's act, the work being in ref_head - was unreachable and a row read open over work the repo had already taken. ingest now closes the proposals its declared head set lands, decided OUTSIDE the transaction because IngestTxn is assembled and validated outside so the transaction stays pure writes after the CAS, and that same CAS is what makes the decision sound rather than racy: generation_expected pins exactly the head set the walk assumed, so an interleaved push fails the swap and takes the closes down with it. the ticket and ADR 0092's predecessor both said this needed nothing new because walk already reports an empty extent for a landed tip, and that is true of the STORED graph and the stored graph is the wrong one: a maintainer lands a proposal by MERGING it, so the change making the tip an ancestor of the new head set rides in this bundle and is not in the store yet, and walking stored edges alone closes nothing on exactly the push this exists for. the walk overlays the prepared changes' parent edges and is red without that overlay. only open rows are candidates and the store applies the decided row only over an open one, so a proposal that went terminal between the decision and the commit keeps its own declaration and its own date - a push cannot overwrite a withdrawal it never saw, asserted in the conformance suite against both stores rather than in one. a repo with no open proposal reads its proposal rows and stops without fetching the graph at all, which is every push to every repo never proposed to. red under mutation, counts read each time: the prepared overlay dropped, so the merge in this bundle lands nothing (421 passed and 1 failed, restored to 422). the SQL is new so it was run rather than read: bash ci/local.sh against a throwaway Postgres, where pg::tests::an_ingest_closes_the_proposals_it_declares_landed passes beside its memory twin and 166 pg tests ran, so the UPDATE was parsed by a real server rather than only by a text test. no migration, no wire or format byte moves, and the forge behaviour moves, so this rides the next deploy (#2177)
fb7c63c8 · dbf3dbe6…diff - the sentence written to replace a deleted list names the definer instead of counting, and the fix-up is read back under the rule it enforces: ADR 0073 said the gated work counters were the object pair and a further one at each amendment since, which is false because the #1903 amendment added none, so the clause now names measure::WORK_COUNTERS and stops. every other count beside a set that can grow is replaced by what decides membership rather than by a corrected number - the first nine and the six artifact counters and the other three Work variants in gate::COUNTERS become every row WORK_COUNTERS does not name, the Tally Display doc shows one label=value per Work::ALL entry and an ellipsis instead of hand-listing the labels, the deposit lane counts point at kind.widens, and the landed proposal state points at store::ProposalState. THE SWEEP MISSED THREE COPIES OF ITS OWN ITEMS and they are corrected here too, because a corrected claim left standing in a second copy is how #1903 shipped one stale: the three lanes sentence in loot-core custody and again in its test, and the ADR 0075 three terminal states sentence duplicated in a forge server test. two code fixes ride along: #2174 landed the wire_state doc inside the propose withdraw doc comment, so the may_propose rationale documented the wrong function and the route documented nothing, and the stale-tip refusal was broken across source lines with no continuation, so an operator read the indentation of the source in the middle of the message. ONE ITEM IS CORRECTED RATHER THAN FOLLOWED: the ticket reads that literal as carrying a newline, and on the tree it is a single line carrying two runs of collapsed indentation, so the rendering defect is real and its shape is runs of spaces rather than a break. red under mutation, counts read each time: the continuations removed so the break rides in the string again failed the strengthened pin (0 passed and 1 failed, the panic printing the refusal across three lines), restored to 1 passed. the CONTEXT rewrap is a pure reflow, identical byte count and identical word stream, so the generated membership sentence and its pin are untouched. no migration, no wire or format byte moves and no host behaviour moves, so this owes no deploy. the workspace suite is green (4174 passed, 0 failed, 8 ignored) (#2234)
f7721acb · dbf3dbe6…diff - the forge keeps the attestations a push carries and serves them back with their change, where until now it kept none: ingest never read the bundle attestation lane and every bundle serve built carried an empty one, so a tag, a note or a sign-off pushed to a forge was gone and the push said nothing, while a relay was never affected because its store is a DagRepo. the rules are the engine own two and none is new: an /ingest keeps an attestation that verifies and whose change rides the same bundle and drops the rest without failing the push, and a pull serves one only with its change in the bundle being sent, so a caller already holding a change is not re-sent its attestations and a metadata-private repo needs no new rule. /stow keeps none, because it is also a proposer path and a stranger attestation must not write into the owner repo. migration 0019 adds the attestation table, repo-scoped, keyed on the engine dedupe triple so a re-push is a no-op and the first signature stands, with row security enabled and bound by the 0017 policy, and it joins the rls census, whose doc still said eight tables with ten listed and whose shipped-policy check named migrations by index - it now reads every migration from 0017 on. the driver sorts role COLLATE C, because the reference store orders by bytes and a text column otherwise sorts by the cluster collation. a bundle that sends changes pays one more batched read, so the two offer-cost pins move from 4 to 5 and a no-op pull still asks nothing. a late attestation on an already-pushed change still does not travel - that is #2251, in the engine and on every host - and a pin refuses the forge keeping one outside the bundle so that widening is done on purpose. red under mutation, counts read each time: keeping unverified attestations (0 passed and 1 failed), keeping them outside the bundle (0 passed and 1 failed), serving none (0 passed and 1 failed), a read that ignores the ask and a last-signature-wins upsert (1 passed and 1 failed each, the pg stamp skipping off-cluster), each restored to 1 and 2 passed; and on a throwaway Postgres 18 the COLLATE dropped failed the ordering case (0 passed and 1 failed) where the unmutated arm had passed, restored byte for byte. bash ci/local.sh is green against Postgres 18 (4200 passed over 134 binaries, 9 ignored), after one unrelated loot-cli flake on its first run, filed as #2258. migration 0019 rides the forge binary and nothing on the wire or in a format moves, so this owes a forge deploy (#2250)
97ace3ec · dbf3dbe6…diff - the forge gains the runner routes, part 1 of 2 of this ticket: the owner registers, retires and lists runner rows through /runners/add, /runners/retire and /runners/list, and a runner claims a job and answers it through /jobs/claim and /jobs/verdict, each with its client half in the new loot_net::runners. a runner key is refused at push and propose by absence and admitted at the claim by its row, and since the live operator door lists the owner alone a route a runner calls passes a delegated door, server::authenticate_runner, which admits a live row only while its registering key still passes the operator list, asked every request, so an owner the operator stops listing takes their runners with them. a verdict is accepted from the job claimant whatever the lease says and refused from anyone else, including a runner whose expired claim another has since taken, which narrows ADR 0091 section 7 and keeps its reason; the compare runs inside the store write, FOR UPDATE in the driver, and the signed pass is written in that same write into the attestation table, after the route checks it is present iff the job passed, verifies, and is this runner pass over this version under this job role. retirement is now terminal per key, because the 2157 upsert rewrote retired_at and a forge envelope carries no freshness, so a replayed add could bring a stopped key back; an account key, the owner own included, is refused as a runner; the claim filters by the runner trust floor through Trust::admits, one predicate for both ends; detail is capped at 16 KiB, a log tail at 256 KiB stored by its hash, a lease at 24 hours, a scope must reach .lootpipeline, and a wake-up URL must be https to a host that is not literally private, with the resolved-address check left to the sender in 2159. JobOutcome, the pass roles and the pipeline file name move to loot_net::pipeline and are re-exported, and the forge act tags become one repr u8 enum so a duplicated tag cannot compile. the job.log_oid cross-reference to 1720 is written in migration 0018 as a comment, so its checksum is unmoved. measured on a throwaway Postgres 18 on the desktop and not the VPS: the claim is an index scan of job_claimable at 0.14 to 0.17 ms over 50000 jobs, 4.0 ms at worst over 20000 inadmissible candidates, and one polling runner gets about 139 claims a second while 32 level off near 2150. red under mutation, counts read each time: fourteen named mutations through the routes and the reference store each went red at 0 passed and 1 failed or 1 passed and 1 failed with the pg stamp skipping, one of them only after a first attempt proved too weak to be a mutation, and two driver-only mutations on Postgres 18 went red at 0 passed and 1 failed, each restored green. bash ci/local.sh is green against Postgres 18 (4243 passed over 135 binaries, 10 ignored). no migration and no format byte moves, and the forge needs a deploy for the routes to exist (#2158)
8dc22d7d · dbf3dbe6…diff - loot runners lists the runner rows a forge holds for this repo, part 2 of 2 of this ticket: name, key, the step kinds each may claim, live or retired, and when the forge last heard from it, owner-only because it is the owner configuration, retired rows included because who was allowed to run a version is what an audit asks. last seen needed a column, migration 0021 runner.last_seen_at, written by the runner door on every admitted request and throttled in its WHERE clause to one write per runner per 60 seconds, so a runner polling every second costs one write a minute; a registration never sets or clears it, so a runner registered and not started reads never seen, and deriving it from job rows was rejected because an idle runner finding no work leaves none. the first spelling of that throttle, last_seen_at <= $3 - $4, failed on Postgres 18 at the first touch, which would have made every runner request a 500 on the live forge while every in-memory test passed; the edge is computed in Rust now, and landed part 1 never wrote the column. the shape is born frozen under ADR 0023: porcelain pubkey state capabilities last_seen and the name last because it may hold a space, JSON with contract, remote and one object per runner, absent times as null, both from one value and pinned byte for byte. the verb census cost, counted: MACHINE_OUTPUT gains runners, the README block and its sentence move to 85 verbs, verb lines 133 to 134 at three main.rs sites and one ADR 0066 claim, dispatched verbs 84 to 85, dispatch table 83 to 84, machine output 32 to 33 with its named list, and two ADR 0066 lines that are dated records were left alone as the census skips them; the site verb list gains runners and its gate is green. the verb runs end to end through the spawned binary against a real forge. red under mutation, counts read each time: the touch unthrottled (1 passed and 1 failed), the door never touching (0 passed and 1 failed), the name not last (0 and 1), an absent time spelled as a dash in JSON (0 and 1), and a registration allowed to set last seen (1 and 1) once its pin moved into the store, where the defence lives, after a first attempt at the route proved unobservable; on Postgres 18 the throttle removed from the SQL went red at 0 passed and 1 failed after a green unmutated arm, each restored green. bash ci/local.sh is green against Postgres 18 (4258 passed over 136 binaries, 10 ignored). migration 0021 rides the forge binary, so the forge owes a deploy (#2158)
6dd6ae69 · 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 - Manifest::addresses reads the frames only after a step over them shows the stored keys strictly ascend in Path order, so a manifest whose frames are not its map, two keys naming one path or keys out of the map order, answers from the map, and a pull over a corrupt local graph file no longer asks for an address the map dropped. tier_counts keeps its backslash test, now documented as not a proof, and the a.txt and a/b ordering it cites is corrected to component order in both places. what the check costs, paired --missing readings, one loot-perf-gate --features count per arm, eight interleaved rounds, load idle at 2 to 10 percent: 0.875 to 0.924 ms without it against 2.09 to 2.18 ms with it at width 200, and 5.76 to 5.88 against 10.50 to 10.63 ms at width 800, where a decode arm read 18.75 to 19.13 and 100.0 to 100.8 ms, so about 9x against the decode where it was about 21x and 17x; the Path comparison is most of it. red under mutation, counts read each time: the check removed, compared by byte, not strict, byte equality only and path equality only (1 passed and 1 failed each), restored to 2 passed. riding along: store.rs and ADR 0075 say the ingest transaction still reads for checks of its own and that the landing decision is what reads nothing inside it, that a push with no proposal open pays 0.28 to 0.43 ms where #2177 asked for nothing measurable, and that the fallback decision rests on one repo size; finish_stage says why any refusal is answered by the address on disk; the dated test counts in format_skew_gate.rs and workflow.md say at the time; two rewraps; the calls.rs runner stubs use the file imports. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4274 passed over 135 binaries, 10 ignored) (#2273)
0a64d280 · 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 review-sweep fix-up over #2303, #2159 and #2172. the wake-up sender: its client is built with no_proxy, so a proxy named in the environment is not used, pinned in a test binary of its own that sets the proxy variables and sees the request reach the pinned address and not the proxy; refused_wake_address now refuses every address that is not globally routable unicast, an ipv6 address carrying an ipv4 one (mapped, compatible, nat64 64:ff9b::/96, 6to4) judged by the one it carries, so 64:ff9b::a9fe:a9fe is refused, with the refused blocks copied from the iana special-purpose registries at the code and a table of example addresses pinning the rule; name resolution runs on a thread waited on for at most WAKE_TIMEOUT_SECS with the request given what is left, and the wake-ups in flight are bounded by MAX_WAKES_IN_FLIGHT, a resolver that outlives its wait keeping its place until it returns. the ingest kinds trailer is now a byte per head in head order, still absent when no head declares a kind, so each head is given jobs for its own kinds only and no job is made for a kind a head has no step of; the pre-2159 decoder still reads the new payload, the one-byte union shape was in no release, and neither format constant moves, pinned against the marker put_version writes rather than its digits. MetadataStore::ingest answers the jobs its commit wrote (store::Ingested) and a push wakes runners for those alone, so a triple a racing request filed first wakes nobody here; the proposal job half answers no error once the proposal is committed and logs instead. the job_cost no-kind pin now states the property, that the job half calls the store for nothing when nothing is declared, instead of a list of methods. the seek busy marker: one that does not parse is believed until its mtime is past the window, so the instant between its creation and its clock no longer admits two holders, and a marker that cannot be created for a reason other than one standing there is retried and then refused naming the state rather than counted as held; the held-marker comment is scoped to a round that claimed it. the census wire half plants each oracle into every payload sent builds and asks each payload for moving bytes, calls the now-public verbs::sync::forge_declaration instead of a copy, records that the fixture declares no kinds, and records the measured flake rate of its minimum reading (200 runs: 40 of 1600 store builds and 15 of 1600 wire builds above the floor, none red). false sentences fixed: jobs.rs says what a false proposal declaration costs the owner and what the trust floor does and does not stop, ADR 0091 section 7 and its 2159 amendment are corrected with a 2307 amendment and the reads-not-writes scope of the job cost measurement, ADR 0090 and ADR 0004 gain corrections, CONTEXT.md names the per-head trailer, the census module doc states the property instead of naming places, and an in_progress rewrap leftover is rewrapped. red under mutation, counts read each time, each restored to green: no_proxy dropped (0 passed and 1 failed), the 2159 refusal rule restored (2 and 2), embedded forms unrecognised (3 and 1), 6to4 unrecognised (3 and 1), the resolver thread not holding its place (0 and 1), the ceiling ignored (0 and 1), the resolve wait unbounded (0 and 1), the trailer encoding the union (25 and 2), the forge unioning kinds across heads (1 and 3), the memory ingest answering every handed job (4 and 2), the push waking for planned jobs (5 and 1), the proposal refusing after a failed job write (3 and 2), the job half reading with nothing declared (3 and 1), an unparseable marker read as idle (1 and 1), a failed create counted as held (1 and 1), the census reading the bundle alone (9 and 4), every payload a copy of the bundle (10 and 3), the ingest payload emptied (11 and 2). no migration, no format byte and no published wording moves; the forge binary changes, so the sender and per-head jobs are live once the forge is redeployed. the workspace suite is green (4360 passed over 138 binaries, 12 ignored), and green under bash ci/local.sh against Postgres 18 (#2307)
665b3cec · dbf3dbe6…diff - the review-sweep fix-up over #2325, #2207 and #2208. a proposal landed close is bound to the tip it was decided for, on both stores: ingest decided the landing outside the transaction and each store closed the row on state open alone, so a revision through record_proposal between the decision and the transaction, which leaves the row open and moves no ref and so is invisible to generation_expected, was closed landed at a tip nobody landed. the rows proposals_this_push_lands hands the store already carry the tip decided on, and the Postgres close now adds tip_version = $4 and the memory close compares the tip, the binding a decline has; no other writer closes a row this way. a new conformance case, a revision between the landing decision and the ingest stays open, runs on each store: red first on the memory store (1 passed and 1 failed, the pg half skipping without a database) and on Postgres 18 with the tip term defeated (106 passed and 1 failed), green with it (107 passed). CONTEXT.md, store.rs, pg/meta.rs, memory.rs, ingest.rs and ADR 0075 stop calling the CAS the whole of the soundness. false sentences fixed: ADR 0012 eleventh amendment now says the ledger ran after the lanes until #2208, and the fifteenth names the plan pin as the fixture where the timed walk reads one seal, measured by hand; the plan pin now deposits every row it plans at another forge as well as the Restricted row at a relay, so dropping the remote from the dedupe empties the plan rather than dropping pitch.md alone (20 passed and 3 failed, all six rows gone), restored green (23 passed); Route::ALL is written by a routes macro from the enum variant list, so a variant added to Route is in it with no second edit, checked by adding a probe variant, and the http.rs sentence states that. the --decline refusal says the handle reads as a change id, and the Judged census doc says it sees only the Spawner. no migration, format byte or wire byte moves; the landed close is live once the forge is redeployed. the workspace suite is green under bash ci/local.sh against Postgres 18 (4404 passed over 138 binaries, 13 ignored; site live suites 7 files passed) (#2347)
facefe81 · dbf3dbe6…diff - a push now carries an attestation recorded over a change the remote already holds, so loot tag after loot push reaches a relay and a forge instead of being left behind under a success line: a local attestation ledger (.loot/attestation-ledger) records per remote what each push delivered and is read by a push and by no open or save, written by RepoStore::record_attestations_sent as a read-merge-write under the shared-store lock; the push sends the attestations over the held changes of the remote that the ledger has not recorded beside the send set and prints how many, the bundle builder keeps a late attestation only over a change inside the have closure of the recipient whoever handed it in, the forge /ingest keeps one over any change its repo holds through a new changes_held store read that costs no query when every attestation rides its change, and /info gains an additive late_attestations field so a forge that does not advertise it is sent none, has nothing recorded as sent, and the push warns how many it left behind. the land gate store_file_reads is 24 on its workload with no move, where a first cut that read the ledger on every open measured 26 and was refused; an open reads 20 store files, 21 with that cut. the #48 bound holds on the wire: a push carrying one late tag sent 256 B at a relay and 306 B at a forge over both 2 and 24 held tags. the ticket recipe, whose fresh clone lacked late-tag2 through the 0.4.24 binary, shows it through a lane build. red under mutation, counts read each time, each restored green: the open reading the ledger again (0 passed and 1 failed), the ledger write overwriting instead of merging (0 and 1), the ledger ignored (0 and 2), the late lane dropped (0 and 2), the privacy filter removed (1 and 1), the forge back to in-this-bundle (2 and 1, and end to end 1 and 1), the forge keeping any change (2 and 1), a relay push recording nothing (0 and 2), a push recording to a forge that does not keep them (1 and 1), the /info flag ignored (1 and 1). no format constant, codec byte or migration moves; the forge change is live once the forge is redeployed. the pull half is not built: a pull still carries an attestation only with a change it sends. the workspace suite is green under bash ci/local.sh against Postgres 18 (4425 passed over 139 binaries, 13 ignored; site live suites 7 files passed) (#2251)
414ba6b2 · dbf3dbe6…diff - a proposal has a conversation on the forge, stored and written and read, and no CLI flag posts to it yet: migration 0022 adds proposal_comment, keyed under the proposal row so a thread outlives a decline and a re-proposal, holding the envelope its author signed and its body, both set to NULL by a withdrawal that leaves a tombstone saying author or owner, with CHECKs that refuse a withdrawn row holding either, row security bound by the 0017 policy and the rls census widened. POST /propose/comment, /propose/comment/edit and /propose/comment/withdraw are tagged acts over propose::comment, where the rules live: a signer must see the proposal and is otherwise told what an absent one is told; the owner class writes, the accountless namespace key included; anyone else needs a live account key, so a bare keypair or a retired key is refused; members, the proposer and the tip author write through a shut door, anyone else only through an open one; a comment binds the tip on file when it arrives; a path must be in that tree and a reply must name a comment on the proposal; the body is at most 16 KiB of UTF-8, refused and not cut; only the author edits, re-signing the same id, anchors unmoved and bound to the BLAKE3 of the envelope it replaces; the author or the owner class withdraws, and members and proposers do not moderate. the comment id is signed by its author, so the same envelope again is one comment and a withdrawn one is not re-posted back. the thread rides POST /propose/read as a trailer and loot propose --show prints each body only when its envelope verifies against the author and anchors beside it. the flags that post, edit and withdraw a comment, and the disclosure printed before posting, are not built, since ADR 0095 names no flags; ADR 0095 amended and CONTEXT.md updated. red with each piece undone, counts read each time, each restored green: a bare keypair admitted, the door not asked, visibility not asked (each 0 passed and 1 failed on the writer-set pin), a member moderating, the owner editing another comment (0 and 1 each), anchors free to move (0 and 1), a withdrawal that writes nothing (0 and 1), no cap, and the body cut to the cap instead of refused (0 and 1 each), no tip binding (0 and 1), an edit not bound to its prior (0 and 1), an edit past the writer rule (0 and 1), the read sending no thread and an unverified body printed (0 and 1 each); on a throwaway Postgres 18, a withdrawal that kept the words with the CHECKs dropped left the conformance case green (1 passed) and turned the pin that reads the whole row red (0 and 1). bash ci/local.sh is green against Postgres 18 (4476 passed over 139 binaries, 13 ignored). no format constant or codec byte moves; migration 0022 rides the forge binary, so this owes a forge deploy (#2331)
6af9b14f · dbf3dbe6…diff - review sweep 13 over the proposal conversation: loot propose --show verifies a comment against the repo and change id of the proposal it reads, since one change proposed to two repos shares its tip and a forge could serve one repo comment as said in another, and cages the body, the path and a refusal quoting the envelope through render sanitize, so ESC or a bare CR from a stranger prints as its control picture. an author is an account: a live key of the account whose key wrote a comment edits and withdraws it after a rotation, the key that signs an edit becomes its author so readers still verify it, and the author withdraws without the visibility check, so a lost view does not strand their words. the door arm calls may_propose_in and a party is asked live_account_in, its second half, where a copy stood. the comment routes read at most COMMENT_REQUEST_MAX, refusing a larger request before its signature is checked. /propose/read serves a thread a page of THREAD_PAGE comments at a time with a cursor, a trailer on both payloads, and --show follows it. an anchor must be spelled as a tree spells a path and is asked of tree_entry by its key through manifest_holds; Manifest::address_of reads manifest bytes the forge does not hold. a runner key under no account is refused moderation, and ADR 0095 now says any live key on the owner account moderates. finalized_before_review sends a lane to lane_landing_route rather than a land with no PR to name. ADR 0095 records the account rule, the paged read, the unbuilt withdrawal warning of its section 6 and the rewritten #2332 pin; ferry.rs says the #2370 pins run in-process; a pin holds the 0022 CHECK equal to COMMENT_BODY_MAX. red with each piece undone, counts read each time, each restored green: the envelope not bound to its proposal (0 passed and 1 failed in loot-net and in the CLI), the body or the path printed uncaged (0 and 1 each), authorship by key, and an edit keeping the old key as author (0 and 1 each), the author asked to see before withdrawing (0 and 1), the door arm a copy (0 and 1), no per-route limit (0 and 1), the forge thread unbounded and the CLI stopping at one page (0 and 1 each), anchors matched by Path over the whole tree (0 and 1), a runner key moderating (0 and 1), the lane land its own (0 and 1), the cap moved on one side (0 and 1). bash ci/local.sh is green against Postgres 18 (4498 passed over 139 binaries, 13 ignored). no format constant or migration moves; the proposal read gains trailers and the forge behaviour changes, so this owes a forge deploy (#2374)
bb95f4fb · dbf3dbe6…diff - a decline may carry a reason signed with it, kept on the proposal row and shown as the thread closing event: ProposalDeclination gains an optional reason as a trailer on its signed bytes, so a decline with none is the bytes #2176 signs and neither format constant moves, and a reason a carrier swaps or strips no longer verifies. migration 0024 adds proposal.decline_reason with a CHECK holding it to a declined row, never empty and at most COMMENT_BODY_MAX bytes, and replaces the three 0020 proposal views with the column appended, which a text guard holds equal to 0020 once the column is taken out. propose::decline refuses an empty or oversized reason whole and a repeated decline with other words, close_proposal writes the reason only with a decline, and a re-proposal clears it with closed_at. POST /propose/read carries the reason as a trailer after the thread continuation, absent from an older forge, which would drop a reason unread, so loot propose --decline <tip> --reason <text> asks the read first and refuses a reason such a forge would drop, then prints who can read it before signing; --show prints the decline under the thread with its reason caged through render sanitize. frozen before_2336 codecs pin old client against new forge and new client against old forge both ways, and tests/proposal_read.rs compares the column for each reader class with an executable red that blanks it in one view (1 class disagreeing). red with each piece undone, counts read each time, each restored green: the reason left out of the signed bytes (0 passed and 1 failed), the read trailer not decoded (0 and 1 on each of two pins), the route dropping the reason, the read serving none (0 and 1 each), no cap and an empty reason admitted (0 and 1 each), a repeated decline changing the reason (0 and 1), a re-proposal keeping it and a reason written with any terminal state in the memory store (1 and 1 each, the pg stamp of the case green without a database), the CLI signing for a forge that would drop it, the reason printed uncaged (0 and 1 each), --reason admitted beside no decline and no disclosure before signing (0 and 1 each), the migration cap moved and a view joined to another family gate (0 and 1 each). ADR 0095 amended, CONTEXT.md and the forge spec updated. bash ci/local.sh is green against Postgres 18 (4519 passed over 139 binaries, 13 ignored). migration 0024 rides the forge binary and the read and decline routes change, so this owes a forge deploy (#2336)
c8d5a6ea · 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 - the forge refuses an /ingest that would drop a current head it neither descends from nor retires by name (ADR 0098 section 3, map #2422): ingest::heads_this_push_drops judges the declared heads against the heads read at the push generation, before the store transaction, so the CAS makes it sound and a push at a stale generation still loses the swap as before; the walk over the push own changes answers an ordinary push with one read, the head set, and only a head that walk does not reach costs the repo graph, through the landed closure propose already has. a refusal is a 412 whose text body names the count, the remedy (loot pull first) and each head, so a client from before this reads a sentence and this client prints the count it read with the same remedy. retirements ride the signed head declaration as a trailer after the kinds, written only when a push names one, so a push retiring nothing is the old bytes and a forge from before this reads past the names and replaces its heads wholesale as it always did. the client names each forge head its declaration does not reach and that this position has seen, holding the change or naming it in its abandoned record, so abandon --head and supersession are covered with no list of verbs, and never names a head it has not seen. four conformance cases run on the memory store and on Postgres through the assembled forge: a fast-forward carried or already held, a dropped unseen head refused and named with the ref unmoved, an explicit retire that lifts only the head it names, and a racing push that still loses the CAS; a runners test that replaced its root with a new root now retires it by name. red under ten named mutations, each 0 passed and 1 failed (2 failed for the trailer) and restored: no refusal, no generation guard, retirements ignored, no overlay fast path (an existing pin saw the graph read), the overlay alone, the client retiring nothing, the client retiring unseen heads, a view-only seen, the kinds left out ahead of the names, and the client not reading the 412. bash ci/local.sh is green against Postgres 18, 4599 passed over 143 binaries with 13 ignored and the site pg suite at 67 passed. no migration; the forge behaviour changes, so this owes a forge deploy (#2426)
e1e5c828 · 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.