Changes touching this path

  • 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…
  • 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
  • a rotated owner now reads their own repo on the sync surface: AccessClass::of asks, after the byte compare and the membership arm, whether the reader is a live key on the account that holds the repo owner pubkey, which is the require_pusher rule asked as a read, so a key that may push to a namespace reads it too. the owner is resolved through the account rather than given a repo_member row, because the forge creates repos on push and may not write that table, a row could be removed from the access panel, and it would put the owner in the member list; ADR 0077 records the choice, the residual that a retired namespace key still reads through the byte compare, and the pins that moved. the byte compare still comes first, so an accountless owner still reads as a member with no store read. the sibling cells of the model.rs matrix flip to Member, a new model.rs case covers the rotation with no membership row, a server.rs case asserts every signer require_pusher admits reads as Member, and member_class.rs now plants the owner pubkey as a retired key of its account and asserts the successor reads Member while a retired key of that account and the same key on another repo read Anon. each went red against the old predicate, and dropping the tenant check, the retirement check or the owner-first order, or comparing the two accounts as options, turned them red. the workspace suite is green bare, and the loot-forge suite is green against a throwaway cluster with the CI env block (#1744) dd139483 · dbf3dbe6…diff
  • a retired namespace key no longer reads its own repo on the sync surface: AccessClass::of still asks about the owner pubkey first, and now asks account_of about it once, answering no row or a live row Member and a retired row Anon, which is the require_pusher answer for the same key, so a leaked retired namespace key stops pulling the Internal tier. the owner arm gives up its zero-read property, and ADR 0077 decision 3 is amended with the cost, one account_of read on every sync read the owner pubkey signs, accountless owners included, as the correction on the ticket restated the done bar, and with why no per-request cache is needed. new model.rs tests pin the retired namespace key reading Anon and, through a counting store, the reads each branch costs, where the owner pubkey cells moved from none to one and the accountless owner asks account_of once, about itself; a new server.rs test asserts that over the #927 cast every signer the read rule admits as owner may push; member_class.rs now asserts the retired namespace key reads Anon against Postgres, with a live namespace key on a new repo as the owner control. before the change the in-memory selection went red (17 passed, 3 failed) and member_class against a throwaway cluster went red (2 passed, 1 failed), and so did the arm with retirement ignored (18 passed, 2 failed; 2 passed, 1 failed), with a missing row read as Anon (14 passed, 6 failed; 2 passed, 1 failed) and with a second owner read (19 passed, 1 failed). the serve.rs cost double now delegates account_of, CONTEXT.md and store.rs state the rule, 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 (#1955) 1ef40df6 · 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
  • the forge test harnesses no longer race each other migrating a scratch database: a migration that adds one of the narrow roles creates it inside a check-then-act DO block and then states the attributes with ALTER ROLE, which writes a pg_authid row no database owns, so two harness threads each migrating their own scratch database write that row at once and postgres answers one of them tuple concurrently updated, the flake #1744 saw once in CI as migration 0003 failing on a fresh database. it reproduces three ways against a throwaway cluster: six concurrent loot-forge --migrate processes over six fresh databases failed 4 of 18 with that error, each leaving a ledger stopped at version 2 with the server log naming migration 0003; the statement alone, looped from sessions in separate databases, failed 2 of 4 where 18 single statements collided 0 times; and the two migrating pg::tests cases run as a pair went red in 5 of 8 runs. the new pg::migration_serial takes a session-level pg_advisory_lock on the admin url database and gives it back when the returned value drops, so the disconnect is the unlock and a test that panicked holding it releases it too. it is a lock on the cluster rather than the MIGRATING mutex the harnesses under tests/ each carried, because the row belongs to the cluster and the lib tests and each tests/*.rs are their own binary; every harness application of a migration now holds it, namely PrefixDb::new, the new apply_migration that apply and the new reapply share, the member read gate restore, and the rls case that makes a role bypass row security, where a second binary migrating during that window would have had migration 0014 refuse it. with the lock the pair is 0 of 8 red and four concurrent processes of it are 0 of 12; a process mutex in its place is 6 of 12 red, no lock in PrefixDb::new is 2 of 6 red with the census naming that function (1 passed, 1 failed), the lock dropped from the member_class harness is named too (0 passed, 1 failed), and the pg_advisory_xact_lock spelling reddens the new lock case (0 passed, 1 failed) and 4 of 6 runs. every_scratch_database_migration_holds_the_cluster_lock walks src/ and tests/ for the statements that apply a migration, holds the set of functions equal to what it expects and names migrate as the one deploy path exempt, since it already serialises runs on one database with LOCK TABLE schema_migration; it went red with ROOTS emptied and with that exemption emptied (0 passed, 1 failed each) and it needs no cluster, unlike the cases it reads. the_migration_lock_is_one_lock_for_the_whole_cluster pins that an advisory lock is per database, which is why the lock is taken on the url unmodified. PrefixDb migration panics print the server sentence through pg::describe now, where the original report read db error. docs/evidence/forge-datastores.md carries the run and answers the suspicion its #1744 section left open, the forge suite is green against the cluster (469 passed over nine binaries, no skip note) and the workspace suite is green (3832 passed) (#1956) 8aebb4de · dbf3dbe6…diff
  • a retired namespace key no longer learns that a metadata-private repo holds a proposal: propose::visible_to decided ownership with a byte compare against repo.owner, the shape #1955 took out of AccessClass::of, so the withdraw route, which asks that gate before the author check, refused a retired namespace key as not the author with a 403 where a key that is no party to the offer is told no such proposal with a 404, and the difference between two refusals discloses the fact metadata_public governs. the gate takes AccessClass::may_read_metadata whole now and compares no pubkey of its own, so the two read gates in loot-forge ask ownership in one place, and the answer is a live or accountless namespace key, a live key of the account that holds the namespace (#1744) and not a retired key (#1955); the two standing readers, the tip author and the presenting proposer, are unmoved and still need no account. taking the class whole widens the gate to a live key of a member account, deliberately and pinned: may_read_metadata is the same axis, a member already pulls every message, tree and author in ref_head, and no content moves, where an account holder with no membership row on this repo still sees nothing. the other half the ticket named, a rotated owner treated as a stranger, is latent, as the correction on the ticket established: no route reads or lists proposals, propose::read runs in production only for the presenter, propose::list has no production caller, and on withdraw the successor key only got a different refusal. the class is resolved once per read, per listing and per withdraw attempt, outside the listing row loop and only for a row that exists, and a party to the offer pays it too. red first, on the unchanged predicate: the three new propose.rs cases went red over that selection (24 passed, 3 failed), the read arm handing a retired namespace key the whole proposal view where a stranger reads none and the withdraw arm answering 403 against 404. with the old byte compare put back as the class answer the four in-memory cases go red (400 passed, 4 failed) and the new member_class.rs case against a throwaway cluster goes red (4 passed, 1 failed); with the class dropped so the flag answers alone nine go red, the write-gate and one-sentence pins among them (395 passed, 9 failed; 4 passed, 1 failed). ADR 0075 and ADR 0077 carry the #2002 amendment, CONTEXT.md names the class, the cost and the widening, and AccessClass::of stops claiming every caller reaches it through serve::Reader::new. one flake was found on the way and fixed: pg::tests the_migration_lock_is_one_lock_for_the_whole_cluster probed the advisory key once after dropping its own hold, and hold blocks, so a queued harness takes the lock as it comes back, which was red on both runs of a loaded workspace suite against a cluster and green on three runs of the lib suite alone; the probe waits for the release now, bounded, and still fails on a leaked lock (0 passed, 1 failed after the whole wait, run alone). no migration, and the forge binary changes, so this owes a forge deploy. the forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (474 passed over nine binaries, no skip note), and the workspace suite is green against the same cluster (3838 passed) (#2002) eed4d01c · dbf3dbe6…diff
  • the live-database checks test-main.yml runs can run locally, from the same script CI runs, because the account is out of Actions minutes and a land never ran them: the provisioning, the database keys and the suites move out of the workflow into ci/test-main.sh, which builds every connection URL from a host and a port it requires rather than defaulting to the one a real cluster listens on, and the workflow keeps only what prepares a fresh runner and calls it. ci/local.sh starts a throwaway Postgres on its own port, refuses if anything already answers there, runs that script against it and deletes the cluster on exit, so it runs beside a real cluster and from a lane before a land. run in a lane against a portable Postgres 18.6 it applied forge migrations 0001 to 0017 and the three site migrations, and 122 cargo test results and all seven site live files passed with LOOT_FORGE_TEST_REQUIRE_DATABASE set, so none skipped, before and after the review fixes. with the returning alias put back into read.ts it failed at the site step on syntax error at or near returning, and pointed at the port a real cluster owns it refused with exit 2. the CI-shape pin reads through the script and its STEPS list, because the job is still named cargo test --workspace and a check on the workflow text alone would pass on the name after the command had gone; it went red when the workflow stopped calling the script and when the script dropped --locked. the comments and living docs that said the workflow sets the keys now say the script does, the dated ADR and evidence entries are left as history, and AGENTS.md, docs/agents/workflow.md and the afk-loop security hunt point at the script. the Actions wiring itself is unexercised until minutes return (#2061) 8ff6117c · dbf3dbe6…diff
  • a withdrawal binds the tip it was written for, and the format minor deliberately does NOT move: a withdrawal names a proposal by its durable change id, which is stable across revisions on purpose, so a captured envelope replayed after a REVISED re-proposal ended an offer its author never withdrew - and the author is the only party withdraw admits, so the captured envelope is exactly the one that works. the signed bytes now carry the tip, compared inside the retry loop against the row that round actually read rather than once outside it, which is the same window #1959 closed for the author check. the replay is run end to end: the author withdraws at one tip, revises and offers again, and the byte-identical envelope is refused with the proposal left open, while a withdrawal naming the version actually on file still works, so the refusal is the binding and not a wall. ⚠⚠ the ticket instructed a FORMAT_MINOR bump and following it would have been a mistake, which is the finding worth more than the field: the marker's minor is ONE GLOBAL CONSTANT stamped onto every artifact rather than a per-payload version, and persist_codec's is_canonical compares a persisted store against the major and minor pair to decide whether the graph file must be REWRITTEN, so bumping it to announce one optional field on one wire message would force a rewrite of every local store on every machine. every read_version caller discards the minor, so the bump buys no reader anything the trailer's presence does not already give, and a test pins both constants unmoved with that reasoning beside it. the same plan sits on #2159 for its kinds trailer and is flagged there rather than left. an older client sends no tip and is accepted unbound, exactly as safe as before the field existed and no less, since an attacker cannot cause that shape and can only replay one already signed - so the exposure shrinks to withdrawals old clients signed rather than widening. both interop directions are pinned: an old payload decodes here reporting no binding rather than inventing one, and a new payload is byte-identical to an old one up to the trailer, which is what every deployed forge reads. red under mutation, counts read each time: the tip comparison made unreachable so the replay succeeds (430 passed and 1 failed, restored to 431). ⚠ three rounds of missed call sites, each invisible to the scope before it - a package lib run compiles neither integration tests nor other packages, and only the full suite answered. no migration, FORMAT_MAJOR and FORMAT_MINOR both unmoved, and the forge behaviour moves, so this rides the next deploy. the workspace suite is green (4169 passed over 132 binaries) (#2180) 7f2cbeca · 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
  • key management on the site: a key can be retired by itself, named, and chosen at unlock. per the operator grill on #2398 (ADR 0100): POST /api/identity/retire retires one key of the signed-in account, refusing the last live key under the account-row lock registration takes and never re-dating a retirement, with no route to un-retire and no deletion, since #506 keeps every key resolvable; /account lists live keys and folds retired ones under a collapsed section, and the retire confirmation names the repos the key owns. names live in a new owner-only account_key_label table (migration 0027) that only forge_identity reaches, trimmed, at most 40 characters, with control, line-separator and direction characters refused and a CHECK repeating the cap; they reach only /account, the unlock picker and the private bar, always beside the fingerprint. the unlock picker lists every live key, defaulting to the key that owns the URL namespace, then the key this device last unlocked, then the only live key, and a key with no stored passphrase asks for its 24 words and can save one. both routes are Bearer only and refuse a key of another account; /api/identity/me stops returning the internal tenant id. rebased by hand over migration 0026 and ADR 0099. pg tests pin the role reach, route and picker tests pin the rules, each red under a named mutation; bash ci/local.sh green against Postgres 18 (4568 passed), site gate 842 passed. owes the setup-forge tripwire update, a forge deploy, then a site deploy (#2398) a90c85fb · 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.