Changes touching this path
- Forge storage drivers: Postgres metadata tier, S3 blob tier, and one conformance suite both must pass (#516)
f65970e6 · dbf3dbe6… - the forge stops shipping keys for retracted publications, and publication finally has a producer (#709)
ddcf4de9 · dbf3dbe6…diff - a change records when it was authored, so a projected commit stops reading '6 years ago' on GitHub (ADR 0043)
loot changes carried no timestamp, so the git bridge fabricated one:
BASE_EPOCH + generation, one second per ancestor depth from a 2020 epoch.
All 526 commits sat inside seven minutes of September 2020. Earlier repairs
(#626's floor, the missing-generation refusal) fixed ORDERING and never
touched the absolute date, which is why this kept coming back.
ADR 0028 inherited the no-timestamp constraint rather than choosing it. The
real reason is upstream: a version id is blake3 over authored content, and a
clock inside that hash gives two peers different ids for identical content,
destroying the dedup and convergence of ADR 0001/0004.
So authored_at rides the label seam ADR 0029 already cut for change_id --
covered by the finalize signature (no relay can restamp it) but never folded
into the version id. The wasm golden vectors prove the separation held:
FROZEN_VERSION_ID, FROZEN_OBJ_ADDR and FROZEN_SIGN are byte-identical, and
only the version marker and one presence byte moved.
Advisory, and never an ordering input: a self-reported clock is a claim, not
evidence (ADR 0025). in_order/ids_topo, buoy and path_touch.ordinal are all
untouched, the forge indexes nothing on it, and the projection floors it past
every git parent so ancestry holds whoever's clock is wrong.
Format v11: additive for readers (a v<=10 change decodes as None and an absent
timestamp adds nothing to the signed message, so every existing signature
still verifies), breaking for writers, so loot-cli and loot-forge go to 0.4.0
in lockstep. Forge migration 0004 stores it as bigint, not timestamptz --
signed data must round-trip bit-exact or pullers reject the change.
Only new work gets real dates. Every commit on main predates v11, main is
push-fast-forward-only, and backfilling would fabricate the very claim this
replaces.
Perf-Baseline: reset the change body grew one presence byte, plus eight where a timestamp is present, so bundle_bytes/store_bytes/wire_bytes step once at the v11 boundary
eaa56d99 · dbf3dbe6…diff - retirement starts meaning something on the write path: may_push and burn entitlement go account-aware, and a rotated identity keeps its namespaces and its burns (#927)
7b9bbaf2 · dbf3dbe6…diff - a first push stops timing out at the door: /wants asks the metadata once for a whole address list instead of two network round trips per object, which cost this repo 10,700 of them and 30 seconds (#1069)
644cf290 · 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 - 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 third sweep's fix-up, and the worst of what it found was a doc block that had been separated from its subject by an insertion: proposal_of landed BETWEEN changes_of's comment and changes_of, so thirteen lines ending in a cross-tenant isolation warning - that without the repo_change join a named id from another tenant's repo answers with that tenant's whole tree - came to sit on a row decoder that makes no join at all, while the function that actually carries that join had no doc. Nothing was broken and that is the point: a tenancy guarantee attached to code which cannot provide it is a claim nobody can check, and it is now back on changes_of with proposal_of saying explicitly that it joins nothing and that the scope belongs to the query which produced its row. Two authored counts go the way #1650 already sent account_of's consumers, from counting to naming: server.rs said authorization is three rules where require_metadata_access is a fourth and IS a function in that file, while the AccessClass rule listed among the three is not a function there at all but is applied in readable_rows, the seam both /offer and bundle share - and the self-justifying sentence explaining why the count was written down is deleted rather than updated. CONTEXT.md's copy turns out not to be merely authored but WRONG, saying second caller where there are three, one entry away from the account_of entry #1650 had already fixed the same way. Two more sites carry the same defect the ticket located in only one: store.rs calls the four proposal methods writes where two are reads, and its list of the repo-scoped group's non-CAS writers was missing set_accepts_proposals entirely, while a second passage both mis-describes the writes and double-counts the refusals, the walk BEING refusal one. #1650's fourth not-derived proof was written on its issue as if pinned when it had been a manual run, so it is pinned rather than downgraded: a reusable prefix harness applies migrations up to n against a scratch database, plants a repo that is metadata-public and holds a live publication and is old, applies 0012 and finds both repos shut - proved red four ways by appending a real backfill to 0012 each time and reverting, with every migration file verified byte-identical afterwards. That fixture also tripped the repo's own tenancy guard, because planting repo_member to cover the membership derivation made account_tier.rs report a Rust producer of the account tier, which is #753's boundary doing its job - so the arm was backed OUT rather than the tripwire evaded, and the membership derivation is recorded as argued from 0012's text rather than from rows, covered by no row-level test before or after. The DDL guards' comment stripper could be blinded by ordinary SQL, since it cut at a double dash with no awareness of string literals and those guards are what prove 0011 and 0012 do not backfill; it now skips quoted regions using the checksum lexer's own primitives, so there is one answer in the file to where a literal ends, and its five cases each carry a control that runs the OLD parser and REQUIRES it to lose the bytes, so a case discriminating nothing cannot be added (#1723)
1abd431b · 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 - the forge role is bound to one repo per transaction: migration 0017 replaces the permissive forge_ingest_is_unconstrained policies with forge_is_bound_to_one_repo, keyed on loot.repo_owner and loot.repo_name, which Pg::with_repo sets before any other statement, so an unbound statement reads no row of those tables and has every write to them refused. every MetadataStore method that reaches a row-secured table now runs in with_repo, held in source by a census test and in behaviour by the conformance suite run as forge, and the ref CAS is still the first statement that locks ref. tests/rls.rs proves the binding refuses cross-repo reads and writes and goes red when any one table predicate is replaced with true, and the recorded-decision test that pinned the unconstrained role is removed. pg::tests now starts its sessions as forge. ADR 0078 decision 3 is amended with the census, the deploy order and what the binding does not constrain, and CONTEXT.md, the schema header, ADR 0077 and the evidence doc follow (#1732)
af1c95ee · dbf3dbe6…diff - the forge live-database suites now run in CI: test-main.yml stands up a postgres:18 service, provisions it the way setup-forge.js does including its default privileges, migrates it with the commit own loot-forge --migrate, sets every LOOT_FORGE_TEST database url the Rust and site suites read, and runs cargo test and then the site pg tests against it. LOOT_FORGE_TEST_REQUIRE_DATABASE turns a database test skip into a failure through pg::live_opt_in and the site skippingLive helper, and with it set and no url 91 Rust tests and all six site pg files fail, while two half-suites that used to skip silently now say so. The default-privilege tripwires run in CI by decision, and fail on a cluster without provisioning. A new migration that neutralises the roster policy passed the bare suite and failed rls.rs and membership.pg.test.ts against the cluster. It is a canary on landed main and not a pre-land gate, and the prose that said no workflow sets these urls now says that instead (#1735)
c0931f78 · dbf3dbe6…diff - migration 0017 and ADR 0078 now state the deploy window setup-forge.js leaves by migrating before it swaps the container: until the new binary is up the old one reads empty repos and has pushes refused, nothing is corrupted, and the order is kept because migrating first is what the deploy script relies on for every release (#573); the checksum pin stays green and went red on a one-token statement edit, and 0017 says statements rather than bytes. CONTEXT.md and ADR 0078 stop saying no CI runs the live suites, the rls.rs header names the key the workflow sets and says the cluster tests are the ones that return early, and the 2026-09-08 evidence entry gets a dated follow-up rather than a rewrite. binding_census now walks every file under src/pg instead of a hand-kept pair, reads fn items of any visibility, follows SQL held in a module-level const and drops cfg(test) items and files, and Pg::with_conn, with_txn and with_repo are private to pg so the compiler keeps other modules from issuing statements through them. a planted pub(crate) method in a new pg/planted.rs reading such a const went red under the new census (1 passed, 1 failed) and stayed green under the old one, and removing each widened shape turned the census red. the source_walk message on flags.rs drops its stale six-lines count, and a new assertion holds that flags.rs calls into the module on more lines than it has consumers, which went red when that premise was broken. the table counts in CONTEXT.md and pg/mod.rs point at the constants that hold those sets rather than restating a number, pg/mod.rs points at the path include instead of listing its includers, the Admitted doc says within and within_leaf read through the leaf spec they are handed, the test-main.yml provisioning comment names the grant repair it drops as a no-op on a fresh cluster, and the rewrap leftovers are fixed. the bare workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block (#1953)
f2cbbb4c · 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 - sweep 5 fix-up: the lane usage refusal no longer prints a run of spaces, because the rewrapped literal gets its line continuation back, and the subcommand gate test now asserts that no line of that refusal holds two spaces past its leading indent, which went red on the old text (0 passed, 1 failed). the cli smoke deregister case writes its seed with an escaped newline again and spawns on one line. rm and rm --deregister now share one owner-scoped lookup and one entry removal, and deregister drops a redundant exists check; disabling the shared ownership guard turned both the rm and the deregister test red (1 passed, 2 failed), and the verb tier width moves to the derived count. the census lexer reads a C-string raw literal, cr with or without hashes, as raw, with a fixture that failed first on a brace count that did not close (3 passed, 1 failed). ADR 0035 gains a dated amendment for --deregister and for keeping the entry of a held lane, the verb tier doc stops narrating per-ticket widenings as a decision beside the #1922 tripwire one, and the proposal loop argument in propose.rs and ADR 0075 is stated as a rule about any route that calls record_proposal or close_proposal instead of naming the routes. doc fixes: the LANE_BARE doc points at the union test, remove_lane says --reap-others also authorizes --deregister, the Resource Monitor advice is hedged, store.rs loses a method count and corrects its upsert heading, ADR 0075 says the old read would have been refused, and rewrap leftovers are reflowed. the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block (#1968)
15bcde31 · 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 - 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 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 - 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 - review sweep 14 over the proposal conversation: the proposer and the tip author are accounts where #2374 left them keys, so a live key of either account is a party at a shut door and sees the proposal on a metadata-private repo, through propose::speaks_for, now beside visible_to, and propose::sees, which read and the comment routes ask one row at a time while list keeps the key arm by key. migration 0025 adds proposal_comment_page on (repo_id, change_id, created_at, comment_id), so a thread page is a range scan the driver statement comment_page reads, where every page sorted the whole thread, and a pg pin reads its plan with the sequential scan and the sort priced out. /propose/decline takes the comment routes body limit, --show says the decline reason prints on the forge word and refuses a thread page that brings nothing, stands still or repeats a comment, and the reason disclosure says a re-proposal clears it. the comment.rs door sentence names may_propose_in and lists no members, PROPOSAL_COLUMNS replaces four copies, and ADR 0095, CONTEXT.md and the code docs say the party rule, the index, the decline limit and that the reason is unverified pending the call ADR 0095 records. red with each piece undone, counts read each time, each restored green: a party by key and seeing by key (0 passed and 1 failed each on the rotated-party pin), the index dropped on a throwaway Postgres 18 (0 and 1), a refusal quoting the envelope printed uncaged (0 and 1), the reason printed without the forge word, the decline route uncapped, a cursor that does not move taken and the re-proposal unsaid (0 and 1 each). bash ci/local.sh is green against Postgres 18 (4525 passed over 139 binaries, 13 ignored). no format constant or codec byte moves; migration 0025 rides the forge binary and the forge rules change, so this owes a forge deploy (#2378)
f168d689 · dbf3dbe6…diff - the forge ingest stops writing a row per statement: write_rows issued one INSERT per object, tree entry, edge, repo change and path touch, about a million round trips for the 620-change push of this 1,600-path repo that timed out at the client 270 s wait in #2388. each table now takes one INSERT SELECT FROM UNNEST ON CONFLICT DO NOTHING per call, tree_entry one per manifest the call itself inserted (a manifest row is only ever written with its entries in one transaction, the memory store already keeps first-arrival entries, and the comment says what would break that), with the conflict clauses, refuse_burned first, the G5 order, per-change parent and predecessor ordinals and path_touch and object order kept. a Statements trait lets a counting wrapper stand in for the sealed GenericClient, and a pin holds 16 and 160 entries to the same 9 statements, red under per-row tree_entry, objects, path_touch and parents and under rewriting a held manifest, each restored. measured on a throwaway Postgres 18, 600 changes of 1,600 paths: 80.0 and 82.5 s before, 13.5 and 14.3 s after. workspace suite 4549 passed over 142 binaries, 13 ignored, and bash ci/local.sh green against Postgres 18 (4549 passed, site live suites 67). no migration; owes a forge deploy (#2391)
9f32e5e1 · 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 - the forge repo graph read stops scanning the whole forge change_parent table: repo_change_graph on Postgres read the repo ids and then looked their parent edges up by an array, which past a repo size the forge edge count sets (between 1,060 and 1,070 ids on a 297,991-edge throwaway forge, 2,810 and 2,820 on 898,012, 7,500 and 8,000 on 2,698,011) the planner answered with a scan of every edge, so every caller of repo_change_graph slowed as other repos grew. it is now one statement, pg::meta::REPO_GRAPH, a lateral join from repo_change whose OFFSET 0 keeps it a nested loop that reads change_parent one change at a time through its primary key; a plain join was measured beside it and steps the same way. measured on a throwaway Postgres 18 at eight repo sizes on three forge sizes, the new read examined the repo own edges and no others in every cell, 3.47 against 27.9 ms at 1,500 changes on the smallest forge and 21.0 against 98.6 ms at 10,000 on the middle one, while on the largest forge the 30,000 and 100,000-change reads slowed as the index outgrew the 128 MB of shared buffers (202 and 402 ms); ADR 0075 records the table and that. a pg test asks the planner for the statement with nested loops and sequential scans priced out and refuses any change_parent read not keyed by the repo_change row, red with the old array shape and with OFFSET 0 dropped (0 passed and 1 failed each), and the graph conformance case gains a merge whose parents are recorded against address order, red on the pg store with the lateral join made inner, the ordinal dropped from the order and a row per edge left ungrouped (2 passed and 1 failed each over the three graph tests, the memory store green), each restored green. bash ci/local.sh is green against Postgres 18 (4566 passed over 141 binaries, 13 ignored, site live suites 67). no migration; the statement changes, so this owes a forge deploy (#2346)
5dd0e327 · 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.