spec: instance update settings rev 8 — §4.6 digest-bound channel pointers (closes #253, at Paul's request)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+63
−2
docs/superpowers/specs/2026-10-07-instance-update-settings-design.md+63 −2
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md b/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md| index 39aa0dcbe..070772d26 100644 |
| --- a/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md |
| +++ b/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md |
| @@ -1,6 +1,6 @@ |
| # Instance update settings: auto-update, channel, major hold, pin |
| -Status: rev 7, 2026-10-07. **Accepted by Astra** (S5: accept with fixes, the one fix applied in rev 7). Awaiting Paul's review. Split out of the zero-downtime deploys |
| +Status: rev 8, 2026-10-07. Rev 7 was accepted by Astra (S5: accept with fixes, the fix applied) and **approved by Paul**. Rev 8 adds §4.6, which closes #253 at Paul's request, and is in review for that section. Split out of the zero-downtime deploys |
| spec (`2026-10-07-zero-downtime-deploys-design.md`) at its rev 6. Rev 1 was |
| a design with console-signed per-instance directives. Astra's round 6 |
| (`CODEX-FINDINGS-ZERO-DOWNTIME-R6.md`) rejected it with 6 findings. |
| @@ -132,7 +132,8 @@ that V was authorized for A:** |
| Pointers sign version names without digests, so a replayed old pointer |
| plus a reused version name could authorize bytes nobody promoted. That |
| weakness exists in the release chain today, independent of this spec. It |
| -is filed as #253, and this spec never widens it. |
| +is filed as #253. This spec never widens it, and §4.6 closes it for |
| +ordinary adoption too. |
| **Builds promoted before this feature have no promotion record, and |
| nothing is backfilled automatically.** Signing whatever is in the bucket |
| @@ -277,6 +278,56 @@ build its box holds as failed unless it has already served that build. |
| The console refuses "Update now" on a pinned instance, naming the pin. |
| +### 4.6 Digest-bound channel pointers (#253) |
| + |
| +Paul asked on 2026-10-07 for #253 to be part of this work. Today a signed |
| +pointer names versions without digests. So an old pointer, together with |
| +a version name that was pruned and then shipped again with other bytes, |
| +installs bytes nobody promoted. |
| + |
| +**The pointer carries digests.** `ChannelPointer` gains: |
| +- `digest`: the sha256 of the canonical manifest bytes of `Version`; |
| +- `history_digests`: one per `History` entry, in the same order. |
| + |
| +These are additive, like `account` was (`internal/release/release.go`). |
| +The signature covers the raw payload bytes, so binaries that predate the |
| +fields ignore them and verify as before. Every promotion and rollback |
| +writes them. A rollback swaps the digests along with the versions. They |
| +are signed by whoever signs the pointer today, the root key or a scoped |
| +grant. A promotion record signed under a scoped grant carries the same |
| +grant chain. |
| + |
| +**Root checks before staging.** When the pointer that authorizes a build |
| +carries a digest for it, root checks that the canonical manifest hashes to |
| +that digest before staging. Staging's artifact sha check then binds the |
| +bytes. On a mismatch, root refuses the build and reports it. This covers |
| +ordinary head adoption, history-based adoption and rollbacks. |
| + |
| +**Pointers without digests.** Every pointer written before this change has |
| +none. Three things close that gap without trusting the bucket's current |
| +contents automatically: |
| +- **A per-box ratchet.** Once a box has verified a pointer with digests for |
| + a channel, it refuses pointers without them for that channel. The mark |
| + is root-owned at `/opt/carlos/pointer-digests/<acct>/<app>/<channel>`. |
| + This stops a saved old pointer being replayed to a box that has seen |
| + the new kind. |
| +- **New promotions** write digests, so active channels gain them as they |
| + ship. |
| +- **`carlos channels resign <app> <channel>`** re-signs a quiet channel's |
| + current pointer with digests. Like `carlos releases authorize`, it shows |
| + the manifest digests and signs only on an operator's confirmation. |
| + |
| +**The fleet-wide switch.** `CARLOS_REQUIRE_POINTER_DIGEST` is a box flag, |
| +off by default. With it on, a box refuses any pointer without digests, |
| +even for a channel it has never seen. That closes the fresh-box case. The |
| +console lists the channels still lacking digests. The flag goes on once |
| +that list is empty, and the roll docs say so. |
| + |
| +**What stays as it is.** Bytes are still trusted when they are first |
| +promoted, as today: the console signs the digest of the manifest the |
| +publisher shipped, at promotion time. This change stops anyone |
| +substituting bytes after that point. |
| + |
| ### 4.4 Retention |
| Bucket pruning (`internal/release/prune.go`, called from |
| @@ -385,6 +436,16 @@ build whatever these settings say. |
| - **Returning residence:** a held instance moving back to a box with a |
| stale live link installs `last_executed`. The wake never runs the stale |
| link. |
| +- **Pointer digests (#253):** |
| + - a reused version name under a replayed digest-bearing pointer is |
| + refused before staging; |
| + - an old binary verifies a pointer with digests; |
| + - a box that has seen digests refuses a pointer without them for that |
| + channel; |
| + - with `CARLOS_REQUIRE_POINTER_DIGEST` on, a fresh box refuses pointers |
| + without digests; |
| + - a rollback carries the digests; |
| + - a pointer signed under a scoped grant carries digests. |
| - **Evidence:** a pin of a pruned and reused version name, with a replayed |
| pointer, is refused, because it lacks a matching promotion record. A |
| build promoted before the feature is recovered from a staged copy, is |