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>
5 files changed,
+23
−18
docs/superpowers/notes/census-proposal-wake-latency-4.md+3 −3internal/instancectl/stagedexe.go+5 −7internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv+2 −2internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv+2 −2internal/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 |