Changes touching this path
- a forge backup is operator recovery, and a restore replays the revocation journal (#586)
#586 asked whether a backup defeats a burn. It does, but not where the ticket
looked, and the grilling moved the question in both directions at once.
NARROWER: the #502 amendment means the forge files a content key only where
`publish::published_rows` authorizes one, so an operator backup reconstitutes
plaintext ONLY for content that was Published -- i.e. world-readable anyway.
And `published_key` is not "the one table with no other copy": the rows come
from the client's own bundle at ingest, so a re-push re-supplies them. Painful,
loud, not unrecoverable.
WIDER, and this is the find: burn is not the only thing a restore inverts. Three
columns are monotone capability-removals that no client can push back --
`burn_tombstone`, `publication.retracted_at`, `grant_log.revoked_at`. A restore
is the single operation that runs time backwards over all three. Stated once,
because it is the whole ADR: A RESTORE RE-GRANTS A CAPABILITY SOMEONE
DELIBERATELY TOOK AWAY. Burn is just the one with an ADR attached.
So the cure is not a shorter window alone. Snapshots stay immutable at rest and
the RESTORE PATH is scrubbed by construction: a key-free, content-free revocation
journal, exported continuously and replayed before the forge binds a port. The
window (7 days, enforced by an R2 lifecycle rule rather than by a prune script
that can fail silently) then bounds media at rest only, never a running system.
The dump is `age`-encrypted to a key the VPS does not hold, because
`tree_entry.path` and `path_touch.path` are PLAINTEXT for every tier -- #610's
barrier views protect the live database and protect a dump not at all. The
journal is deliberately NOT encrypted: putting the one unrecoverable artifact
behind a losable key buys nothing.
R2 gets no mirror. The realistic threat to blobs is #493's own unbuilt reaper,
which that ticket says of itself deletes live data unrecoverably when it drifts
-- so the reaper reaps into a `reaped/` prefix under the same 7 days, and burn
keeps deleting outright. Two paths remove blobs; only one may be recoverable.
Weekly automated restore-verification is rejected as SELF-DEFEATING rather than
expensive: automating decryption puts the private key back on the VPS.
The one scoping correction: #586 said the work lands beside `setup-forge.js`.
That holds for the machinery. The honesty half -- burn's third tier and the
retention window advertised on `/info` -- lands in loot proper, so this is two
build tickets in two repos.
Decision only; nothing here is built. ADR 0046, three CONTEXT.md entries, a
third tier marked into ADR 0038 in place, and forge-datastores.md's closing
"still unspecified" line replaced with a plan that says out loud it is a plan
until the drill's negative has been run.
79fa0586 · dbf3dbe6… - a forge advertises its retention window on /info, and burn prints one honest bound per host it disclosed to (#814)
652f4023 · dbf3dbe6…diff - the backup machinery gets its record: ADR 0046 is amended by what building it found, and the drill is run as far as a key can take it (#813)
The build lands in the `scripts` repo (6d31542). This is loot's half: the decision
doc corrected where it was wrong, and the evidence file told what was actually run.
Three amendments, each because building the thing falsified the text.
Section 5's lifecycle rule is PREFIX-SCOPED. Sections 4 and 5 together ask one
bucket for two opposite retentions, and a bucket-wide rule deletes the artifact
section 6 calls genuinely unrecoverable -- silently, a week later.
Section 3's burn record carries the burner and the timestamp, not the oid alone.
burn_tombstone declares two more NOT NULL columns, so an oid on its own cannot be
replayed at all. signed_tombstone stays out because it carries the burned PATH into
a file that is unencrypted and kept forever, and the cost of that -- a replayed
tombstone cannot be the audit record -- is now written down rather than discovered.
Section 2's five replay steps are three today. The blob delete and the CDN purge
belong to a durable job purge.rs says does not exist, and the blob tier is never
restored from backup anyway.
The evidence file gains sections 8.1 through 8.4, run 2026-08-16. The recipient
keypair is minted -- rage rather than the reference age, because age ships only
sigsum proofs nothing here can verify, and the act that mints the key protecting
every backup took the checksummed path. Custody is proved rather than asserted: a
probe round-tripped through the offline key and a freshly minted stranger identity
was refused. Then a runner-shaped artifact -- dump, TOC, row manifest, both
credentials files -- was encrypted, its plaintext deleted, decrypted with the
offline key alone, restored and replayed. The negative first, because it is what
makes the positive mean anything: without the replay all four assertions came back
true, with it all four false, and a second replay moved nothing.
What is still not proved is said plainly. Section 8.5 wants the superuser password
coming back out of a REAL artifact and actually authenticating against the SCRAM
verifier, and the artifact above carried a stand-in because nothing has deployed.
The age-to-rage interop is assumed on the spec and gets proved by the first real
run. Until setup-forge.js has run once, this forge has no backup and
/root/.postgres-credentials has no second copy.
c26f154c · dbf3dbe6…diff - the forge has backups: deployed, and the drill run whole against a real artifact (#813)
`npm run setup:forge` has run. /root/.postgres-credentials has a second copy for
the first time in its life, and the first journal export took 12 records off the
live forge -- 8 burns and 4 revocations that a restore would have put straight
back. This was never hypothetical.
ADR 0046 section 8, all five steps, against an artifact pulled off R2:
The artifact decrypts with the offline key and nothing else. That also settles the
interop question I had left open: the VPS encrypts with Ubuntu's Go age 1.1.1 and
the key was minted by rage, so one spec and two implementations, proved rather
than assumed. The negative is the half that matters and it fires -- restored
without the replay, all four capabilities came back; with it, none did. And the
recovered 48-character superuser password matches pg_authid's SCRAM verifier,
while a wrong one does not.
Section 5's real claim is exercised too: the forge's own R2 credential is REFUSED
on the backup bucket. An account-wide token would have satisfied every other check
in the deploy and silently meant a compromised forge reads seven days of history.
The open question in the consequence list is answered. R2 has no versioning
endpoint, confirmed against the live API, and the deploy re-asks every run rather
than trusting a sentence in a document.
Three amendments, each because deploying it falsified something.
The artifact now carries pg_dumpall --globals-only. pg_dump -d forge dumps one
DATABASE and roles are cluster-level, so a restore had no forge, forge_read or
forge_identity -- every GRANT in the archive an error -- and no pg_authid, which
is the only thing section 8.5 can check a password against. That step was
uncheckable until this landed.
Section 8.3 and 8.4 used real rows for the burn and the revocation and fixtures
for the publication, and had to: the row-count manifest in that very artifact
records publication 0 and published_key 0. The live forge has published nothing
yet. That is the same fact that leaves section 7's named trigger unfired.
Two of section 2's replay steps still have nothing to mirror, unchanged from the
build: the blob delete belongs to a durable job purge.rs says does not exist.
What is not proved is said plainly. The timer has never fired on its own schedule
-- every run so far was systemctl start from the deploy -- and the age private key
has no second copy, which the ADR's own consequence list warns makes every backup
landfill. That is now literally true rather than prospective.
a2d9f41b · dbf3dbe6…diff - the forge stops saying it does not know its own window: ADR 0046 section 9 last step, taken (#813)
The media existed and the bucket enforced the window, while /info still answered
backup_retention_days null -- so `loot burn` went on printing an unknown-retention
line against a forge that does in fact keep a copy for seven days. Section 9
assigns that key to this ticket in as many words, and it was the one step nobody
had taken.
https://forge.millerbyte.com/info now answers 7.
Written only when the backup half is on, because section 9 rule cuts both ways and
a window nobody keeps is the worse lie. In the env half rather than the mounted
credentials file: it is not a secret, and the drift check greps it out of docker
inspect, so moving it would blind that check while everything kept appearing to
work.
And the part that makes it a fact rather than a claim. A container reads its
environment at CREATE, so one older than the backup half advertises null however
often the script rewrites .env.forge. That is drift now, and it fired on the real
deploy before recreating; probeForge then asserts /info returns the number, which
is the only place the answer belongs to the running process rather than to a file
this script wrote.
Section 9 says the advertised number and the rule enforcing it are tied by one key
name written once at deploy. They are tied a little better now -- one
RETENTION_DAYS constant feeds both -- and the bucket is still the enforcer.
630c9069 · dbf3dbe6…diff
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.