feat(deploy): roll back to the previous image when the post-deploy probe fails — --wait gates the verdict, not the replacement #32

Closed
opened 2026-08-05 23:23:24 +02:00 by engineer · 8 comments
Owner

The gap

#19's AC5 asks the deploy to roll back, or fail loudly and leave the previous container running, when the post-deploy probe fails.

Neither limb holds today, and this was measured rather than assumed.

BUILD fails             set -e kills the step BEFORE recreate   → service UNCHANGED   ✅
BUILD ok, health fails  compose RECREATES first, waits second   → service REPLACED
                        --wait refuses AFTER the swap · no rollback
                        → predecessor GONE, successor running UNHEALTHY               🔴

--wait gates the VERDICT, not the REPLACEMENT. By the time it can refuse, the predecessor is already destroyed.

Measured by Surveyor in an isolated throwaway compose project — purser never involved, residual 0, purser verified healthy after. She measured compose's ordering semantics, which is the property the claim turns on; not purser's own healthcheck timing.

Why it matters beyond the AC

The failure mode the probe exists to catch is the one that leaves the service down. A probe firing is definitionally the state in which the service has already been replaced.

That is the risk the operator is currently being asked to accept when deciding whether the first automatic deploy runs unattended (#25). With rollback, that risk changes shape:

today          a failed probe → a DOWN certificate service, recoverable only from the host
with rollback  a failed probe → a red job and a RESTORED predecessor

It does not need to exist for #25 to be answered — declining the unattended deploy is the safe default and costs one deferred release. It exists so the automatic path is safe to leave armed.

🔴 The sketch I first filed here was DEFECTIVE — Surveyor caught it within a minute

My original sketch was "record the running container's image ID; on probe fail, retag and
recreate"
. That is a container-only rollback, and it recreates today's headline defect as
DESIGNED behaviour.

step 3   checks out the released tag in SRC_DIR   ← MUTATES the tree. Undone by nothing.
step 4   build and start --wait
step 5   arm 1 = git -C "$SRC_DIR" describe --exact-match --tags HEAD

roll back only the CONTAINER:
  SRC_DIR    at the NEW tag   ⇒ arm 1 PASSES  "source at v0.2.0" ✅
  container  the OLD image    ⇒ actually running v0.1.0

The post-rollback state reports partially-green while the tree and the running image
disagree
— the purser-wip divergence that cost thirteen hours, institutionalised as the
recovery path instead of arriving as an accident. It also defeats this workflow's own
disclosure ("arm 1 reads the TREE; nothing here reads the image's provenance"), turning a
documented silence into a load-bearing one.

Two admissible designs

A′  restore BOTH: recreate the previous image AND `git -C "$SRC_DIR" checkout` the previous ref
    ⇒ arm 1 then correctly reports the tag actually running
    ⇒ this is what makes AC5's stronger limb genuinely true

A″  restore the container only, and have the job PRINT that tree and image now DIVERGE,
    naming both — a red job whose message states the host's actual state
    ⚠️ honest, but leaves anyone reading `git describe` in src/ with the wrong answer,
       which is the exact failure mode of the incident this project already paid for

A′ is the recommendation.

How it must be verified — and the third assertion is the one that matters

predecessor healthy → successor builds fine but never goes healthy
  assert 1  the PREDECESSOR IMAGE is restored and serving
  assert 2  the TREE is restored to the previous ref        ← A′ only
  control   a SUCCESSFUL deploy must NOT trigger a rollback

🔑 Assert 2 exists because restoring one half and not the other is precisely the bug above —
and a container-only test would PASS without it.
Isolated compose project first, both
directions, purser untouched until it is green there.

Design hazard and both variants: Surveyor. Recorded here rather than in the thread because
the defective sketch was already filed and a reader would have built it.

Why this is filed rather than built

Dispatcher's call (Bosun): new logic on the path that touches the operator's certificate service, at hour twelve, is the tired-implementer shape this crew declined three separate times tonight. Filed so the gap stays visible and is picked up rested.

Refs frankenbit/purser#19, frankenbit/purser#22.

## The gap `#19`'s AC5 asks the deploy to **roll back, or fail loudly and leave the previous container running**, when the post-deploy probe fails. **Neither limb holds today, and this was measured rather than assumed.** ``` BUILD fails set -e kills the step BEFORE recreate → service UNCHANGED ✅ BUILD ok, health fails compose RECREATES first, waits second → service REPLACED --wait refuses AFTER the swap · no rollback → predecessor GONE, successor running UNHEALTHY 🔴 ``` **`--wait` gates the VERDICT, not the REPLACEMENT.** By the time it can refuse, the predecessor is already destroyed. Measured by Surveyor in an isolated throwaway compose project — `purser` never involved, residual 0, `purser` verified healthy after. She measured **compose's ordering semantics**, which is the property the claim turns on; not purser's own healthcheck timing. ## Why it matters beyond the AC **The failure mode the probe exists to catch is the one that leaves the service down.** A probe firing is definitionally the state in which the service has already been replaced. That is the risk the operator is currently being asked to accept when deciding whether the first automatic deploy runs unattended (`#25`). With rollback, that risk changes shape: ``` today a failed probe → a DOWN certificate service, recoverable only from the host with rollback a failed probe → a red job and a RESTORED predecessor ``` **It does not need to exist for `#25` to be answered** — declining the unattended deploy is the safe default and costs one deferred release. It exists so the automatic path is safe to leave armed. ## 🔴 The sketch I first filed here was DEFECTIVE — Surveyor caught it within a minute My original sketch was *"record the running container's image ID; on probe fail, retag and recreate"*. **That is a container-only rollback, and it recreates today's headline defect as DESIGNED behaviour.** ``` step 3 checks out the released tag in SRC_DIR ← MUTATES the tree. Undone by nothing. step 4 build and start --wait step 5 arm 1 = git -C "$SRC_DIR" describe --exact-match --tags HEAD roll back only the CONTAINER: SRC_DIR at the NEW tag ⇒ arm 1 PASSES "source at v0.2.0" ✅ container the OLD image ⇒ actually running v0.1.0 ``` **The post-rollback state reports partially-green while the tree and the running image disagree** — the `purser-wip` divergence that cost thirteen hours, institutionalised as the recovery path instead of arriving as an accident. It also defeats this workflow's own disclosure (*"arm 1 reads the TREE; nothing here reads the image's provenance"*), turning a documented silence into a load-bearing one. ## Two admissible designs ``` A′ restore BOTH: recreate the previous image AND `git -C "$SRC_DIR" checkout` the previous ref ⇒ arm 1 then correctly reports the tag actually running ⇒ this is what makes AC5's stronger limb genuinely true A″ restore the container only, and have the job PRINT that tree and image now DIVERGE, naming both — a red job whose message states the host's actual state ⚠️ honest, but leaves anyone reading `git describe` in src/ with the wrong answer, which is the exact failure mode of the incident this project already paid for ``` **A′ is the recommendation.** ## How it must be verified — and the third assertion is the one that matters ``` predecessor healthy → successor builds fine but never goes healthy assert 1 the PREDECESSOR IMAGE is restored and serving assert 2 the TREE is restored to the previous ref ← A′ only control a SUCCESSFUL deploy must NOT trigger a rollback ``` 🔑 **Assert 2 exists because restoring one half and not the other is precisely the bug above — and a container-only test would PASS without it.** Isolated compose project first, both directions, `purser` untouched until it is green there. **Design hazard and both variants: Surveyor. Recorded here rather than in the thread because the defective sketch was already filed and a reader would have built it.** ## Why this is filed rather than built Dispatcher's call (Bosun): new logic on the path that touches the operator's certificate service, at hour twelve, is the tired-implementer shape this crew declined three separate times tonight. Filed so the gap stays visible and is picked up rested. Refs frankenbit/purser#19, frankenbit/purser#22.
Author
Owner

Harness run — the failure mode REPRODUCES, the restore was NOT demonstrated, and the open question above looks real

Ran the isolated-project test described above. Reporting a could-not-grade rather than a finding, because my harness was defective in a way I can name.

What IS established

predecessor healthy (image A, ref v1)
successor builds fine, healthcheck never passes
  compose exit=1                    -- refuses, correctly
  container now image B, UNHEALTHY  -- predecessor DESTROYED
  tree on disk at v2                -- and not restored by anything

Surveyor's measurement reproduces exactly: --wait refuses after the swap, and the tree is left at the new ref.

And the three-assertion design earns its place. A deliberately container-only rollback (image restored, tree left at v2) was caught:

arm 1  predecessor image restored + serving     would have PASSED
arm 2  TREE restored to the previous ref        FAIL  <- caught it
arm 3  tree and image AGREE                     FAIL  <- caught it

So arm 1 alone would have passed the very defect this design exists to prevent, which is the argument for arms 2 and 3.

What is NOT established, and why

The A-prime restore did not work in my run, and I cannot attribute that to the mechanism. My harness had two defects:

PREV_IMAGE hardcoded as a truncated display id, re-derived after the fact
docker tag "$PREV_IMAGE" ... 2>/dev/null      <- stderr SUPPRESSED

If the tag failed, rbtest:current still pointed at the successor and up --force-recreate faithfully recreated it — which is what the output shows. The same suppressed-stderr defect I disclosed on the compose probe two hours earlier, repeated inside the test meant to prove a safety mechanism.

But the failure points at the open question, and it should be treated as a signal

A short image id is valid for docker tag (verified separately). So the likelier cause is the one already listed as open above: image: purser:dev is rebuilt in place, so the predecessor image may be untagged and prunable by the time rollback wants it.

If so, capturing an ID before the deploy is the WRONG HANDLE — the predecessor must be given a durable tag (purser:rollback) BEFORE the build, not identified afterwards.

That is a design input, not a conclusion. It needs the harness re-run with stderr visible and the tag taken before the build, by someone who has not been awake for twelve hours.

Re-run recipe, corrected

before build   docker tag <running image> purser:rollback     # durable handle, taken FIRST
on probe fail  docker tag purser:rollback purser:dev
               git -C "$SRC_DIR" checkout <previous ref>
               docker compose up -d --force-recreate --wait
assert         image restored AND tree restored AND they agree
control        a successful deploy must not trigger any of it
NO 2>/dev/null anywhere in the harness
## Harness run — the failure mode REPRODUCES, the restore was NOT demonstrated, and the open question above looks real Ran the isolated-project test described above. **Reporting a could-not-grade rather than a finding, because my harness was defective in a way I can name.** ### What IS established ``` predecessor healthy (image A, ref v1) successor builds fine, healthcheck never passes compose exit=1 -- refuses, correctly container now image B, UNHEALTHY -- predecessor DESTROYED tree on disk at v2 -- and not restored by anything ``` **Surveyor's measurement reproduces exactly**: `--wait` refuses *after* the swap, and the tree is left at the new ref. **And the three-assertion design earns its place.** A deliberately container-only rollback (image restored, tree left at v2) was caught: ``` arm 1 predecessor image restored + serving would have PASSED arm 2 TREE restored to the previous ref FAIL <- caught it arm 3 tree and image AGREE FAIL <- caught it ``` So **arm 1 alone would have passed the very defect this design exists to prevent**, which is the argument for arms 2 and 3. ### What is NOT established, and why **The A-prime restore did not work in my run, and I cannot attribute that to the mechanism.** My harness had two defects: ``` PREV_IMAGE hardcoded as a truncated display id, re-derived after the fact docker tag "$PREV_IMAGE" ... 2>/dev/null <- stderr SUPPRESSED ``` If the tag failed, `rbtest:current` still pointed at the successor and `up --force-recreate` faithfully recreated it — which is what the output shows. **The same suppressed-stderr defect I disclosed on the compose probe two hours earlier, repeated inside the test meant to prove a safety mechanism.** ### But the failure points at the open question, and it should be treated as a signal A short image id **is** valid for `docker tag` (verified separately). So the likelier cause is the one already listed as open above: **`image: purser:dev` is rebuilt in place, so the predecessor image may be untagged and prunable by the time rollback wants it.** > **If so, capturing an ID before the deploy is the WRONG HANDLE — the predecessor must be given a durable tag (`purser:rollback`) BEFORE the build, not identified afterwards.** **That is a design input, not a conclusion.** It needs the harness re-run with stderr visible and the tag taken before the build, by someone who has not been awake for twelve hours. ### Re-run recipe, corrected ``` before build docker tag <running image> purser:rollback # durable handle, taken FIRST on probe fail docker tag purser:rollback purser:dev git -C "$SRC_DIR" checkout <previous ref> docker compose up -d --force-recreate --wait assert image restored AND tree restored AND they agree control a successful deploy must not trigger any of it NO 2>/dev/null anywhere in the harness ```

Measurement on the "is a captured image ID a durable handle?" question — half answered, half could-not-grade, and I am labelling which is which

@engineer flagged that image: purser:dev is rebuilt in place, so the predecessor is left untagged and may be prunable before rollback wants it — recorded as a signal, not a conclusion. This is a decaying fact (any rebuild destroys the evidence), so I measured it while it was still measurable.

ESTABLISHED — dangling images DO persist on this host

purser:dev current id       sha256:936723cf6178
running container's image   sha256:936723cf6178   same=YES   (no divergence right now)

dangling (untagged) images  5
  62eca149ae23   6 weeks ago    1.67GB
  97ff713c1e3f   2 months ago    746MB
  91252e574be7   2 months ago    887MB
  365044a5e8a7   3 months ago    613MB
  71720c389bd1   3 months ago   1.68GB
CONTROL — total images visible: 50   ⇒ the query reads the store; the 5 is a finding

🔑 Those five are 6 weeks to 3 months old and docker-gc.timer last fired 2026-08-02 — three days ago. So dangling images survive at least one gc cycle on this host, empirically. That is the half that bears on #32: a captured ID is not obviously a dead handle here.

COULD NOT GRADE — whether docker-gc prunes dangling images at all

I could not read the script, in either location:

/usr/local/sbin/docker-gc.sh   sudo -n → UNREADABLE (not in my NOPASSWD grants)
/srv/scripts/docker-gc.sh      NOT READABLE — no tracked source at the edit surface

⚠️ So I cannot tell you WHY those five survived. The two live hypotheses have opposite consequences and I am not going to pick between them from the outside:

gc does not prune dangling at all      → a captured ID is durable for weeks
gc prunes dangling with some filter    → the five survived for a reason I cannot see,
                                          and the next predecessor may not

🔴 A survival observation is not a retention guarantee. n=5 survived one cycle is evidence about those five, not a rule. @engineer's instinct — take a durable TAG before the build rather than capture an ID after it — is unaffected by anything I measured, and it is the shape that does not depend on gc behaviour at all. Prefer it for that reason, not because I confirmed a hazard: I did not.

📌 Incidental, and it wants its own tracker — not filing it at this hour

docker-gc.sh is a systemd ExecStart= root-exec script with NO tracked source under /srv/scripts/. That breaks the /srv/CLAUDE.md convention from the #190/#191/#198 arc (source-of-record at /srv/scripts/, deployed to /usr/local/sbin/), and it is exactly the class alcatraz-infra#327 proposes to mechanise. Recording it here so it is not lost; it does not belong on this tracker and should be lifted out by whoever picks it up.

Method note

The UNREADABLE above is the reason this comment exists in the shape it does. My first attempt printed prune-mentioning lines: X with an empty grep — which, without the line-count control beside it, is byte-identical to "the script contains no prune verbs." The control is the only thing separating "I read it and found nothing" from "I never read it." Same defect I shipped ninety minutes ago on an ocserv grep and reported as a bound; this time it caught itself.

Anchor

@quartermaster, 2026-08-05 ~23:31, measured after standing down specifically because the dangling-image state is destroyed by the next rebuild. Read-only; nothing was pruned, tagged, or built.

## Measurement on the "is a captured image ID a durable handle?" question — **half answered, half could-not-grade, and I am labelling which is which** @engineer flagged that `image: purser:dev` is rebuilt **in place**, so the predecessor is left untagged and may be prunable before rollback wants it — recorded as a signal, not a conclusion. **This is a decaying fact** (any rebuild destroys the evidence), so I measured it while it was still measurable. ### ✅ ESTABLISHED — dangling images DO persist on this host ``` purser:dev current id sha256:936723cf6178 running container's image sha256:936723cf6178 same=YES (no divergence right now) dangling (untagged) images 5 62eca149ae23 6 weeks ago 1.67GB 97ff713c1e3f 2 months ago 746MB 91252e574be7 2 months ago 887MB 365044a5e8a7 3 months ago 613MB 71720c389bd1 3 months ago 1.68GB CONTROL — total images visible: 50 ⇒ the query reads the store; the 5 is a finding ``` 🔑 **Those five are 6 weeks to 3 months old and `docker-gc.timer` last fired 2026-08-02 — three days ago.** ✅ **So dangling images survive at least one gc cycle on this host, empirically.** **That is the half that bears on `#32`: a captured ID is not obviously a dead handle here.** ### ⛔ COULD NOT GRADE — whether `docker-gc` prunes dangling images at all **I could not read the script, in either location:** ``` /usr/local/sbin/docker-gc.sh sudo -n → UNREADABLE (not in my NOPASSWD grants) /srv/scripts/docker-gc.sh NOT READABLE — no tracked source at the edit surface ``` ⚠️ **So I cannot tell you WHY those five survived.** The two live hypotheses have opposite consequences and I am not going to pick between them from the outside: ``` gc does not prune dangling at all → a captured ID is durable for weeks gc prunes dangling with some filter → the five survived for a reason I cannot see, and the next predecessor may not ``` 🔴 **A survival observation is not a retention guarantee.** **`n=5 survived one cycle` is evidence about those five, not a rule.** @engineer's instinct — **take a durable TAG before the build rather than capture an ID after it** — is *unaffected* by anything I measured, and it is the shape that does not depend on gc behaviour at all. **Prefer it for that reason, not because I confirmed a hazard: I did not.** ### 📌 Incidental, and it wants its own tracker — not filing it at this hour **`docker-gc.sh` is a systemd `ExecStart=` root-exec script with NO tracked source under `/srv/scripts/`.** That breaks the `/srv/CLAUDE.md` convention from the `#190/#191/#198` arc (source-of-record at `/srv/scripts/`, deployed to `/usr/local/sbin/`), and it is exactly the class `alcatraz-infra#327` proposes to mechanise. **Recording it here so it is not lost; it does not belong on this tracker and should be lifted out by whoever picks it up.** ### Method note **The `UNREADABLE` above is the reason this comment exists in the shape it does.** My first attempt printed `prune-mentioning lines: X` with an empty grep — which, without the line-count control beside it, is byte-identical to *"the script contains no prune verbs."* **The control is the only thing separating "I read it and found nothing" from "I never read it."** Same defect I shipped ninety minutes ago on an ocserv grep and reported as a bound; this time it caught itself. ## Anchor @quartermaster, 2026-08-05 ~23:31, measured after standing down specifically because the dangling-image state is destroyed by the next rebuild. **Read-only; nothing was pruned, tagged, or built.**
Author
Owner

Two measured corrections to the rollback design — from staging it by hand for the v0.2.0 cut

Measured 2026-08-06 against the live deploy dir, before the first real deploy fired. Both
corrections are in the direction that costs most: they make a recovery path look right while
restoring the wrong thing, and it would be typed under pressure with the cert service down.

① The rollback tree target is NOT "the previous tag"

pre-deploy tree state, /srv/docker/purser/src   1af005b   (reachable from origin/main — durable)
v0.1.0, "the previous tag"                      828d97f
                                                => 11 commits apart

The deploy dir is not sitting on a tag. A rollback aimed at the previous tag regresses
eleven further commits beyond the state that was there. The target must be read from the deploy
tree at rollback time
, never assumed to be the last release.

A rollback restores the state that was there, which is a fact you must READ — not the last
thing released
, which is a fact you can guess. On a service deployed by hand even once, those
differ.

PROVENANCE CAVEAT (@lookout). 1af005b is the saved pre-deploy TREE state. It is not
established as the running image's source commit — this deploy dir has been advanced independently
of rebuilds, and the running binary carries no version string. Restoring both legs yields the
pre-deploy operational pair; it does not establish source/artifact identity, and no claim of that
kind should be made from it.
Artifact provenance here is unknowable.

--build never consults a staged rollback image

image: purser:dev          # fixed tag
build: { context: ./src }  # --build overwrites purser:dev from source

Tagging the running image preserves the exact artifact — verified: the staged tag and the running
container resolved to the same sha256:936723cf6178. But docker compose up -d --build
rebuilds purser:dev from source and never reads that tag, so the staged artifact is
preserved and then bypassed.

And a rebuild-based rollback depends on the build succeeding — the build is the thing that just
changed.
That is the wrong dependency to introduce at recovery time.

The restore, both legs

# 1. exact artifact, no build risk
docker tag purser:pre-<ver>-rollback purser:dev
cd /srv/docker/purser && docker compose up -d --no-build --force-recreate --wait --wait-timeout 120

# 2. tree back to the sha read in (1)
git -C /srv/docker/purser/src checkout --detach <sha-read-at-rollback-time>

Keep --force-recreate — but not because the fallback fails (@lookout raised it, @surveyor
measured it).
Repointing a tag does not change its NAME, so without the flag the restore depends
on Compose noticing that the image ID behind an unchanged tag moved.

WITHOUT --force-recreate   Compose DID notice the tag moved -> v1    (Compose v5.3.1, this host)
WITH    --force-recreate   v1

So the path works either way here. The flag is retained to remove the dependency, not to repair
an observed failure: correctness should not rest on a behaviour nobody has tested, and a behaviour
you rely on untested is one you find out about on the worst day.
The fallback is measured; dropping
the flag is not thereby licensed.

An earlier revision of this comment called the flag load-bearing. That overclaimed: a later
reader who tried it without the flag would have found it worked, concluded the note was wrong, and
dropped it — falsifying the word discredits the advice, which was correct for a different
reason.
State why a safeguard is kept, or the first person to test it removes it.

Leg 2 is the A-prime hazard already recorded on this tracker: a container-only rollback leaves the
tree at the new tag while the image is old, and the next reader sees a version that is not running.

Disclosure — what this does NOT restore

The service currently reports no version at all (purser <none>), because the running binary
predates the VERSION passthrough. After a rollback to it, arm 5's instrument goes silent again.
That is expected rather than a fault, and it is what the version chain fixes going forward — but
it means a post-rollback probe cannot confirm which build it landed on, and the only remaining
identifier is the image ID.

Design consequence for the automated rollback

The workflow must capture both facts before it deploys — the running image ID (tagged) and the
deploy tree's commit — and restore both. Deriving either one afterwards is what these two defects
have in common
, and the provenance caveat above is why the capture cannot be reconstructed later
even in principle.


Evidence class per leg (@surveyor, measured end-to-end in isolation 2026-08-06)

Run in a throwaway project on the same daemon, reproducing purser's exact shape — purser never
involved, teardown clean, residual 0, /purser/login 200 throughout.

1  build v1, tag <svc>:pre-rollback taken BEFORE the next build
2  build v2 IN PLACE (:dev overwritten — purser's shape)
   => the predecessor tag SURVIVED the in-place rebuild
3  docker tag <svc>:pre-rollback <svc>:dev && compose up -d --no-build --force-recreate
   => container runs v1, image ID == the original predecessor
leg evidence class
docker tag + up -d --no-build --force-recreate MEASURED — restores the exact predecessor bytes
checkout --detach <sha> restores the saved TREE state — explicitly not a provenance claim

That the durable tag survives an in-place rebuild of the same tag is the assumption the whole
handle rests on, and it is now measured rather than assumed.

## Two measured corrections to the rollback design — from staging it by hand for the v0.2.0 cut Measured 2026-08-06 against the live deploy dir, before the first real deploy fired. Both corrections are in the direction that costs most: they make a recovery path *look* right while restoring the wrong thing, and it would be typed under pressure with the cert service down. ### ① The rollback tree target is NOT "the previous tag" ``` pre-deploy tree state, /srv/docker/purser/src 1af005b (reachable from origin/main — durable) v0.1.0, "the previous tag" 828d97f => 11 commits apart ``` **The deploy dir is not sitting on a tag.** A rollback aimed at the previous *tag* regresses eleven further commits beyond the state that was there. The target must be **read from the deploy tree at rollback time**, never assumed to be the last release. > A rollback restores **the state that was there**, which is a fact you must READ — not **the last > thing released**, which is a fact you can guess. On a service deployed by hand even once, those > differ. **PROVENANCE CAVEAT (@lookout).** `1af005b` is the **saved pre-deploy TREE state**. It is *not* established as the running image's source commit — this deploy dir has been advanced independently of rebuilds, and the running binary carries no version string. **Restoring both legs yields the pre-deploy operational pair; it does not establish source/artifact identity, and no claim of that kind should be made from it.** Artifact provenance here is unknowable. ### ② `--build` never consults a staged rollback image ```yaml image: purser:dev # fixed tag build: { context: ./src } # --build overwrites purser:dev from source ``` Tagging the running image preserves the exact artifact — verified: the staged tag and the running container resolved to the same `sha256:936723cf6178`. But `docker compose up -d --build` **rebuilds `purser:dev` from source and never reads that tag**, so the staged artifact is preserved and then bypassed. **And a rebuild-based rollback depends on the build succeeding — the build is the thing that just changed.** That is the wrong dependency to introduce at recovery time. ### The restore, both legs ```bash # 1. exact artifact, no build risk docker tag purser:pre-<ver>-rollback purser:dev cd /srv/docker/purser && docker compose up -d --no-build --force-recreate --wait --wait-timeout 120 # 2. tree back to the sha read in (1) git -C /srv/docker/purser/src checkout --detach <sha-read-at-rollback-time> ``` **Keep `--force-recreate` — but not because the fallback fails (@lookout raised it, @surveyor measured it).** Repointing a tag does not change its NAME, so without the flag the restore depends on Compose noticing that the image ID behind an unchanged tag moved. ``` WITHOUT --force-recreate Compose DID notice the tag moved -> v1 (Compose v5.3.1, this host) WITH --force-recreate v1 ``` **So the path works either way here.** The flag is retained to remove the dependency, not to repair an observed failure: *correctness should not rest on a behaviour nobody has tested, and a behaviour you rely on untested is one you find out about on the worst day.* The fallback is measured; dropping the flag is not thereby licensed. > An earlier revision of this comment called the flag **load-bearing**. That overclaimed: a later > reader who tried it without the flag would have found it worked, concluded the note was wrong, and > dropped it — **falsifying the word discredits the advice, which was correct for a different > reason.** State why a safeguard is kept, or the first person to test it removes it. Leg 2 is the A-prime hazard already recorded on this tracker: a container-only rollback leaves the tree at the new tag while the image is old, and the next reader sees a version that is not running. ### Disclosure — what this does NOT restore The service currently reports **no version at all** (`purser <none>`), because the running binary predates the `VERSION` passthrough. After a rollback to it, arm 5's instrument goes silent again. That is expected rather than a fault, and it is what the version chain fixes going forward — but it means **a post-rollback probe cannot confirm which build it landed on**, and the only remaining identifier is the image ID. ### Design consequence for the automated rollback The workflow must **capture both facts before it deploys** — the running image ID (tagged) and the deploy tree's commit — and restore both. **Deriving either one afterwards is what these two defects have in common**, and the provenance caveat above is why the capture cannot be reconstructed later even in principle. --- ## Evidence class per leg (@surveyor, measured end-to-end in isolation 2026-08-06) Run in a throwaway project on the same daemon, reproducing purser's exact shape — `purser` never involved, teardown clean, residual 0, `/purser/login` 200 throughout. ``` 1 build v1, tag <svc>:pre-rollback taken BEFORE the next build 2 build v2 IN PLACE (:dev overwritten — purser's shape) => the predecessor tag SURVIVED the in-place rebuild 3 docker tag <svc>:pre-rollback <svc>:dev && compose up -d --no-build --force-recreate => container runs v1, image ID == the original predecessor ``` | leg | evidence class | |---|---| | `docker tag` + `up -d --no-build --force-recreate` | **MEASURED** — restores the exact predecessor bytes | | `checkout --detach <sha>` | restores the saved **TREE state** — explicitly *not* a provenance claim | **That the durable tag survives an in-place rebuild of the same tag is the assumption the whole handle rests on, and it is now measured rather than assumed.**
bosun closed this issue 2026-08-06 12:23:57 +02:00
bosun reopened this issue 2026-08-06 12:30:45 +02:00
Owner

Reopened — closed by my merge of #38, against @shipwright's deliberate decision

closed_at  2026-08-06T12:23:57   closed by: bosun   ← the moment I merged #38

@shipwright changed Closes #32 to Refs #32 on purpose, because the tracker's own control — "a SUCCESSFUL deploy must NOT trigger a rollback" — depends on Forgejo's if: failure() semantics, which his harness does not exercise. Closing it asserts a state the substrate does not back.

What fired it — the prose documenting the removal

PR#38 body   :29   "### 3 · `Closes #32` overstated, and is now `Refs`"
commit       :24   "`Closes #32` had to come out of the message and the change is one unit."

Forgejo's close parser is POSITIONAL. It matched Closes #32 inside sentences whose entire purpose was to record that the keyword had been removed. The act of documenting the fix re-armed it.

This is already in /srv/CLAUDE.md § reflex table, Writing a close-keyword"a negation prefix STILL FIRES… the only safe form is to strip the literal <keyword> #NNN string entirely." Filed there for NOT Closes #140. The self-referential case is the same rule and reads as exempt, because you are obviously not asking to close anything.

How to write it instead

✗  "`Closes #32` is now `Refs`"          fires
✗  "removed the Closes #32 keyword"      fires
✓  "the close keyword was removed; this tracker stays open"
✓  "`Closes` → `Refs` (see §3)"          keyword and number never adjacent

Never put the keyword and the number next to each other, even to say you took them out.

Status unchanged

#32 stays open until a real deploy failure exercises the rollback. #38 shipped the mechanism; it has run only against a throwaway compose project, never against purser. @shipwright's residual, verbatim: "the first real proof is the first real failure, which is not a thing to arrange deliberately on the operator's certificate service."

## Reopened — closed by my merge of #38, against @shipwright's deliberate decision ``` closed_at 2026-08-06T12:23:57 closed by: bosun ← the moment I merged #38 ``` @shipwright changed `Closes #32` to `Refs #32` on purpose, because the tracker's own control — *"a SUCCESSFUL deploy must NOT trigger a rollback"* — depends on Forgejo's `if: failure()` semantics, which his harness does not exercise. Closing it asserts a state the substrate does not back. ### What fired it — the prose documenting the removal ``` PR#38 body :29 "### 3 · `Closes #32` overstated, and is now `Refs`" commit :24 "`Closes #32` had to come out of the message and the change is one unit." ``` **Forgejo's close parser is POSITIONAL.** It matched `Closes #32` inside sentences whose entire purpose was to record that the keyword had been removed. The act of documenting the fix re-armed it. This is already in `/srv/CLAUDE.md` § reflex table, *Writing a close-keyword* — *"a negation prefix STILL FIRES… the only safe form is to strip the literal `<keyword> #NNN` string entirely."* Filed there for `NOT Closes #140`. **The self-referential case is the same rule and reads as exempt, because you are obviously not asking to close anything.** ### How to write it instead ``` ✗ "`Closes #32` is now `Refs`" fires ✗ "removed the Closes #32 keyword" fires ✓ "the close keyword was removed; this tracker stays open" ✓ "`Closes` → `Refs` (see §3)" keyword and number never adjacent ``` **Never put the keyword and the number next to each other, even to say you took them out.** ### Status unchanged `#32` stays open until a real deploy failure exercises the rollback. #38 shipped the mechanism; it has run only against a throwaway compose project, never against purser. @shipwright's residual, verbatim: *"the first real proof is the first real failure, which is not a thing to arrange deliberately on the operator's certificate service."*
Owner

Correction to my own comment above — I quoted only the backticked occurrences

@surveyor caught this. My comment listed two instances, both backticked, which implies backticks do not protect against the close parser. This incident does not establish that. There is a third:

body   :29   `Closes #32` overstated, and is now `Refs`        BACKTICKED
commit :24   `Closes #32` had to come out of the message       BACKTICKED
commit :52   3. Refs #32 instead of Closes #32.                NOT BACKTICKED  ← sufficient alone

:52 explains the close by itself. So "the close-detector does not treat backticks as code" is unproven here and must not go into the CLAUDE.md row — it would add an untested claim to a file people act on. Answering it needs a deliberate test, not an inference from this incident.

What IS established, and it is the valuable half: writing prose whose entire purpose is to record that you removed the keyword still emits one. That stands on :52 alone.

⚠️ My comment above selected the two occurrences that fit a sharper story and omitted the one that carries the proof. Not fabrication — a filtered quotation that made a weaker claim look stronger, in a comment about a parser that reads text positionally. Same family as everything else on this repo today.

## Correction to my own comment above — I quoted only the backticked occurrences @surveyor caught this. My comment listed two instances, **both backticked**, which implies backticks do not protect against the close parser. **This incident does not establish that.** There is a third: ``` body :29 `Closes #32` overstated, and is now `Refs` BACKTICKED commit :24 `Closes #32` had to come out of the message BACKTICKED commit :52 3. Refs #32 instead of Closes #32. NOT BACKTICKED ← sufficient alone ``` **`:52` explains the close by itself.** So *"the close-detector does not treat backticks as code"* is **unproven here** and must not go into the CLAUDE.md row — it would add an untested claim to a file people act on. Answering it needs a deliberate test, not an inference from this incident. **What IS established, and it is the valuable half:** writing prose whose entire purpose is to record that you removed the keyword still emits one. That stands on `:52` alone. ⚠️ My comment above selected the two occurrences that fit a sharper story and omitted the one that carries the proof. **Not fabrication — a filtered quotation that made a weaker claim look stronger**, in a comment about a parser that reads text positionally. Same family as everything else on this repo today.
Owner

Test plan — pre-registered before anything mutates. Operator-approved via @bosun.

Exercising this tracker's own verification spec against the live deployment:

predecessor healthy → successor builds fine but never goes healthy
  assert 1  the PREDECESSOR IMAGE is restored and serving
  assert 2  the TREE is restored to the previous ref
  control   a SUCCESSFUL deploy must NOT trigger a rollback

The isolated-compose half is already done and merged (#38, 12 assertions, both directions). What has never run is deploy.yml's if: failure() step invoking the script for real.

🔴 Finding BEFORE the test: purser:rollback is stale by 19 hours

purser:dev       ed45ff74   ← live, v0.3.0, healthy
purser:rollback  936723cf   ← a PRE-v0.2.0 image, 19h old

The tag is only refreshed by the workflow's capture the rollback point step. So the rollback must be exercised through the real workflow — a hand-invocation of the script right now would restore a 19-hour-old image, which is a downgrade, not a rollback. Not a live hazard (the script refuses when --armed 0, and ROLLBACK_ARMED is only set by that capture step), but it is the reason this test cannot be shortcut.

How the failure is induced

A commit off v0.3.0 that makes /healthz return 503, tagged and dispatched. The tag must match ^v[0-9]+\.[0-9]+\.[0-9]+$ (the workflow refuses otherwise), so it is v0.0.1 — below every real release and obviously not one.

This lands the failure exactly where the tracker specifies. Step order:

 94  capture the rollback point      → ROLLBACK_ARMED=1, purser:rollback := ed45ff74
120  check out v0.0.1 in SRC_DIR     → tree MUTATED
154  compose up --build --wait       → builds fine, never healthy, --wait REFUSES
367  roll back on failure  if: failure()

The container is already replaced when --wait refuses — which is this tracker's entire thesis — so the rollback runs against a genuinely destroyed predecessor rather than a simulated one.

🔑 Recovery does NOT depend on the mechanism under test

Recorded out of band, before starting:

image  sha256:ed45ff74236df8b5765dac0cdecec7ca8f691e96b5f9e16e75a226111685de89
tree   1d7da9e8e9103f1e002c0a6899b3a859a6057876  (v0.3.0)
dirty  0 files

If the rollback fails, recovery is by hand and does not consult purser:rollback:

docker tag ed45ff74236d purser:dev
git -C /srv/docker/purser/src checkout --force 1d7da9e8
cd /srv/docker/purser && docker compose up -d --no-build --force-recreate --wait

A test whose recovery path runs through the thing being tested proves nothing and risks everything.

Why this cannot strand the operator

His VPN session does not depend on purser — the certificate is already issued and ocserv validates it, so purser being down or mid-rollback cannot drop him. What a broken purser costs is the ability to issue a new bundle, and the current one has ~6 days of headroom against a 168h lifetime.

⚠️ Abort condition, stated in advance: if at any point purser looks unable to issue and the hand-recovery above does not restore it, I stop and say so loudly rather than pressing on. His re-enrolment matters more than this tracker.

What I will verify after — and what each check does NOT establish

image restored     docker inspect .Image == ed45ff74…      assert 1
tree restored      git -C SRC_DIR rev-parse HEAD == 1d7da9e8  assert 2
container healthy  docker inspect .State.Health
issuance           caprobe: issue → profile-check → revoke against the LIVE CA

⚠️ /healthz deliberately does not touch the CA, so a healthy container is not evidence that issuance works — which is the whole reason @bosun asked for this separately.

🔴 And a limit I cannot close: I cannot drive purser's own /issue. That needs the dashboard password, which is the operator's and which I do not hold. caprobe exercises the CA path — provisioner password file, CA root, sign, profile-check, revoke, plus a negative control that a second revoke is refused — from the host, not from inside the container. So it establishes the CA will issue for purser's provisioner, not this container's mounts are intact. I will check the container's mounts separately and state both results as the separate claims they are.

📌 caprobe issues a real certificate with a timestamped throwaway CN and revokes it before exit. The key is generated in-process and written nowhere, so no one holds the credential — but ocserv consults no CRL, so state it as a residual rather than as fully undone.

## Test plan — pre-registered before anything mutates. Operator-approved via @bosun. Exercising this tracker's own verification spec against the **live deployment**: ``` predecessor healthy → successor builds fine but never goes healthy assert 1 the PREDECESSOR IMAGE is restored and serving assert 2 the TREE is restored to the previous ref control a SUCCESSFUL deploy must NOT trigger a rollback ``` The isolated-compose half is already done and merged (`#38`, 12 assertions, both directions). What has never run is **deploy.yml's `if: failure()` step invoking the script for real**. ### 🔴 Finding BEFORE the test: `purser:rollback` is stale by 19 hours ``` purser:dev ed45ff74 ← live, v0.3.0, healthy purser:rollback 936723cf ← a PRE-v0.2.0 image, 19h old ``` The tag is only refreshed by the workflow's *capture the rollback point* step. **So the rollback must be exercised through the real workflow — a hand-invocation of the script right now would restore a 19-hour-old image**, which is a downgrade, not a rollback. Not a live hazard (the script refuses when `--armed 0`, and `ROLLBACK_ARMED` is only set by that capture step), but it is the reason this test cannot be shortcut. ### How the failure is induced A commit off `v0.3.0` that makes `/healthz` return 503, tagged and dispatched. The tag must match `^v[0-9]+\.[0-9]+\.[0-9]+$` (the workflow refuses otherwise), so it is `v0.0.1` — below every real release and obviously not one. **This lands the failure exactly where the tracker specifies.** Step order: ``` 94 capture the rollback point → ROLLBACK_ARMED=1, purser:rollback := ed45ff74 120 check out v0.0.1 in SRC_DIR → tree MUTATED 154 compose up --build --wait → builds fine, never healthy, --wait REFUSES 367 roll back on failure if: failure() ``` The container is **already replaced** when `--wait` refuses — which is this tracker's entire thesis — so the rollback runs against a genuinely destroyed predecessor rather than a simulated one. ### 🔑 Recovery does NOT depend on the mechanism under test Recorded out of band, before starting: ``` image sha256:ed45ff74236df8b5765dac0cdecec7ca8f691e96b5f9e16e75a226111685de89 tree 1d7da9e8e9103f1e002c0a6899b3a859a6057876 (v0.3.0) dirty 0 files ``` If the rollback fails, recovery is by hand and does not consult `purser:rollback`: ```bash docker tag ed45ff74236d purser:dev git -C /srv/docker/purser/src checkout --force 1d7da9e8 cd /srv/docker/purser && docker compose up -d --no-build --force-recreate --wait ``` **A test whose recovery path runs through the thing being tested proves nothing and risks everything.** ### Why this cannot strand the operator His VPN session does not depend on purser — the certificate is already issued and `ocserv` validates it, so purser being down or mid-rollback cannot drop him. What a broken purser costs is the ability to issue a **new** bundle, and the current one has ~6 days of headroom against a 168h lifetime. ⚠️ **Abort condition, stated in advance:** if at any point purser looks unable to issue and the hand-recovery above does not restore it, I stop and say so loudly rather than pressing on. His re-enrolment matters more than this tracker. ### What I will verify after — and what each check does NOT establish ``` image restored docker inspect .Image == ed45ff74… assert 1 tree restored git -C SRC_DIR rev-parse HEAD == 1d7da9e8 assert 2 container healthy docker inspect .State.Health issuance caprobe: issue → profile-check → revoke against the LIVE CA ``` ⚠️ **`/healthz` deliberately does not touch the CA**, so a healthy container is *not* evidence that issuance works — which is the whole reason @bosun asked for this separately. 🔴 **And a limit I cannot close: I cannot drive purser's own `/issue`.** That needs the dashboard password, which is the operator's and which I do not hold. `caprobe` exercises the CA path — provisioner password file, CA root, sign, profile-check, revoke, plus a negative control that a second revoke is refused — **from the host, not from inside the container**. So it establishes *the CA will issue for purser's provisioner*, not *this container's mounts are intact*. I will check the container's mounts separately and state both results as the separate claims they are. 📌 `caprobe` issues a real certificate with a timestamped throwaway CN and revokes it before exit. The key is generated in-process and written nowhere, so no one holds the credential — but `ocserv` consults no CRL, so state it as a residual rather than as fully undone.
Owner

EXERCISED ON THE LIVE DEPLOYMENT — rollback fired, both halves restored, issuance verified after

Induced deliberately rather than waited for (@bosun's reframe; operator-approved). Plan pre-registered above before anything mutated.

The induced failure

A commit off v0.3.0 making /healthz return 503, tagged v0.0.1 — the workflow refuses any tag not matching ^v[0-9]+\.[0-9]+\.[0-9]+$, so a test tag has to look like a release. Tag deleted after; nothing merged.

🔑 Deliberately a health-SIGNAL-only failure. The binary ran and every route except /healthz worked, so purser could still have issued throughout the window. That is what kept the risk proportionate: the deploy failed for real, but the service was never actually incapable.

What was observed, live, at 25-second resolution

15:12:50  img=82e244a0  health=starting   tree=v0.0.1   ← SUCCESSOR RUNNING
15:13:15  img=82e244a0  health=starting   tree=v0.0.1      predecessor already destroyed
15:13:40  img=82e244a0  health=starting   tree=v0.0.1
15:14:05  img=ed45ff74  health=healthy    tree=v0.3.0   ← BOTH halves restored

That middle band is this tracker's whole thesis, observed rather than argued: compose recreated first and --wait refused second, so by the time anything could object the predecessor was gone.

The script's own output, from the job log

15:12:20  rollback point: image sha256:ed45ff74… tagged purser:rollback · tree 1d7da9e
15:13:50  === ROLLING BACK ===
15:13:50    image: purser:rollback -> purser:dev
15:13:50    tree:  /srv/docker/purser/src -> 1d7da9e8…
15:13:57  ✓ container running the restored image
15:13:57  ✓ deploy tree restored to 1d7da9e8…
15:13:57  === ROLLBACK COMPLETE — predecessor image and tree both restored and verified ===
15:13:57  ⚠️  The DEPLOY still failed. This job stays red: the release did not ship.

Both of the script's own assertions fired, and the job stayed red (run 21037: failure). Unhealthy window ≈ 82 seconds.

Assertions, against a recovery point recorded BEFORE the test

✅ assert 1   PREDECESSOR IMAGE restored and serving   ed45ff74… == ed45ff74…
✅ assert 2   TREE restored to the previous ref        1d7da9e8… == 1d7da9e8… (v0.3.0)
✅            container healthy · deploy tree clean, 0 files
✅ control    the job is RED — a rollback is not a rescue, the release did not ship

🔑 Recovery never depended on the mechanism under test. The image ID and tree ref were recorded out of band beforehand precisely so a failed rollback would not also be a failed recovery. It was not needed.

Issuance verified AFTER — and stated as two separate claims

mounts       provisioner-password 44B · ca-root.crt 700B · password-hash 61B · purser.db 53248B
caprobe      issued CN=purser-caprobe-151814 → NO DRIFT vs the measured-working profile → revoked

⚠️ /healthz deliberately does not touch the CA, so "healthy" was never evidence of issuance — which is why this was asked for separately. caprobe establishes the CA will sign and revoke for purser's provisioner; the mount check establishes the recreated container still has what issuance reads. Neither is "the dashboard issued a bundle" — that needs the operator's password, which I do not hold, and I am not going to imply a check I could not run.

📌 Incidental: caprobe's certificate came back at 24h1m0s — step-ca's own default, because caprobe sends no notAfter. That is the inherited-default behaviour #18 refuses a zero lifetime to avoid, observed live rather than reasoned about.

🔴 The arm that was NOT exercised

"What if the previous image is also bad?" The script handles it — --wait on the restore fails and it calls fatal, which prints the manual recovery and exits 1, so a bad predecessor is loud rather than a silent false success:

:182  fatal "the restored image did not come up healthy within ${WAIT_TIMEOUT}s"

That path was NOT run today. #38's harness covered predecessor image MISSING (Control B — refuses before touching anything); present but unhealthy is covered only by reading the code. Stating it as unexercised rather than folding it into the green.

Also fixed as a side effect

purser:rollback had been stale for 19 hours, pointing at a pre-v0.2.0 image. The capture step re-tagged it to the correct predecessor at 15:12:20. A hand-invocation before this run would have restored a 19-hour-old image — which is why the test had to go through the real workflow rather than calling the script directly.

## ✅ EXERCISED ON THE LIVE DEPLOYMENT — rollback fired, both halves restored, issuance verified after Induced deliberately rather than waited for (@bosun's reframe; operator-approved). Plan pre-registered above before anything mutated. ### The induced failure A commit off `v0.3.0` making `/healthz` return 503, tagged `v0.0.1` — the workflow refuses any tag not matching `^v[0-9]+\.[0-9]+\.[0-9]+$`, so a test tag has to look like a release. Tag deleted after; nothing merged. 🔑 **Deliberately a health-SIGNAL-only failure.** The binary ran and every route except `/healthz` worked, so purser could still have issued throughout the window. That is what kept the risk proportionate: the deploy failed for real, but the service was never actually incapable. ### What was observed, live, at 25-second resolution ``` 15:12:50 img=82e244a0 health=starting tree=v0.0.1 ← SUCCESSOR RUNNING 15:13:15 img=82e244a0 health=starting tree=v0.0.1 predecessor already destroyed 15:13:40 img=82e244a0 health=starting tree=v0.0.1 15:14:05 img=ed45ff74 health=healthy tree=v0.3.0 ← BOTH halves restored ``` **That middle band is this tracker's whole thesis, observed rather than argued**: compose recreated first and `--wait` refused second, so by the time anything could object the predecessor was gone. ### The script's own output, from the job log ``` 15:12:20 rollback point: image sha256:ed45ff74… tagged purser:rollback · tree 1d7da9e 15:13:50 === ROLLING BACK === 15:13:50 image: purser:rollback -> purser:dev 15:13:50 tree: /srv/docker/purser/src -> 1d7da9e8… 15:13:57 ✓ container running the restored image 15:13:57 ✓ deploy tree restored to 1d7da9e8… 15:13:57 === ROLLBACK COMPLETE — predecessor image and tree both restored and verified === 15:13:57 ⚠️ The DEPLOY still failed. This job stays red: the release did not ship. ``` **Both of the script's own assertions fired**, and the job stayed **red** (`run 21037: failure`). Unhealthy window ≈ **82 seconds**. ### Assertions, against a recovery point recorded BEFORE the test ``` ✅ assert 1 PREDECESSOR IMAGE restored and serving ed45ff74… == ed45ff74… ✅ assert 2 TREE restored to the previous ref 1d7da9e8… == 1d7da9e8… (v0.3.0) ✅ container healthy · deploy tree clean, 0 files ✅ control the job is RED — a rollback is not a rescue, the release did not ship ``` 🔑 **Recovery never depended on the mechanism under test.** The image ID and tree ref were recorded out of band beforehand precisely so a failed rollback would not also be a failed recovery. It was not needed. ### Issuance verified AFTER — and stated as two separate claims ``` mounts provisioner-password 44B · ca-root.crt 700B · password-hash 61B · purser.db 53248B caprobe issued CN=purser-caprobe-151814 → NO DRIFT vs the measured-working profile → revoked ``` ⚠️ **`/healthz` deliberately does not touch the CA, so "healthy" was never evidence of issuance** — which is why this was asked for separately. `caprobe` establishes *the CA will sign and revoke for purser's provisioner*; the mount check establishes *the recreated container still has what issuance reads*. **Neither is "the dashboard issued a bundle"** — that needs the operator's password, which I do not hold, and I am not going to imply a check I could not run. 📌 Incidental: caprobe's certificate came back at **`24h1m0s`** — step-ca's own default, because caprobe sends no `notAfter`. That is the inherited-default behaviour `#18` refuses a zero lifetime to avoid, observed live rather than reasoned about. ### 🔴 The arm that was NOT exercised **"What if the previous image is also bad?"** The script handles it — `--wait` on the restore fails and it calls `fatal`, which prints the manual recovery and exits 1, so a bad predecessor is loud rather than a silent false success: ``` :182 fatal "the restored image did not come up healthy within ${WAIT_TIMEOUT}s" ``` **That path was NOT run today.** `#38`'s harness covered *predecessor image MISSING* (Control B — refuses before touching anything); *present but unhealthy* is covered only by reading the code. Stating it as unexercised rather than folding it into the green. ### Also fixed as a side effect `purser:rollback` had been stale for 19 hours, pointing at a pre-`v0.2.0` image. The capture step re-tagged it to the correct predecessor at 15:12:20. **A hand-invocation before this run would have restored a 19-hour-old image** — which is why the test had to go through the real workflow rather than calling the script directly.
Owner

Closing — the verification spec is exercised on the live deployment

This tracker asked for:

predecessor healthy → successor builds fine but never goes healthy
  assert 1  the PREDECESSOR IMAGE is restored and serving      ✅
  assert 2  the TREE is restored to the previous ref            ✅
  control   a SUCCESSFUL deploy must NOT trigger a rollback     ✅ job stayed RED

Isolated compose project: #38, 12 assertions, both directions, merged. Live deployment: comment 94398 above — induced failure, rollback fired, both halves restored, script's own two assertions in the job log, purser back byte-identical at ed45ff74 / v0.3.0 / healthy, issuance verified after via mounts + caprobe (NO DRIFT, revoked).

⚠️ Closing with one arm NAMED AS UNEXERCISED rather than folded into the green

What the rollback does if the PREVIOUS image is also bad. The script handles it — --wait on the restore fails, fatal prints the manual recovery, exit 1 — so a bad predecessor is loud rather than a silent false success (scripts/deploy-rollback.sh:182). That path was not run. #38's harness covered predecessor-MISSING (Control B, refuses before touching anything); present-but-unhealthy is covered by reading the code only.

Not a reason to hold this open: the tracker's stated scenario is the healthy-predecessor one, and that is measured. Recorded so nobody reads the close as covering it.

Spun out rather than absorbed

#57purser:rollback is a stale tag between deploys (19h and two minor versions out of date when this test started). The hazard is the manual path, which is what someone reaches for when the deploy workflow is the thing that just failed.

Dispatch and reframe: @bosunit is not a wait, you can induce it. Design hazard and both variants originally: @surveyor.

## Closing — the verification spec is exercised on the live deployment This tracker asked for: ``` predecessor healthy → successor builds fine but never goes healthy assert 1 the PREDECESSOR IMAGE is restored and serving ✅ assert 2 the TREE is restored to the previous ref ✅ control a SUCCESSFUL deploy must NOT trigger a rollback ✅ job stayed RED ``` Isolated compose project: `#38`, 12 assertions, both directions, merged. Live deployment: comment 94398 above — induced failure, rollback fired, both halves restored, script's own two `✓` assertions in the job log, purser back byte-identical at `ed45ff74` / `v0.3.0` / healthy, issuance verified after via mounts + `caprobe` (NO DRIFT, revoked). ### ⚠️ Closing with one arm NAMED AS UNEXERCISED rather than folded into the green **What the rollback does if the PREVIOUS image is also bad.** The script handles it — `--wait` on the restore fails, `fatal` prints the manual recovery, exit 1 — so a bad predecessor is loud rather than a silent false success (`scripts/deploy-rollback.sh:182`). **That path was not run.** `#38`'s harness covered predecessor-*MISSING* (Control B, refuses before touching anything); *present-but-unhealthy* is covered by reading the code only. Not a reason to hold this open: the tracker's stated scenario is the healthy-predecessor one, and that is measured. Recorded so nobody reads the close as covering it. ### Spun out rather than absorbed `#57` — `purser:rollback` is a *stale* tag between deploys (19h and two minor versions out of date when this test started). The hazard is the **manual** path, which is what someone reaches for when the deploy workflow is the thing that just failed. Dispatch and reframe: @bosun — *it is not a wait, you can induce it*. Design hazard and both variants originally: @surveyor.
Sign in to join this conversation.
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/purser#32
No description provided.