Changes touching this path

  • Forge service skeleton: /ingest, the sync surface, and CLI forge remotes (#502) A new `loot-forge` crate and binary, sibling to the relay and never a mutation of it: `loot-net` is a dependency of `loot-cli`, so a storage driver in the relay would ship inside the released CLI binary (ADR 0041:53-57). The ban runs one way -- the forge may use the other crates; none of them may gain a driver. Every endpoint is envelope-authenticated, where a relay authenticates only /stow. The one declared exception is GET /info: a client reads the capability probe before it knows how to authenticate. POST /ingest = envelope_sign(bundle || head_declaration), one signature over both, so a genuine bundle can never be replayed under someone else's head claim. CAS first; nothing is written before the generation compare. A lost CAS answers with a 409 BODY carrying the current generation and head set -- behind, not bounced -- so the client re-carries with no extra round trip. /stow keeps relay parity and moves no ref, and consumes no generation either, since a client reads the generation once and then stows N batches. It DEFERS a change whose objects have not all arrived; /ingest REFUSES one, because a declared head whose objects are missing is a repo no pull can complete. That asymmetry is what keeping /stow is for. Bundles are filtered PER OBJECT by the caller's access class, closing the leak the ticket names: bundle_impl ships a key for every ANYONE-granted object, i.e. every Internal object. Change metadata rides the second #494 axis and gates the whole walk -- a ChangeNode carries the full tree, so shipping one while withholding its bytes still discloses every path, address, message and author. Validation before any write: author signatures, and a tree whose visibility contradicts its objects is rejected. vis_tag is derived from the SealedObject and never trusted from the tree, which is what makes content-addressed upserts safe. Forge purge policy: burn iff burner == object.introduced_by, recorded at first arrival as introducing.author else the envelope pubkey; maroon read off grant_log, global and exact. Both are judged before any object is stored and outside the CAS transaction. Repos are pubkey-addressed at /k/<pubkey>/<repo>, auto-created on first push, and may_push holds iff the signer owns the namespace. resolve_remote is untouched -- still no default host. CORS from day one. Storage sits behind three narrow traits with in-process reference implementations the whole suite runs against; the Postgres DDL they mirror is checked in at docs/sql/forge-schema.sql and its driver is #516. CLI: /info capability detection, the live head declaration off the Liveness view, and a refusal to push a lane -- v1 ingests the primary position only. verify_authored_change moves to loot-codec so the forge runs the identical gate without linking the fs-hardwired engine. Recorded in ADR 0041, the spec and CONTEXT.md: the forge stores no content key but a published one, because filing the ANYONE key lane would let the server read every Internal object, and the keystone is that only Published is server-readable. The cost is stated rather than hidden -- a fresh clone of your own Internal content from a forge is ciphertext you cannot open until #488 lands. Closes #502. 300e06ff · dbf3dbe6…
  • Forge publish ingest: file the published key the bundle already carries (#499) 417aaf83 · dbf3dbe6…diff
  • loot-forge: the anonymous read surface is 11 barrier views and a forge_read role confined to them (#610) bb193149 · dbf3dbe6…diff
  • the published index outflanks the metadata gate for exactly the published facts, and the site grows the anonymous read tier that serves it (#749) 8ed81d63 · dbf3dbe6…diff
  • tenant_blob counts what a tenant references, and the account tier it belongs to stays unwritten on purpose #612 asked what `tenant_blob.bytes` counts before anything counts it, and said the dedup question decides whether the producer belongs at the blob `put` "where dedup is visible" or at `ingest` "where tenancy is". Both halves resolve, and neither the way the ticket framed them. `bytes` is REFERENCED FOOTPRINT. What settles it is #482's own accepted cost -- deleting a tenant is "refcount-decrement-and-delete-if-zero, because a fork legitimately holds the same object". A decrement presupposes many tenants holding a row for one oid, which is the reference reading; under the origination reading there is one row per oid forever and nothing to decrement. The shorter argument -- that the primary key forces it -- is tempting and does not work, so the comment says so rather than leaving the next reader to try it. `(tenant_id, object_oid)` does settle the ticket's other half, running total vs append-only ledger, because a set whose `bytes` is a property of the oid cannot accumulate. It does not separate reference from origination: "one row per oid, for the tenant that introduced it" is equally a set with that key. Origination is already recorded and is not this table's: `object.introduced_by` (#501) is first-write-wins on the address, keyed by PUBKEY, not tenant_id. What it lacks is a byte count, so billing on origination is a column on `object`. The put-vs-ingest question dissolves rather than being answered by the dedup verdict. Dedup is not visible at `put` at all -- `BlobStore::put` returns no novelty bit, and an `exists`-then-`put` is a race between concurrent pushes -- and a set-membership upsert never needs to know. So: ingest, where tenancy is. The reason nothing counted anything is bigger than this table, and is the find worth keeping: `tenant` HAS NO PRODUCER EITHER. #504 resolved "first push auto-creates the `tenant` and `repo` rows"; only `repo` was built, correctly, because `repo` is keyed by owner_pubkey and has no tenant_id at all. `tenant`, `account_key`, `repo_member` and `tenant_blob` are one unbuilt ACCOUNT TIER -- #487 decided its shape and is closed, #753 is the build. So tenant_blob was never a missing producer; it is a member of a tier held whole, flagged alone because #482 named it by table. Its consumer is behind the same door: quotas arrive with #504's open multi-tenant push. No producer is built, and no migration is added. The record goes in the schema comment, which is legitimate because migration 0001 IS docs/sql/forge-schema.sql and comment-only edits are the #720-enforced exception to append-only -- `released_migration_statements_are_pinned_by_checksum` proves no statement token moved. Per-tenant export, the third consumer, is recorded as deferred rather than answered: it needs #493's reachability walk, which a byte count is not. tests/account_tier.rs is the tripwire that keeps the absence from re-opening silently, and it is written to be deletable when #753 arrives. Its own first cut failed open twice -- on a `\` line continuation (the house SQL style) and a quoted identifier (`INSERT INTO "ref"` is real) -- so the scan now normalises both, plus schema qualifiers and Rust escapes, and covers the migrations directory rather than only src/. Every spelling it claims to catch is pinned by a positive control, because a scan that has quietly stopped matching reproduces the exact silence #612 was filed about, inside its own guard. Two claims in the first draft were corrected by review before landing: the primary-key argument above, and pointing the reader at #487 (closed) rather than #753. #690's allowlist is also named as what it is -- a deployment setting, not a code closure, since an empty list means open and the binary only warns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> a0106e52 · dbf3dbe6…diff
  • the browser mints the seed and the account binds the key: Clerk in the site's own routes, variant-B onboarding, and the account tier's one producer (#753) Migration 0007 adds account_seed (the passphrase-wrapped envelope, opaque ciphertext the server cannot open) and forge_identity, a NOLOGIN role scoped to exactly the identity tier -- SELECT/INSERT on tenant, SELECT/INSERT plus a column-scoped UPDATE of retired_at on account_key, the full row on account_seed, and nothing else (pinned live by forge_identity_is_scoped_to_the_identity_tier). The producer lives in site/src/server/identity/ behind a verified Clerk session (verifyToken in the site's own route handlers, reusing the millerbyte Clerk instance and pattern, never the gateway service). Registration is one transaction: upsert tenant on the verified clerk_user_id, add the key, store the envelope, retire what rotation names -- and never zero live keys. The Rust service still writes none of the tier; tests/account_tier.rs now guards that boundary instead of a vacancy. Onboarding is #487's variant B, the two-column contract: the app owns the 32 bytes (crypto.getRandomValues -> 24-word BIP39 over the ENTROPY, never the 64-byte derivation -> ed25519 pubkey via @noble/curves, held to the engine's from_seed by the pinned RFC 8032 vector), a WebCrypto PBKDF2+AES-GCM envelope, a .txt download that excludes the passphrase, and two consent checkboxes that no download waives. Clerk mounts only under /account, which joins /k/ on the prerender deny-list; every static surface stays static. Follow-ons filed rather than folded: #926 (CLI raw-seed import, so BIP39 recovery reaches the CLI) and #927 (account-aware may_push, burn entitlement via account, and the #506 historical-key 301s the anonymous read tier deliberately cannot serve today). 278e1886 · 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
  • 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
  • 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 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
  • 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
  • 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

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.