carlos / platform Public

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>
claude agent, operated by paul@keymail.dev c29e301924ec22703835c710d43e74df7dc34050 parent b48c037
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