M7-04: bothy restore finalize and sync epoch (ADR 0059)¶
Status: ready | Depends on: M2-07, M2-26, M7-02 | Milestone: M7
Goal¶
After this ticket a restore can no longer silently mix two timelines. bothy restore finalize sets, in one transaction, a new instance.sync_epoch (UUIDv7), all agent_consumer_cursors.acked_seq, webhook_subscriptions.last_delivered_seq and event_consumer_cursors.last_seq to the new head_seq, writes one audit entry, and stores the identity of the restored database (pg_control_system().system_identifier and pg_control_checkpoint().timeline_id) in instance.pg_system_identifier and instance.pg_timeline_id (03 6.7, R-DM-26, R-DM-50, ADR 0059 item 1). The epoch in hello, ready, resumed, the snapshot and GET /api/v1/events and its comparison have existed since M1-63 and M1-64; this ticket proves them end to end after a real restore: a client with a cursor from the old timeline gets resync_required with epoch_changed, even when head_seq has overtaken its cursor. Outgoing webhooks carry sync_epoch in their envelope (04 2.2, ADR 0059 item 4). A start after a restore without restore finalize is detected by the changed database identity and does not become ready (/readyz reports restore_not_finalized), and a relocation without a restore does not change the epoch. Why: without the epoch the new timeline overtakes old cursors after the first writes and clients skip events silently; without the start lock an operator who forgets the step gets exactly that.
Read first¶
docs/spec/03-data-model.md(core contract, read only):instance(4.1, including the columnspg_system_identifierandpg_timeline_idand R-DM-50), 6.7,agent_consumer_cursors,webhook_subscriptions,event_consumer_cursors,audit_events.docs/spec/04-sync-protocol.md(core contract, read only): 2.2 (envelope, fieldsync_epochof webhook deliveries), 6.3, 7.3 to 7.5 (R-SY-04, R-SY-18).docs/spec/16-operations.md: 8.4, 9, 11 (relocation keeps the epoch), 14;docs/spec/02-architecture.md3.3 (commandbothy restore finalize), 11 (readiness), 15.1.docs/adr/0059-sync-epoch-after-restore.md(all, including "More Information": the start lock);docs/operations/backup-restore.md5.2 to 5.3,docs/operations/cloud-migration.md,docs/operations/disaster-recovery.md.docs/api/webhooks.md(envelope),docs/api/openapi.yaml(GatewayHello,listEventsparameterepoch, webhook envelope schema).docs/DECISIONS.md: D-W-100 and its extension to M7 with R-DM-50 (D-W-125); section 10 (format ofD-M7-04-<n>rows).- Briefs (merged names win, D-W-100):
M1-63-sync-catch-up.md(CatchUp.Check,ResyncReason),M1-64-gateway-connection.md(epoch inhello,resume),M1-65-gateway-delivery.md,M1-11-lifecycle-health.md(readiness reasons),M1-12-postgres-migrations.md(migrate upcreates theinstancerow),M2-01-worker-event-consumers.md,M2-07-agent-consumers.md,M2-26-webhooks-outgoing.md,M7-01-postgres-image.mdandM7-02-backup-service.md(PITR in the image test). - Code on the base branch:
services/server/internal/instance,internal/sync,internal/gateway,internal/agents,internal/apps(webhooks),cmd/bothy,internal/platform/health.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits and switches only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the value. - Specification first: a change to behaviour, API, configuration or a readiness reason updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md,actions.yaml) is not edited by this ticket; the twoinstancecolumns are built exactly as 03 has them. Everything this ticket adds is English. - No cost (D-W-125): local containers only.
Product requirements implemented here:
restore finalizeis atomic: epoch, the three cursor kinds, the audit entry and the stored database identity change in one transaction or not at all (ADR 0059 confirmation).- The epoch changes only through
restore finalize;migrate upsets the stored identity only when it is empty (first installation) and never changessync_epoch(03 4.1). A relocation without data loss (16 11) also ends recovery on a new timeline;bothy restore finalize --relocation <manifest>then stores the new identity and keeps epoch and cursors, but only whenhead_seq, the audit head andsync_epochof the restored database equal those of the given manifest of the final backup (Design note 3). - A mismatch between the stored and the current database identity keeps every server role not ready with the reason
restore_not_finalized, unlessdatabase.restore_guardisoff(Design note 3);restore finalizeitself andmigratestill run. - Outgoing webhook deliveries carry the current
sync_epoch; after a restore the first delivery carries the new one. restore finalizeneeds no runningapiorworkerand refuses to run while the database reports another server process connected with the rolebothy_app(so a running stack cannot see half a change).
Scope¶
Server module (paths under services/server/ unless they start with docs/; every new Go file starts with // SPDX-License-Identifier: Apache-2.0):
- Migration: the columns
instance.pg_system_identifierandinstance.pg_timeline_idexactly as 03 4.1 (R-DM-50);migrate upfills them when empty. internal/instance: use caseFinalizeRestore(new epoch, stored identity, audit entry throughkernel/auditing), the identity query, the readiness checkrestore_not_finalized; it calls ports ofagents,appsandsyncto move their cursors in the same unit of work (Design note 2); 02 4.5 andarchitecture/modules.yamlget the dependencies if they are missing.internal/agents,internal/apps,internal/sync: one port method each that sets their cursors to a givenhead_seqinside the caller's transaction.cmd/bothy: subcommandrestore finalize [--relocation <manifest file>](exit codes as 02 3.3:0done,1error or refused,2usage) with its help text; the row in 02 3.3 names the option.- Configuration: key
database.restore_guard(onoroff, defaulton) in 16 14 and the configuration reference. - Webhook envelope: every delivery fills
sync_epochfrom the current epoch (M2-26 built the field; this ticket proves it after a restore and corrects the delivery where it does not);docs/api/webhooks.mdand the envelope schema stay as they are. - Docs:
docs/spec/16-operations.md8.4 (the start lock and the key),docs/spec/02-architecture.md11 (readiness reason),docs/operations/backup-restore.md5.2 step 6,docs/operations/disaster-recovery.md,docs/operations/cloud-migration.md(relocation keeps the epoch; managed failover and the key),docs/adr/0059-*.md"More Information" (the open point closed, with a link to the ruling), READMEs of the touched modules,services/server/CHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M7-04-<n>.
Out of scope¶
- Applying the deletion journal on restore (M7-07); the restore test (M7-03).
- The
moved_todiscovery of 16 11 (planned, question to team B; not built). - Client behaviour on
epoch_changedbeyond what M4-14 already built.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M7-04-restore-finalize-epoch; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(instance): ...,feat(sync): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag, never publish an image or a release, never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules). Tests bind only
127.0.0.1and port 0; PostgreSQL only as a throwaway container on a random port throughpgtestor the image test (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes and containers you started, by PID or container ID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M7 by D-W-125). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Database identity (ADR 0059 "More Information"; Ruling for docs/DECISIONS.md). Read
system_identifierfrompg_control_system()andtimeline_idfrompg_control_checkpoint(). Verify against the PostgreSQL 18 documentation which roles may execute them; ifbothy_appmay not,migrate up --bootstrapgrantsEXECUTEon exactly these two functions tobothy_appand the grant test of 16 7.1 rule 3 lists it. A PITR keeps the system identifier and raises the timeline; a logical restore into a fresh cluster changes the system identifier; both are mismatches. -
One transaction (03 6.7 item 1).
FinalizeRestoreopens one unit of work, locks theinstancerow, readshead_seq, calls the cursor ports ofagents,appsandsyncwith that value, writes the epoch and identity, appends the audit entry (system actor, action named in the ruling, no content). Any error rolls back everything; a test injects a failure in the last port and checks that nothing changed. -
Start lock and managed failover (Ruling for docs/DECISIONS.md). On start, the readiness component compares the stored identity with the current one; a mismatch keeps
/readyzatrestore_not_finalizedand logs once with the runbook step. A managed service with automatic failover raises the timeline without a restore. Only where the provider documents that a failover loses no committed transaction (synchronous replication) may the operator setdatabase.restore_guard = offand rely on R-SY-04; a failover that can lose commits is handled like a restore (not ready untilrestore finalizestarts a new epoch), because new writes could reuse sequence numbers a client already holds; 16 andcloud-migration.mdsay so. An empty stored identity (instance older than this ticket) is filled at the nextmigrate upand never blocks. A relocation (16 11 step 4) restores to the latest state, which also starts a new timeline: the runbook callsrestore finalize --relocationwith the manifest of the final backup; the command compareshead_seq, the audit head (seq,hash) andsync_epochof the restored database with the manifest and, only when all are equal, stores the new identity without touching epoch and cursors (audit entry with its own action name); any difference aborts with exit1and names the differing field, and the operator then runsrestore finalizewithout the option. -
Webhooks (04 2.2, ADR 0059 item 4). The epoch is read with
head_seqper delivery batch; afterrestore finalizethe webhook cursor is at the new head, so receivers see only the new timeline and its new epoch. -
Rulings for
docs/DECISIONS.md(D-M7-04-<n>): identity source and grants (note 1), the audit action name, the start lock,--relocationanddatabase.restore_guard(note 3), the refusal whilebothy_appsessions are connected.
Tests (write them first)¶
Unit:
instance/domain:TestIdentityMismatch(same, other timeline, other system identifier, empty stored value).
Integration (pgtest):
TestFinalizeRestore_Atomic: after the command the epoch is new, all three cursor kinds equalhead_seq, one audit entry exists; with an injected failure nothing changed.TestFinalizeRestore_RefusesWithConnectedApp: an openbothy_appsession makes it exit1without a change.TestEpochChanged_AfterOvertake: a gateway client and a REST catch-up with a cursor of the old epoch getresync_requiredwithepoch_changedafter new writes madehead_seqlarger than the cursor.TestRelocationKeepsEpoch: after a relocationrestore finalize --relocation <manifest>with a matching manifest keeps the epoch and the cursors, the server becomes ready and a client continues with its cursor without a resync; with a manifest whosehead_seqdiffers the command exits1and changes nothing.TestWebhookCarriesEpoch: a delivery afterrestore finalizecarries the new epoch.
Image test (extend postgres-image-test):
TestStartAfterPITRWithoutFinalize: a PITR with the image of M7-01, thenbothy serveagainst it stays not ready withrestore_not_finalized; afterbothy restore finalizeit becomes ready.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/instance/... ./internal/sync/... ./internal/gateway/... ./internal/agents/... ./internal/apps/... ./cmd/... ./internal/wire/...make postgres-image postgres-image-teston the build hostmake migrations-lint,make migrations-lock,make codegen-check,make arch,make register-check,make config-doc-check,make docs,make linkcheckmake check
Acceptance criteria¶
- A client with a cursor of the old timeline gets
resync_requiredwithepoch_changed, even whenhead_seqhas overtaken its cursor. -
restore finalizemoves epoch, cursors, audit and stored identity atomically; a relocation without a restore keeps the epoch. - Outgoing webhooks carry
sync_epoch. - A start after a restore without
restore finalizedoes not become ready; withdatabase.restore_guard = offit does. - 16, 02, the runbooks, ADR 0059, the READMEs, the CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of the touched modules updated in English.
- Rulings of Design note 5 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make checkand ofmake postgres-image-test. - Review and red team verdicts and the findings fixed.
- Which roles may execute the identity functions in PostgreSQL 18 and the source.
- What is not proven (a managed service and its failover).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.