Changes touching this path
- membership authorizes ciphertext and a grant decrypts it, which is how a collaborator read arrives without resurrecting the repo-level Public/Private that ADR 0041 threw out: a repo_member row is repo-scoped AUTHORIZATION that moves sealed bytes and metadata, while what may actually be read stays path-scoped and decided by a grant, so a member row alone decrypts nothing and every tier stays sealed. Membership and granting are TWO acts and not one, because membership keys on clerk_user_id for ADR 0016 rotation where a grant keys on grantee_pubkey, and collapsing them is exactly how a repo-ACL fiction gets in. The producer inherits #753's boundary rather than arguing an exception - the site writes the row over a new narrow role, never from Rust - so account_tier.rs's assertion is unamended and no Rust producer ships here. Three of the ticket's own premises did not survive being checked. Its issue search is stale, returning twelve more tickets than the three it names, though the claim under it that no ticket owned the row held. The owner access class is NOT scoped to account_key: migration 0009's gate is a pubkey parameter and its views name account_key in no form, the account resolution being app-side, so a membership predicate is the FIRST join the read tier makes into the account tier rather than a widening of one that already exists, and that is why it graduates as a fourth role and a third barrier-view family rather than an OR. And #1043 is identity-agnostic in its unlock, keyring, CSP and presigner but not in its routes, which reach withOwnedRepo. Two defects in account_tier.rs's own header are repointed rather than deleted: its walk covers src plus migrations plus docs/sql/forge-schema.sql and NOT tests, where the prose read nothing in it of the whole crate, and that exclusion is forced rather than incidental - proved by running the file's own flattened and writes_found rules over itself, which reports six writes every one of them from its positive control. Its second sentence, that the trait has no method naming an account, has been false since MetadataStore::account_of arrived with #927 and meant writes. Graduated as #1725 the producer, #1726 the collaborator read class, #1727 the view invariant being scoped to forge_read_* by name so the eleven forge_owner_* views are unguarded and opt-in besides, and #1728 AccessClass::Member (#1639)
9fdaacab · dbf3dbe6… - 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 - 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 - loot apply-patch refuses a patch that deletes a path and also renames it away: refuse_shared_paths now counts every path a stanza removes, as a deletion or as a rename old path, and refuses both rows, where the run used to move the file, remove the old path once and discard the second NotFound, and a row it refuses that was already refused keeps its first reason with the shared one appended. a rename old path that another stanza writes stays allowed, as #1995 pins, and ADR 0082 says why and where git apply differs; the rename writer now reports every removal error. the offer_cost double counts account_of, and the two constant-cost cases pin it at the one read Reader::new makes since #1955. the apply_strict comment and ADR 0082 no longer say an unmarked kept last line claims nothing: format-patch writes one when only the new side ends with a newline, so it stays unchecked, and a hunk that adds no line loses that newline. plan_one shares one helper for the rename and add verdicts, the ClassReads, ingest_cost and offer_cost doubles and Reader::new have docs true to the code, no shared base double is added since the conformance double delegates every method, and the CONTEXT.md, ADR 0077 and ADR 0082 rewrap leftovers are fixed. before the change the new rename test went red (12 passed, 1 failed), as it did with deletions uncounted, with the deletion refusal removed and with the rename refusal removed (12 passed, 1 failed each); the kept-reason test went red with the first reason overwritten (8 passed, 1 failed); the account pins went red with a per-object account_of read in readable_rows (11 passed, 2 failed), which the old double let pass (13 passed), and with the owner arm reading nothing (11 passed, 2 failed). the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (#2006)
f16115c2 · dbf3dbe6…diff - a retired key no longer burns what it introduced on the forge: purge::honor_burn now asks account_of about the burner and applies the require_pusher rule with the introducer as owner, so a key with no account row burns by the byte compare as before, a live key burns as the introducer or as a key of the account that holds the introducer, and a retired key is refused, as the new RetiredKey verdict where the live rule would admit it and as NotEntitled otherwise. same_live_account becomes same_account and no longer asks liveness. the maroon arm still asks no retirement, since it is the only way to revoke a forge grant and has no account lane, and the relay and peer policies read no account tier, so none of them moves. a new server.rs test asserts that the burn verdict agrees with require_pusher over the #927 cast plus a retired stranger, and member_class.rs runs the rule against real account_key rows. before the change the in-memory reproduction went red (11 passed, 1 failed) and member_class against a throwaway cluster went red (3 passed, 1 failed). over the purge tests and the server.rs agreement tests the pins went red with the retirement check removed (13 passed, 4 failed), with the introducer compare answering first (14 passed, 3 failed), with an accountless burner refused (15 passed, 2 failed), with retirement asked before entitlement (15 passed, 2 failed) and with the tenant equality dropped (14 passed, 3 failed), and member_class went red under the first three (3 passed, 1 failed each). ADR 0038 and ADR 0077 are amended, CONTEXT.md, store.rs and server.rs name honor_burn, and the Rotate card on the account page no longer says burning is not covered and names the maroon limit instead. the site gate is green, the workspace suite is green, and the loot-forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (#2004)
e6641d95 · dbf3dbe6…diff - 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 - sweep 16 fix-up: the removal census header stops denying a spelling its own fixture holds, and the at-most-one-class claim the no-cache decision rested on is replaced by the worst case a contended withdrawal really pays. the census header said it does not strip block comments or string literals and that there are none of either, while the classifier fixture below it spells six removals in string literals, addresses among them, and the expected set names that fixture because of them; the bullet now says the spellings exist, that the set names the fixture for that reason, and that one arriving in a function the set does not already name arrives red where one inside a function it does name would not. the ticket said five, which is why the corrected sentence carries no count at all. the same header said #1897 gave pack_every_loose_object its address so this census could see it, which it cannot: that site asks the helper, spells no removal and is correctly absent, so what the address buys is that a regression there spelled by address would be visible, and one spelled the old way still would not. ADR 0077 and ADR 0075 both said a request resolves at most one class and rested the no-per-request-cache decision on it, which the comment beside the gate refutes: the existence gate is called inside the withdraw retry loop, so a contended withdrawal resolves one per round. the cache question is re-answered on that: the worst case is rounds times one resolution, the loop carries no round cap so it is unbounded in principle, and what bounds it in practice is who can turn it, since a round is lost only to a write another party committed to this row, the production routes that write over an existing row admit only parties to the offer first, and most such writes end the loop rather than prolong it, an amendment away from this signer refusing at the author check and a close making the row terminal. the resolution stays per round rather than cached, because a cache would pin a class for the life of a request where a round decides against the class as it then stands, and the new conformance case the_withdraw_gate_resolves_a_class_for_every_round_it_turns is the run under that sentence: an uncontended withdrawal as the control, an amendment inside the window, and the contended one resolving twice as many classes as the control. with the resolution hoisted out of the loop it goes red against the memory store and against a live cluster alike (0 passed, 2 failed, left 1 right 2; 2 passed restored). ADR 0075 also stops saying the standing arms answer whatever the class says, which inverted its own rule: they admit independently of the class, the predicate asking the class first and falling through to them. visible_to takes the class and the key as one Viewer now, private fields and one resolver, so the pair cannot be mismatched: a literal outside the module is E0451, measured, and the read-cost claims are unchanged at one resolution per call of read, list and the withdraw gate. the evolog human doc said the cell is blank where the message is empty, the fourth home of the wording #2023 moved, and a blank first line blanks it too, with a spawned pin over the human column and an ordinary subject as its control (5 passed, 1 failed with the whole message back in that column). census mutations: a bare removal by address put back in pack_every_loose_object reddens the census naming that function (2 passed, 1 failed), and the fixture dropped from the expected set reddens it too (2 passed, 1 failed). no migration, and the forge binary changes, so this owes a forge deploy; the live forge suite is green against a throwaway 17.0 cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (476 passed over nine binaries, no skip note) and the workspace suite is green (3846 passed) (#2028)
3168156f · 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.