| D-M7-04-0 |
2026-10-20 |
Base int/m1-wave49 at eb15f40, branch feat/M7-04-restore-finalize-epoch in its own worktree; not merged locally, not pushed, no pull request (the launcher's task forbids the push, so the build branch build/M7-04 and the GitHub run of the full suite do not exist for this ticket; locally only the tests of the touched packages ran). The core rule R-DM-50 of main (the two instance columns, item 4 of 03 6.7) is on main but not on the base and comes as its own commit first, in English inside the German chapter 03-datenmodell.md. The base names the chapters in German (02-architektur.md, 16-betrieb.md, 0059-sync-epoche-nach-wiederherstellung.md) and the brief's 02 section 11 (readiness) is 15.1 and 15.2 there; added text is English. The rows stand in a section of the ticket, because the base has no section 10 in this file. |
The launcher's instructions take precedence over the brief's landing step; merged names win over the brief (D-W-100). |
| D-M7-04-1 |
2026-10-20 |
Identity source and grants: system_identifier of pg_control_system() as decimal text and timeline_id of pg_control_checkpoint(), read in one statement. PostgreSQL 18 grants EXECUTE on both to PUBLIC: pg_proc.proacl is NULL (the default ACL) in the pinned image postgres:18.6-trixie, and has_function_privilege is true for a role without grants (checked 2026-10-20); the documentation lists them under "Control Data Functions" (https://www.postgresql.org/docs/18/functions-info.html). So migrate up --bootstrap grants nothing and the grant test of 16 7.1 rule 3 stays as it is. |
Brief design note 1: a grant is only needed when bothy_app may not execute them, which the catalog of the image disproves. |
| D-M7-04-2 |
2026-10-20 |
Audit actions: instance.restore.finalize after a restore and instance.restore.finalize_relocation after a relocation; decision allow, no actor (system), resource instance with instance_id, details head_seq, previous_sync_epoch, sync_epoch, pg_system_identifier (code) and pg_timeline_id, no content. The entry is written by the audit hook of the unit of work that moves epoch, cursors and identity. The actions are not entries of the action catalogue (05, actions.yaml), which this ticket may not edit; they are host commands without authorization, like the other audit codes of system steps. |
Brief design notes 2 and 5; the catalogue is core contract (fixed requirement 10). |
| D-M7-04-3 |
2026-10-20 |
Start lock: every role compares the stored identity with the current one once at start, after the instance row loaded (02 15.1 step 5); a mismatch logs one ERROR line with the runbook step and registers the readiness check restore_not_finalized, which fails for good like schema_requires_newer_server (the process keeps running, /healthz stays 200); unlike the version lock it also skips the steps that serve or process (log tail, River jobs, worker consumers, public listener), because the Compose stack publishes api without a proxy that follows /readyz, so readiness alone would not keep writes and deliveries of a mixed timeline out (red team finding). The documented command in the stack is docker compose run --rm migrate restore finalize (the entrypoint of the image is bothy), with the manifest of --relocation mounted into the read-only container. A read error of the identity ends the start. An empty stored identity never blocks. Because the comparison runs at start, a failover while the server runs is detected at the next start. bothy restore finalize refuses while a server runs (D-M7-04-5), so the order is: stop api and worker, finalize, start. |
Brief design note 3; the same pattern as the version lock of M1-15. |
| D-M7-04-4 |
2026-10-20 |
database.restore_guard (on, off; default on, chapter 16): off skips the comparison at start; bothy restore finalize behaves the same with either value. --relocation <file> reads backup_manifest.v1 (checked by status/domain.ParseManifest as bothy-backupd writes it) before the database is touched and compares, in this order, event_log.head_seq with event_seq (equal to max(seq) because the partition of the head is never dropped), audit.last_seq and audit.last_hash (lowercase hex) with the last row of audit_events and sync_epoch with the instance row; the first difference is exit 1 naming the field, and nothing changes. A major upgrade into a new cluster and a lossless managed failover are finalized the same way with the manifest of a backup taken right before (runbooks 16 8.4, disaster recovery case G). |
Brief design note 3 and requirement 13; reading the manifest first keeps a wrong path from touching the database. |
| D-M7-04-5 |
2026-10-20 |
Refusal while servers run: inside the unit of work, before the instance row is locked, bothy restore finalize counts the sessions of pg_stat_activity with usename = bothy_app on the current database (datname = current_database()) other than its own and refuses with exit 1 when there is one. The command runs as database.user, the schema owner, as in the Compose service migrate. Restricting the count to the current database keeps other databases of a shared cluster out (the integration tests share one cluster). |
Brief requirement 16; usename and datname of every row are visible to every role. |
| D-M7-04-6 |
2026-10-20 |
Module boundaries: instance declares the outgoing ports CursorMover and AuditHeadSource itself, and agents, apps and sync each get a new one-method port RestoreCursors (MoveCursorsTo) with app.NewRestoreCursors over their repository; internal/wire passes them in (RestoreCursors, in the order agents, apps, sync) and adapts the audit repository's bounds and hash to AuditHeadSource. No depends_on changes in architecture/modules.yaml and 02 4.5, because instance imports none of these modules. The webhook epoch needed no change: every attempt already reads sync_epoch from the instance row when it signs (M2-26), and TestWebhookCarriesEpoch proves the new epoch after finalize. Differences to the brief's names (D-W-100): the use case is instance.Restore.Finalize (the brief says FinalizeRestore, which is its input type here), the identity check is instance.State.CheckIdentity, and the readiness test of the brief's TestFinalizeRestore_RefusesWithConnectedApp exists in internal/wire and, for the exit code, in cmd/bothy. |
agents, apps and sync depend on instance (02 4.5), so a direct call would be an import cycle; one small port per module keeps the change minimal. |
| D-M7-04-7 |
2026-10-22 |
Port onto main from the ticket branch (own commits 5df6ccd..114c1aa on int/m1-wave49) as one change, with the integration fixes of int/m1-wave51 that concern this ticket: the migration is 00054_instance_restore_identity.sql on main (after 00053 of M2-05); the ticket's additions to the German chapters are placed in their English counterparts (02-architecture.md, 16-operations.md, ADR 0059 0059-sync-epoch-after-restore.md, the runbooks), and references in code name the English files; R-DM-50 is already on main, so its commit is not ported; the start step agent_presence of M2-20 runs through whileFinalized, so the presence tracker starts neither its start rule nor its flush loop while the start lock holds, and TestStart_RestoreNotFinalized_NotReady counts its starts (none under the lock, one after finalize); the image test of this ticket is test/postgresimage/restore_finalize_test.go, so it does not collide with the restore test of M7-03; the system identifiers of the domain test have 16 digits; the product name test accepts restore as a subcommand of the binary. The rows above keep their wording of the build (D-M7-04-0 describes the build on the old base). |
main has no integration branch; the rules for a port bring only the ticket's own commits and adapt them to main. The presence tracker writes agents and so must wait for the finalize like every other step that serves or processes (R-DM-50). |
| D-M7-04-8 |
2026-10-22 |
Every wait of this ticket's tests for something that must happen goes through testkit (Until, Patience, Context, or the image test's eventually): the wait for ended bothy_app sessions before a finalize (also in the PITR image test after docker stop), the not-ready start of each role, the resync_required frame after the overtake, the REST catch-up request and the webhook deliveries of the apps world. Only the wait for a frame that must not come (no resync_required after a relocation) keeps its short bound. |
Found by the red team of the port: fixed wait limits let a correct change fail on a slow, loaded host; testkit is the one place that bounds such waits for the whole repository. |
| D-M7-04-9 |
2026-10-22 |
No change of this ticket to TestGatewayDeadlines/TestClose4011AgentTokenExpired: its wait for the take of the agent consumer before the clock moves was dropped in the rebase, because main fixed the same race in the test (#253, after the heartbeat_ack and the consumer's catchup.complete). |
The test failed two of three CI runs of this pull request with 4004 instead of 4011; the fix on main covers that and the remaining window of the consumer's start check (4012). |
| D-M7-04-10 |
2026-10-22 |
The terminal reconcile of M2-44 (the start step routes ends opening and detaches active sessions of the previous process, with audit entries and events) runs only when the start lock of R-DM-50 is free (server.finalized, which whileFinalized uses too); the next start after bothy restore finalize reconciles. TestStart_RestoreNotFinalized_NotReady seeds an active terminal session and checks after every start under the lock that the session is unchanged and that neither event_seq nor audit_seq moved; the start with the guard off then detaches it. |
Found by the delta red team after the rebase onto M2-44: the reconcile wrote on the restored timeline before the finalize and moved the heads that --relocation compares, so a relocation could no longer keep its epoch. It is the second step that wrote under the lock (after the presence tracker, D-M7-04-7), so the test now checks the class, any event or audit write of a start under the lock, not only the known steps. |