carlos / platform Public

instancectl: root hashes a staged build in full before every bind; only the agent's check caches (Paul's ruling on Astra P2)

Astra: a write through an already-writable mapping changes a file's bytes
without moving any timestamp, so no file identity proves the bytes are the
ones hashed. Root's staged-exe check is the boundary before a build is
bound into a tenant's namespace, so it hashes in full again; the agent's
VerifyStaged (a duplicate of it on the nested path) keeps the cache.
Census rows updated to say the agent is the only caller.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a120f49361ea4bc0eb72dd82f50c9c7bf4195e26 parent 3e63469
5 files changed, +23 −18
  • docs/superpowers/notes/census-proposal-wake-latency-4.md +3 −3
  • internal/instancectl/stagedexe.go +5 −7
  • internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv +2 −2
  • internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv +2 −2
  • internal/release/digestcache.go +11 −4

CI failed — run details

CI log
amadan: sync for CI failed: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429
fatal: expected 'acknowledgments'
 (and could not reset the workspace: unlinkat /var/lib/amadan-runner/w2/repos/carlos__platform/cache/go-mod/github.com/benbjohnson/litestream@v0.5.14/restore_fuzz_test.go: permission denied)
diff --git a/docs/superpowers/notes/census-proposal-wake-latency-4.md b/docs/superpowers/notes/census-proposal-wake-latency-4.md
index c0c4f3e21..d5afb0b24 100644
--- a/docs/superpowers/notes/census-proposal-wake-latency-4.md
+++ b/docs/superpowers/notes/census-proposal-wake-latency-4.md
@@ -14,7 +14,7 @@ Overlay: `internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv`, app
| unit | reads | proposed disposition |
|---|---|---|
-| `FileSHA256Cached` | `file:Open`: opens the staged build it is asked about, as `FileSHA256` does | `authority`, B3,J4, `root-input;kept=B3;scope=protected-decision-only`, the same as `FileSHA256` |
-| `SHA256Cached` | `file:Stat` ×2: fstats the descriptor the caller opened, before and after hashing it | the same |
+| `FileSHA256Cached` | `file:Open`: opens the staged build `VerifyStaged` names, as `FileSHA256` does | `authority`, B3,J4, `root-input;kept=B3;scope=protected-decision-only`, the same as `FileSHA256` |
+| `SHA256Cached` | `file:Stat` ×2: fstats that descriptor before and after hashing it | the same |
-The callers are the same as `FileSHA256`'s: the agent's `VerifyStaged`, and root instance control's staged-exe check, which opens the file beneath the version store without following a symlink. What changes is that the digest is reused for an unchanged file identity: device, inode, size, mtime and ctime. A file changed in the last two seconds, or while it was being hashed, is never cached.
+Only the agent calls these, through `release.VerifyStaged`, which is its duplicate of root's check on the nested path. Root instance control's staged-exe check is unchanged: it hashes in full before every bind into a tenant's namespace. That was Paul's ruling on 2026-10-25, after Astra's finding that a write through an already-writable mapping changes the bytes without moving a timestamp. The agent reuses a digest for an unchanged file identity (device, inode, size, mtime, ctime). A file changed in the last two seconds, or while it was being hashed, is never cached.
diff --git a/internal/instancectl/stagedexe.go b/internal/instancectl/stagedexe.go
index 2416e884c..f5753917f 100644
--- a/internal/instancectl/stagedexe.go
+++ b/internal/instancectl/stagedexe.go
@@ -2,8 +2,10 @@ package instancectl
import (
"crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
+ "io"
"net/http"
"os"
"path/filepath"
@@ -65,15 +67,11 @@ func (s *Server) validateStagedExe(host string, x StagedExe) error {
return fmt.Errorf("%w: %v", ErrStagedExe, err)
}
defer f.Close()
- // Hashed once per identity of the opened file (release.SHA256Cached):
- // root alone writes the version store, a write moves the file's ctime
- // and a reinstall is a new inode, so an unchanged identity is the bytes
- // already checked.
- got, err := release.SHA256Cached(f)
- if err != nil {
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("%w: %v", ErrStagedExe, err)
}
- if got != strings.ToLower(x.Digest) {
+ if got := hex.EncodeToString(h.Sum(nil)); got != strings.ToLower(x.Digest) {
return fmt.Errorf("%w: %s has digest %s, the attempt verified %s", ErrStagedExe, want, got, x.Digest)
}
return nil
diff --git a/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv b/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv
index 702c667d8..615db99c4 100644
--- a/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv
+++ b/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv
@@ -1,3 +1,3 @@
# Census overlay wake-latency-4-new (wake latency pass 2c), hand-written: the two new units census-propose leaves to a decision. census-ruling-wake-latency-4, asked of the census owner with the approval card (docs/superpowers/notes/census-proposal-wake-latency-4.md).
-internal/release/digestcache.go FileSHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent (VerifyStaged) and root instance control (the staged-exe check), as FileSHA256;basis=FileSHA256 with SHA256Cached: opens the staged build it is asked about and hashes it, or returns the digest cached for the same file identity; decides only the digest the caller compares file:Open@64 3a4557e9a9aef30afd681962f77cff0586a787f95d664c426c8f2b3d70e08cb4
-internal/release/digestcache.go SHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent and root instance control, on a descriptor the caller opened (root: beneath the version store, no symlink followed);basis=fstats the open file before and after hashing it to key a digest by device, inode, size, mtime and ctime; a file changed in the last two seconds, or while hashed, is not cached; decides only whether the digest is reused file:Stat@60,file:Stat@645 f965eb2656c2356e210d84946e70a1f9498aee542c21f3d2b7cb926fd4fa09d7
+internal/release/digestcache.go FileSHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent (/opt/carlos/carlos agent, user carlos), through release.VerifyStaged only;basis=FileSHA256 with SHA256Cached: opens the staged build VerifyStaged names and hashes it, or returns the digest cached for the same file identity; root instance control does not use it and hashes in full before every bind (Paul, 2026-10-25); decides only the digest VerifyStaged compares file:Open@64 3a4557e9a9aef30afd681962f77cff0586a787f95d664c426c8f2b3d70e08cb4
+internal/release/digestcache.go SHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent, through FileSHA256Cached only;basis=fstats the open file before and after hashing it to key a digest by device, inode, size, mtime and ctime; a file changed in the last two seconds, or while hashed, is not cached; decides only whether VerifyStaged reuses a digest; root never calls it file:Stat@60,file:Stat@645 f965eb2656c2356e210d84946e70a1f9498aee542c21f3d2b7cb926fd4fa09d7
diff --git a/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv b/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv
index 738a9ab0c..6aef32fd9 100644
--- a/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv
+++ b/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv
@@ -1,4 +1,4 @@
# Census overlay wake-latency-4, written by census-propose (#369). census-ruling-wake-latency-4, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-wake-latency-4.md.
# 0 body, 0 sites, 2 envelope: each changed unit re-frozen at its existing disposition with its claim appended.
-@coverage internal/instancectl c925445f5c5b0d753659f0ac645d3620fcdc2282ef9fa3ca3ea268b42550e799
-@coverage internal/release c13f62764f4f47b21c39a2b96f23d457f1fc01311b7e414e7f14c1ab8a4ec22c
+@coverage internal/instancectl c9d70004f78997724526ab2acdc80f4bdd1a6fe4621f911440ba1c2eb547c3a0
+@coverage internal/release e52b41652b566af823ceb76758edce99b7055eb6bed7df5945bf1d814e5acc73
diff --git a/internal/release/digestcache.go b/internal/release/digestcache.go
index 327cb7b36..b63e1bb42 100644
--- a/internal/release/digestcache.go
+++ b/internal/release/digestcache.go
@@ -12,12 +12,19 @@ import (
// A staged build was hashed whole on every wake, by the agent
// (VerifyStaged) and again by root (instance control's staged-exe check):
// 24 ms for a 9 MB binary on a dev box, so a 100 MB bundle paid a quarter
-// of a second a wake. The digest is now kept per file identity: device,
+// of a second a wake. The agent's check now keeps its digest per file
+// identity. Root's check does not, and hashes in full before every bind
+// into a tenant's namespace (Paul's ruling, 2026-10-25, on Astra's
+// finding): a write through a mapping that is already writable changes the
+// bytes without moving a timestamp, so no identity proves a file
+// unchanged, and root's check is the boundary. The agent's is a duplicate
+// of it on the nested path. The identity is: device,
// inode, size, and the modification and change times to the nanosecond,
-// read from the descriptor being hashed. A write to the file moves its
+// read from the descriptor being hashed. An ordinary write moves its
// ctime, which no unprivileged caller can set back, and a replacement by
-// rename (how the version store installs a build) is a new inode, so any
-// change is a new identity and is hashed again. Where the platform gives no
+// rename (how the version store installs a build) is a new inode, so such
+// a change is a new identity and is hashed again; a write through an
+// existing writable mapping may not be, which is why root does not cache. Where the platform gives no
// such identity (fileIdentity is linux-only), every call hashes, as before.
//
// File times are coarse (a clock tick), so a file changed moments before