Changes touching this path
- tenant_blob counts what a tenant references, and the account tier it belongs to stays unwritten on purpose
#612 asked what `tenant_blob.bytes` counts before anything counts it, and said
the dedup question decides whether the producer belongs at the blob `put`
"where dedup is visible" or at `ingest` "where tenancy is". Both halves resolve,
and neither the way the ticket framed them.
`bytes` is REFERENCED FOOTPRINT. What settles it is #482's own accepted cost --
deleting a tenant is "refcount-decrement-and-delete-if-zero, because a fork
legitimately holds the same object". A decrement presupposes many tenants
holding a row for one oid, which is the reference reading; under the origination
reading there is one row per oid forever and nothing to decrement.
The shorter argument -- that the primary key forces it -- is tempting and does
not work, so the comment says so rather than leaving the next reader to try it.
`(tenant_id, object_oid)` does settle the ticket's other half, running total vs
append-only ledger, because a set whose `bytes` is a property of the oid cannot
accumulate. It does not separate reference from origination: "one row per oid,
for the tenant that introduced it" is equally a set with that key.
Origination is already recorded and is not this table's: `object.introduced_by`
(#501) is first-write-wins on the address, keyed by PUBKEY, not tenant_id. What
it lacks is a byte count, so billing on origination is a column on `object`.
The put-vs-ingest question dissolves rather than being answered by the dedup
verdict. Dedup is not visible at `put` at all -- `BlobStore::put` returns no
novelty bit, and an `exists`-then-`put` is a race between concurrent pushes --
and a set-membership upsert never needs to know. So: ingest, where tenancy is.
The reason nothing counted anything is bigger than this table, and is the find
worth keeping: `tenant` HAS NO PRODUCER EITHER. #504 resolved "first push
auto-creates the `tenant` and `repo` rows"; only `repo` was built, correctly,
because `repo` is keyed by owner_pubkey and has no tenant_id at all. `tenant`,
`account_key`, `repo_member` and `tenant_blob` are one unbuilt ACCOUNT TIER --
#487 decided its shape and is closed, #753 is the build. So tenant_blob was
never a missing producer; it is a member of a tier held whole, flagged alone
because #482 named it by table. Its consumer is behind the same door: quotas
arrive with #504's open multi-tenant push.
No producer is built, and no migration is added. The record goes in the schema
comment, which is legitimate because migration 0001 IS docs/sql/forge-schema.sql
and comment-only edits are the #720-enforced exception to append-only --
`released_migration_statements_are_pinned_by_checksum` proves no statement token
moved. Per-tenant export, the third consumer, is recorded as deferred rather
than answered: it needs #493's reachability walk, which a byte count is not.
tests/account_tier.rs is the tripwire that keeps the absence from re-opening
silently, and it is written to be deletable when #753 arrives. Its own first cut
failed open twice -- on a `\` line continuation (the house SQL style) and a
quoted identifier (`INSERT INTO "ref"` is real) -- so the scan now normalises
both, plus schema qualifiers and Rust escapes, and covers the migrations
directory rather than only src/. Every spelling it claims to catch is pinned by
a positive control, because a scan that has quietly stopped matching reproduces
the exact silence #612 was filed about, inside its own guard.
Two claims in the first draft were corrected by review before landing: the
primary-key argument above, and pointing the reader at #487 (closed) rather than
#753. #690's allowlist is also named as what it is -- a deployment setting, not
a code closure, since an empty list means open and the binary only warns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a0106e52 · dbf3dbe6… - 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 - 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…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 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 - key management on the site: a key can be retired by itself, named, and chosen at unlock. per the operator grill on #2398 (ADR 0100): POST /api/identity/retire retires one key of the signed-in account, refusing the last live key under the account-row lock registration takes and never re-dating a retirement, with no route to un-retire and no deletion, since #506 keeps every key resolvable; /account lists live keys and folds retired ones under a collapsed section, and the retire confirmation names the repos the key owns. names live in a new owner-only account_key_label table (migration 0027) that only forge_identity reaches, trimmed, at most 40 characters, with control, line-separator and direction characters refused and a CHECK repeating the cap; they reach only /account, the unlock picker and the private bar, always beside the fingerprint. the unlock picker lists every live key, defaulting to the key that owns the URL namespace, then the key this device last unlocked, then the only live key, and a key with no stored passphrase asks for its 24 words and can save one. both routes are Bearer only and refuse a key of another account; /api/identity/me stops returning the internal tenant id. rebased by hand over migration 0026 and ADR 0099. pg tests pin the role reach, route and picker tests pin the rules, each red under a named mutation; bash ci/local.sh green against Postgres 18 (4568 passed), site gate 842 passed. owes the setup-forge tripwire update, a forge deploy, then a site deploy (#2398)
a90c85fb · dbf3dbe6…diff
Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.