Changes touching this path
- Forge storage drivers: Postgres metadata tier, S3 blob tier, and one conformance suite both must pass (#516)
f65970e6 · dbf3dbe6… - loot-forge: the anonymous read surface is 11 barrier views and a forge_read role confined to them (#610)
bb193149 · dbf3dbe6…diff - forge: the migration ledger records a statement checksum and refuses silent divergence (#720)
1f851cda · dbf3dbe6…diff - the forge stops shipping keys for retracted publications, and publication finally has a producer (#709)
ddcf4de9 · dbf3dbe6…diff - a change records when it was authored, so a projected commit stops reading '6 years ago' on GitHub (ADR 0043)
loot changes carried no timestamp, so the git bridge fabricated one:
BASE_EPOCH + generation, one second per ancestor depth from a 2020 epoch.
All 526 commits sat inside seven minutes of September 2020. Earlier repairs
(#626's floor, the missing-generation refusal) fixed ORDERING and never
touched the absolute date, which is why this kept coming back.
ADR 0028 inherited the no-timestamp constraint rather than choosing it. The
real reason is upstream: a version id is blake3 over authored content, and a
clock inside that hash gives two peers different ids for identical content,
destroying the dedup and convergence of ADR 0001/0004.
So authored_at rides the label seam ADR 0029 already cut for change_id --
covered by the finalize signature (no relay can restamp it) but never folded
into the version id. The wasm golden vectors prove the separation held:
FROZEN_VERSION_ID, FROZEN_OBJ_ADDR and FROZEN_SIGN are byte-identical, and
only the version marker and one presence byte moved.
Advisory, and never an ordering input: a self-reported clock is a claim, not
evidence (ADR 0025). in_order/ids_topo, buoy and path_touch.ordinal are all
untouched, the forge indexes nothing on it, and the projection floors it past
every git parent so ancestry holds whoever's clock is wrong.
Format v11: additive for readers (a v<=10 change decodes as None and an absent
timestamp adds nothing to the signed message, so every existing signature
still verifies), breaking for writers, so loot-cli and loot-forge go to 0.4.0
in lockstep. Forge migration 0004 stores it as bigint, not timestamptz --
signed data must round-trip bit-exact or pullers reject the change.
Only new work gets real dates. Every commit on main predates v11, main is
push-fast-forward-only, and backfilling would fabricate the very claim this
replaces.
Perf-Baseline: reset the change body grew one presence byte, plus eight where a timestamp is present, so bundle_bytes/store_bytes/wire_bytes step once at the v11 boundary
eaa56d99 · dbf3dbe6…diff - 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 - a forge storage error carries the server's own words, so a failed migration stops reporting 'db error' (#776)
pg::storage formatted a postgres::Error with Display, which looks like it
preserves the cause but is the literal string "db error" -- the message,
SQLSTATE, detail and constraint all hang off as_db_error() and were dropped.
connect_failed and migrate.rs's connect error had the same shape.
That is worse than cosmetic. ForgeError::Storage is the ONLY ERROR-level
variant, so the class the forge logs loudest carried nothing actionable; and
--migrate funnels through it, so a failed migration reported 'storage: db
error' with an empty ledger -- indistinguishable from the migration you had
just written being wrong.
Found applying ADR 0043's migration 0004 against a cluster that had defaulted
to WIN1252: the real message named the offending U+26A0 byte sequence and the
encoding, and was visible only in the postgres log on the box. It pointed the
investigation at the new migration instead of at the cluster.
pg::describe now unwraps as_db_error() into message/detail/hint/constraint plus
SQLSTATE, and walks the source chain for non-DbError failures where Display is
equally terse. All three sites route through it.
It deliberately omits the statement and bound parameters: parameters carry
tenant content and this string reaches logs. The server's message plus SQLSTATE
names the fault without naming the data.
before: cannot reach the metadata tier to migrate: db error
after: cannot reach the metadata tier to migrate:
database "nonexistent_db" does not exist [SQLSTATE 3D000]
The regression test provokes a REAL server error rather than constructing one,
because the bug was that the detail lives where Display does not look -- a
hand-built ForgeError::Storage would have passed throughout this file's
history. Verified to fail against the old one-liner.
9d4f91a6 · dbf3dbe6…diff - the forge's metadata pool caps every connection it opens, so a burst queues for one instead of being refused by Postgres (#611)
f2d8ee80 · dbf3dbe6…diff - the change ledger becomes the history surface, banded by what the server can read (#760)
8ad80be0 · 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 - the browser opens what the forge cannot: own-private read ships an owner read tier, a once-per-device unlock, and a grant mailbox that never acks (#1043)
c8dd4da1 · dbf3dbe6…diff - a first push stops timing out at the door: /wants asks the metadata once for a whole address list instead of two network round trips per object, which cost this repo 10,700 of them and 30 seconds (#1069)
644cf290 · dbf3dbe6…diff - an ingest stops asking the store once per tree entry: the arriving delta's visibilities load in one query, so a first push's head declaration returns instead of hanging (#1072)
68b86ccc · dbf3dbe6…diff - a forge pull stops probing Postgres and R2 once per (change x entry): /offer and the bundle walk resolve entitlement in two batched queries, and the counting double finally points at them (#1154)
b28ca59e · dbf3dbe6…diff - a forge pull reads the delta's manifests instead of every manifest in the repo twice, so deciding there is nothing to send stops costing the whole repo, and an incremental pull stops re-paying it once per 32-object batch (#1195)
c78dd8f5 · dbf3dbe6…diff - the two forge backends stop each keeping their own copy of when an embargo opens, because ADR 0027's gate and ADR 0057's standing split are lifted out of the adapters into one choreography both servers call, and a live-Postgres case now reads the same mailbox at five clocks and compares the driver's answers to the reference's (#1412)
882f9837 · dbf3dbe6…diff - an embargo past the end of the storable clock stops reading back as due-now on Postgres, because reveal_at is admitted against one stated bound at the wire and in every backend instead of cast into a bigint that files it negative, and the agreement harness gains the row it could not carry until that decision was made (#1449)
13fd52b5 · dbf3dbe6…diff - the forge's five hand-kept wrapper lists stop being lists that could drift and become one macro stamp per adapter, so a case reaches every adapter by construction rather than because somebody remembered: each suite is a callback macro handing its names to the adapter's own arm, and one repetition emits both the tests and the const the roll call compares against, which is the stronger form of the ticket's ask - making the list unforgeable beats parsing for evidence it drifted, and the parse the ticket specified would have had to survive three Case::new calls that are not cases, two spellings of the path, and calls sitting in helpers. no source walker was needed or added, because everything here already lives under cfg(test) so test code is the subject rather than the thing to exclude. one text parse survives and only one side of it is text, since a case can still be written and named by no suite and rust has no reflection over a module's functions while this repo cannot run deny warnings: it reads column-zero pub(crate) fn in its own file only - and its premise is true by construction rather than by care, because the checker had to move inside an indented module or it named itself, which is not a thing anyone designed but what the guard did on its first run, red, on its own name. both halves were proved able to fail: deleting a case from a suite leaves every roll-call test green and the total quietly falling from 278 to 275 with only the parse catching it, and an adapter stamping a subset names the adapter, the count, the suite and the case. the limit is stated narrowly on purpose - every case is asked of every adapter that takes a suite, never that any adapter answered, since 56 of 66 pg cases and 7 of 12 s3 cases skip green with no credentials in CI - and a second limit nobody asked for is written down too, that nothing counts implementors, so a new store whose module never invokes the suite is asked nothing and this mechanism stays silent about it (#1655)
66876d46 · 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 - repo_member gets the producer it has never had and it is the site's: migration 0013 creates forge_member_writer holding SELECT, INSERT and DELETE on repo_member plus SELECT on account_key and repo and nothing else, named writer rather than forge_member because ADR 0077 reserves that name for #1726's read role - and a test enforces the reservation. The load-bearing half is that membership is addressed by pubkey and STORED BY ACCOUNT: the owner names a collaborator pubkey, the server resolves it through account_key to a tenant_id, and storing the key instead would silently un-member anyone who rotated, which ADR 0016 makes an account fact and #927 already taught the push path. That resolution deliberately ignores retired_at, proved by adding the filter and watching a rotation test refuse a member who had merely rotated. The site module is a SIBLING of identity, read and owner rather than a widening of any of them, and the route reuses withOwnedRepo for authorization while every membership statement ALSO joins repo on owner_pubkey, so the database refuses a stranger independently of the route - proved by dropping that clause and watching a stranger's repo really take the row. Thirteen guards were each broken and read before being restored, and the one worth naming is the column-ACL half: a live GRANT UPDATE (tenant_id) ON repo_member PASSES the set equality and is caught only by the column check, because has_table_privilege cannot see a column-scoped grant, and its positive control revokes 0007's own column grant to prove the query can see one at all. The accountless refusal is its own sentence rather than a foreign-key error because a keypair is free and infinitely mintable, so an accountless member can be neither quota'd nor banned; and the honest-revoke copy interpolates MAX_PRESIGN_SECONDS from read/blob.ts so it cannot drift from the sixty-second window it admits is small and not zero. account_tier.rs's assertion is untouched and passes unamended, which is what ADR 0077 predicted - what needed repointing was #1639's own replacement header, true when written and false on landing. ADR 0059's count of the site's Postgres URLs was a live count, is now four, and is named rather than counted. Membership is write-only until #1726 and #1728 give it a reader, and provisioning is a different repo, so the route throws until scripts gives the role LOGIN (#1730) (#1725)
4fac0c99 · 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 - a move becomes a recorded fact instead of a later guess, and the premise this ticket rested on was false: #98's object reuse is keyed by PATH, so a moved path is absent from the outgoing tree at its new key, falls through to put_sealed and gets a fresh address - the same object under a different key was not a fact waiting to be read off the tree, it had to be made true by extending the reuse across the move, and everything else follows from that. The rule is that a move is recorded only when the path's sealed object survives it, so a move whose content also changed in the same capture window shares no object, records nothing and is two rows, while a move and then an edit across two captures of one change composes and reads as one row saying the content also changed. Empty content and any ambiguity are refused for the same reason the whole design exists: zero bytes equal all zero bytes, and choosing between two vanished twins would make a signed fact depend on iteration order. On the boundary the render side compares nothing at all - resolve reads keys and never an address, a visibility or a byte, pinned by running it twice over trees that agree on every key and differ on every address at three unopenable tiers, with a positive control beside it so agreement is not evidence it answers nothing. The capture side compares once: it opens a vanished object with this identity's own keys, fail-closed so an unopenable object is never read or hashed, and pairs only against an addition at an identical visibility and publication tier, with the digest living for one call, never an address, never stored and never on the wire. What a relay newly learns is that two keys held equal ciphertext, which recording a move states in plaintext anyway since tree paths are plaintext at every tier, so it is inherent to the feature rather than the ADR 0004 oracle - written into that ADR rather than left in a message. Renames ride the label seam change_id and authored_at already use: covered by the finalize signature so a relay cannot rewrite a move, never folded into the version id so two peers reaching one tree by different routes still agree on its address, which is why every existing id and signature is unchanged and a v11 store reads with no move recorded. That was tested rather than argued, against a real store built by the shipped binary, where the old move still renders as a delete and an add in the same repo the new one renders as a rename, because inferring the old one would be the equality oracle arriving through the compatibility door. Three further things were wrong and are corrected rather than worked around: ADR 0019 says an additive change bumps the minor and has never described this project, since the minor is still zero and all five additive changes took the major; plan_moves own doc comment claimed the tier is checked before the digest, three lines above code that does the lookup first, when the property actually holds by the stronger route that nothing unopenable enters the index at all; and the empty-content refusal was written on both sides of the pairing, where each made the other unprovable and removing either left the test green - a duplicated guard is not belt and braces, it is two guards neither of which can be shown to be doing anything (#1539)
3c7e029b · dbf3dbe6…diff - the forge role is bound to one repo per transaction: migration 0017 replaces the permissive forge_ingest_is_unconstrained policies with forge_is_bound_to_one_repo, keyed on loot.repo_owner and loot.repo_name, which Pg::with_repo sets before any other statement, so an unbound statement reads no row of those tables and has every write to them refused. every MetadataStore method that reaches a row-secured table now runs in with_repo, held in source by a census test and in behaviour by the conformance suite run as forge, and the ref CAS is still the first statement that locks ref. tests/rls.rs proves the binding refuses cross-repo reads and writes and goes red when any one table predicate is replaced with true, and the recorded-decision test that pinned the unconstrained role is removed. pg::tests now starts its sessions as forge. ADR 0078 decision 3 is amended with the census, the deploy order and what the binding does not constrain, and CONTEXT.md, the schema header, ADR 0077 and the evidence doc follow (#1732)
af1c95ee · dbf3dbe6…diff - the forge live-database suites now run in CI: test-main.yml stands up a postgres:18 service, provisions it the way setup-forge.js does including its default privileges, migrates it with the commit own loot-forge --migrate, sets every LOOT_FORGE_TEST database url the Rust and site suites read, and runs cargo test and then the site pg tests against it. LOOT_FORGE_TEST_REQUIRE_DATABASE turns a database test skip into a failure through pg::live_opt_in and the site skippingLive helper, and with it set and no url 91 Rust tests and all six site pg files fail, while two half-suites that used to skip silently now say so. The default-privilege tripwires run in CI by decision, and fail on a cluster without provisioning. A new migration that neutralises the roster policy passed the bare suite and failed rls.rs and membership.pg.test.ts against the cluster. It is a canary on landed main and not a pre-land gate, and the prose that said no workflow sets these urls now says that instead (#1735)
c0931f78 · dbf3dbe6…diff - propose refusal 5 and its write can no longer be separated by another writer: record_proposal takes the row its caller read and writes only if that is still the row on file, compared as a whole row, answering false otherwise, and admit reads again and decides again when it does, so a caller who loses the race meets refusal 5 against the winners row and the rule stays in admit rather than the store. the reference store compares under its lock, and the postgres driver compares and writes in one bound transaction, reading with FOR UPDATE and creating with ON CONFLICT DO NOTHING; no migration. two conformance cases hold a second writer between the authorizing read and the write, one with two concurrent proposers and one with the author withdrawing, and both went red on the old code on both stores, with two proposes admitted where one is right and the withdrawal undone; a third pins the compare itself on both stores, and a postgres case holds the row from another session inside the call and went red with FOR UPDATE removed. removing either store compare or ignoring the answer in admit also turned cases red. ADR 0075 gains a 1747 amendment and CONTEXT.md says the race is closed. the loot-forge suite is green against a throwaway cluster with the CI env block, and the rest of the workspace is green bare (#1747)
14232fd5 · dbf3dbe6…diff - withdraw and its close can no longer be separated by another writer: close_proposal takes the row its caller read and closes only if that is still the row on file, compared as a whole row, answering false otherwise, and withdraw reads again and decides again when it does, as admit has since 1747, so an author whose tip a carrier moved in between meets the author check against the moved row. the reference store compares under its lock and the postgres driver reads the whole row with FOR UPDATE in the transaction that already read its state; no migration. a conformance case holds a carrier amendment between the read and the close, and it went red on the old code on both stores (0 passed, 2 failed) with the withdrawal closing a row whose tip another key wrote; a second pins the compare on both stores, and a postgres case holds the row from another session inside the call and went red with FOR UPDATE removed. ignoring the answer in withdraw, or removing either store compare, also turned cases red. binding_census now opens a fn item only outside another fn body, found by counting braces outside the string and char literals and comments it recognises, and fails a file whose count does not close; a planted pg/planted.rs method holding a nested fn before unbound SQL went red under the new census (2 passed, 1 failed) and stayed green under the old one (1 passed, 0 failed), and a fixture case with unbalanced braces in literals went red on the old census and on the new one with its string or char handling removed. the visibility fixture now covers pub(super), pub(self), pub(in path) and private methods, the stale upsert prose and the statement count in pg/meta.rs are fixed, the misplaced memory.rs comment moves to its arm, CONTEXT.md points at the matrix doc instead of a column count and adds the 1955 caveat, the evidence entry calls the 1956 mechanism suspected, the rewrap leftovers in model.rs, ADR 0078 and conformance.rs are fixed, and ADR 0075 and CONTEXT.md record the withdraw follow-up. the loot-forge suite is green against a throwaway cluster with the CI env block and bare, and the rest of the workspace is green bare (#1959)
e3d4cd50 · dbf3dbe6…diff - 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 - review sweep 15 fix-up: the evolog dash is on an empty FIRST LINE, the subject rule now has one home, and the surface prune says plainly that it discards. loot evolog --porcelain dashes its last column when the message first line is empty, which an empty message and a message whose body starts under a blank one both are, and three places said an empty message: the doc above the encoder, the ADR 0023 amendment of 2026-09-17 and CONTEXT.md. The wording moved rather than the bytes, because dashing on an empty message instead is a porcelain byte change for a message that opens with a newline, and a new spawned pin, a_message_whose_first_line_is_empty_is_the_dash_too, puts a run under the sentence: the file reads 5 passed, and with the whole message put back in the column it is 2 passed, 3 failed, the single-line control among the two that stay green. render::subject_line was billed as the one home while six call sites derived that first line inline, the four the ticket named plus the shortlog author rollup and the missing-object listing, so log --oneline, heads, show, range-diff and those two all ask it now, and the near-cousins that each do something more, the note ellipsis, the revert subject and the loot-first PR title, are named beside it rather than routed. The control is the spawned binary: 28 outputs over two repos, one carrying a multi-line message and one a blank first line, are byte-identical before and after, and with subject_line returning the whole message 10 of 11 of them move and the loot-cli lib suite reads 1219 passed, 4 failed, naming the shortlog rollup and the oneline row among them. The surface stale-debt prune keeps the removal error it discards, which #2018 declined and its closing note then reported as fixed, and the doc above it now says the let _ is deliberate, that apply_change_delta propagates where this one does not, and why: the delta writer snapshots, so a refused removal would be recorded as a deletion the disk never took. The removal census in workspace.rs states what its matcher is blind to, a type-annotated binding, a call split over lines and a path derived from a root-joined one, and names disk_entry::drop_empty_parents, whose remove_dir climbs from a dest its caller joined and which the fold cannot reach, because remove_dir takes an empty directory only and a directory holding a file the writer just wrote is not empty; widening the matcher would not have reached that call, so the blindness is stated instead. CONTEXT.md gives that census its crate and spelling scope. migration_serial records that hold blocks and is not re-entrant, and that it serialises migration runs rather than pg_authid, naming the fixed-name CREATE ROLE in tests/rls.rs that writes the shared catalog outside it; Held loses its Option and its Drop impl, since dropping the field is what the compiler already does. The migration census compares a BTreeSet the way the removal census does: with the const reordered it stays green where the Vec compare it replaced goes red (0 passed, 1 failed), and a name dropped from the const reddens it (0 passed, 1 failed). pg/tests.rs cross-references its own source walk and fn finder to source_walk in loot-cli and states where the pair differs. The folds_case helpers in two test files become folds_case_under, so neither shadows the disk_entry rule it asks. The rewrap leftover in account.tsx that #2018 moved rather than fixed is rewrapped with its neighbours, and so is a second one mid-paragraph in CONTEXT.md that the same rewrap reached. The forge suite is green against a throwaway 17.0 cluster (469 passed over nine binaries, no skip note) and its lock case goes red with the session dropped rather than held (0 passed, 1 failed), the site gate is green (643 passed, every surface under its ceiling), and the workspace suite is green (3833 passed) (#2023)
c38e3299 · dbf3dbe6…diff - a retired namespace key no longer learns that a metadata-private repo holds a proposal: propose::visible_to decided ownership with a byte compare against repo.owner, the shape #1955 took out of AccessClass::of, so the withdraw route, which asks that gate before the author check, refused a retired namespace key as not the author with a 403 where a key that is no party to the offer is told no such proposal with a 404, and the difference between two refusals discloses the fact metadata_public governs. the gate takes AccessClass::may_read_metadata whole now and compares no pubkey of its own, so the two read gates in loot-forge ask ownership in one place, and the answer is a live or accountless namespace key, a live key of the account that holds the namespace (#1744) and not a retired key (#1955); the two standing readers, the tip author and the presenting proposer, are unmoved and still need no account. taking the class whole widens the gate to a live key of a member account, deliberately and pinned: may_read_metadata is the same axis, a member already pulls every message, tree and author in ref_head, and no content moves, where an account holder with no membership row on this repo still sees nothing. the other half the ticket named, a rotated owner treated as a stranger, is latent, as the correction on the ticket established: no route reads or lists proposals, propose::read runs in production only for the presenter, propose::list has no production caller, and on withdraw the successor key only got a different refusal. the class is resolved once per read, per listing and per withdraw attempt, outside the listing row loop and only for a row that exists, and a party to the offer pays it too. red first, on the unchanged predicate: the three new propose.rs cases went red over that selection (24 passed, 3 failed), the read arm handing a retired namespace key the whole proposal view where a stranger reads none and the withdraw arm answering 403 against 404. with the old byte compare put back as the class answer the four in-memory cases go red (400 passed, 4 failed) and the new member_class.rs case against a throwaway cluster goes red (4 passed, 1 failed); with the class dropped so the flag answers alone nine go red, the write-gate and one-sentence pins among them (395 passed, 9 failed; 4 passed, 1 failed). ADR 0075 and ADR 0077 carry the #2002 amendment, CONTEXT.md names the class, the cost and the widening, and AccessClass::of stops claiming every caller reaches it through serve::Reader::new. one flake was found on the way and fixed: pg::tests the_migration_lock_is_one_lock_for_the_whole_cluster probed the advisory key once after dropping its own hold, and hold blocks, so a queued harness takes the lock as it comes back, which was red on both runs of a loaded workspace suite against a cluster and green on three runs of the lib suite alone; the probe waits for the release now, bounded, and still fails on a leaked lock (0 passed, 1 failed after the whole wait, run alone). no migration, and the forge binary changes, so this owes a forge deploy. the forge suite is green against a throwaway cluster with the CI env block and LOOT_FORGE_TEST_REQUIRE_DATABASE (474 passed over nine binaries, no skip note), and the workspace suite is green against the same cluster (3838 passed) (#2002)
eed4d01c · dbf3dbe6…diff - the live-database checks test-main.yml runs can run locally, from the same script CI runs, because the account is out of Actions minutes and a land never ran them: the provisioning, the database keys and the suites move out of the workflow into ci/test-main.sh, which builds every connection URL from a host and a port it requires rather than defaulting to the one a real cluster listens on, and the workflow keeps only what prepares a fresh runner and calls it. ci/local.sh starts a throwaway Postgres on its own port, refuses if anything already answers there, runs that script against it and deletes the cluster on exit, so it runs beside a real cluster and from a lane before a land. run in a lane against a portable Postgres 18.6 it applied forge migrations 0001 to 0017 and the three site migrations, and 122 cargo test results and all seven site live files passed with LOOT_FORGE_TEST_REQUIRE_DATABASE set, so none skipped, before and after the review fixes. with the returning alias put back into read.ts it failed at the site step on syntax error at or near returning, and pointed at the port a real cluster owns it refused with exit 2. the CI-shape pin reads through the script and its STEPS list, because the job is still named cargo test --workspace and a check on the workflow text alone would pass on the name after the command had gone; it went red when the workflow stopped calling the script and when the script dropped --locked. the comments and living docs that said the workflow sets the keys now say the script does, the dated ADR and evidence entries are left as history, and AGENTS.md, docs/agents/workflow.md and the afk-loop security hunt point at the script. the Actions wiring itself is unexercised until minutes return (#2061)
8ff6117c · dbf3dbe6…diff - landed becomes a state something actually writes, and the walk that decides it had to see the push's own changes: ADR 0075 declares three terminal proposal states and until now close_proposal's only non-test caller was propose::withdraw, so landed - the one that is nobody's act, the work being in ref_head - was unreachable and a row read open over work the repo had already taken. ingest now closes the proposals its declared head set lands, decided OUTSIDE the transaction because IngestTxn is assembled and validated outside so the transaction stays pure writes after the CAS, and that same CAS is what makes the decision sound rather than racy: generation_expected pins exactly the head set the walk assumed, so an interleaved push fails the swap and takes the closes down with it. the ticket and ADR 0092's predecessor both said this needed nothing new because walk already reports an empty extent for a landed tip, and that is true of the STORED graph and the stored graph is the wrong one: a maintainer lands a proposal by MERGING it, so the change making the tip an ancestor of the new head set rides in this bundle and is not in the store yet, and walking stored edges alone closes nothing on exactly the push this exists for. the walk overlays the prepared changes' parent edges and is red without that overlay. only open rows are candidates and the store applies the decided row only over an open one, so a proposal that went terminal between the decision and the commit keeps its own declaration and its own date - a push cannot overwrite a withdrawal it never saw, asserted in the conformance suite against both stores rather than in one. a repo with no open proposal reads its proposal rows and stops without fetching the graph at all, which is every push to every repo never proposed to. red under mutation, counts read each time: the prepared overlay dropped, so the merge in this bundle lands nothing (421 passed and 1 failed, restored to 422). the SQL is new so it was run rather than read: bash ci/local.sh against a throwaway Postgres, where pg::tests::an_ingest_closes_the_proposals_it_declares_landed passes beside its memory twin and 166 pg tests ran, so the UPDATE was parsed by a real server rather than only by a text test. no migration, no wire or format byte moves, and the forge behaviour moves, so this rides the next deploy (#2177)
fb7c63c8 · dbf3dbe6…diff - the forge keeps the attestations a push carries and serves them back with their change, where until now it kept none: ingest never read the bundle attestation lane and every bundle serve built carried an empty one, so a tag, a note or a sign-off pushed to a forge was gone and the push said nothing, while a relay was never affected because its store is a DagRepo. the rules are the engine own two and none is new: an /ingest keeps an attestation that verifies and whose change rides the same bundle and drops the rest without failing the push, and a pull serves one only with its change in the bundle being sent, so a caller already holding a change is not re-sent its attestations and a metadata-private repo needs no new rule. /stow keeps none, because it is also a proposer path and a stranger attestation must not write into the owner repo. migration 0019 adds the attestation table, repo-scoped, keyed on the engine dedupe triple so a re-push is a no-op and the first signature stands, with row security enabled and bound by the 0017 policy, and it joins the rls census, whose doc still said eight tables with ten listed and whose shipped-policy check named migrations by index - it now reads every migration from 0017 on. the driver sorts role COLLATE C, because the reference store orders by bytes and a text column otherwise sorts by the cluster collation. a bundle that sends changes pays one more batched read, so the two offer-cost pins move from 4 to 5 and a no-op pull still asks nothing. a late attestation on an already-pushed change still does not travel - that is #2251, in the engine and on every host - and a pin refuses the forge keeping one outside the bundle so that widening is done on purpose. red under mutation, counts read each time: keeping unverified attestations (0 passed and 1 failed), keeping them outside the bundle (0 passed and 1 failed), serving none (0 passed and 1 failed), a read that ignores the ask and a last-signature-wins upsert (1 passed and 1 failed each, the pg stamp skipping off-cluster), each restored to 1 and 2 passed; and on a throwaway Postgres 18 the COLLATE dropped failed the ordering case (0 passed and 1 failed) where the unmutated arm had passed, restored byte for byte. bash ci/local.sh is green against Postgres 18 (4200 passed over 134 binaries, 9 ignored), after one unrelated loot-cli flake on its first run, filed as #2258. migration 0019 rides the forge binary and nothing on the wire or in a format moves, so this owes a forge deploy (#2250)
97ace3ec · dbf3dbe6…diff - the migration-lock census reads the two scratch-database sites tests/proposal_read.rs adds, Scratch::new and restore_the_views, both of which already hold pg::migration_serial's cluster lock, so the unlocked list stayed empty and only the set it reads before trusting moved: ci/local.sh caught it red on the census and green once named. with the carried line, ci/local.sh is green against Postgres 18 (4229 passed over 135 binaries, 9 ignored), proposal_read's four among them run against the cluster rather than skipped; one earlier run lost loot-core's concurrent_stage_of_same_address_does_not_tear to an os error 5 on a rename, which passed three times alone and is filed as a sighting (#2175)
aa12f574 · dbf3dbe6…diff - the kinds trailer on /ingest and /propose and the job creation it drives, with the wake-up sender; the runner-deposit reap is not built. loot_net::forge::HeadDeclaration and ProposalClaim gain kinds: a push declares the kinds of the on main steps of its live heads and a proposal the kinds of the on change steps of its tip, read by pipeline::declared_kinds from the .lootpipeline each version holds, as one trailing byte in Kind::ALL order that is not written when nothing is declared, so a payload declaring nothing is byte-identical to the one an older client signs; a decoder reads the byte iff bytes remain, and a bit this build cannot name is dropped. FORMAT_MINOR is not bumped, per the 2026-09-22 comment on the ticket that overrides step 1 of its body (a minor bump makes persist_codec rewrite every store graph file, the #2180 finding), and adding_the_kinds_moves_neither_format_constant pins both constants; a new client payload decodes on a frozen copy of the old decoder and an old client payload decodes here as declaring nothing. /ingest makes a main job per declared head and declared kind, less those on file, inside the ref-moving transaction (IngestTxn::jobs on both stores, with a conformance case run on memory and on Postgres), and /propose makes a change job per declared kind for the tip once the proposal row stands; member is the author of the version classified by AccessClass::of and approved is a verifying pipeline/approve from a key require_pusher admits, both read once at creation. after the commit each live runner whose row covers a created job kind and carries a URL is woken through jobs::Wake, whose HttpWake calls loot_net::runners::send_wake: the URL checked as registration checks it, the send refused if any resolved address is one registration would refuse (refused_wake_address, which both ask), the checked address pinned, no redirect followed, the request bounded by WAKE_TIMEOUT_SECS. the ticket said the site TypeScript client learns the field, and no code under site or sdk encodes an /ingest or /propose payload, so nothing there changed. the reap is recorded as unbuilt in the ADR 0091 #2159 amendment: grant_inbox is keyed by recipient alone while runner, ref_head, proposal and job are bound to one repo under migration 0017, so a reap run per repo cannot see an address live in another repo the same runner key serves, and how to scope it is a decision left to the reap ticket; nothing writes a runner deposit before #2130. measured: the trailer is one byte when a kind is declared and none otherwise, and ingest::job_cost pins that a push declaring nothing makes no call for jobs and that job creation reads per declared head, not per kind. red under mutation, counts read each time, each 0 passed and 1 failed on a filter selecting its one pin unless said otherwise: the trailer written before the heads, the trailer read unconditionally, the trailer always written, FORMAT_MINOR at 1, every kind made rather than the declared ones (through the push route, the proposal handler and the binary end to end, each), a wake-up sent to every row with a URL, a wake-up sent before the commit, member never set, approved by any attester, the memory ingest dropping jobs, the memory ingest overwriting them, the Postgres ingest dropping them (through bash ci/local.sh, 461 passed and 1 failed in loot-forge), only the first resolved address checked, the binary declaring the kinds of every trigger, the push declaring nothing, approved read once per kind, and the no-kind early return removed; each restored to green. no migration, no format byte and no published wording moves; the forge binary changes, so a forge makes jobs only once it is redeployed, and a forge older than this still accepts a client that declares kinds. the workspace suite is green under bash ci/local.sh against Postgres 18 (4345 passed over 137 binaries, 12 ignored) (#2159)
09cefc35 · dbf3dbe6…diff - a proposal has a conversation on the forge, stored and written and read, and no CLI flag posts to it yet: migration 0022 adds proposal_comment, keyed under the proposal row so a thread outlives a decline and a re-proposal, holding the envelope its author signed and its body, both set to NULL by a withdrawal that leaves a tombstone saying author or owner, with CHECKs that refuse a withdrawn row holding either, row security bound by the 0017 policy and the rls census widened. POST /propose/comment, /propose/comment/edit and /propose/comment/withdraw are tagged acts over propose::comment, where the rules live: a signer must see the proposal and is otherwise told what an absent one is told; the owner class writes, the accountless namespace key included; anyone else needs a live account key, so a bare keypair or a retired key is refused; members, the proposer and the tip author write through a shut door, anyone else only through an open one; a comment binds the tip on file when it arrives; a path must be in that tree and a reply must name a comment on the proposal; the body is at most 16 KiB of UTF-8, refused and not cut; only the author edits, re-signing the same id, anchors unmoved and bound to the BLAKE3 of the envelope it replaces; the author or the owner class withdraws, and members and proposers do not moderate. the comment id is signed by its author, so the same envelope again is one comment and a withdrawn one is not re-posted back. the thread rides POST /propose/read as a trailer and loot propose --show prints each body only when its envelope verifies against the author and anchors beside it. the flags that post, edit and withdraw a comment, and the disclosure printed before posting, are not built, since ADR 0095 names no flags; ADR 0095 amended and CONTEXT.md updated. red with each piece undone, counts read each time, each restored green: a bare keypair admitted, the door not asked, visibility not asked (each 0 passed and 1 failed on the writer-set pin), a member moderating, the owner editing another comment (0 and 1 each), anchors free to move (0 and 1), a withdrawal that writes nothing (0 and 1), no cap, and the body cut to the cap instead of refused (0 and 1 each), no tip binding (0 and 1), an edit not bound to its prior (0 and 1), an edit past the writer rule (0 and 1), the read sending no thread and an unverified body printed (0 and 1 each); on a throwaway Postgres 18, a withdrawal that kept the words with the CHECKs dropped left the conformance case green (1 passed) and turned the pin that reads the whole row red (0 and 1). bash ci/local.sh is green against Postgres 18 (4476 passed over 139 binaries, 13 ignored). no format constant or codec byte moves; migration 0022 rides the forge binary, so this owes a forge deploy (#2331)
6af9b14f · dbf3dbe6…diff - a decline may carry a reason signed with it, kept on the proposal row and shown as the thread closing event: ProposalDeclination gains an optional reason as a trailer on its signed bytes, so a decline with none is the bytes #2176 signs and neither format constant moves, and a reason a carrier swaps or strips no longer verifies. migration 0024 adds proposal.decline_reason with a CHECK holding it to a declined row, never empty and at most COMMENT_BODY_MAX bytes, and replaces the three 0020 proposal views with the column appended, which a text guard holds equal to 0020 once the column is taken out. propose::decline refuses an empty or oversized reason whole and a repeated decline with other words, close_proposal writes the reason only with a decline, and a re-proposal clears it with closed_at. POST /propose/read carries the reason as a trailer after the thread continuation, absent from an older forge, which would drop a reason unread, so loot propose --decline <tip> --reason <text> asks the read first and refuses a reason such a forge would drop, then prints who can read it before signing; --show prints the decline under the thread with its reason caged through render sanitize. frozen before_2336 codecs pin old client against new forge and new client against old forge both ways, and tests/proposal_read.rs compares the column for each reader class with an executable red that blanks it in one view (1 class disagreeing). red with each piece undone, counts read each time, each restored green: the reason left out of the signed bytes (0 passed and 1 failed), the read trailer not decoded (0 and 1 on each of two pins), the route dropping the reason, the read serving none (0 and 1 each), no cap and an empty reason admitted (0 and 1 each), a repeated decline changing the reason (0 and 1), a re-proposal keeping it and a reason written with any terminal state in the memory store (1 and 1 each, the pg stamp of the case green without a database), the CLI signing for a forge that would drop it, the reason printed uncaged (0 and 1 each), --reason admitted beside no decline and no disclosure before signing (0 and 1 each), the migration cap moved and a view joined to another family gate (0 and 1 each). ADR 0095 amended, CONTEXT.md and the forge spec updated. bash ci/local.sh is green against Postgres 18 (4519 passed over 139 binaries, 13 ignored). migration 0024 rides the forge binary and the read and decline routes change, so this owes a forge deploy (#2336)
c8d5a6ea · dbf3dbe6…diff - review sweep 14 over the proposal conversation: the proposer and the tip author are accounts where #2374 left them keys, so a live key of either account is a party at a shut door and sees the proposal on a metadata-private repo, through propose::speaks_for, now beside visible_to, and propose::sees, which read and the comment routes ask one row at a time while list keeps the key arm by key. migration 0025 adds proposal_comment_page on (repo_id, change_id, created_at, comment_id), so a thread page is a range scan the driver statement comment_page reads, where every page sorted the whole thread, and a pg pin reads its plan with the sequential scan and the sort priced out. /propose/decline takes the comment routes body limit, --show says the decline reason prints on the forge word and refuses a thread page that brings nothing, stands still or repeats a comment, and the reason disclosure says a re-proposal clears it. the comment.rs door sentence names may_propose_in and lists no members, PROPOSAL_COLUMNS replaces four copies, and ADR 0095, CONTEXT.md and the code docs say the party rule, the index, the decline limit and that the reason is unverified pending the call ADR 0095 records. red with each piece undone, counts read each time, each restored green: a party by key and seeing by key (0 passed and 1 failed each on the rotated-party pin), the index dropped on a throwaway Postgres 18 (0 and 1), a refusal quoting the envelope printed uncaged (0 and 1), the reason printed without the forge word, the decline route uncapped, a cursor that does not move taken and the re-proposal unsaid (0 and 1 each). bash ci/local.sh is green against Postgres 18 (4525 passed over 139 binaries, 13 ignored). no format constant or codec byte moves; migration 0025 rides the forge binary and the forge rules change, so this owes a forge deploy (#2378)
f168d689 · dbf3dbe6…diff - review sweep 15 over the proposal conversation and the published tier: acting on one proposal decides its parties by account as seeing it does, so the withdrawal and decline gates ask propose::sees, the withdrawal author check and the re-proposal parties in admit ask speaks_for, and a rotated tip author whose original key is retired withdraws their proposal with the account live key on a metadata-private repo, where by key it was told the proposal did not exist, and on a public one, where it was told it did not author the tip; a rotated proposer offers a declined proposal again. the #2378 rotation pin now retires both original keys on a Rotated fixture that files the offer before the rotation. the comment-page EXPLAIN pin asks for Sort anywhere in the plan, where its line-start form could never see a nested Sort node. an unread seal answers not published in rows_of and with_sealed_publish_status, the direction that never overstates, where it fell back to the rule; show renders an unread object by address as sealed, so the listing carries the pin. propose.rs, ADR 0095 and CONTEXT.md state which gates are by account as a property, a read of or act on one proposal, and cite #2006 for list, CONTEXT.md scopes the #2352 tier sentence and points at #2381, and append_page refuses a comment repeated within one page. red with each piece undone, counts read each time, each restored green: the withdrawal seeing by key, the withdrawal author by key, admit parties by key (0 passed and 1 failed each), a retired key speaking for nobody (0 and 1 on the rotation pin), a misshapen page index on a throwaway Postgres 18, comment_id before created_at and created_at descending (0 and 1 each, 1 passed restored), the listing falling back to the rule (0 and 1), a repeat within one page taken (0 and 1); the rows_of fallback mutation stays green, since no verb reaches it. bash ci/local.sh is green against Postgres 18 (4530 passed over 139 binaries, 13 ignored). no format constant, codec byte or migration moves; the forge rules change, so this owes a forge deploy (#2382)
eceef7eb · dbf3dbe6…diff - the forge ingest stops writing a row per statement: write_rows issued one INSERT per object, tree entry, edge, repo change and path touch, about a million round trips for the 620-change push of this 1,600-path repo that timed out at the client 270 s wait in #2388. each table now takes one INSERT SELECT FROM UNNEST ON CONFLICT DO NOTHING per call, tree_entry one per manifest the call itself inserted (a manifest row is only ever written with its entries in one transaction, the memory store already keeps first-arrival entries, and the comment says what would break that), with the conflict clauses, refuse_burned first, the G5 order, per-change parent and predecessor ordinals and path_touch and object order kept. a Statements trait lets a counting wrapper stand in for the sealed GenericClient, and a pin holds 16 and 160 entries to the same 9 statements, red under per-row tree_entry, objects, path_touch and parents and under rewriting a held manifest, each restored. measured on a throwaway Postgres 18, 600 changes of 1,600 paths: 80.0 and 82.5 s before, 13.5 and 14.3 s after. workspace suite 4549 passed over 142 binaries, 13 ignored, and bash ci/local.sh green against Postgres 18 (4549 passed, site live suites 67). no migration; owes a forge deploy (#2391)
9f32e5e1 · dbf3dbe6…diff - the private shell unlocks without waiting for its mailbox, and the mailbox pull becomes incremental: measured live, forge /pull-grants took 2.78 s of a 4 s /private load, because the whole mailbox of 1,640 standing self-grants was downloaded before the shell counted as unlocked. /pull-grants now takes an optional 17-byte cursor in its signed payload (a tag byte, then the inbox seq high-water mark and the forge clock at the answer, issued by the server and opaque to the browser, which may only send the all-zero one) and answers rows with seq past it or a reveal_at past its time, due on the forge clock, followed by the next cursor after the counted frames; the Postgres read takes a share lock on grant_inbox so a deposit that took a lower seq but commits later cannot fall behind every future cursor, and an empty payload answers byte-identically to before, so the CLI, the SDK and the relay client are unchanged and either deploy order works. the site keeps the sealed grant blobs and the cursor in IndexedDB beside the vault (version 2, the seed record kept), builds the keyring from them, shows the shell unlocked and pulls only the delta in the background; a file whose key is missing waits for an in-flight pull, the bars say keys are updating, and Lock clears every store with the vault. the gate shows Unlocking while a silent unlock runs rather than flashing the unlock card. forge tests on memory and Postgres and site tests, each red under a named mutation and restored; bash ci/local.sh green against Postgres 18 (4559 passed), site gate 819 passed. owes a forge deploy and a site deploy (#2403)
11cc1e3a · dbf3dbe6…diff - key management on the site: a key can be retired by itself, named, and chosen at unlock. per the operator grill on #2398 (ADR 0100): POST /api/identity/retire retires one key of the signed-in account, refusing the last live key under the account-row lock registration takes and never re-dating a retirement, with no route to un-retire and no deletion, since #506 keeps every key resolvable; /account lists live keys and folds retired ones under a collapsed section, and the retire confirmation names the repos the key owns. names live in a new owner-only account_key_label table (migration 0027) that only forge_identity reaches, trimmed, at most 40 characters, with control, line-separator and direction characters refused and a CHECK repeating the cap; they reach only /account, the unlock picker and the private bar, always beside the fingerprint. the unlock picker lists every live key, defaulting to the key that owns the URL namespace, then the key this device last unlocked, then the only live key, and a key with no stored passphrase asks for its 24 words and can save one. both routes are Bearer only and refuse a key of another account; /api/identity/me stops returning the internal tenant id. rebased by hand over migration 0026 and ADR 0099. pg tests pin the role reach, route and picker tests pin the rules, each red under a named mutation; bash ci/local.sh green against Postgres 18 (4568 passed), site gate 842 passed. owes the setup-forge tripwire update, a forge deploy, then a site deploy (#2398)
a90c85fb · dbf3dbe6…diff - the forge repo graph read stops scanning the whole forge change_parent table: repo_change_graph on Postgres read the repo ids and then looked their parent edges up by an array, which past a repo size the forge edge count sets (between 1,060 and 1,070 ids on a 297,991-edge throwaway forge, 2,810 and 2,820 on 898,012, 7,500 and 8,000 on 2,698,011) the planner answered with a scan of every edge, so every caller of repo_change_graph slowed as other repos grew. it is now one statement, pg::meta::REPO_GRAPH, a lateral join from repo_change whose OFFSET 0 keeps it a nested loop that reads change_parent one change at a time through its primary key; a plain join was measured beside it and steps the same way. measured on a throwaway Postgres 18 at eight repo sizes on three forge sizes, the new read examined the repo own edges and no others in every cell, 3.47 against 27.9 ms at 1,500 changes on the smallest forge and 21.0 against 98.6 ms at 10,000 on the middle one, while on the largest forge the 30,000 and 100,000-change reads slowed as the index outgrew the 128 MB of shared buffers (202 and 402 ms); ADR 0075 records the table and that. a pg test asks the planner for the statement with nested loops and sequential scans priced out and refuses any change_parent read not keyed by the repo_change row, red with the old array shape and with OFFSET 0 dropped (0 passed and 1 failed each), and the graph conformance case gains a merge whose parents are recorded against address order, red on the pg store with the lateral join made inner, the ordinal dropped from the order and a row per edge left ungrouped (2 passed and 1 failed each over the three graph tests, the memory store green), each restored green. bash ci/local.sh is green against Postgres 18 (4566 passed over 141 binaries, 13 ignored, site live suites 67). no migration; the statement changes, so this owes a forge deploy (#2346)
5dd0e327 · dbf3dbe6…diff - the forge reads the changes of a repo without scanning whole tables: changes_of, behind repo_changes and changes_with_manifests on Postgres, read the change rows through a join the planner could hash and their parent edges, predecessor edges and tree entries by id arrays, and past a repo or array size the forge row counts set each became a scan of the whole table (on throwaway forges of 298,000, 898,000 and 2,698,000 changes, parent edges from 2,070, 2,820 and 7,880 ids, predecessors from 820, 990 and 2,170, tree entries from 4,440, 8,440 and 18,700 manifests). pg::meta::ChangeReads now reads the change rows and both edge kinds as lateral subqueries with OFFSET 0 driven from repo_change, for the whole repo and for a named delta, and MANIFEST_ENTRIES reads tree_entry one manifest at a time by unnesting the manifest ids into a lateral subquery with OFFSET 0. measured on a throwaway Postgres 18 at eight repo sizes on three forge sizes, each arm for the whole repo and for a delta naming every id, the new reads examined the rows of the repo alone wherever their plans were read: 35.4 against 100 ms at 3,000 changes on the smallest forge and 13.2 against 33.8 ms at 1,000 on the middle one, while the largest repos read slower, 1,597 against 1,057 ms at 100,000 changes on the smallest forge and 2,457 against 1,976 ms on the largest; covering indexes on the edge tables were measured and moved the read by less than the run spread, so none is built, and ADR 0075 records the tables. a pg test asks the planner for each statement with nested loops and sequential scans priced out and refuses a read not keyed by the row its loop is on, sharing its check with the #2346 pin, and another writes the edges of a change backwards and reads them in ordinal order. red under named mutations, each restored: the rows as the old join, OFFSET 0 dropped from the rows, predecessors by an id array, OFFSET 0 dropped from the edges, tree entries by a manifest array, OFFSET 0 dropped from them and the named delta rows as the old join (7 passed and 1 failed each over 8 tests), and the edge ordinal dropped from the order and REPO_GRAPH without OFFSET 0 (8 passed and 1 failed each over 9); the named delta losing its repo scope stayed green, since migration 0017 binds repo_change to the repo. bash ci/local.sh is green against Postgres 18, 4632 passed over 142 binaries with 13 ignored and the site live suites at 78. no migration; the statements change, so this owes a forge deploy (#2449)
62ba0ebf · 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.