fix(restore): name the restore in the hook log and in the install conflict (#493, #494) - #519
Merged
Merged
Conversation
…flict (#493, #494) Two operator-facing papercuts from the same spec 007 disposable-VM pass. #494 — the shared hook runner logged the backup vocabulary for every caller, so a restore hook announced itself as "Running backup preHook": exactly the log an operator reads when a failing restore hook has failed their whole install (FR-021), pointing them at the wrong subsystem. The runner stays shared — declaration order, fail-closed and started-only cleanup are the same policy (research R14) — and instead takes a required `HookVocabulary` ('backup' | 'restore') that every caller must name, so a future third caller cannot silently inherit the wrong noun. The noun is now on every line of a hook run, not just the first (`backup preHook ok:` / `restore preHook ok:`), which is what made the source-quiesce and target-load phases distinguishable without cross-referencing deployment ids. `runBackupHook` becomes `runParticipationHook` for the same reason. #493 — a restore source is by definition live, so for a single-instance app the restoring install always trips spec 005's guard: `allowMultiple` is the normal path here, and nothing surfaced that. A restore choice deliberately still does NOT imply the override — the source keeps running and the restored copy resumes with live credentials, which is precisely what an operator should confirm rather than inherit from a different choice — so the refusal explains itself instead: `details.restore { candidateId, candidateIsExisting }` is ADDED to the existing `ALREADY_INSTALLED` shape (same top-level CONFLICT, same `details.code`, byte-identical when no restore is in play). The CLI pairs `--restore-from` with `--allow-multiple` from `details`, never the message; the wizard's conflict panel says the install would be a second live copy, and its restore step now sets that expectation up front. Docs: spec 007 contracts/cli.md + api.md record the pairing and the added details, OPERATIONS.md's restore section says it in operator terms, and the API explorer's CONFLICT row lists the new field. Closes #493 Closes #494 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two operator-facing papercuts found on the same spec 007 disposable-VM pass (task T079), fixed together because they are the same class of bug — the platform knowing a restore is happening and not saying so. One branch, two independent sections.
#494 — a restore hook logged as "Running backup preHook"
Spec 007 runs restore hooks through
runPreHooksFailClosed/runPostHooksverbatim (research R14). That sharing is right: declaration order, fail-closed and started-only cleanup are the same policy. What was not shareable is the noun — the runner hardcoded "backup", so the restore hook that loads a dump into the target announced itself as a backup hook, in exactly the log an operator reads when a failing restore hook has just failed their whole install (FR-021).HookVocabulary('backup' | 'restore') that every caller must name — no default, so a future third caller cannot silently inherit this one's word.runBackupHook→runParticipationHook: a method calledrunBackupHook('restore')is the same confusion one level down.preHook ok: …becomesbackup preHook ok: …. The issue names this as part of the bug ("both saypreHook, and only thedeploymentIddiffers between quiescing the source and loading the target"), so a noun on theRunningline alone does not fix it.Running backup preHook/postHookare unchanged, and the format matches the existingRunning push postHook/push postHook failedprecedent. Nothing parses these strings (grepped: server logger + job log only).Before → after for the run in the issue:
The restore-source quiesce keeps the backup vocabulary, because those genuinely are the source's
backup@1hooks.#493 — restoring from a live source always collides with the single-instance guard
A restore candidate is by definition live (FR-048/FR-004a), so for a single-instance app — most of the catalog, including every Postgres-backed app that most needs restore —
--restore-fromalways trips spec 005's guard.allowMultipleis the normal path here, and nothing surfaced that.Implemented as decided: a restore choice does NOT imply
allowMultiple. I agree with the decision. The restored copy comes up live, holding the source's credentials, beside a source that keeps running — the exact risk spec 007's own summary acknowledgement warns about (jobs/webhooks firing against real systems). Inferring "run two live copies of this app" from "restore from this one" would take a consent the operator gave for one thing and spend it on another. The guard also stays honest for the disaster-recovery case: once a source can be gone (spec 008's provider origin), the collision disappears on its own rather than having been papered over.So the refusal explains itself instead:
assertInstanceAllowed):detailsgainsrestore { candidateId, candidateIsExisting }, only when the refused install carried arestoreFrom. Added to the spec 005 shape, never repurposing it — the top-levelCONFLICTanddetails.codeare untouched, and a non-restore refusal is byte-identical (asserted withtoEqual).candidateIsExistingseparates the two honest sentences: the copy in the way is the source (ordinary local restore), or it is some other copy (a provider-held capture restored onto a host already running the app). The verdict is unchanged; the guard only got more explicable. Messages stay surface-neutral (SC-003).ALREADY_INSTALLEDbranch now has two arms, both built fromdetailsand never from the message. Withdetails.restoreit pairs the flags and drops the channel advice (switching a channel is no route to a restore); without it, the spec 005 hint is byte-identical (asserted withtoBeon the exact string).ALREADY_INSTALLEDpanel gains one sentence whendetails.restoreis present ("…this install would be a second live copy…Continue only if you mean to run both"), and the restore step now says up front that the copy you restore from keeps running. No redesign: the way forward — "Install another copy (operator override)" — already existed and already sendsallowMultiple: true.details.restoreis narrowed separately and dropped when malformed, so a pre-restore-on-install: restoring from a live source needs allowMultiple for single-instance apps, and nothing says so #493 server never degrades the spec 005 panel.contracts/cli.md(a section on the pairing + a row in the hint table + the "Unchanged" note corrected),contracts/api.md(the added details),docs/OPERATIONS.md(an operator-terms paragraph in Restore-on-install + the restore variant of the conflict hint), and the API explorer'sCONFLICTrow.Tests
typecheck → lint → typecheck → test → build, all green, foreground. Server 1397 → 1400, web 377 → 379, CLI 286 → 289 (8 added, 0 failures anywhere; no web flake seen).Revert-proofed — each production change reverted in turn, matching tests confirmed failing, then restored:
#494: the restore hook logs restore vocabulary…)details.restoreon the server#493: …refused with the restore named in details)#493hint tests)#493: with restore details…)In every case the paired regression test (non-restore refusal / hint / panel unchanged) kept passing, which is the point of having it.
Closes #493
Closes #494
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh