carlos / platform Public

zdd-owed-items

Squashed from zdd-owed-items into main.
claude agent, operated by paul@keymail.dev 1c5718f695c72e47bd4a3b13b0abfde49ae765f3 parent 132cf99
13 files changed, +769 −203
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md +119 −128
  • internal/activator/activator.go +8 −3
  • internal/activator/backend_exec.go +14 −1
  • internal/activator/beforeexec.go +158 −32
  • internal/activator/beforeexec_test.go +52 −7
  • internal/activator/genbackend.go +52 −9
  • internal/activator/wakeidentity_test.go +24 −12
  • internal/activator/wakerecord_recheck_test.go +168 −0
  • internal/activator/wakerecord_refused_test.go +137 −0
  • internal/host/buildstate_planned_test.go +6 −1
  • internal/provision/plan14_root_reads_test.go +12 −0
  • internal/provision/testdata/plan14-root-reads-census-ruling-owed.tsv +13 −0
  • internal/release/staged.go +6 −10

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	278.557s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.585s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.238s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.710s
ok  	github.com/carlosframework/platform/internal/activator	238.893s
ok  	github.com/carlosframework/platform/internal/agentsock	0.019s
ok  	github.com/carlosframework/platform/internal/alerting	0.006s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.322s
ok  	github.com/carlosframework/platform/internal/apperr	0.084s
ok  	github.com/carlosframework/platform/internal/applog	0.136s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.849s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.015s
ok  	github.com/carlosframework/platform/internal/certwarm	0.003s
ok  	github.com/carlosframework/platform/internal/cgroup	0.701s
ok  	github.com/carlosframework/platform/internal/changelog	0.312s
ok  	github.com/carlosframework/platform/internal/clamd	0.057s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	361.140s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.379s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.004s
ok  	github.com/carlosframework/platform/internal/domains	2.261s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.003s
ok  	github.com/carlosframework/platform/internal/edge	26.026s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.120s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.021s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/host	126.826s
ok  	github.com/carlosframework/platform/internal/hostactors	0.386s
ok  	github.com/carlosframework/platform/internal/hostbox	2.133s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.506s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	4.806s
ok  	github.com/carlosframework/platform/internal/hostfence	0.029s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.555s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	1.002s
ok  	github.com/carlosframework/platform/internal/hostops	1.399s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.006s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.452s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.137s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.004s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	3.495s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.704s
ok  	github.com/carlosframework/platform/internal/instancefs	0.004s
ok  	github.com/carlosframework/platform/internal/instancekey	0.002s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.008s
ok  	github.com/carlosframework/platform/internal/layout	1.401s
ok  	github.com/carlosframework/platform/internal/lease	9.785s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.008s
ok  	github.com/carlosframework/platform/internal/ledger	0.030s
ok  	github.com/carlosframework/platform/internal/mail	0.153s
ok  	github.com/carlosframework/platform/internal/maintenance	0.084s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.549s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.066s
ok  	github.com/carlosframework/platform/internal/metrics	0.008s
ok  	github.com/carlosframework/platform/internal/moves	20.961s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.752s
ok  	github.com/carlosframework/platform/internal/objstore	0.164s
ok  	github.com/carlosframework/platform/internal/ops	0.169s
ok  	github.com/carlosframework/platform/internal/park	0.199s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	12.909s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.026s
ok  	github.com/carlosframework/platform/internal/relay	7.233s
ok  	github.com/carlosframework/platform/internal/release	19.383s
ok  	github.com/carlosframework/platform/internal/rollout	3.719s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.007s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.304s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.350s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.120s
ok  	github.com/carlosframework/platform/internal/status	0.043s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.215s
ok  	github.com/carlosframework/platform/internal/svcrole	0.005s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.003s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.006s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.545s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 45744290 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.021s
=== RUN   TestT4StorageProbeMinio
2026/10/15 06:45:01 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEA0FB13C35BCD, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
index 48548fc37..398479619 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
@@ -29,21 +29,55 @@ plans 1 to 6, in `platform-design-zero-downtime-deploys`.
## Before Gate E: owed
-This is the flag-on precondition. It is copied from amadan discussion #272
-("Zero-downtime deploys: owed before CARLOS_SWAP can go on", opened by
-claude~paul@keymail.dev), as read on 2026-10-11, including its replies.
-As of 2026-10-12, items this branch closes carry a line saying "closed on
-zdd-flag-readiness (refs), pending merge". Those are built and task-reviewed on
-the branch, and the whole-branch review's first round (Astra,
-`CODEX-FINDINGS-ZDD-READINESS.md`) found gaps in several of them, which its
-fix wave closed (the commits cited as "final review round 1"). They are not
-merged, not released and not rolled, and only Paul closes them on #272. Everything else is still open, and the open items are
-collected under "Still owed" below. **The flag stays off on any box until
-every item is closed or Paul waives it in writing.** Gates A to D do not
-turn the flag on and can go ahead while this list is open.
-
-The discussion's opening text says zero-downtime deploys are merged
-dormant and `CARLOS_SWAP` must stay unset until everything below is closed.
+**Current state, 2026-10-14.** This list is copied from amadan discussion
+#272 ("Zero-downtime deploys: owed before CARLOS_SWAP can go on"). Every
+item on it that blocked the flag is now closed, or is one of the open items
+named below. The subsections after this one are the history of how each
+item closed, kept for their commit references. Where they say "pending
+merge", read the state here instead.
+
+- **Merged and released.** Readiness rounds 1 to 3 (`zdd-flag-readiness`,
+ `zdd-flag-readiness-2` and `zdd-flag-readiness-3`). Rev 19, the generation
+ socket names, is merged as `ebddcfc` and released in v0.27.2. #292 is
+ merged as `e15ad6b` and released in v0.27.3.
+- **The flag.** Gate F passed on annie-welding, and Gate G armed the other
+ three flagship boxes. All four flagship boxes are armed as of 2026-10-14.
+ Dev is being armed on 2026-10-14.
+- **Kernel checks: closed.** On 2026-10-14 a root test runner ran them on
+ Debian 13 arm64, kernel 6.12.96, origin/main `2683f16a`, as `User=carlos`
+ with `Delegate=yes` and `cpuset cpu io memory pids` in the unit's
+ `subtree_control`. All passed and none skipped:
+ - `TestClaimContainmentCleanupRespawn`, all three subtests;
+ - `TestGenerationStartRefusedBesideADirectParentMember`, with its sibling
+ and recovery controls;
+ - the `ensureParent` positive control, which is `internal/cgroup`'s
+ `TestGenKernelGenerationRefusedBesideAFlatMember`
+ (`CARLOS_GEN_KERNEL=1`) and `TestGenKernelShimRespawnAfterKill`
+ (`CARLOS_SHIM_KERNEL=1`).
+
+ The details are on #272.
+- **Mixed-version retries.** Dev round 4 passed three cases: a new box with
+ a new console, a book that had not saved, and an old box with a new
+ console. A new box with an old console was not run. That case is covered
+ by construction. An old console can only write an unsigned retry, and
+ every new box ignores an unsigned retry, so the worst it can do is a retry
+ that does nothing.
+- **Owed items from settings plan 4** (branch `zdd-owed-items`):
+ - Closed: a cold wake's build-state record is now written at the exec,
+ after the replica-key gate and the cgroup refusals, so a refused launch
+ leaves no record. The exec is decided again after the write, and a
+ nested seed writes its obligation before its record (Astra's review of
+ the branch, P1 and P2). This was owed before O8 (replica keys) is
+ switched on.
+ - Closed: `release.ResolveLivePath` now calls `release.ResolveLiveLink`.
+ The two had identical bodies.
+ - MOVED to #250: leftover `consumed/` files refuse the claims census. See
+ "Still owed (code)".
+- **Claims-on swaps** remain a future feature. A box with host claims on
+ declines the flag with the reason code `host-claims-on`.
+
+The flag stays off on any box that has not passed its gate, and only Paul
+closes items on #272.
### Correctness, from Astra's plan 5 review (`CODEX-FINDINGS-ZDD-IMPL-5*.md`)
@@ -140,18 +174,15 @@ dormant and `CARLOS_SWAP` must stay unset until everything below is closed.
#### Dev-box flag blockers
-Two defects blocked the flag on the dev box. Both are fixed in code on
-zdd-flag-readiness, pending merge. Their tests are kernel-gated and skip on
-the build machine, so neither fix has been seen working on a real kernel.
-The dev-box rerun is owed. Both kernel tests now start through the shim, and
-zdd-flag-readiness-3 notes that they cannot run on DOCS-T1 (see "Roll notes
-from zdd-flag-readiness-3", dev re-check step 7).
+Two defects blocked the flag on the dev box. Both are fixed, merged and
+released, and both kernel tests passed on a real kernel on 2026-10-14 (see
+"Kernel checks" at the top of this section).
-- The legacy-parent guard (2c5adbe8). Code fixed. Its kernel test
- `TestGenerationStartRefusedBesideADirectParentMember` needs the rerun.
-- The `cgroup.kill` respawn (c76a1d44, 66c92efe). Code fixed: a cgroup that
- took `cgroup.kill` is recreated, and KillHost and KillSidecar respawn.
- Its kernel test `TestClaimContainmentCleanupRespawn` needs the rerun.
+- The legacy-parent guard (2c5adbe8). Its kernel test is
+ `TestGenerationStartRefusedBesideADirectParentMember`.
+- The `cgroup.kill` respawn (c76a1d44, 66c92efe). A cgroup that took
+ `cgroup.kill` is recreated, and KillHost and KillSidecar respawn. Its
+ kernel test is `TestClaimContainmentCleanupRespawn`.
### Surfaces (plan 6)
@@ -241,109 +272,69 @@ two builds side by side, so it restarts instances the old way". The code
`host-claims-on` is in the instance's JSON record (`rollout.unavailable`) and
in the agent log line `REFUSING CARLOS_SWAP=overlap (host-claims-on)`.
-### Still owed (code), after zdd-flag-readiness
-
-These stay open after the branch, and each one still blocks the flag
-unless Paul waives it. Astra's whole-branch review, round 1
-(`CODEX-FINDINGS-ZDD-READINESS.md`), classes each of them as a flag
-blocker, and the fix wave for that round did not close them.
-
-Readiness round 2 (branch `zdd-flag-readiness-2`, from `650e6152`, pending
-merge) closes most of them. A closed item below says "Closed on
-zdd-flag-readiness-2 (<commit>), pending merge". That means built and
-task-reviewed on the branch: not merged, not released, not rolled, and only
-Paul closes it on #272. The branch's whole-branch review (Astra) and the
-3000-seed rollout-simulation sweep come after these notes. Still open or
-still owed after the branch: claims-on swaps (a future feature); the
-`ensureParent` kernel positive control; and the dev mixed-version check (see
-"Real-box checks"). Paul approved the spec amendment (rev 18) and the seven
-declined-box and old-box strings, unchanged, on 2026-10-13 (the copy-apply
-commit on the branch, "copy: readiness round 2's seven strings, approved by
-Paul unchanged; rev-18 amendment approved"). The code for the other items
-is closed on the branch, pending merge.
-
-Readiness round 2's dev check (2026-10-13) then found four blockers, and a
-fifth defect that was only untidy. All of them are closed in code on
-zdd-flag-readiness-3 (`ff4b814a` to `496fb6ed`), pending merge, and they still
-block the flag until Paul's dev re-check passes. They are: the edge unit's
-`RestrictNamespaces=yes` made every generation placement fail (the exec-shim);
-arming did not check placement (`cgroup-placement-failed`); the probe app
-rejected `--db` (`cmd/zddprobe`); and `TestStrictKernel` had a defect. The
-untidy one is failed generation units lingering in `systemctl --failed`.
-Commits and the re-check are in "Roll notes from zdd-flag-readiness-3".
-
-- Nested live-link race on a declined box: needs an expected identity on
- `Control.Start`, root-side (task 9). Closed on zdd-flag-readiness-2
- (`903ed7ca`, `c910d020`), pending merge: a declined box pins the live link
- once (its identity and the exec gate's verdict) and nested starts and
- seeds exec the pinned build.
-- Seeds exec the live link ungated on a declined box (task 9). A failed
- seed's descendants are not proven (flag off and on), and `removeSeedDB`
- can delete a DB a descendant still writes (task 15). Closed on
- zdd-flag-readiness-2 (`903ed7ca` for the gate; `4c5e43b5`, `5e51c008`,
- `4771d147` for the proof), pending merge: a seed's processes are proven
- gone before its database goes, nested seeds included, and an unproven seed
- leaves a durable obligation (see the roll note on `.seed-unproven`).
-- A legacy sidecar left stranded beside a retained incumbent has no
- recovery path yet (task 9). Closed on zdd-flag-readiness-2 (`c910d020`),
- pending merge: a refused sidecar restarts on the instance's own build.
-- A clean one-shot's good build is published without checking that the
- activator still owns the host and that no fence or lost lease came first,
- and a WakeWorker can still start a duplicate wake in a narrow window
- (task 12). Closed on zdd-flag-readiness-2 (`deb4c811`), pending merge: a
- one-shot is recorded good only after a clean park, and a waiting wake
- never starts a second.
-- The flag-off normal-stop half of F6: a normal stop still proves only the
- leader (task 15). Closed on zdd-flag-readiness-2 (`d6996537`, `ac9f3fef`,
- `097ca930`, `79032948`), pending merge: seeds and flag-off stops prove
- their processes gone before the lease goes, and a flag-off wake proves its
- host cgroup empty before it restores. This is an authorized flag-off
- change: one `cgroup.events` read per flag-off stop.
-- `ensureParent`'s other failures still run a generation uncapped: no
- `+memory`, EBUSY or EACCES on `subtree_control`, the mkdir fallback, and
- `applyLimit` on `g<n>`. The kernel positive control (a real cgroupfs
- showing the populated-parent refusal and the respawn work) is also owed
- (task 14). The code is closed on zdd-flag-readiness-2 (`98593909`,
- `8530210e`), pending merge: a generation whose limits cannot be applied
- does not start (`cgroup.ErrLimitNotApplied`). The kernel positive control
- is still owed (see "Real-box checks").
-- The console instance page shows no hold row or Retry button for a declined
- box's hold. This needs copy. Closed on zdd-flag-readiness-2 (`6e6ad000`,
- `65d4e135`), pending merge. Paul approved the seven new strings
- unchanged on 2026-10-13 (the copy-apply commit; record
- `docs/superpowers/plans/2026-10-12-zero-downtime-flag-readiness-2-copy.json`).
+### Still owed (code)
+
+**Current state, 2026-10-14.** Readiness rounds 1 to 3 are merged and
+released, so every item this section listed after zdd-flag-readiness is
+closed on main. Rev 19 (`ebddcfc`, v0.27.2) and #292 (`e15ad6b`, v0.27.3)
+are merged and released too. The kernel checks that were owed here are
+closed (see "Kernel checks" at the top of "Before Gate E: owed").
+
+Closed, with the commits that closed them:
+
+- The nested live-link race on a declined box (`903ed7ca`, `c910d020`). A
+ declined box pins the live link once, and nested starts and seeds exec the
+ pinned build.
+- Seeds that exec'd the live link ungated, and the failed seed's proof
+ (`903ed7ca`; `4c5e43b5`, `5e51c008`, `4771d147`). A seed's processes are
+ proven gone before its database goes, and an unproven seed leaves a
+ durable obligation.
+- The stranded legacy sidecar (`c910d020`). A refused sidecar restarts on the
+ instance's own build.
+- The one-shot's good build and WakeWorker's duplicate wake (`deb4c811`).
+- The flag-off normal-stop half of F6 (`d6996537`, `ac9f3fef`, `097ca930`,
+ `79032948`).
+- `ensureParent`'s uncapped paths (`98593909`, `8530210e`). A generation
+ whose limits cannot be applied does not start (`cgroup.ErrLimitNotApplied`).
+ Its kernel positive control passed on 2026-10-14.
+- The declined box's hold row and Retry button (`6e6ad000`, `65d4e135`). Paul
+ approved the seven strings on 2026-10-13.
+- The rev-18 spec amendment for console-signed host retries, approved by
+ Paul on 2026-10-13. The design branch's spec still has to be raised to rev
+ 18 there.
+- The reopen epoch, now the hard rule (`7c7c6141`, `191c12b9`, `692d1533`,
+ `a6eb8b35`). The proof and its two assumptions are on `FailureBook`
+ (`internal/rollout/failures.go`).
+- Mixed-version retry sequencing (`6e9e84bd`, `9f27cdfa`, `4d8957c1`,
+ `18bde560`). Grants ride their own object. Dev round 4 checked the
+ transitions; the one not run is covered by construction (see "Mixed-version
+ retries" at the top of "Before Gate E: owed").
+- Readiness round 2's dev blockers, closed by readiness round 3: the
+ exec-shim for `RestrictNamespaces=yes`, the `cgroup-placement-failed` arming
+ check, `cmd/zddprobe` and the `TestStrictKernel` fix.
+- Task 10's grant consumed while the row is moving or landing (`076bd9c0`).
+- From settings plan 4, on branch `zdd-owed-items`: a cold wake's build-state
+ record is written at the exec, after the replica-key gate and the cgroup
+ refusals, so a refused launch leaves no record (owed before O8). The exec
+ is decided again after the write, and a nested seed writes its obligation
+ first.
+- Also from settings plan 4, on the same branch: `release.ResolveLivePath`
+ calls `release.ResolveLiveLink`.
+
+Still open:
+
- Claims-on swaps (the four gaps from `CODEX-FINDINGS-ZDD-IMPL-5-MERGE.md`).
- A future feature. Still open and not scheduled.
-- Spec section 11 still says a retry is a re-signed pointer. The spec needs a
- revision for console-signed host-retry grants. Drafted on
- zdd-flag-readiness-2 as
- `docs/superpowers/specs/2026-10-12-zero-downtime-deploys-rev18-amendment.md`
- (this commit), approved by Paul 2026-10-13 (the copy-apply commit). The
- design branch's spec is not edited from this branch: the copy step that
- raises it to rev 18 is still to do there.
-- The reopen epoch bound (c12c35c3), now the hard rule. Paul's ruling
- (2026-10-12): a stale grant must never clear a later failure, including
- across a crash. Closed on zdd-flag-readiness-2 (`7c7c6141`, `191c12b9`),
- pending merge. The rule: the box keeps one restriction generation per app,
- moved by every change to any of the app's restrictions; the book is saved
- before a generation is published, a save that fails leaves it unpublished
- (epoch 0, so no Retry is offered) and is retried on its own; a reopen
- starts past every saved value; and a failure book written by an earlier
- agent is given a new identity when adopted, so earlier grants name the old
- one. The 2^20 advance on every open is gone. The proof sketch and its two
- assumptions are in the doc comment on `FailureBook`
- (`internal/rollout/failures.go`). The cost if the ruling is wrong: a box
- with a failing disk shows holds with no Retry until a save lands.
-- Mixed-version retry sequencing: the order in which boxes and consoles take
- the signed, book-bound grants needs writing down and checking on dev before
- the flag goes on (see "Retries: supported transitions" below). Closed on
- zdd-flag-readiness-2 (`6e9e84bd`, `9f27cdfa`, `4d8957c1`, `18bde560`),
- pending merge, by construction: grants ride their own object, so no
- ordering of box and console rolls applies one unsafely. The dev-box check
- across a mixed-version roll is still owed (see "Real-box checks").
-
-Task 10's "a grant consumed while the row is moving or landing" is closed
-by 076bd9c0 (final review round 1), pending merge.
+ A future feature, not scheduled. A box with host claims on declines the
+ flag.
+- **MOVED to #250.** Leftover `consumed/` files refuse the claims census.
+ Root's request record `/opt/carlos/consumed/<host>` and its fence lock
+ `.<host>.lock` outlive the installation, and the empty-installation census
+ refuses both. It also refuses another host's record, which is an
+ unclassified file. platform-1e owns it now, under Plan 14. Root's
+ retirement removes the consumed file, its lock and the record atomically,
+ so a re-install passes the empty-installation census. A runner test covers
+ install, retire and re-install. It is owed before host claims are switched
+ on, and does not block the flag.
### Roll notes from zdd-flag-readiness
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 236a41ab7..61fd883bf 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -2278,8 +2278,11 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
}
}
if ib != nil {
- var idErr error
- id, w.pend, idErr = a.settleWakeIdentity(ctx, ib, e, rt, held, heldID)
+ var (
+ idErr error
+ wctx context.Context
+ )
+ id, w.pend, wctx, idErr = a.settleWakeIdentity(ctx, ib, e, rt, held, heldID)
if idErr != nil {
w.stopOutcome.leaseReleased = a.giveBack(ctx, host, lease)
return w, fmt.Errorf("start %s: %w", host, idErr)
@@ -2289,7 +2292,9 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
// R-EXEC-NOVERSION-2): today's live-link wake, nothing recorded.
ib = nil
} else {
- ctx = withRecordedBuild(ctx, id.Version)
+ // The record is pending on wctx, written at the first exec
+ // (amadan #272).
+ ctx = wctx
}
}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index ddaa90770..0843a90bd 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -1037,7 +1037,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe stri
// wedged seed would hold the lease until the wake's own 5m budget expired.
cmd.WaitDelay = seedDrainGrace
return cmd
- }, nil)
+ }, pendingRecordHook(ctx)) // the wake's record, at the exec (amadan #272)
spawned := err == nil
if spawned {
err = cmd.Wait()
@@ -1492,6 +1492,19 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route, exe *inst
if err := recordSeedObligation(host, dbPath, seedObligation{ownsDB: seedOwnsDB, layout: LayoutNested}); err != nil {
return err
}
+ // Then the wake's pending record (amadan #272), the last step before
+ // root is asked, as the flat seed's comes at its exec after its
+ // obligation (Astra review of zdd-owed-items, P2): an obligation that
+ // cannot be written records nothing. A record that fails, or a recheck
+ // that refuses after it, runs no seed, so the obligation just written
+ // is discharged and the database, which no seed touched, is kept.
+ if _, err := runPendingRecord(ctx); err != nil {
+ cause := fmt.Errorf("seed for %s: %w", host, err)
+ if derr := dischargeSeedObligation(dbPath, false); derr != nil {
+ return fmt.Errorf("%w; and %w", cause, derr)
+ }
+ return cause
+ }
log.Printf("activator: seeding %s: %s", host, strings.Join(argv, " "))
// The client already bounds a seed call at instancectl.DeadlineSeed.
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go
index 81a7366b5..4b7219665 100644
--- a/internal/activator/beforeexec.go
+++ b/internal/activator/beforeexec.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
+ "sync"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -28,10 +29,13 @@ var errBuildStateUnwritten = errors.New("the build state could not be written, s
// those carry startWorker's exec-time hook on a worker spawn's context, and
// that hook is one of recordBuild's callers.
//
-// A wake that already recorded this build (withRecordedBuild) is not
-// written twice. A build with no name (an executable outside the version
-// store) is refused while the hook is set: no build state could name it
-// (ruling R-EXEC-NOVERSION).
+// A cold wake's own record is pending until its first exec
+// (settleWakeIdentity, amadan #272): a call for that build writes nothing
+// here, and the exec seam writes it (runPendingRecord), after every refusal
+// the backend makes. A call for another build writes now and supersedes
+// it, so the exec seam never writes the wake's build over this one. A build
+// with no name (an executable outside the version store) is refused while
+// the hook is set: no build state could name it (ruling R-EXEC-NOVERSION).
//
// With host claims off the lease is the hibernate lease, so a context that
// names no location gets that one (Config.Bucket, HibernatePrefix), and the
@@ -42,7 +46,7 @@ func (a *Service) recordBuild(ctx context.Context, host, build string) error {
if a.BeforeExec == nil {
return nil
}
- if build != "" && recordedBuild(ctx) == build {
+ if p := pendingRecordFrom(ctx); p != nil && p.defers(build) {
return nil
}
if build == "" {
@@ -96,8 +100,9 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error {
}
// settleWakeIdentity is doWake's step 2b (settings spec §4.2, Astra I1
-// #5): the one file this wake runs, its seed included, chosen and recorded
-// under the wake's lease after the restore and before anything execs.
+// #5): the one file this wake runs, its seed included, chosen under the
+// wake's lease after the restore and before anything execs. Its record is
+// made pending on the context it returns, and written at the first exec.
//
// - A planned wake runs its plan's boot build, and a pinned wake (a hold
// swap's) its pin. Step 0.8 published both before the lease.
@@ -131,9 +136,21 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error {
// identity with no file comes back, nothing is published or recorded,
// and the caller takes the live-link calls in every layout.
//
-// Then the record (recordBuild). Any error means nothing was seeded or
-// spawned; the caller releases the lease.
-func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route, held string, heldID *release.StagedIdentity) (release.StagedIdentity, pendingGeneration, error) {
+// The record is not written here (amadan #272). The context returned
+// carries it pending (withPendingRecord), and the seed's or the spawn's
+// exec seam writes it (runPendingRecord): after the backend's own refusals
+// (the replica-key gate, and the cgroup's: a killed cgroup kept, a
+// populated legacy parent, a limit not applied, a placement that failed),
+// immediately before the process execs. A launch refused before its exec
+// leaves no record naming a build that never ran, and completes no request
+// (settings spec §4.2: the record is what completes a request, at
+// execution). A recovery plan's boot (the good build in place of the
+// installed one the failure book blocks) is recorded with the planner's
+// selection (ruling R-PLANNED-RECOVERY), so the agent's revalidation admits
+// it before root's consumed file names the installed build failed. With no
+// hook nothing is pending, as nothing is recorded. Any error means nothing
+// was seeded or spawned; the caller releases the lease.
+func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route, held string, heldID *release.StagedIdentity) (release.StagedIdentity, pendingGeneration, context.Context, error) {
a.mu.Lock()
pin, plan := e.pin, e.plan
a.mu.Unlock()
@@ -152,24 +169,24 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e
default:
var err error
if id, err = ib.LiveIdentity(ctx, rt); err != nil {
- return release.StagedIdentity{}, 0, err
+ return release.StagedIdentity{}, 0, ctx, err
}
captured = true
}
if !heldIdentityOK(held, id, plan, rt) {
log.Printf("activator: %s not woken: it is held on %s, and this wake would run %q (the live link moved after the check); the next wake checks again", rt.Host, held, id.Version)
- return release.StagedIdentity{}, 0, fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, rt.Host, id.Version, held)
+ return release.StagedIdentity{}, 0, ctx, fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, rt.Host, id.Version, held)
}
if captured {
if id.Version == "" {
- return release.StagedIdentity{}, 0, a.recordBuild(ctx, rt.Host, "")
+ return release.StagedIdentity{}, 0, ctx, a.recordBuild(ctx, rt.Host, "")
}
// A declined box's exec-time gate, asked about the file this wake
// will run, before it is published or recorded (askLiveExecGate):
// the spawn below runs it by path, past the backend's live-link
// check. Its refusal comes back as it is, as the backend's would.
if err := a.askLiveExecGate(rt, id.Version); err != nil {
- return release.StagedIdentity{}, 0, err
+ return release.StagedIdentity{}, 0, ctx, err
}
pend = a.addPendingGeneration(rt.Host, id)
if err := a.persistGenerations(); err != nil {
@@ -177,35 +194,144 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e
if e.worker {
// A worker's wake reads as its exec-time refusal always has
// (startWorker's hook, final review F9).
- return id, pend, fmt.Errorf("%w: %v", ErrBuildNotPublished, err)
+ return id, pend, ctx, fmt.Errorf("%w: %v", ErrBuildNotPublished, err)
}
- return id, pend, errStagedBuildNotPublished
+ return id, pend, ctx, errStagedBuildNotPublished
}
}
- // A recovery plan's boot (the good build in place of the installed one
- // the failure book blocks) is recorded with the planner's selection
- // (ruling R-PLANNED-RECOVERY), so the agent's revalidation admits it
- // before root's consumed file names the installed build failed.
+ if a.BeforeExec == nil {
+ return id, pend, ctx, nil
+ }
rctx := ctx
if plan.planned() {
rctx = withRecoverySelection(ctx, plan, rt, id.Version)
}
- if err := a.recordBuild(rctx, rt.Host, id.Version); err != nil {
- return id, pend, err
+ build := id.Version
+ write := func() error { return a.recordBuild(rctx, rt.Host, build) }
+ // The wake's own admission, asked again once the write is done (Astra
+ // review of zdd-owed-items, P1): the bucket write takes time, and a
+ // fence raised or the wake's context ended meanwhile must start
+ // nothing, as doWake's own check before the spawn refuses it.
+ recheck := func() error {
+ if ctx.Err() != nil || !a.wakeAllowed(rt.Host) {
+ return ErrFenced
+ }
+ return nil
}
- return id, pend, nil
+ return id, pend, withPendingRecord(ctx, build, write, recheck), nil
}
-type recordedBuildKey struct{}
+// pendingRecord is one wake's record of build, written at most once, at
+// the first exec that asks for it (runPendingRecord).
+//
+// The write is a bucket PUT, and the lease can be lost, a fence raised or
+// an attempt superseded while it runs. So every exec that consults the
+// record asks recheck (the wake's own admission: its context and the
+// maintenance fence) after it, and an exec seam that has an exec gate asks
+// that gate again once the write has run (runExecGate, launchGateWith).
+// Either refusal starts nothing.
+//
+// A refusal there leaves the record naming a build that did not run. It is
+// not undone. An undo would be a second bucket write, made after the lease
+// may already be lost, so it could land under another holder's lease; the
+// object's CAS would stop it overwriting that holder's record, but writing
+// outside the lease is exactly what the build state's single-writer rule
+// (ruling R-BS-UPDATE) forbids. The window is the one main had before
+// amadan #272, where step 2b wrote the record and doWake then checked the
+// fence: the record names a build that passed every refusal of its own and
+// was about to run, the next wake records again, and a request it completed
+// was the request for that build.
+type pendingRecord struct {
+ build string
+ write func() error
+ recheck func() error
-// withRecordedBuild marks ctx as carrying a wake that has already recorded
-// build (doWake's step 2b records once, before its seed and its spawn).
-func withRecordedBuild(ctx context.Context, build string) context.Context {
- return context.WithValue(ctx, recordedBuildKey{}, build)
+ mu sync.Mutex
+ // done is set once the write ran; err is its answer, which every later
+ // exec of the wake gets too.
+ done bool
+ err error
+ // superseded is set once a record of another build was written in this
+ // wake (recordBuild): the pending one would name the wrong build.
+ superseded bool
}
-// recordedBuild is the build ctx's wake already recorded, or "".
-func recordedBuild(ctx context.Context) string {
- s, _ := ctx.Value(recordedBuildKey{}).(string)
- return s
+type pendingWakeKey struct{}
+
+// withPendingRecord returns ctx carrying build's pending record, written by
+// write and followed by recheck (nil asks nothing) at every exec.
+func withPendingRecord(ctx context.Context, build string, write, recheck func() error) context.Context {
+ return context.WithValue(ctx, pendingWakeKey{}, &pendingRecord{build: build, write: write, recheck: recheck})
+}
+
+func pendingRecordFrom(ctx context.Context) *pendingRecord {
+ p, _ := ctx.Value(pendingWakeKey{}).(*pendingRecord)
+ return p
+}
+
+// defers reports whether a record of build is the pending one's to write
+// (recordBuild then writes nothing). A record of another build supersedes
+// it, and once it is superseded nothing defers to it.
+func (p *pendingRecord) defers(build string) bool {
+ if build == "" {
+ return false
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if build != p.build {
+ p.superseded = true
+ return false
+ }
+ return !p.superseded
+}
+
+// run writes the record once, then asks recheck. wrote reports that this
+// call made the write. A superseded record writes and asks nothing.
+func (p *pendingRecord) run() (wrote bool, err error) {
+ p.mu.Lock()
+ if p.superseded {
+ p.mu.Unlock()
+ return false, nil
+ }
+ if !p.done {
+ p.err, p.done, wrote = p.write(), true, true
+ }
+ err = p.err
+ p.mu.Unlock()
+ if err != nil {
+ return wrote, err
+ }
+ if p.recheck != nil {
+ if err := p.recheck(); err != nil {
+ return wrote, err
+ }
+ }
+ return wrote, nil
+}
+
+// runPendingRecord writes ctx's pending wake record, if it carries one that
+// is still due. Every exec seam a cold wake reaches asks it immediately
+// before its exec: the flat seed's and spawn's cgroup before-start hook,
+// a worker's, and, nested, right before root is asked to run the seed or
+// the run unit. An error refuses that exec, with nothing run. wrote
+// reports that this call made the write, so a seam with an exec gate asks
+// it again.
+func runPendingRecord(ctx context.Context) (wrote bool, err error) {
+ if p := pendingRecordFrom(ctx); p != nil {
+ return p.run()
+ }
+ return false, nil
+}
+
+// pendingRecordHook is runPendingRecord as a cgroup before-start hook for
+// a spawn that has no other: nil when ctx carries no pending record, so
+// that spawn is exactly as it was.
+func pendingRecordHook(ctx context.Context) func() error {
+ if pendingRecordFrom(ctx) == nil {
+ return nil
+ }
+ return func() error {
+ _, err := runPendingRecord(ctx)
+ return err
+ }
}
diff --git a/internal/activator/beforeexec_test.go b/internal/activator/beforeexec_test.go
index 239bf27c9..6c90b47a6 100644
--- a/internal/activator/beforeexec_test.go
+++ b/internal/activator/beforeexec_test.go
@@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/registry"
)
// execLog is a BeforeExec hook that records host@build, the backend's spawn
@@ -102,22 +104,65 @@ func TestTheHookIsToldWhereTheLeaseIs(t *testing.T) {
}
}
-// A wake that already recorded this build (Task 5b) is not written twice;
-// another build is.
+// A wake's own record (Task 5b) is pending until its first exec (amadan
+// #272): a call for that build writes nothing, the exec seam writes it
+// once, and another build is written at once. A record of another build
+// supersedes the pending one, which the exec seam then never writes.
func TestARecordedBuildIsNotWrittenTwice(t *testing.T) {
+ rt := registry.Route{Host: "one.example.com"}
a := &Service{cfg: Config{Bucket: "b"}}
var l execLog
a.BeforeExec = l.hook
- ctx := withRecordedBuild(context.Background(), "v1")
- if err := a.recordBuild(ctx, "one.example.com", "v1"); err != nil {
+ ctx := wakeRecord(a, rt.Host, "v1")
+ if err := a.recordBuild(ctx, rt.Host, "v1"); err != nil {
t.Fatal(err)
}
- if err := a.recordBuild(ctx, "one.example.com", "v2"); err != nil {
+ if calls, _, _ := l.seen(); len(calls) != 0 {
+ t.Fatalf("calls = %v before any exec, want none", calls)
+ }
+ for range 2 {
+ if _, err := runPendingRecord(ctx); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := a.recordBuild(ctx, rt.Host, "v2"); err != nil {
t.Fatal(err)
}
- if calls, _, _ := l.seen(); len(calls) != 1 || calls[0] != "one.example.com@v2" {
- t.Fatalf("calls = %v, want only the fallback's v2", calls)
+ if calls, _, _ := l.seen(); !slices.Equal(calls, []string{"one.example.com@v1", "one.example.com@v2"}) {
+ t.Fatalf("calls = %v, want v1 once at the exec, then the fallback's v2", calls)
}
+
+ var s execLog
+ a.BeforeExec = s.hook
+ ctx = wakeRecord(a, rt.Host, "v1")
+ if err := a.recordBuild(ctx, rt.Host, "v2"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runPendingRecord(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if calls, _, _ := s.seen(); !slices.Equal(calls, []string{"one.example.com@v2"}) {
+ t.Fatalf("calls = %v, want only v2: the superseded v1 is never written", calls)
+ }
+
+ // A write that fails refuses every exec of the wake, and is not retried.
+ f := execLog{fail: failAll}
+ a.BeforeExec = f.hook
+ ctx = wakeRecord(a, rt.Host, "v1")
+ for range 2 {
+ if _, err := runPendingRecord(ctx); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v, want errBuildStateUnwritten", err)
+ }
+ }
+ if calls, _, _ := f.seen(); len(calls) != 1 {
+ t.Fatalf("calls = %v, want one attempt", calls)
+ }
+}
+
+// wakeRecord is the context settleWakeIdentity returns for a wake of host
+// that settled build: its record pending.
+func wakeRecord(a *Service, host, build string) context.Context {
+ return withPendingRecord(context.Background(), build, func() error { return a.recordBuild(context.Background(), host, build) }, nil)
}
// Ruling R-WAKEWORDS: a member never sees the internal text.
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index 06fcc0b21..4daef066e 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -84,11 +84,36 @@ func withExecGate(ctx context.Context, gate func() error) context.Context {
return context.WithValue(ctx, execGateKey{}, gate)
}
-// runExecGate asks ctx's exec gate, if it carries one. A spawn with a gate
-// also honours its context there: one that has ended starts nothing. With
-// no gate (every live-link spawn, the flag off) it is a no-op, so those
+// runExecGate asks ctx's exec gate, if it carries one, then writes ctx's
+// pending wake record, if it carries one (runPendingRecord, amadan #272),
+// and, once that write has run, asks the gate again (Astra review of
+// zdd-owed-items, P1): the lease, a fence or the attempt can change during
+// the bucket write, and the exec follows the second answer. A spawn with a
+// gate also honours its context there: one that has ended starts nothing.
+// With neither (every live-link spawn, the flag off) it is a no-op, so those
// spawns are exactly as they were.
func runExecGate(ctx context.Context) error {
+ if err := askExecGate(ctx); err != nil {
+ return err
+ }
+ wrote, err := runPendingRecord(ctx)
+ if err != nil {
+ return err
+ }
+ if wrote {
+ return askExecGate(ctx)
+ }
+ return nil
+}
+
+// RunExecGate is runExecGate for a Backend implemented outside this
+// package: such a backend asks it immediately before its exec, as
+// ExecBackend does, so the caller's gate and the wake's pending record
+// (amadan #272) are honoured there.
+func RunExecGate(ctx context.Context) error { return runExecGate(ctx) }
+
+// askExecGate is runExecGate's gate alone.
+func askExecGate(ctx context.Context) error {
gate, ok := ctx.Value(execGateKey{}).(func() error)
if !ok || gate == nil {
return nil
@@ -103,25 +128,43 @@ func runExecGate(ctx context.Context) error {
// worker's) before-start hook for the cgroup
// manager (final review F1): launchHook (the test seam), then ctx's exec
// gate, asked immediately before each start attempt, after the cgroup's
-// preparation. Nil when ctx carries no gate and no test seam is set, so a
-// live-link spawn hands the manager no hook and starts exactly as it did.
+// preparation, then ctx's pending wake record (runPendingRecord), and the
+// gate again once that write has run. Nil
+// when ctx carries no gate and no pending record and no test seam is set, so
+// a live-link spawn hands the manager no hook and starts exactly as it did.
func (b *ExecBackend) launchGate(ctx context.Context, host string) func() error {
return b.launchGateWith(ctx, host, nil)
}
// launchGateWith is launchGate with live, a live-link spawn's LiveExecGate
// check for the build it resolved (Task 9), asked after ctx's exec gate at
-// the same point. A nil live is launchGate exactly, so a spawn on a box with
-// no LiveExecGate hands the manager the hook it always did.
+// the same point, before the pending record and again after its write. A
+// nil live is launchGate
+// exactly, so a spawn on a box with no LiveExecGate hands the manager the
+// hook it always did.
func (b *ExecBackend) launchGateWith(ctx context.Context, host string, live func() error) func() error {
- if _, ok := ctx.Value(execGateKey{}).(func() error); !ok && b.launchHook == nil && live == nil {
+ if _, ok := ctx.Value(execGateKey{}).(func() error); !ok && b.launchHook == nil && live == nil && pendingRecordFrom(ctx) == nil {
return nil
}
return func() error {
if b.launchHook != nil {
b.launchHook(host)
}
- if err := runExecGate(ctx); err != nil {
+ if err := askExecGate(ctx); err != nil {
+ return err
+ }
+ if live != nil {
+ if err := live(); err != nil {
+ return err
+ }
+ }
+ wrote, err := runPendingRecord(ctx)
+ if err != nil || !wrote {
+ return err
+ }
+ // The write took time: the gate and the live check are asked again
+ // immediately before the exec (Astra review of zdd-owed-items, P1).
+ if err := askExecGate(ctx); err != nil {
return err
}
if live != nil {
diff --git a/internal/activator/wakeidentity_test.go b/internal/activator/wakeidentity_test.go
index b0affa772..0b220a689 100644
--- a/internal/activator/wakeidentity_test.go
+++ b/internal/activator/wakeidentity_test.go
@@ -125,7 +125,12 @@ func (b *fakeBackend) LiveIdentity(_ context.Context, rt registry.Route) (releas
return fakeIdentity(b.ident.live), nil
}
-func (b *fakeBackend) StartGenExe(_ context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+func (b *fakeBackend) StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+ // The exec seam, as the real backend's: the exec gate and the wake's
+ // pending record (amadan #272), before anything starts.
+ if err := runExecGate(ctx); err != nil {
+ return nil, Gen{}, err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.starts++
@@ -147,7 +152,10 @@ func (b *fakeBackend) StartGenExe(_ context.Context, rt registry.Route, n uint64
return proc, g, nil
}
-func (b *fakeBackend) SeedExe(_ context.Context, rt registry.Route, id release.StagedIdentity) error {
+func (b *fakeBackend) SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error {
+ if _, err := runPendingRecord(ctx); err != nil {
+ return err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.seeds++
@@ -399,8 +407,9 @@ func TestAReplanBeforeTheSeedSeedsAndBootsOneBuild(t *testing.T) {
}
// D6: a replan that only the exec's own selection gate makes (after step
-// 2b recorded the first plan's boot) is recorded again by startPinnedAt
-// before it execs.
+// 2b settled the first plan's boot) is recorded by startPinnedAt before it
+// execs. The first plan's boot never ran, so its pending record is never
+// written (amadan #272).
func TestAReplanAfterTheRecordRecordsTheNewBuild(t *testing.T) {
dir := t.TempDir()
stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
@@ -415,8 +424,8 @@ func TestAReplanAfterTheRecordRecordsTheNewBuild(t *testing.T) {
}
done()
calls, at, _ := l.seen()
- if !slices.Equal(calls, []string{"one.example.com@v2", "one.example.com@v3"}) || at[1] != 0 {
- t.Fatalf("records %v at spawn counts %v, want v2 at the wake, then v3 before the replanned exec", calls, at)
+ if !slices.Equal(calls, []string{"one.example.com@v3"}) || at[0] != 0 {
+ t.Fatalf("records %v at spawn counts %v, want only v3, before the replanned exec", calls, at)
}
if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
t.Fatalf("spawned %+v, want the replanned v3", gens)
@@ -612,8 +621,10 @@ func TestARefusingHookStartsNothing(t *testing.T) {
if err := sa.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); !errors.Is(err, errBuildStateUnwritten) {
t.Fatalf("WakeWorker = %v, want errBuildStateUnwritten", err)
}
- if n := len(sb.calls()); n != 0 || sb.workerStarts() != 0 {
- t.Fatalf("%d staged worker spawns, %d children after a refused record", n, sb.workerStarts())
+ // The record is written at the worker's exec (amadan #272), so the
+ // spawn is entered and refused there: no child.
+ if sb.workerStarts() != 0 {
+ t.Fatalf("%d children after a refused record", sb.workerStarts())
}
next := stagedID(t, t.TempDir(), "v3")
@@ -925,7 +936,8 @@ func TestARefusedSidecarRecordStartsNoSidecar(t *testing.T) {
}
// At the wake: a replanned build is recorded again by its sidecar (the
- // wake's context names the first plan's), and that record is refused.
+ // wake's context names the first plan's, which never ran and so is never
+ // written), and that record is refused.
dir := t.TempDir()
stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
pa, pb, _ := planActivator(t, WakePlan{})
@@ -933,15 +945,15 @@ func TestARefusedSidecarRecordStartsNoSidecar(t *testing.T) {
pb.sidecarStaged = true
pa.Planner = laterReplan(stale, next)
var n atomic.Int32
- pl := &execLog{fail: func(string) bool { return n.Add(1) == 3 }}
+ pl := &execLog{fail: func(string) bool { return n.Add(1) == 2 }}
pa.BeforeExec = pl.hook
done, err = pa.beginRequest(context.Background(), identityRoute(t, host))
if err != nil {
t.Fatalf("the wake failed over its sidecar's record: %v", err)
}
done()
- if calls, _, _ := pl.seen(); !slices.Equal(calls, []string{host + "@v2", host + "@v3", host + "@v3"}) {
- t.Fatalf("records %v, want the wake's, the replanned spawn's and the sidecar's", calls)
+ if calls, _, _ := pl.seen(); !slices.Equal(calls, []string{host + "@v3", host + "@v3"}) {
+ t.Fatalf("records %v, want the replanned spawn's and the sidecar's", calls)
}
if sc := pb.sidecarsOn(); len(sc) != 0 || pb.sidecarStartCount() != 0 {
t.Fatalf("sidecars %+v (%d live): a sidecar ran unrecorded", sc, pb.sidecarStartCount())
diff --git a/internal/activator/wakerecord_recheck_test.go b/internal/activator/wakerecord_recheck_test.go
new file mode 100644
index 000000000..a221e7c25
--- /dev/null
+++ b/internal/activator/wakerecord_recheck_test.go
@@ -0,0 +1,168 @@
+//go:build linux
+
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Astra's review of zdd-owed-items. P1: the pending record is a bucket
+// write, and the lease, a fence or the attempt can change while it runs, so
+// the exec is decided again after it. P2: a nested seed writes its
+// obligation before its record, so an obligation that fails records
+// nothing.
+
+// P1, the exec gate: a gate that passes before the write and refuses after
+// it (the attempt superseded during the PUT) starts nothing, at both seams
+// that carry a gate.
+func TestAnExecGateRefusingDuringTheRecordStartsNothing(t *testing.T) {
+ for _, seam := range []string{"runExecGate", "launchGate"} {
+ t.Run(seam, func(t *testing.T) {
+ var superseded atomic.Bool
+ superseding := errors.New("attempt superseded")
+ gate := func() error {
+ if superseded.Load() {
+ return superseding
+ }
+ return nil
+ }
+ writes := 0
+ write := func() error { writes++; superseded.Store(true); return nil }
+ ctx := withPendingRecord(withExecGate(context.Background(), gate), "v1", write, nil)
+ var err error
+ if seam == "runExecGate" {
+ err = runExecGate(ctx)
+ } else {
+ err = (&ExecBackend{}).launchGate(ctx, "one.example.com")()
+ }
+ if !errors.Is(err, superseding) {
+ t.Fatalf("%s = %v, want the gate's refusal after the record", seam, err)
+ }
+ if writes != 1 {
+ t.Fatalf("%d writes, want 1", writes)
+ }
+ })
+ }
+}
+
+// P1, the fence: a maintenance fence raised while a cold wake's record is
+// being written starts nothing, at the seed and at the spawn.
+func TestAFenceRaisedDuringTheRecordStartsNothing(t *testing.T) {
+ for _, seeded := range []bool{false, true} {
+ name := "spawn"
+ if seeded {
+ name = "seed"
+ }
+ t.Run(name, func(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ b.restoreFresh = seeded
+ rt := identityRoute(t, "one.example.com")
+ if seeded {
+ rt.Seed = `["seed"]`
+ }
+ l := &execLog{}
+ a.BeforeExec = func(ctx context.Context, host, build string) error {
+ a.mu.Lock()
+ if a.fences == nil {
+ a.fences = map[string]uint64{}
+ }
+ a.fences[host] = 7 // a maintenance fence lands mid-write
+ a.mu.Unlock()
+ return l.hook(ctx, host, build)
+ }
+ _, err := a.beginRequest(context.Background(), rt)
+ if !errors.Is(err, ErrFenced) {
+ t.Fatalf("wake = %v, want ErrFenced", err)
+ }
+ if b.startCount() != 0 || b.seedCount() != 0 {
+ t.Fatalf("started %d, seeded %d after a fence raised during the record", b.startCount(), b.seedCount())
+ }
+ if calls, _, _ := l.seen(); !slices.Equal(calls, []string{"one.example.com@v1"}) {
+ t.Fatalf("records %v, want the one write that ran", calls)
+ }
+ })
+ }
+}
+
+// P2: a nested seed whose obligation cannot be written records nothing and
+// asks root for nothing; a nested seed whose record fails discharges the
+// obligation it wrote, keeps the database, and asks root for nothing.
+func TestANestedSeedWritesItsObligationBeforeItsRecord(t *testing.T) {
+ seedOnce := func(t *testing.T, plant func(rt registry.Route), fail bool) (*fakeControl, registry.Route, []string, error) {
+ t.Helper()
+ ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "success", Status: "0", Code: "exited"}}
+ b, rt := nestedBackend(t, ctl, &fakeUnit{})
+ b.Control = identityControl{ctl}
+ rt.Seed = `["seed"]`
+ stageVersion(t, b, rt.Host, "vA", "exit 0\n")
+ id, err := b.LiveIdentity(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(rt.DBPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ plant(rt)
+ var records []string
+ write := func() error {
+ records = append(records, id.Version)
+ if fail {
+ return errBuildStateUnwritten
+ }
+ return nil
+ }
+ ctx := withPendingRecord(context.Background(), id.Version, write, nil)
+ err = b.SeedExe(ctx, rt, id)
+ return ctl, rt, records, err
+ }
+
+ t.Run("the obligation fails", func(t *testing.T) {
+ ctl, _, recs, err := seedOnce(t, func(rt registry.Route) {
+ // An earlier seed's unanswered obligation refuses this one.
+ if err := os.WriteFile(rt.DBPath+seedUnprovenSuffix, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, false)
+ if !errors.Is(err, ErrSeedUnproven) {
+ t.Fatalf("SeedExe = %v, want the obligation's refusal", err)
+ }
+ if len(recs) != 0 {
+ t.Fatalf("records %v, want none: no seed ran", recs)
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("root was asked:\n%s", ctl.joined())
+ }
+ })
+
+ t.Run("the record fails", func(t *testing.T) {
+ ctl, rt, recs, err := seedOnce(t, func(rt registry.Route) {
+ if err := os.WriteFile(rt.DBPath, []byte("data"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, true)
+ if !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("SeedExe = %v, want the record's refusal", err)
+ }
+ if len(recs) != 1 {
+ t.Fatalf("records %v, want one attempt", recs)
+ }
+ if _, err := os.Stat(rt.DBPath + seedUnprovenSuffix); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the obligation stayed after a refused record: %v", err)
+ }
+ if raw, err := os.ReadFile(rt.DBPath); err != nil || string(raw) != "data" {
+ t.Fatalf("the database was touched: %q, %v", raw, err)
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("root was asked:\n%s", ctl.joined())
+ }
+ })
+}
diff --git a/internal/activator/wakerecord_refused_test.go b/internal/activator/wakerecord_refused_test.go
new file mode 100644
index 000000000..224175449
--- /dev/null
+++ b/internal/activator/wakerecord_refused_test.go
@@ -0,0 +1,137 @@
+//go:build linux
+
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Amadan #272, owed before O8: a cold wake's build-state record names the
+// build about to run against the instance's database, and completes an
+// install request (settings spec §4.2). It used to be written at step 2b,
+// before the backend's own refusals: the replica-key gate and the cgroup's
+// (a populated legacy parent, a killed cgroup kept, a limit not applied).
+// A launch refused there ran nothing, yet left a record naming its build.
+// The record is now written at the exec, so a refused launch leaves none.
+
+// execIdentityBackend is fakeBackend for the lease and the restore, with a
+// real ExecBackend for the identity, the seed and the spawn: the exec seams
+// and the refusals under test are the production ones.
+type execIdentityBackend struct {
+ *fakeBackend
+ eb *ExecBackend
+}
+
+func (b *execIdentityBackend) identityFor(registry.Route) identityBackend { return b.eb }
+
+// stageScript makes body host's live build v1, as the adopt pass lays it
+// out (<opt>/versions/v1/app, linked from <opt>/live/<host>).
+func stageScript(t *testing.T, eb *ExecBackend, host, body string) {
+ t.Helper()
+ stageLive(t, eb, host, "v1")
+ if err := os.WriteFile(filepath.Join(eb.OptDir, "versions", "v1", "app"), []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// refusedWake runs one cold wake of rt over eb with a recording BeforeExec,
+// and returns the wake's error and the records written.
+func refusedWake(t *testing.T, eb *ExecBackend, rt registry.Route) (error, []string) {
+ t.Helper()
+ fb := &fakeBackend{restoreFresh: true}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &execIdentityBackend{fakeBackend: fb, eb: eb}, reg)
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ done, err := a.beginRequest(ctx, rt)
+ if err == nil {
+ done()
+ }
+ calls, _, _ := l.seen()
+ return err, calls
+}
+
+func TestARefusedLaunchLeavesNoBuildStateRecord(t *testing.T) {
+ const host = "shop.example.com"
+ route := func(eb *ExecBackend) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", Hibernate: true, App: "app",
+ Socket: noControlSocket(t), DBPath: filepath.Join(eb.DataDir, host+".db")}
+ }
+
+ t.Run("replica seed refused by the replica-key gate", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Replica, rt.Seed = true, `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("wake = %v, want the replica-key gate's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused seed, want none", calls)
+ }
+ })
+
+ t.Run("replica spawn refused by the replica-key gate", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Replica = true
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("wake = %v, want the replica-key gate's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused spawn, want none", calls)
+ }
+ })
+
+ t.Run("seed refused beside a populated legacy parent", func(t *testing.T) {
+ eb, parent := delegatedBackend(t, host)
+ eb.GenContainment = true
+ if err := os.WriteFile(filepath.Join(parent, "cgroup.procs"), []byte("4242\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Seed = `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, cgroup.ErrLegacyParentPopulated) {
+ t.Fatalf("wake = %v, want the cgroup's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused seed, want none", calls)
+ }
+ })
+
+ // The control: a seed that runs (and fails) did execute its build
+ // against the database, so it is recorded, once, before it ran.
+ t.Run("a seed that ran is recorded", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 1\n")
+ rt := route(eb)
+ rt.Seed = `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if err == nil {
+ t.Fatal("a failing seed woke the instance")
+ }
+ if !slices.Equal(calls, []string{host + "@v1"}) {
+ t.Fatalf("records %v, want v1 once", calls)
+ }
+ })
+}
diff --git a/internal/host/buildstate_planned_test.go b/internal/host/buildstate_planned_test.go
index 38eec7b52..13a29801c 100644
--- a/internal/host/buildstate_planned_test.go
+++ b/internal/host/buildstate_planned_test.go
@@ -80,7 +80,12 @@ func (b *plannedBackend) StartWorker(context.Context, registry.Route, map[string
return nil, "", errors.New("no worker")
}
-func (b *plannedBackend) StartStaged(_ context.Context, _ registry.Route, g activator.Gen) (activator.Gen, activator.Proc, error) {
+func (b *plannedBackend) StartStaged(ctx context.Context, _ registry.Route, g activator.Gen) (activator.Gen, activator.Proc, error) {
+ // The exec seam, as ExecBackend's: the caller's gate and the wake's
+ // pending record (amadan #272), before anything starts.
+ if err := activator.RunExecGate(ctx); err != nil {
+ return g, nil, err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.started = append(b.started, g.Build)
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 1c7a2443b..298c5e9db 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -691,6 +691,18 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-census-ruling-275.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-census-ruling-owed.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ // Census ruling owed (amadan #272, zdd-owed-items, platform-1e): a cold
+ // wake's build-state record is written at its first exec, after the
+ // replica-key gate and the cgroup refusals, and the exec is decided
+ // again after the write. Four changed activator rows add four frozen
+ // declarations.
+ seal = "79db70c4c396457e563bf792027f98eeed7cef3dc54fff8abedb025e7db1dfbb"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-census-ruling-owed.tsv b/internal/provision/testdata/plan14-root-reads-census-ruling-owed.tsv
new file mode 100644
index 000000000..37e2989a7
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-census-ruling-owed.tsv
@@ -0,0 +1,13 @@
+# Plan 14 census ruling owed (amadan #272, branch zdd-owed-items; approved by platform-1e as census-ruling-owed; loaded after census-ruling-275): the cold wake's build-state record moves from step 2b to the first exec, after the replica-key gate and the cgroup refusals, so a refused launch leaves no record (owed before O8). After the write the exec is decided again (the exec gate, the live check, the wake's context and maintenance fence), and a nested seed writes its obligation before its record (Astra's review, P1 and P2).
+# The four changed activator units stay accepted legacy because they stay agent-side, running as carlos: only runAgent builds the activator, under carlos-edge.service with User=carlos. The record is the existing BeforeExec build-state write, moved later, never earlier. No new read and no root decision. If any of these units ever runs as root or reads outside that boundary it stops being legacy and needs the Plan 14 owner's ruling. Earlier overlays are immutable.
+@legacy bdd27149b9dcce980ebca238207c286eeefd52353b2daa752516123d71a2c0a7 internal/activator/activator.go Service.doWake legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; step 2b no longer writes the build-state record: the wake carries it pending on its context (settleWakeIdentity's) and the first exec writes it. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. registry:DBPath@1032,registry:DBPath@1048,registry:DBPath@1175,registry:DBPath@1186,registry:DBPath@20401,registry:Host@1014,registry:Host@1166,registry:Host@1277,registry:Host@174,registry:Host@2137,registry:Host@3858,registry:Host@861,registry:Host@936,registry:Replication@20436,registry:Seed@11207,registry:Socket@1061,registry:Socket@1077,registry:Socket@1200,registry:Socket@1211,whole:Route@10058,whole:Route@11308,whole:Route@11379,whole:Route@1231,whole:Route@1237,whole:Route@1303,whole:Route@1311,whole:Route@13763,whole:Route@15621,whole:Route@159,whole:Route@16267,whole:Route@17574,whole:Route@17728,whole:Route@18084,whole:Route@18178,whole:Route@18850,whole:Route@19849,whole:Route@19997,whole:Route@2027,whole:Route@20510,whole:Route@21408,whole:Route@21503,whole:Route@3481,whole:Route@3740,whole:Route@3810,whole:Route@3816,whole:Route@3885,whole:Route@3893,whole:Route@4681,whole:Route@5680,whole:Route@6713,whole:Route@7594,whole:Route@9176,whole:Route@9368,whole:Route@959 f09606c9a22d961f17610105b6b0b126676f62874fe8ef8a5b09649a8b36b73f
+internal/activator/activator.go Service.doWake legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; step 2b no longer writes the build-state record: the wake carries it pending on its context (settleWakeIdentity's) and the first exec writes it. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. registry:DBPath@1032,registry:DBPath@1048,registry:DBPath@1175,registry:DBPath@1186,registry:DBPath@20401,registry:Host@1014,registry:Host@1166,registry:Host@1277,registry:Host@174,registry:Host@2137,registry:Host@3858,registry:Host@861,registry:Host@936,registry:Replication@20436,registry:Seed@11207,registry:Socket@1061,registry:Socket@1077,registry:Socket@1200,registry:Socket@1211,whole:Route@10058,whole:Route@11308,whole:Route@11379,whole:Route@1231,whole:Route@1237,whole:Route@1303,whole:Route@1311,whole:Route@13763,whole:Route@15621,whole:Route@159,whole:Route@16267,whole:Route@17574,whole:Route@17728,whole:Route@18084,whole:Route@18178,whole:Route@18850,whole:Route@19849,whole:Route@19997,whole:Route@2027,whole:Route@20510,whole:Route@21408,whole:Route@21503,whole:Route@3481,whole:Route@3740,whole:Route@3810,whole:Route@3816,whole:Route@3885,whole:Route@3893,whole:Route@4681,whole:Route@5680,whole:Route@6713,whole:Route@7594,whole:Route@9176,whole:Route@9368,whole:Route@959 f09606c9a22d961f17610105b6b0b126676f62874fe8ef8a5b09649a8b36b73f
+@legacy 9dcf78cbffdc590abbafd3245215de0ffeda0d54f236f23a7e63583ed2a71495 internal/activator/backend_exec.go ExecBackend.seedChild legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the flat seed's cgroup before-start hook writes the wake's pending record, after the cgroup's refusals and immediately before the exec. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. file:CommandContext@3135,file:Stat@2036,registry:App@4384,registry:Host@120,whole:Route@1409,whole:Route@149,whole:Route@1541,whole:Route@1745,whole:Route@176,whole:Route@3083,whole:Route@4379,whole:Route@672,whole:Route@7143,whole:Route@755,whole:Route@9949 4b5fcf0426aeb5b573d1dd0b0ab6ae09b141eca1ee266fd8bcd9ca642242796b
+internal/activator/backend_exec.go ExecBackend.seedChild legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the flat seed's cgroup before-start hook writes the wake's pending record, after the cgroup's refusals and immediately before the exec. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. file:CommandContext@3135,file:Stat@2036,registry:App@4384,registry:Host@120,whole:Route@1409,whole:Route@149,whole:Route@1541,whole:Route@1745,whole:Route@176,whole:Route@3083,whole:Route@4379,whole:Route@672,whole:Route@7143,whole:Route@755,whole:Route@9949 4b5fcf0426aeb5b573d1dd0b0ab6ae09b141eca1ee266fd8bcd9ca642242796b
+@legacy a929d4da62b876ce456ac082b71b888be0dace9a588504839785f0075304aaaf internal/activator/backend_exec.go ExecBackend.seedUnit legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the nested seed writes its obligation, then the wake's pending record, before root is asked; a record that fails or a recheck that refuses discharges the obligation and keeps the database. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. file:Stat@1644,registry:Host@135,whole:Route@1123,whole:Route@167,whole:Route@3863,whole:Route@3993,whole:Route@4528,whole:Route@532,whole:Route@613,whole:Route@679,whole:Route@997 c4665215b34dfcd1da63912a0dbe210da9fec2136b22178d3490115662df0d72
+internal/activator/backend_exec.go ExecBackend.seedUnit legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the nested seed writes its obligation, then the wake's pending record, before root is asked; a record that fails or a recheck that refuses discharges the obligation and keeps the database. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. file:Stat@1644,registry:Host@135,whole:Route@1123,whole:Route@167,whole:Route@3863,whole:Route@3993,whole:Route@4528,whole:Route@532,whole:Route@613,whole:Route@679,whole:Route@997 c4665215b34dfcd1da63912a0dbe210da9fec2136b22178d3490115662df0d72
+@legacy f8e748d7e1d462ea6d0c3e973fdf38bab439f767bde22c1dde0fffc5f1581cf5 internal/activator/beforeexec.go Service.settleWakeIdentity legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the record of the identity it settles is returned pending on the context, with the wake's own admission (its context and the maintenance fence) asked again after the write, instead of being written here. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. registry:Host@1007,registry:Host@1135,registry:Host@1267,registry:Host@1702,registry:Host@1870,registry:Host@2402,registry:Host@2759,whole:Route@1588,whole:Route@2310,whole:Route@722,whole:Route@849 844cca5d8ef92ac53a34d580db0fb51f4dc9b3c01c3959e23f90e90ffe5f0846
+internal/activator/beforeexec.go Service.settleWakeIdentity legacy accepted-legacy accepted-legacy;rev3=census-ruling-owed;basis=Census ruling owed (amadan #272, zdd-owed-items): changed legacy row; the record of the identity it settles is returned pending on the context, with the wake's own admission (its context and the maintenance fence) asked again after the write, instead of being written here. Boundary: agent-side, running as carlos (only runAgent builds the activator, under carlos-edge.service, User=carlos); the record is the existing BeforeExec build-state write, moved later, never earlier; no new read, no root decision, and no registry, /data, release or claim read beyond what the unit read before. registry:Host@1007,registry:Host@1135,registry:Host@1267,registry:Host@1702,registry:Host@1870,registry:Host@2402,registry:Host@2759,whole:Route@1588,whole:Route@2310,whole:Route@722,whole:Route@849 844cca5d8ef92ac53a34d580db0fb51f4dc9b3c01c3959e23f90e90ffe5f0846
+# Envelopes, census ruling owed: each changed root source envelope below is accepted only because the units inside it are the ones listed above. internal/activator also gains the pending-record helpers (pendingRecord, withPendingRecord, runPendingRecord, pendingRecordHook), askExecGate and the exported RunExecGate, none of which reads anything. internal/release changes only ResolveLivePath, which now calls ResolveLiveLink; its body was identical, so nothing reads differently.
+@coverage internal/activator 281cf9604cfd728e1aa96196e35c15b9a6c88ca180f36aa2784e94d8c4732677
+@coverage internal/release 0261f9202141ea3d57e3a97071df1b2ac342cde420ce27f23193007a7e6df5a9
diff --git a/internal/release/staged.go b/internal/release/staged.go
index 2c9e666b3..8487a0743 100644
--- a/internal/release/staged.go
+++ b/internal/release/staged.go
@@ -163,17 +163,13 @@ func RetainedStaged(optDir, acct, app, ver string) (*StagedIdentity, *Manifest)
return &id, m
}
-// ResolveLivePath is ResolveLive without the hash: one read of host's live
-// link, the file at the end of its chain (linkChain) and the link's version,
-// with Digest empty. It is for a start that needs only to exec exactly the
-// build it named (the activator's live exec gate, Task 9), where hashing a
-// build of up to a hundred megabytes at every wake would buy nothing.
+// ResolveLivePath is ResolveLiveLink: one read of host's live link, the file
+// at the end of its chain (linkChain) and the link's version, with Digest
+// empty. It is for a start that needs only to exec exactly the build it
+// named (the activator's live exec gate, Task 9), where hashing a build of
+// up to a hundred megabytes at every wake would buy nothing.
func ResolveLivePath(optDir, host string) (StagedIdentity, error) {
- raw, hops, _, err := linkChain(LiveLink(optDir, host))
- if err != nil {
- return StagedIdentity{}, err
- }
- return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: hops[len(hops)-1]}, nil
+ return ResolveLiveLink(optDir, host)
}
// VerifyStaged checks the file at id.StagedPath against id.Digest, the digest