Changes touching this path
- Forge datastores: both tiers provisioned, and what proved them (#575, #576, #577)
045f8dd8 · dbf3dbe6… - Forge storage drivers: Postgres metadata tier, S3 blob tier, and one conformance suite both must pass (#516)
f65970e6 · dbf3dbe6…diff - evidence: separate the privilege assertions from the credential check that proved nothing (#694)
2c0542d4 · dbf3dbe6…diff - loot-forge: the anonymous read surface is 11 barrier views and a forge_read role confined to them (#610)
bb193149 · dbf3dbe6…diff - a forge backup is operator recovery, and a restore replays the revocation journal (#586)
#586 asked whether a backup defeats a burn. It does, but not where the ticket
looked, and the grilling moved the question in both directions at once.
NARROWER: the #502 amendment means the forge files a content key only where
`publish::published_rows` authorizes one, so an operator backup reconstitutes
plaintext ONLY for content that was Published -- i.e. world-readable anyway.
And `published_key` is not "the one table with no other copy": the rows come
from the client's own bundle at ingest, so a re-push re-supplies them. Painful,
loud, not unrecoverable.
WIDER, and this is the find: burn is not the only thing a restore inverts. Three
columns are monotone capability-removals that no client can push back --
`burn_tombstone`, `publication.retracted_at`, `grant_log.revoked_at`. A restore
is the single operation that runs time backwards over all three. Stated once,
because it is the whole ADR: A RESTORE RE-GRANTS A CAPABILITY SOMEONE
DELIBERATELY TOOK AWAY. Burn is just the one with an ADR attached.
So the cure is not a shorter window alone. Snapshots stay immutable at rest and
the RESTORE PATH is scrubbed by construction: a key-free, content-free revocation
journal, exported continuously and replayed before the forge binds a port. The
window (7 days, enforced by an R2 lifecycle rule rather than by a prune script
that can fail silently) then bounds media at rest only, never a running system.
The dump is `age`-encrypted to a key the VPS does not hold, because
`tree_entry.path` and `path_touch.path` are PLAINTEXT for every tier -- #610's
barrier views protect the live database and protect a dump not at all. The
journal is deliberately NOT encrypted: putting the one unrecoverable artifact
behind a losable key buys nothing.
R2 gets no mirror. The realistic threat to blobs is #493's own unbuilt reaper,
which that ticket says of itself deletes live data unrecoverably when it drifts
-- so the reaper reaps into a `reaped/` prefix under the same 7 days, and burn
keeps deleting outright. Two paths remove blobs; only one may be recoverable.
Weekly automated restore-verification is rejected as SELF-DEFEATING rather than
expensive: automating decryption puts the private key back on the VPS.
The one scoping correction: #586 said the work lands beside `setup-forge.js`.
That holds for the machinery. The honesty half -- burn's third tier and the
retention window advertised on `/info` -- lands in loot proper, so this is two
build tickets in two repos.
Decision only; nothing here is built. ADR 0046, three CONTEXT.md entries, a
third tier marked into ADR 0038 in place, and forge-datastores.md's closing
"still unspecified" line replaced with a plan that says out loud it is a plan
until the drill's negative has been run.
79fa0586 · dbf3dbe6…diff - the backup machinery gets its record: ADR 0046 is amended by what building it found, and the drill is run as far as a key can take it (#813)
The build lands in the `scripts` repo (6d31542). This is loot's half: the decision
doc corrected where it was wrong, and the evidence file told what was actually run.
Three amendments, each because building the thing falsified the text.
Section 5's lifecycle rule is PREFIX-SCOPED. Sections 4 and 5 together ask one
bucket for two opposite retentions, and a bucket-wide rule deletes the artifact
section 6 calls genuinely unrecoverable -- silently, a week later.
Section 3's burn record carries the burner and the timestamp, not the oid alone.
burn_tombstone declares two more NOT NULL columns, so an oid on its own cannot be
replayed at all. signed_tombstone stays out because it carries the burned PATH into
a file that is unencrypted and kept forever, and the cost of that -- a replayed
tombstone cannot be the audit record -- is now written down rather than discovered.
Section 2's five replay steps are three today. The blob delete and the CDN purge
belong to a durable job purge.rs says does not exist, and the blob tier is never
restored from backup anyway.
The evidence file gains sections 8.1 through 8.4, run 2026-08-16. The recipient
keypair is minted -- rage rather than the reference age, because age ships only
sigsum proofs nothing here can verify, and the act that mints the key protecting
every backup took the checksummed path. Custody is proved rather than asserted: a
probe round-tripped through the offline key and a freshly minted stranger identity
was refused. Then a runner-shaped artifact -- dump, TOC, row manifest, both
credentials files -- was encrypted, its plaintext deleted, decrypted with the
offline key alone, restored and replayed. The negative first, because it is what
makes the positive mean anything: without the replay all four assertions came back
true, with it all four false, and a second replay moved nothing.
What is still not proved is said plainly. Section 8.5 wants the superuser password
coming back out of a REAL artifact and actually authenticating against the SCRAM
verifier, and the artifact above carried a stand-in because nothing has deployed.
The age-to-rage interop is assumed on the spec and gets proved by the first real
run. Until setup-forge.js has run once, this forge has no backup and
/root/.postgres-credentials has no second copy.
c26f154c · dbf3dbe6…diff - a long-lived container holds no credential: the forge and the site seed their environment from a file the container env only points to - ADR 0059, the loot half of (#1034)
27df4dc9 · dbf3dbe6…diff - the forge has backups: deployed, and the drill run whole against a real artifact (#813)
`npm run setup:forge` has run. /root/.postgres-credentials has a second copy for
the first time in its life, and the first journal export took 12 records off the
live forge -- 8 burns and 4 revocations that a restore would have put straight
back. This was never hypothetical.
ADR 0046 section 8, all five steps, against an artifact pulled off R2:
The artifact decrypts with the offline key and nothing else. That also settles the
interop question I had left open: the VPS encrypts with Ubuntu's Go age 1.1.1 and
the key was minted by rage, so one spec and two implementations, proved rather
than assumed. The negative is the half that matters and it fires -- restored
without the replay, all four capabilities came back; with it, none did. And the
recovered 48-character superuser password matches pg_authid's SCRAM verifier,
while a wrong one does not.
Section 5's real claim is exercised too: the forge's own R2 credential is REFUSED
on the backup bucket. An account-wide token would have satisfied every other check
in the deploy and silently meant a compromised forge reads seven days of history.
The open question in the consequence list is answered. R2 has no versioning
endpoint, confirmed against the live API, and the deploy re-asks every run rather
than trusting a sentence in a document.
Three amendments, each because deploying it falsified something.
The artifact now carries pg_dumpall --globals-only. pg_dump -d forge dumps one
DATABASE and roles are cluster-level, so a restore had no forge, forge_read or
forge_identity -- every GRANT in the archive an error -- and no pg_authid, which
is the only thing section 8.5 can check a password against. That step was
uncheckable until this landed.
Section 8.3 and 8.4 used real rows for the burn and the revocation and fixtures
for the publication, and had to: the row-count manifest in that very artifact
records publication 0 and published_key 0. The live forge has published nothing
yet. That is the same fact that leaves section 7's named trigger unfired.
Two of section 2's replay steps still have nothing to mirror, unchanged from the
build: the blob delete belongs to a durable job purge.rs says does not exist.
What is not proved is said plainly. The timer has never fired on its own schedule
-- every run so far was systemctl start from the deploy -- and the age private key
has no second copy, which the ADR's own consequence list warns makes every backup
landfill. That is now literally true rather than prospective.
a2d9f41b · dbf3dbe6…diff - the forge stops saying it does not know its own window: ADR 0046 section 9 last step, taken (#813)
The media existed and the bucket enforced the window, while /info still answered
backup_retention_days null -- so `loot burn` went on printing an unknown-retention
line against a forge that does in fact keep a copy for seven days. Section 9
assigns that key to this ticket in as many words, and it was the one step nobody
had taken.
https://forge.millerbyte.com/info now answers 7.
Written only when the backup half is on, because section 9 rule cuts both ways and
a window nobody keeps is the worse lie. In the env half rather than the mounted
credentials file: it is not a secret, and the drift check greps it out of docker
inspect, so moving it would blind that check while everything kept appearing to
work.
And the part that makes it a fact rather than a claim. A container reads its
environment at CREATE, so one older than the backup half advertises null however
often the script rewrites .env.forge. That is drift now, and it fired on the real
deploy before recreating; probeForge then asserts /info returns the number, which
is the only place the answer belongs to the running process rather than to a file
this script wrote.
Section 9 says the advertised number and the rule enforcing it are tied by one key
name written once at deploy. They are tied a little better now -- one
RETENTION_DAYS constant feeds both -- and the bucket is still the enforcer.
630c9069 · 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 - RLS arrives on the trigger #702 named rather than on a schedule, because #1725 made it fire the same night: forge_member_writer holds SELECT, INSERT and DELETE on repo_member and SELECT on account_key and repo, which are base tables and not views, and its owner-scoping lived entirely in three SQL statements the site writes. It also held a bare unpredicated SELECT on repo, so it could enumerate every namespace on the forge including the metadata-private ones. Migration 0014 enables RLS on eight tables - the schema header's own repo-scoped group plus proposal, which 0011 added to that group after this ticket was written - and the verdict on the forge role is that it is UNCONSTRAINED, said out loud rather than decorated: all eight of its policies are USING (true) WITH CHECK (true), named forge_ingest_is_unconstrained so the catalogue itself prints the admission, because it writes every tenant from one process with no request-scoped identity and a predicate would be either inert or an outage. What ENABLE does buy is exactly this ticket's condition 2, the accident it was deferred against: a role no policy names now reads zero rows, so an ALTER DEFAULT PRIVILEGES that hands a reader every future table is answered by the tables themselves. The per-transaction ingest binding that would make the forge role genuinely constrainable is filed as #1732. FORCE ROW LEVEL SECURITY was proved rather than assumed, and is deliberately NOT set: on a real cluster a postgres-owned table with RLS enabled AND forced and zero policies still reads 2 of 2 as postgres, where a NOBYPASSRLS role reads 1 of 2 at the same moment, and a non-superuser-owned table reads 2 without FORCE and 0 with it - so the mechanism works, it is inert against a superuser owner, and setting it would be precisely the decorative control this ticket forbids. The red proofs are the output that matters and one of them corrected the method: a policy must be proved by REPLACING its predicate with true, never by dropping the policy, because enabled-with-no-policy is deny-all and returns zero, which proves nothing about the predicate - the first draft did exactly that and it is recorded so the next reader does not repeat it. With the predicate widened instead, the cross-owner INSERT lands and owner B's roster gains a collaborator its owner never named. One thing only a real run could find: membership.pg.test.ts's foreign-key case went red because the policy now stands in FRONT of the constraint, so an unscoped statement never reaches the foreign key the test is about, and left alone it would have quietly become an RLS test named for the wrong mechanism - so it is fixed and the RLS half split into its own case. The site half ships in the same change because it must: membership/db.ts gains withOwner, owner/db.ts's shape minus READ ONLY, and without it 0014 empties the membership surface (#702)
c562196e · dbf3dbe6…diff - the sweep's fix-up, and the finding that mattered most is one no single-lane review could see: docs/sql/forge-schema.sql still read that repo_member and tenant_blob remain producer-less, false since #1725, and #702 had EDITED that very file and left the line standing - which matters because account_tier.rs scans this file and its failure message sends a reader here first, while CONTEXT.md had already been corrected to say tenant_blob is the only table in the tier without one, so the two documents contradicted each other outright. Migration 0014 derived seven tables and enabled eight, with repo_member appearing nowhere in the derivation, so the set is NAMED now rather than derived at three sites and the schema header's own bullet says not to derive 0014's set from it. The account tier had THREE mutually inconsistent lists and the tie is broken by a fourth the ticket did not know about: account_tier.rs carries the list as CODE and matches CONTEXT.md's five exactly, so store.rs gains the account_seed its own next sentence already named, and 0014 stops calling the tier untouched while carrying two policies on repo_member - the resolution being that repo_member is in BOTH tiers, account by tenant_id and repo-scoped by repo_id, and it is that second column that gives a policy something to key on, which is exactly why it is the one tier table 0014 can constrain. rls.rs asserted a COUNT in set language, and the red proof is the contrast rather than the failure: two separate breaks that each keep the cardinality at eight - enabling tenant while disabling proposal, and moving the ingest policy from proposal to tenant - make the new set equality print both lists and fire, while the old count assertions were left standing beside them and reported both passing under the break at 8 and 8. The claim that nothing in CI runs rls.rs is true, and the ticket's own scope was one test too wide: there are eight tests, seven need the cluster, and the eighth reads 0014's text and runs bare, so every site now says seven of eight and names what the eighth holds, in the register ADR 0077 already used for the view invariant, pointing at #1735 for the build. The honest-revoke copy was ADR 0038's failure with the sign flipped, promising that removal refuses reads when membership grants none yet, so it leads with refuses nothing new today and keeps the refusal as the future half that #1726 and #1728 turn on, pinned by a case that also refuses the old opening. Two scope-drift sentences are narrowed to the statements they are actually true of, and CONTEXT.md's copy of one is deliberately left alone because it says every WRITE, which is true of both (#1734)
750a3b64 · dbf3dbe6…diff - the view invariant stops being a name filter and becomes a property of the schema: grant_log_is_refused_in_every_form already enumerated pg_views rather than a literal list, but filtered it to the forge_read name prefix, so the eleven forge_owner views migration 0009 creates sat outside it entirely and 0009's own loudest paragraph - that the owner read surface carries no key material, not published_key, not grant_log, not grant_inbox - was prose with no assertion under it. It now holds over EVERY view in public, and the contrast is COMMITTED rather than transcribed: a new case plants a leaking owner view in a scratch database, asserts the exact violation sentence, runs the pre-1727 query beside it and REQUIRES it to still read zero, then drops the view and requires green - so the case cannot later degrade into one that would have passed before. The ticket was wrong about one table, and implementing it literally would have shipped a permanently red test: published_key is not named by no view at all, since forge_read_published_key has named it since migration 0003 as the anonymous tier's key path, gated two hops back on metadata_public plus a live publication. It becomes an exact set instead, which is strictly stronger than the absence the ticket asked for, because it fires both on an owner or member view GAINING key material and on the read view LOSING its definition. repo_member is expressed the same way and is correct before and after #1726 with no edit, because both sides are computed from the same live catalogue - today both are empty, after #1726 both become the member family - and it is not vacuous today either, since the planted owner view puts a name on the left and none on the right. The vacuity guard runs first and is what makes the rest mean anything, because every assertion here is an absence and an absence is true of an empty result: it asserts the read and owner families are both non-empty and that one view from each is present BY NAME, proved by pointing it at a second database migrated only to 0008, where it answers that the family this ticket exists to cover is the one that is missing. The counts are verified from the catalogue rather than from the ticket - eleven owner views, fifteen read views, zero member views, twenty-six in public - and the opt-in limit is stated rather than fixed, in the register #1734 set hours earlier, naming both tests, the env vars they need, the zero LOOT_FORGE occurrences under the workflows directory, and #1735 for the build that would change it (#1727)
19f9c87a · dbf3dbe6…diff - the collaborator can read someone else's namespace: migration 0015 adds the fourth role forge_member and eleven forge_member views, one per relation 0009 serves, so the shared prefix-parameterised TreeReader reads either family unchanged - and the gate is strictly stronger than 0009's, because the app names only the account the Clerk session resolved to while the membership join sits INSIDE the barrier view, so the application cannot name a repo it is not a member of even by mistake. metadata_public is absent as both column and predicate, and repo_member is named exactly once, by the gate. Two premises did not survive, and the first is a defect in the decision itself: ADR 0077's and the ticket's literal current_setting of loot.reader_tenant cast to bigint DOES NOT fail closed. It fails closed for an UNSET parameter and not for a CLEARED one, because set_config with NULL leaves the empty string behind and casting that to bigint RAISES - which 0009 never met because decoding an empty string as hex does not. Proved by removing the fold from the shipped migration and reading invalid input syntax for type bigint, so the gate ships as nullif and then the cast, malformed values still raise deliberately, and withMember validates a positive decimal with the pin on both sides. The second is a defect in #1727, which landed earlier tonight: its exact-set spelling, that the views naming repo_member equal the forge_member family, is satisfiable ONLY by a family of one, because the ten chained views name the GATE view and never the base table - so a second member view gaining the roster, which is precisely the disclosure ADR 0077 forbids, read as CORRECTNESS under the old spelling. It is re-spelled to exactly forge_member_repo whenever the family exists, its vacuity guard extended to require the member family non-empty, and its positive control now plants four breaks where it planted two. The row-level-security interaction is proved rather than assumed, as three readings on one database in one moment: the seven enabled tables this family reads really carry relrowsecurity, read as a set; the role handed a direct SELECT on repo reads ZERO rows, which is 0014's own named accident and shows the policy system live for that role; and the same role on the same connection at the same moment reads its repo through the gate view and gets one. The mechanism is then named from the catalogue and red-proved by setting security_invoker, which takes the read to insufficient_privilege, and the failure text names the non-superuser deployment case so a future deploy diagnoses itself. The isolation break is ADR 0077's own named careless shape rather than an invented one - the gate keeps its repo_member join and loses only the correlation to repo, which passes every text guard in migrate.rs - and seven of eight tests fire on it. Rows are planted from tests and from the site, outside account_tier.rs's scan of src, the way rls.rs already recorded, so the tripwire is untouched and green. forge_member ships NOLOGIN and provisioning is a different repo, so the class answers 404 rather than 500 and is not an existence oracle (#1726)
e76ca863 · dbf3dbe6…diff - the second sweep's fix-up, and the guard at its centre could never have failed: it asserted that a view statement contains the member family prefix, while the statement BEGINS with create or replace view forge_member and then the view's own name - so the needle matched the name it was reading - and the two assertions beside it were absences that an UNGATED view also passes. The replacement computes the TRANSITIVE set of views reaching the gate, matching whole identifiers against each view's body rather than its header, and the contrast is committed rather than transcribed: a synthetic family per gate carrying an ungated base-table view, a shadow-prefix trap, and a one-hop and a two-hop view, asserting the OLD needle green on the first two while the new check refuses them, with the chained views as positive controls - a spelling that demands a direct FROM the gate fails that control, which is exactly why it is not the fix. The same shape had left the owner family unguarded since #1043, and both were red-proved on one planted break, deleting the join from a chained view and reading back that it reaches its gate by no chain at all. Beyond the ticket, migration 0003 had NO reach check whatsoever, only the outer-join tripwire, so the ANONYMOUS family was the least guarded of the three rather than the most; it gains the same check, red-proved the same way, and all three migration files are restored byte-identical with md5 verified. The night's dominant defect was counting: two different roles were each called the fourth role, ADR 0077 called the same thing a third family and a fourth family 284 lines apart, and ADR 0059's heading said FOUR and they are named rather than counted from here on, directly above a sentence listing five. Roughly twenty sites now list NAMES instead, because a corrected ordinal drifts again on the next role while a list of names cannot, and the arithmetic is settled against a live cluster rather than by reading - pg_views reads fifteen, eleven and eleven, so thirty-seven views in THREE families, which makes ADR 0077's own amendment the half that was wrong. The sentence that had been wrong three separate times is made CODE-DERIVED and stops being prose anyone must remember: removal now asks whether the member read tier is configured, the same question the pool itself asks, so the web half follows the deployment - while the sync half stays prose and is NAMED as the one line left to remember, because nothing in that process can observe the access class. The tenant validator is fixed in the CODE rather than in its claim, since the claim is what later readers rely on and narrowing the regex to eighteen digits would reject legal ids: set_config was measured accepting a nineteen-digit value with the first member query raising out of range, so a range check joins the pattern and the maximum itself is asserted as the positive control (#1739)
74afeacc · 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 - 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 - 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 - 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 - 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 - 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
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.