rootfs apps: Astra R2 fix round (all 6 accepted); deck 372 live rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/superpowers/plans/2026-10-24-rootfs-apps-0-index.md+50 −11docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md+123 −33docs/superpowers/plans/2026-10-24-rootfs-apps-2-box-isolation.md+121 −16docs/superpowers/plans/2026-10-24-rootfs-apps-3-lifecycle.md+17 −3docs/superpowers/plans/2026-10-24-rootfs-apps-4-console-and-docs.md+54 −18docs/superpowers/plans/2026-10-24-rootfs-apps-5-egress-allow.md+20 −6docs/superpowers/plans/2026-10-24-rootfs-apps-6-interactive.md+12 −2docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md+21 −13
CI failed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.014s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (1 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (144 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.745s
ok github.com/carlosframework/platform/cmd/moveprobe 11.880s
ok github.com/carlosframework/platform/cmd/zddprobe 0.775s
ok github.com/carlosframework/platform/internal/activator 59.575s
ok github.com/carlosframework/platform/internal/agentsock 0.020s
ok github.com/carlosframework/platform/internal/alerting 0.009s
ok github.com/carlosframework/platform/internal/analytics 0.021s
ok github.com/carlosframework/platform/internal/appconfig 3.070s
ok github.com/carlosframework/platform/internal/apperr 0.115s
ok github.com/carlosframework/platform/internal/applog 0.382s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.011s
ok github.com/carlosframework/platform/internal/boxid 0.895s
? 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.012s
ok github.com/carlosframework/platform/internal/caps 0.015s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.805s
ok github.com/carlosframework/platform/internal/changelog 0.649s
ok github.com/carlosframework/platform/internal/claimactivation 1.314s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? 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 7.636s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.016s
ok github.com/carlosframework/platform/internal/dbquery 0.006s
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
--- FAIL: TestADrainingBoxDoesNotPick (0.13s)
agent.go:135: dbreads: dbr_tiquf6abtgmfoy7j4lnrsrwu5q: planned 1 files (720 bytes) and restored to 1 in 14ms
agent.go:135: dbreads: dbr_tiquf6abtgmfoy7j4lnrsrwu5q: output 4 bytes in 1 confirmed chunks in 36ms
loop_test.go:170: no result for dbr_smehnaynapl6kyogwoxpd5oiyq: dirstore: db-reads/dbr_smehnaynapl6kyogwoxpd5oiyq/result.json: file does not exist
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000004
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000004 maxTXID=0000000000000004 size=537
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000004 txid.db=0000000000000004
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:46:16 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:46:16 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
FAIL
FAIL github.com/carlosframework/platform/internal/dbreads 2.130s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.010s
ok github.com/carlosframework/platform/internal/domains 2.968s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 45.294s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.181s
ok github.com/carlosframework/platform/internal/gate 1.931s
ok github.com/carlosframework/platform/internal/genselect 0.019s
ok github.com/carlosframework/platform/internal/geo 0.023s
ok github.com/carlosframework/platform/internal/geoip 0.045s
ok github.com/carlosframework/platform/internal/historyreadback 0.268s
ok github.com/carlosframework/platform/internal/host 140.959s
ok github.com/carlosframework/platform/internal/hostactors 6.281s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.012s
ok github.com/carlosframework/platform/internal/hostbox 3.013s
ok github.com/carlosframework/platform/internal/hostclaim 0.748s
ok github.com/carlosframework/platform/internal/hostenv 0.010s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.466s
ok github.com/carlosframework/platform/internal/hostfence 0.047s
ok github.com/carlosframework/platform/internal/hostfiles 10.209s
ok github.com/carlosframework/platform/internal/hostfloor 0.026s
ok github.com/carlosframework/platform/internal/hostguard 1.015s
ok github.com/carlosframework/platform/internal/hostops 2.185s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.010s
ok github.com/carlosframework/platform/internal/hostquarantine 0.006s
ok github.com/carlosframework/platform/internal/hostrelocate 0.584s
ok github.com/carlosframework/platform/internal/hostresolve 0.216s
ok github.com/carlosframework/platform/internal/hostwrite 0.008s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.225s
ok github.com/carlosframework/platform/internal/instanceexec 2.806s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.866s
ok github.com/carlosframework/platform/internal/lbhealth 0.004s
ok github.com/carlosframework/platform/internal/lease 10.209s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.564s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.192s
ok github.com/carlosframework/platform/internal/maintenance 0.087s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.594s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.085s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.187s
ok github.com/carlosframework/platform/internal/moves/archive 1.060s
ok github.com/carlosframework/platform/internal/objstore 0.245s
ok github.com/carlosframework/platform/internal/ops 0.203s
ok github.com/carlosframework/platform/internal/park 0.268s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 14.006s
ok github.com/carlosframework/platform/internal/publicrelease 0.016s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.473s
ok github.com/carlosframework/platform/internal/relay 7.230s
ok github.com/carlosframework/platform/internal/release 25.125s
ok github.com/carlosframework/platform/internal/rollout 3.738s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.010s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.512s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.701s
ok github.com/carlosframework/platform/internal/seeds 0.015s
ok github.com/carlosframework/platform/internal/sim/engines 2.786s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.144s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.464s
ok github.com/carlosframework/platform/internal/status 0.084s
ok github.com/carlosframework/platform/internal/steering 0.009s
ok github.com/carlosframework/platform/internal/stopevidence 0.257s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.008s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.662s
FAIL
=== internal_console@0 (84 s)
ok github.com/carlosframework/platform/internal/console 81.167s
=== internal_console@1 (44 s)
ok github.com/carlosframework/platform/internal/console 41.072s
=== internal_console@2 (40 s)
ok github.com/carlosframework/platform/internal/console 37.892s
=== internal_console@3 (40 s)
ok github.com/carlosframework/platform/internal/console 37.692s
=== internal_console@4 (43 s)
ok github.com/carlosframework/platform/internal/console 40.318s
=== internal_console@5 (40 s)
ok github.com/carlosframework/platform/internal/console 37.945s
=== internal_console@6 (37 s)
ok github.com/carlosframework/platform/internal/console 34.861s
=== internal_console@7 (47 s)
ok github.com/carlosframework/platform/internal/console 44.151s
=== root@0 (109 s)
ok github.com/carlosframework/platform 107.300s
=== root@1 (84 s)
ok github.com/carlosframework/platform 82.611s
=== root@2 (91 s)
ok github.com/carlosframework/platform 89.239s
FAILED: rest
make: *** [Makefile:19: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
Intent
after fixes
<task-notification> <task-id>aa263d692428dc3e2</task-id> <tool-use-id>toolu_01B1NULZbQjcLCTrWeb59F3t</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/622e3b2d-1bba-40c2-bd40-b65d02622c4c/tasks/aa263d692428dc3e2.output</output-file> <status>completed</status> <summary>Agent "Fix Astra round 2 findings" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>All six findings were real. I accepted every one, and they are fixed in the six plans, the index and the copy deck. Nothing is committed. I checked the deck itself with a Python stand-in for the new parser: all 372 ids parse, none repeat, and none is struck. No Go was run, since there is no implementation yet. ## Dispositions (Astra R2) 1. **JIT cleanup leaves stale settings: accepted** (Plan 2 Task 5, Plan 6 Task 6). Every cleanup path deletes the JIT drop-in without reloading systemd, and the run, worker, cmd and tty unit names are reused. I fixed it "before reuse": a start reloads when its drop-in changed or a reload is owed. Removals mark a reload owed, only a reload that succeeds clears it, and a restarted control service owes one. I chose this over reloading after each removal so stop paths stay reload-free, which fits the earlier wake-latency work. Tests: `TestRootfsNonJITStartAfterJITCleanupReloads` (JIT start, cleanup, then a non-JIT start with the unit kept loaded, using a fake that models systemd's loaded setting) and `TestRootfsJITReloadStaysOwedUntilItSucceeds`. Plan 6 adds `TestRootfsTTYNonJITSessionAfterAJITOneReloads`. 2. **Instance create races the first ship: accepted** (Plan 1 Task 3, Plan 4 Tasks 2 to 5). - Plan 1 adds `release.EstablishedKind`, which reads the release history and falls back to the kind marker. It also exports `release.EstablishFirstKind`. - The shared boundary is the console's existing per-app lock, `lockApp`. Both doors take it only while the app has no kind, and recheck under it. The ship door counts binary records and writes the marker inside the lock; the create door rereads the head and writes its record inside it. - `WithAppMutation` is a plain call when host claims are off, so it could not serve as the boundary on its own. - Tests cover both interleavings: `TestCreateDuringAnUnpublishedFirstRootfsShipMintsRootfs` and `TestFirstRootfsShipWaitsForAnInFlightCreate`. 3. **The refusal sat on an unreachable path: accepted** (Plan 1 Task 8, Plan 4 Task 5). Plan 1 now names a slot in `shipRootfsParts`, after the identical-reship short circuit and before `ShipSpooled`; Plan 4 fills it and no longer touches `apicli.go`. The real-door test stays, and now also checks that no marker is left behind and that shipping again after deleting the instance works. 4. **Rootfs launches skip the egress readiness pass: accepted** (Plan 5 Task 7, Plan 3 Tasks 9 to 12, Plan 2 Task 9). The readiness call now comes before every rootfs launch: web and generations, workers, `release` and `carlos run`. It always runs before the exec gate and the call to root. Tests: `TestEveryRootfsLaunchWaitsForEgressReady` (activator ordering) and `TestEgressReadyAppliesAFreshGrantWithThePeriodicPassDelayed` (in the host package, to avoid an import cycle). 5. **The copy gate cannot parse its deck: accepted** (Plan 1 Task 1). The id pattern is now `^RFS-[1-6]-C(0[1-9]|[1-9][0-9]{1,2})$`. As you asked, the parser learns `struck` through a new `parseCopyTableStatuses`, for this deck only. A struck row is never reviewed, built or checked as applied, and the older decks keep their two statuses. `TestRootfsCopyDeckParses` reads the actual deck. 6. **The compatibility test contradicts Plan 2's staging: accepted** (Plan 1 Task 2, Plan 2 Task 3). - Old adopters are now tested against a frozen copy of the 64a002c8 artifact pick, checked against `git show`. - The current refusal sits in the adopt pass, which is where the row meets the manifest. A new field, `stageCheck.runtime`, makes staging refuse a release of the other kind before anything downloads. `adoptHost` re-checks the manifest it ends up with, because the retained-copy path never reaches the staging check. - Tests: `TestStageFromRefusesAReleaseOfTheOtherRuntime`, `TestAdoptPassRefusesARootfsReleaseForABinaryRow` and `TestAdoptPassStagesARootfsReleaseForARootfsRow`. ## Decisions to review - **Finding 2 relies on one console per deployment bucket.** The lock is in-process. That is enough only because instance records are written only by the console, and one console serves a bucket (the rule stated in `applocks.go`). The direct-bucket `carlos ship` never ran this refusal and still does not. - **Two deck rows were wrongly struck.** RFS-1-C15 is written by Plan 1 Task 8 until Plan 5 replaces it, and RFS-3-C28 by Plan 3 Task 12 until Plan 6 does. As struck rows, the gate would have refused the tasks that write them. I set both back to `pending` so Paul reviews them. Plans 5 and 6 now mark them `struck` when they remove the strings. The deck counts are now 372 live, 0 struck. ## String IDs - **Added:** none. - **Changed:** RFS-1-C15 and RFS-3-C28 (status back to `pending`, file-cell note updated, text unchanged). ## New shared names in the index `release.EstablishedKind`, `release.EstablishFirstKind`, the `lockApp` first-kind boundary, `rootfsHead.Unfixed`, `jitReloadOwed`, `stageCheck.runtime` / `errRuntimeMismatch`, `rootfsCopyStruck` / `parseCopyTableStatuses`, the egress hook sites, and the new id pattern. Files are in /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-rootfs-apps/docs/superpowers/plans/: - 2026-10-24-rootfs-apps-0-index.md - 2026-10-24-rootfs-apps-1-ship.md - 2026-10-24-rootfs-apps-2-box-isolation.md - 2026-10-24-rootfs-apps-3-lifecycle.md - 2026-10-24-rootfs-apps-4-console-and-docs.md - 2026-10-24-rootfs-apps-5-egress-allow.md - 2026-10-24-rootfs-apps-6-interactive.md - 2026-10-24-rootfs-apps-copy.md</result> <usage><subagent_tokens>368541</subagent_tokens><tool_uses>105</tool_uses><duration_ms>1149587</duration_ms></usage> </task-notification>
Attributed to this commit: 26.3 g CO₂e (3213188 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on design-rootfs-apps, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-0-index.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-0-index.md| index 3aadcafba..8c873a582 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-0-index.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-0-index.md |
| @@ -25,14 +25,18 @@ plans cite the section beside the task that answers it. |
| carries (IDs `RFS-<plan>-C<nn>`). Plan 1 Task 1 puts the gate in place and |
| gets Paul's review before any task writes a string. Copy voice: no em |
| dashes; neutral names only (`shop`, `example.com`), never a tenant's, in |
| -tests too. IDs match `^RFS-[1-6]-C[0-9]{2}$` (so Plan 2's first-draft |
| -`RFS-2-C03a` is `RFS-2-C12`). A placeholder is written `<name>` (Plans 1, 2 |
| +tests too. IDs match `^RFS-[1-6]-C(0[1-9]|[1-9][0-9]{1,2})$`: two digits |
| +to C99, three from C100 (Plan 4 runs to C138), no leading zero (so Plan 2's |
| +first-draft `RFS-2-C03a` is `RFS-2-C12`; Astra R2 finding 5). A placeholder is written `<name>` (Plans 1, 2 |
| and 4) or as the Go verb the code formats (`%s`, `%d`, `%q`; Plans 3, 5 |
| and 6); the gate (`movesTextApplied`) reads both. Two rows are struck by a |
| later plan, through the gate's struck-row rule: RFS-1-C15 by Plan 5 Task 2a |
| (replaced by RFS-5-C43) and RFS-3-C28 by Plan 6 Task 16 (replaced by |
| -RFS-6-C62). The deck file does not exist yet; it is assembled before Plan 1 |
| -Task 1 runs. |
| +RFS-6-C62). Both are live, reviewed and built until then. The rule (Plan 1 |
| +Task 1): the deck's status `struck`, read by `parseCopyTableStatuses` for |
| +this deck only; a struck row stays in `rootfsCopyIDs`, joins |
| +`rootfsCopyStruck`, leaves `rootfsCopyBuilt`, and is never reviewed, built |
| +or checked as applied. The deck exists (372 rows, none struck). |
| ## The series |
| @@ -113,6 +117,24 @@ code stops and records the problem at the top of its own file under |
| app's prefix on purge. |
| - `release.KindConflictError` and `HibernateConflictError` carry a |
| `FirstShip` field. Doors use `Error()`, never a copy of the sentence. |
| +- `release.EstablishedKind(ctx, s, acct, app) (AppKindInfo, error)` (Plan 1, |
| + Astra R2 finding 2): `AppKind`, falling back to the marker when no release |
| + is readable. Every console door that decides by an app's kind (instance |
| + create, placement, the first-rootfs-ship refusal) reads it, so a first |
| + ship whose marker is written and whose manifest is not is honoured. |
| + `AppKind` still reads manifests only. |
| +- `release.EstablishFirstKind(ctx, s, acct string, m *Manifest) error` |
| + (Plan 1): the marker step of publication, exported, so a caller can fix |
| + the first kind inside its own boundary before `ShipSpooled`. |
| +- **The first-kind boundary** (Plan 4, Astra R2 finding 2): the console's |
| + per-app lock `(*Server).lockApp(sqid, app)` (`internal/console/applocks.go`), |
| + taken only while `EstablishedKind` answers no kind. Inside it the ship door |
| + rechecks, counts the app's binary instance records (RFS-4-C11) and calls |
| + `EstablishFirstKind`; the instance-create door rechecks the head and |
| + writes its record. Instance creation exists only through the console, and |
| + one console serves a deployment bucket (`applocks.go`'s rule), so the |
| + in-process lock is the boundary both doors share. The direct-bucket |
| + `carlos ship` never ran the first-rootfs-ship refusal and still does not. |
| ### Fleet run relay (Plan 6, Astra R1 findings 11 and 12) |
| @@ -237,6 +259,11 @@ code stops and records the problem at the top of its own file under |
| ### Egress (Plan 5) |
| +- Every rootfs launch waits for the bounded egress readiness pass |
| + (`ExecBackend.EgressReady`, Plan 5 Task 7, Astra R2 finding 4): web and |
| + generations (`startRootfs`), workers (`StartRootfsWorker`), `release` |
| + (`StartRelease`) and `carlos run` (the rootfs `StartCmd`), each before its |
| + exec gate and root call. It never fails a start. |
| - Capability `egress`, a list of destinations (`10.0.0.0/16`, |
| `db.example.com`). The build declares it; the app's grant names it (R8: |
| the account that owns the fleet grants for its own apps on its own fleet |
| @@ -255,8 +282,12 @@ code stops and records the problem at the top of its own file under |
| root-staged manifest and renders the start without |
| `MemoryDenyWriteExecute` (a per-start drop-in, the same mechanism as the |
| gen and worker drop-ins), then checks the effective property after start |
| - and stops on mismatch (Plan 2). Plan 4's docs add a Node note. No record |
| - or route field: it is per release. |
| + and stops on mismatch (Plan 2). A start daemon-reloads when its drop-in |
| + changed or when any jit drop-in was removed since the last successful |
| + reload (`Server.jitReloadOwed`, Plan 2 Task 5, Astra R2 finding 1), so a |
| + reused unit name never starts on cached jit settings; Plan 6's tty start |
| + inherits it. Plan 4's docs add a Node note. No record or route field: it |
| + is per release. |
| - **R8. Who grants egress.** The account that owns a fleet grants egress for its own apps on its own fleet boxes, and within that account **owners only** (Paul, 2026-10-24; members cannot grant or widen a grant); on a single-tenant deployment the platform operator grants (Plan 5 F3, Task 8). The box's `OwnBoxAllowed` is unchanged and still decides where a grant may open anything. |
| - **R9. Rootfs instances default to the 1 GB tier** (`cgroup.TierChildren`), not 512 MB. Plan 2 owns the tier selection (`rootfsTier`, `GenTier`); Plan 1's `--check` limits memory to it; Plan 4's docs say 1 GB. |
| - **R10. The dev e2e box.** A new `t4g.small` (arm64) in the dev account (AWS profile `kass`, eu-west-1), as a fleet box, plus its fleet token and a `carlos store` bucket and IAM user, deleted after the checks. Approved by Paul 2026-10-24 for Plan 2 Task 10 and Plan 4 Task 11, which cite it. Any change in account, region, resource type, cost or duration needs a fresh approval; Plans 5 and 6's dev checks are not covered by it. |
| @@ -348,9 +379,10 @@ plan's reader finds them here. |
| 10. **One kind reader, `release.AppKind`** (conflict 9). Plan 1 exports |
| `AppKind(ctx, s, acct, app) (AppKindInfo, error)`: the newest readable, |
| valid release among the 20 newest. `CheckShipKind` is `AppKind` plus |
| - the comparison, and Plan 4's create, placement and first-ship doors |
| - call it, so ship and console cannot disagree. Plan 4 writes no |
| - `kind.go`. |
| + the comparison and the marker, and Plan 4's create, placement and |
| + first-ship doors call `EstablishedKind` (`AppKind` plus the same |
| + marker; Astra R2 finding 2), so ship and console cannot disagree. Plan |
| + 4 writes no `kind.go`. |
| 11. **The process uid** (conflict 10). Rootfs units run as `User=carlos` |
| (Plan 2 deviation 6): not root, not the image's `USER`, not in the |
| image's `/etc/passwd`. `--check` uses 65534, which has the same three |
| @@ -363,8 +395,8 @@ plan's reader finds them here. |
| reason `release` or `release-timeout` (Plan 3 Tasks 10, 11). |
| 13. **`carlos run` on fleet boxes** (conflict 12, superseded by ruling R7): |
| refused until Plan 6 Task 16, then carried over the box-dials-the-console |
| - door. Plan 3 Task 12 says so in copy (RFS-3-C28, struck by Plan 6 Task |
| - 16 in favour of RFS-6-C62); Plan 4's guide and checklist do not rely on |
| + door. Plan 3 Task 12 says so in copy (RFS-3-C28, live until Plan 6 Task |
| + 16 strikes it in favour of RFS-6-C62); Plan 4's guide and checklist do not rely on |
| it. Interactive sessions are admitted on fleet boxes from Plan 6 Task 10 |
| on: Plan 6 splits `runClassRefusal` into `instanceClassRefusal` |
| (sessions) and the fleet arm (runs, until Task 16). |
| @@ -405,6 +437,13 @@ plan's reader finds them here. |
| `DefaultReleaseTimeoutSec`, `MaxReleaseTimeoutSec` live in |
| `internal/release/rootfs.go`; hardlinks share data, not inodes; a rootfs |
| release may declare only the `egress` capability (Plan 5 Task 2a). |
| +22. **A row's runtime decides what the adopt pass stages** (Plan 2 Task 3, |
| + Astra R2 finding 6). `stageCheck.runtime` carries the row's runtime into |
| + `stageFrom`, which refuses a manifest of the other kind before any |
| + download (`errRuntimeMismatch`), and `adoptHost` re-checks the manifest |
| + it holds, so a binary row never stages or points at a rootfs image and |
| + a rootfs row never at a binary. Old adopters are pinned by frozen copies |
| + of 64a002c8's kind rule and artifact pick (Plan 1 Task 2). |
| ## Open, for Paul |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md| index ed3ae3ebb..869ba7d77 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md |
| @@ -27,6 +27,17 @@ String IDs added: RFS-1-C80, C81, C82, C83, C84, C85. Changed: RFS-1-C13, C29, C |
| - **Plan 4**: `release.AppKind` and `AppKindInfo` are unchanged, so Plan 4's doors read the same function. `KindConflictError` and `HibernateConflictError` gain a `FirstShip bool` field; a Plan 4 door that formats or matches them by text must use `Error()`, never a restated sentence. Plan 4's docs mention `carlos ship --jit` (R11). |
| - **The index** shared contract: add `JIT bool \`json:"jit,omitempty"\`` to the manifest's new fields (already in R11), and the new bucket key `release.AppKindMarkerKey(acct, app) = "apps/<acct>/<app>/kind.json"` (body `release.AppKindMarker`), written create-only by the first ship and removed only by the app's purge. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, each checked against the plans and the code first. |
| + |
| +- **Finding 2: accepted (Plan 1 half).** Checked: `AppKind` reads manifests only, so a first ship's marker is invisible to Plan 4's instance create until the manifest lands. Plan 1 adds `EstablishedKind` (AppKind plus the marker) and exports `EstablishFirstKind`; Plan 4 builds the boundary. Tests `TestEstablishedKindHonoursAnUnpublishedFirstShip`, `TestEstablishFirstKindThenShipSpooledWritesTheMarkerOnce` (Task 3). Deviation 12. |
| +- **Finding 3: accepted (Plan 1 half).** Checked: Task 8 step 8 returns from `shipRootfsParts` before the binary path's `release.Ship` (`apicli.go:2264`), where Plan 4 had put its refusal. Task 8 now names the slot inside `shipRootfsParts`, after the re-ship short circuit and before `ShipSpooled`; Plan 4 Task 5 fills it. |
| +- **Finding 5: accepted.** Checked: the deck holds 39 three-digit ids (RFS-4-C100 to C138) and two `struck` rows, and `parseCopyTable` accepts only `pending` and `approved` (`approved_moves_copy_test.go:197`). The id pattern now takes canonical two- and three-digit ids; the parser learns `struck` through `parseCopyTableStatuses`, for the rootfs deck only; a struck row is never reviewed, built or checked as applied; `TestRootfsCopyDeckParses` reads the real deck. The deck's two struck rows were wrong to be struck now: RFS-1-C15 is written by Task 8 until Plan 5 Task 2a replaces it, and RFS-3-C28 by Plan 3 Task 12 until Plan 6 Task 16 does, so both are `pending` again and the later plans set `struck` when they remove the string. |
| +- **Finding 6: accepted (Plan 1 half).** Checked: Plan 2 Task 3 makes today's `ensureStaged` stage a rootfs image on linux-arm64, and its no-build message names `rootfs-<platform>`, so `TestAdoptStagesNothingFromARootfsOnlyManifest` would fail on a box and on any other platform. It is replaced by `TestTheOldStageRuleFindsNoBuildInARootfsManifest`, over a frozen copy of 64a002c8's pick (`legacyStagePick64a002c8`), checked against `git show`. The current binary-row refusal is Plan 2 Task 3's, at the adopt pass. |
| + |
| +String IDs added: none. Changed: RFS-1-C15 (deck status `struck` back to `pending`; text unchanged). |
| + |
| ## Contract deviations |
| None renames or changes a contract name. These are additions the later plans must know about: |
| @@ -35,13 +46,14 @@ None renames or changes a contract name. These are additions the later plans mus |
| 2. **`Process.Env` carries the image's own environment.** Flattening loses the OCI config, and Rails images set `PATH`, `BUNDLE_PATH`, `RAILS_ENV` and friends with `ENV`. `carlos ship` merges the config's `Env` into every process's `Env` (the declared value wins) and fills an empty `WorkingDir` from the config's `WorkingDir`. Plans 2 and 3 read `Process.Env` and `Process.WorkingDir` as the whole story: no second source of image config exists. The image's `ENTRYPOINT`, `CMD` and `USER` are not carried; a command is the whole argv. |
| 3. **A rootfs manifest refuses `Capabilities`** in v1: unit-shape capabilities (ports, runtime dir, host role) mean nothing to the rootfs templates, and silently ignoring them is config that lies. Plan 5 lifts this for `egress` only. |
| 4. **New shared constants** in `internal/release/rootfs.go`: `MaxRootfsBytes` (2 GiB, the console's `apiShipMaxBytesRootfs` is set from it), `RootfsHealthTimeout` (120 s, the contract's health bound; `--check` uses it and Plans 2 and 3 should too), `DefaultReleaseTimeoutSec` (600) and `MaxReleaseTimeoutSec` (21600, pinned equal to `runs.MaxTimeout` by a test because `internal/runs` imports `internal/release`). |
| -5. **An app's kind and hibernation are read from its release manifests by one exported reader, `release.AppKind`** (index decision 9). `AppKind(ctx, s, acct, app) (AppKindInfo, error)` answers the newest readable, valid manifest among the app's 20 newest (`VersionCandidates`): its `Kind`, `Version` and `Hibernate`; `Kind == ""` means the app has no release. `release.CheckShipKind` is `AppKind` plus the comparison, and Plan 4's console doors (instance create, placement, the first-rootfs-ship refusal) call `AppKind` too, so the ship door and the console can never disagree. No app-level column is added. Plan 3's record carries the per-instance copy (spec §5.0). |
| +5. **An app's kind and hibernation are read from its release manifests by one exported reader, `release.AppKind`** (index decision 9). `AppKind(ctx, s, acct, app) (AppKindInfo, error)` answers the newest readable, valid manifest among the app's 20 newest (`VersionCandidates`): its `Kind`, `Version` and `Hibernate`; `Kind == ""` means the app has no release. `release.CheckShipKind` is `AppKind` plus the comparison (and the marker, deviation 10), and Plan 4's console doors (instance create, placement, the first-rootfs-ship refusal) call `EstablishedKind`, which is `AppKind` plus the same marker (deviation 12), so the ship door and the console can never disagree, even while a first ship's publication is incomplete. No app-level column is added. Plan 3's record carries the per-instance copy (spec §5.0). |
| 6. **`--check` runs every process as uid and gid 65534.** Plan 2 answers the box half (its deviation 6): every rootfs unit runs as `User=carlos`, the box's own unprivileged service uid, which is not root, not the image's `USER`, and not in the image's `/etc/passwd`. 65534 is the local stand-in with the same three properties; the number differs because the carlos uid is box-specific (index decision 10). |
| 7. **Hardlinks** come out of the squashfs writer as separate inodes that share one copy of the data, not as one inode with two names (a limit of the library, `CloneInode`). Content and size are what an app sees; `st_ino` identity is not kept. The round-trip test pins the shared data. |
| 8. **`carlos ship` also adds `/etc` and an empty `/etc/resolv.conf`** (Plan 2 deviation 4, index decision 6). The rootfs unit binds root's resolver copy onto `/etc/resolv.conf`, and a file bind needs its target in the read-only image. `AddMountPoints` creates `etc` (0755, 0:0) if missing and `etc/resolv.conf` (an empty regular file, 0644, 0:0) if missing; an existing regular file is kept (the bind hides it); a symlink or any other type at `etc/resolv.conf` is replaced by the empty file, because the box binds its own resolver there whatever the image says. Plan 2's other half of its deviation 4 (a process's `working_dir` and `PATH` from the OCI config) is already deviation 2: `ApplyImageConfig` fills both. |
| 9. **The memory limit is the 1 GiB tier** (`cgroup.TierChildren`, Paul's ruling R9 in the index): `--check` limits memory to `cgroup.TierChildren.MemoryMax`, the tier Plan 2 gives every rootfs row by default. |
| 10. **An app's first ship writes a kind marker** (Astra R1 finding 9). `release.AppKindMarkerKey(acct, app) = "apps/<acct>/<app>/kind.json"`, body `release.AppKindMarker{Schema, Account, App, Kind, Hibernate, Version, CreatedAt}`, written create-only (`Put` with `ifMatch ""`) by the ship that finds the app with no other release and no marker, and never rewritten. It is what makes first-kind establishment atomic across the console door, the direct-mode ship and a second console. The key sits under `apps/<acct>/<app>/`, so the claim boundary already classifies it as the app-scoped `apps` family (`internal/hostops/mutation_classifier.go:110-111`) and the app's purge deletes it with the prefix (`trash.go:272`); no classifier family is added. `AppKind` keeps reading manifests only (deviation 5 is unchanged); `CheckShipKind` reads the marker when the app has no other release. A marker whose ship never published (the ship failed after the marker and before its manifest) still fixes the app's kind; the refusal names it as the app's "first ship", never as a release (RFS-1-C80 to C83), and shipping that kind again works. |
| 11. **The JIT opt-in** (Paul's ruling R11, spec §5.11). `Manifest.JIT bool \`json:"jit,omitempty"\``, rootfs only (`ErrRootfsFieldsOnly` otherwise, RFS-1-C13), set by `carlos ship --rootfs --jit`. It is per release: a later release may turn it on or off, so `CheckShipKind` and the kind marker ignore it. A re-ship of a version with a different `jit` is a version conflict (RFS-1-C29). `--check` cannot test it (a local runtime does not apply `MemoryDenyWriteExecute`), so it warns (RFS-1-C85) when a process runs Node, Bun or Deno and `jit` is not set (`rootfsimage.JITProgram`, Task 7). |
| +12. **Two marker-aware names for Plan 4** (Astra R2 finding 2). `release.EstablishedKind(ctx, s, acct, app) (AppKindInfo, error)` is `AppKind`, falling back to the kind marker when no release is readable, so a door honours a first ship whose marker is written and whose manifest is not. `release.EstablishFirstKind(ctx, s, acct string, m *Manifest) error` is `establishShipKind` exported, so the console can fix an app's first kind inside its own per-app lock before the slow `ShipSpooled`. The boundary itself (the console's `lockApp` around first-kind establishment and instance creation) is Plan 4's, Tasks 3 and 5. |
| ## Global Constraints |
| @@ -211,16 +223,17 @@ This is the series' first task. No task of any rootfs plan writes a user-facing |
| **Files:** |
| - Create: `approved_rootfs_copy_test.go` |
| +- Modify: `approved_moves_copy_test.go:149-198` (`parseCopyTable` becomes a wrapper over `parseCopyTableStatuses`; Astra R2 finding 5) |
| - Modify: `docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md` (statuses and Paul's edits) |
| **Interfaces:** |
| - Consumes: `parseCopyTable(src, name string, ids *regexp.Regexp, idKind string) ([]copyRow, error)` (`approved_moves_copy_test.go:149`), `checkMovesInventory(rows []copyRow, want []string) []string` (`:211`), `checkMovesNoEmDash(rows []copyRow) []string` (`:785`), `reportAll(t *testing.T, problems []string)` (`:820`), `checkRunBuilt(rows []copyRow, built []string) []string`, `builtRows(rows []copyRow, built []string) []copyRow`, `checkRunApplied(rows []copyRow, root string) []string` (all `approved_run_copy_test.go`, package `main`). |
| -- Produces: `rootfsCopyRecordPath`, `rootfsCopyIDPattern`, `rootfsCopyIDs []string` (the deck's inventory, pinned), `rootfsCopyBuilt []string` (empty here; every later task of every rootfs plan appends), `mustParseRootfsRecord(t) []copyRow`. |
| +- Produces: `rootfsCopyRecordPath`, `rootfsCopyIDPattern`, `rootfsCopyIDs []string` (the deck's inventory, pinned), `rootfsCopyStruck []string` (the struck rows, pinned), `rootfsCopyStatuses`, `parseCopyTableStatuses` (in `approved_moves_copy_test.go`), `rootfsCopyBuilt []string` (empty here; every later task of every rootfs plan appends), `mustParseRootfsRecord(t) []copyRow`. |
| - [ ] **Step 0: Confirm the deck exists** |
| Run: `test -f docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md && grep -c '^| RFS-' docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md` |
| -Expected: a count that includes this plan's 79 rows. If the file is missing, stop: the orchestrator assembles it from the six plans' tables, and this task cannot pin an inventory that does not exist. |
| +Expected: 372, the deck's "Counts" total (this plan's 85 rows included), and `grep -c '| struck |$'` on the same file prints 0. If the file is missing, stop: the orchestrator assembles it from the six plans' tables, and this task cannot pin an inventory that does not exist. |
| - [ ] **Step 1: Write the gate** |
| @@ -240,42 +253,87 @@ package main |
| // - TestRootfsBuiltCopyIsApplied: a built row's text is in the file it names. |
| const rootfsCopyRecordPath = "docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md" |
| -var rootfsCopyIDPattern = regexp.MustCompile(`^RFS-[1-6]-C[0-9]{2}$`) |
| +// Canonical ids only: two digits from C01 to C99, three digits from C100 |
| +// (Plan 4 runs to RFS-4-C138). No leading zero on a three-digit id, no C00. |
| +var rootfsCopyIDPattern = regexp.MustCompile(`^RFS-[1-6]-C(0[1-9]|[1-9][0-9]{1,2})$`) |
| // rootfsCopyIDs is the deck's inventory, pinned as a literal list in the |
| -// deck's order: RFS-1-C01 to RFS-1-C85, then each later plan's rows. |
| +// deck's order: RFS-1-C01 to RFS-1-C85, then each later plan's rows. A |
| +// struck row stays in it: the deck keeps the row for history. |
| var rootfsCopyIDs = []string{ /* every id in the deck, in order */ } |
| +// rootfsCopyStruck is every id the deck marks struck, pinned, so a row can |
| +// neither become struck nor stop being struck without this list changing in |
| +// the same commit. Empty at Task 1: RFS-1-C15 and RFS-3-C28 are live until |
| +// Plan 5 Task 2a and Plan 6 Task 16 strike them. |
| +var rootfsCopyStruck = []string{} |
| + |
| +// rootfsCopyStatuses are the statuses the deck may carry. struck is the |
| +// rootfs deck's own: a struck row is never reviewed, never built and never |
| +// checked as applied. The older decks keep parseCopyTable's two statuses. |
| +var rootfsCopyStatuses = []string{"pending", "approved", "struck"} |
| + |
| // rootfsCopyBuilt is every id whose text the code or docs carry. The task |
| // that writes a row's text adds its id here in the same commit, once Paul |
| // has approved the row. |
| var rootfsCopyBuilt = []string{} |
| func TestRootfsCopyIDPattern(t *testing.T) { |
| - for _, ok := range []string{"RFS-1-C01", "RFS-6-C99"} { |
| + for _, ok := range []string{"RFS-1-C01", "RFS-6-C99", "RFS-4-C100", "RFS-4-C138"} { |
| if !rootfsCopyIDPattern.MatchString(ok) { |
| t.Errorf("%s refused", ok) |
| } |
| } |
| - for _, bad := range []string{"RFS-1-C1", "RFS-7-C01", "RFS-0-C01", "RFS-1-C001", "RUN-C1", "rfs-1-c01"} { |
| + for _, bad := range []string{"RFS-1-C1", "RFS-7-C01", "RFS-0-C01", "RFS-1-C00", "RFS-1-C001", "RFS-4-C0100", "RFS-4-C1000", "RUN-C1", "rfs-1-c01"} { |
| if rootfsCopyIDPattern.MatchString(bad) { |
| t.Errorf("%s accepted", bad) |
| } |
| } |
| } |
| + |
| +// The real deck, not a fixture (Astra R2 finding 5): it parses under the |
| +// rootfs statuses, holds three-digit ids, and its struck rows are exactly |
| +// rootfsCopyStruck. |
| +func TestRootfsCopyDeckParses(t *testing.T) { |
| + rows := mustParseRootfsRecord(t) |
| + if len(rows) != len(rootfsCopyIDs) { |
| + t.Fatalf("the deck has %d rows, the inventory %d", len(rows), len(rootfsCopyIDs)) |
| + } |
| + var three bool |
| + var struck []string |
| + for _, r := range rows { |
| + three = three || r.ID == "RFS-4-C100" |
| + if r.Status == "struck" { |
| + struck = append(struck, r.ID) |
| + } |
| + } |
| + if !three { |
| + t.Error("the deck has no RFS-4-C100: three-digit ids did not parse") |
| + } |
| + if !slices.Equal(struck, rootfsCopyStruck) { |
| + t.Errorf("struck rows %v, pinned %v", struck, rootfsCopyStruck) |
| + } |
| + // The older decks' parser still refuses the rootfs-only status. |
| + src := "| id | file | text | status |\n|---|---|---|---|\n| RFS-1-C01 | a.go | one | struck |\n" |
| + if _, err := parseCopyTable(src, "x.md", rootfsCopyIDPattern, "an RFS-<plan>-C<nn>"); err == nil || !strings.Contains(err.Error(), "neither pending nor approved") { |
| + t.Errorf("parseCopyTable accepted struck: %v", err) |
| + } |
| +} |
| ``` |
| -with `mustParseRootfsRecord` (reads the file, `parseCopyTable(..., rootfsCopyIDPattern, "an RFS-<plan>-C<nn>")`), and the four tests over `checkMovesInventory`, `checkMovesNoEmDash`, `checkRunBuilt` and `checkRunApplied`. `TestRootfsBuiltCopyIsApplied` replaces a row's literal `\n` with a newline before checking (RFS-1-C34 is two lines), as `TestDBForwardBuiltCopyIsApplied` does. |
| +with `mustParseRootfsRecord` (reads the file, `parseCopyTableStatuses(..., rootfsCopyIDPattern, "an RFS-<plan>-C<nn>", rootfsCopyStatuses...)`), and the four tests over `checkMovesInventory`, `checkMovesNoEmDash`, `checkRunBuilt` and `checkRunApplied`. `TestRootfsBuiltCopyIsApplied` replaces a row's literal `\n` with a newline before checking (RFS-1-C34 is two lines), as `TestDBForwardBuiltCopyIsApplied` does. `checkRunBuilt` already refuses a built id whose row is not `approved`, so a struck row in `rootfsCopyBuilt` fails `TestRootfsBuiltCopyIsApproved` ("is built but struck"), and `checkRunApplied` only ever sees built rows, so a struck row is never checked as applied. |
| + |
| +The parser change (in `approved_moves_copy_test.go`, the one edit outside the new file): `parseCopyTable(src, name, ids, idKind)` becomes a one-line wrapper over `parseCopyTableStatuses(src, name string, ids *regexp.Regexp, idKind string, statuses ...string) ([]copyRow, error)` with `"pending", "approved"`, and the status check becomes `!slices.Contains(statuses, r.Status)`. The wrapper keeps today's refusal text exactly (`which is neither pending nor approved`, pinned by `approved_moves_copy_test.go:878-879`); the variadic form says `which is not one of <statuses joined by ", ">`. Every existing deck keeps its two statuses. |
| - [ ] **Step 2: Run it** |
| -Run: `nice -n 10 go test . -run 'TestRootfsCopy|TestRootfsBuilt' -count=1` |
| -Expected: PASS (every row pending, nothing built). |
| +Run: `nice -n 10 go test . -run 'TestRootfsCopy|TestRootfsBuilt|TestMovesCopy|TestParseMovesCopyTable|TestRunCopy' -count=1` |
| +Expected: PASS (every row pending, none struck, nothing built; the older decks' gates unchanged). |
| - [ ] **Step 3: Commit the gate** |
| ```bash |
| -git add approved_rootfs_copy_test.go |
| +git add approved_rootfs_copy_test.go approved_moves_copy_test.go |
| git commit -m "rootfs: the series copy gate (Plan 1 Task 1) |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| @@ -283,11 +341,11 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - [ ] **Step 4: Paul's copy review** |
| -Invoke the `copy-review` skill with every row of the deck, grouped by plan and, within this plan, by the file column, with the placeholder key above as context. Paul reviews the whole series in one pass. **Stop here until Paul has submitted the review.** Read the review's `result.json` before restarting the review server for any reason: a relaunch deletes it. |
| +Invoke the `copy-review` skill with every live row of the deck (every row not `struck`; none is at this point), grouped by plan and, within this plan, by the file column, with the placeholder key above as context. Paul reviews the whole series in one pass. **Stop here until Paul has submitted the review.** Read the review's `result.json` before restarting the review server for any reason: a relaunch deletes it. |
| - [ ] **Step 5: Apply the review verbatim** |
| -For every row: the text cell becomes Paul's text exactly, the status `approved`. A struck row is deleted from the deck and from `rootfsCopyIDs`, and the task that names it drops the string. An added row takes the next free id of its plan and joins `rootfsCopyIDs`. No em dash. |
| +For every row: the text cell becomes Paul's text exactly, the status `approved`. A row Paul strikes gets the status `struck` and a file-cell note `(struck in Paul's review)`, stays in the deck and in `rootfsCopyIDs`, joins `rootfsCopyStruck`, and the task that names it drops the string. A later plan that supersedes an approved row does the same in the commit that removes the string (Plan 5 Task 2a for RFS-1-C15, Plan 6 Task 16 for RFS-3-C28), and drops the id from `rootfsCopyBuilt`. An added row takes the next free id of its plan and joins `rootfsCopyIDs`. No em dash. |
| Run: `nice -n 10 go test . -run 'TestRootfsCopy|TestRootfsBuilt' -count=1` |
| Expected: PASS. |
| @@ -459,26 +517,53 @@ func TestTheOldKindRuleRefusesARootfsManifest(t *testing.T) { |
| } |
| } |
| -// Today's adopt pass, against a release that carries only the rootfs |
| -// artifact, finds no GOOS-GOARCH build and stages nothing (spec C2's second |
| -// half). Plan 2 adds rootfs staging for rootfs rows and keeps this for every |
| -// other row. |
| -func TestAdoptStagesNothingFromARootfsOnlyManifest(t *testing.T) { |
| - s := NewDirStore(t.TempDir()) |
| - ctx := context.Background() |
| - opt := t.TempDir() |
| - putRootfsRelease(t, s, "bab", "shop", "0.1.0-1", []byte("squashfs bytes")) |
| - _, err := ensureStaged(ctx, s, opt, "bab", "shop", "0.1.0-1", "shop.example.com") |
| - if err == nil || !strings.Contains(err.Error(), "has no "+adoptPlatform+" build") { |
| - t.Fatalf("ensureStaged = %v, want the no-build skip", err) |
| +// errLegacyNoBuild stands for the old stage pass's "no build for this |
| +// platform, skipping" refusal (adopt.go:2373-2375 at 64a002c8). |
| +var errLegacyNoBuild = errors.New("legacy stage rule: no build for this platform") |
| + |
| +// legacyStagePick64a002c8 is stageFrom's artifact pick as main had it at |
| +// 64a002c8 (adopt.go:2327 and :2373-2375): the GOOS-GOARCH key and nothing |
| +// else, whatever the manifest's kind. Frozen here (Astra R2 finding 6): an |
| +// old box runs this rule forever, while Plan 2 changes the current |
| +// ensureStaged to stage rootfs images, so the old rule cannot be tested |
| +// through today's code. |
| +func legacyStagePick64a002c8(m *Manifest, platform string) (Artifact, error) { |
| + art, hasBuild := m.Artifacts[platform] |
| + if !hasBuild { |
| + return Artifact{}, errLegacyNoBuild |
| } |
| - if _, err := os.Lstat(VersionPath(opt, "bab", "0.1.0-1", "shop")); !errors.Is(err, os.ErrNotExist) { |
| - t.Fatalf("something was staged: %v", err) |
| + return art, nil |
| +} |
| + |
| +// C2's second half, against the frozen old rule: even an old box that |
| +// somehow parsed a rootfs manifest finds no build on any platform and |
| +// stages nothing. The current binary-row refusal is Plan 2 Task 3's, at the |
| +// adopt pass that receives the row. |
| +func TestTheOldStageRuleFindsNoBuildInARootfsManifest(t *testing.T) { |
| + m := rootfsManifest() |
| + for _, platform := range []string{"linux-arm64", "linux-amd64", "darwin-arm64"} { |
| + if _, err := legacyStagePick64a002c8(m, platform); !errors.Is(err, errLegacyNoBuild) { |
| + t.Errorf("%s: the old rule picked a build from a rootfs manifest: %v", platform, err) |
| + } |
| + } |
| + bin := &Manifest{Kind: "binary", Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64"}}} |
| + if a, err := legacyStagePick64a002c8(bin, "linux-arm64"); err != nil || a.File != "linux-arm64" { |
| + t.Fatalf("the frozen copy no longer picks a binary build: %+v %v", a, err) |
| + } |
| + // The frozen copy is honest: the base commit's source holds that pick. |
| + out, err := exec.Command("git", "show", "64a002c8:internal/release/adopt.go").Output() |
| + if err != nil { |
| + t.Skipf("the base commit is not in this checkout (a shallow clone?): %v", err) |
| + } |
| + for _, line := range []string{"art, hasBuild := m.Artifacts[adoptPlatform]", "if !hasBuild {"} { |
| + if !bytes.Contains(out, []byte(line)) { |
| + t.Fatalf("64a002c8's adopt.go no longer holds %q, which legacyStagePick64a002c8 copies", line) |
| + } |
| } |
| } |
| ``` |
| -`putRootfsRelease(t, s, acct, app, ver string, body []byte)` is `putRawRelease`'s shape (`demand_test.go:18`): the artifact at `ArtifactKey(acct, app, ver, RootfsArtifactFile)`, then a manifest with kind `rootfs`, the one `rootfs-linux-arm64` artifact and a `web` process. |
| +`putRootfsRelease(t, s, acct, app, ver string, body []byte)` is `putRawRelease`'s shape (`demand_test.go:18`): the artifact at `ArtifactKey(acct, app, ver, RootfsArtifactFile)`, then a manifest with kind `rootfs`, the one `rootfs-linux-arm64` artifact and a `web` process. Task 3 and later tasks seed history with it. |
| In `golden_test.go`, `TestYesterdaysManifestParsesWithNoProcesses`: yesterday's binary manifest bytes parse with `Processes == nil`, `Hibernate == false` and `JIT == false`, and `json.MarshalIndent` of the parsed value contains none of `"processes"`, `"hibernate"` and `"jit"`. |
| @@ -494,7 +579,7 @@ func TestReleaseTimeoutMaxIsTheRunMax(t *testing.T) { |
| - [ ] **Step 2: Run them to see them fail** |
| -Run: `nice -n 10 go test ./internal/release/ ./internal/runs/ -run 'TestValidWorkerName|TestValidateProcesses|TestValidateManifestRootfsRules|TestTheOldKindRule|TestAdoptStagesNothing|TestYesterdaysManifestParsesWithNoProcesses|TestReleaseTimeoutMax' -count=1` |
| +Run: `nice -n 10 go test ./internal/release/ ./internal/runs/ -run 'TestValidWorkerName|TestValidateProcesses|TestValidateManifestRootfsRules|TestTheOldKindRule|TestTheOldStageRule|TestYesterdaysManifestParsesWithNoProcesses|TestReleaseTimeoutMax' -count=1` |
| Expected: FAIL to compile (`KindRootfs`, `Process`, `ValidWorkerName` undefined). |
| - [ ] **Step 3: Implement `internal/release/rootfs.go` and the two `release.go` edits** |
| @@ -508,7 +593,7 @@ Before committing, list every parser of the manifest (`grep -rn 'release.Manifes |
| - [ ] **Step 4: Run them to see them pass** |
| Run: the Step 2 command. |
| -Expected: PASS (`TestTheOldKindRuleRefusesARootfsManifest` may report SKIP only where the base commit is absent). |
| +Expected: PASS (`TestTheOldKindRuleRefusesARootfsManifest` and `TestTheOldStageRuleFindsNoBuildInARootfsManifest` may report SKIP only where the base commit is absent). |
| - [ ] **Step 5: Commit** |
| @@ -542,6 +627,8 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - `func AppKindMarkerKey(acct, app string) string` (`appPrefix(acct, app) + "kind.json"`) |
| - `func readAppKindMarker(ctx context.Context, s Store, acct, app string) (*AppKindMarker, error)`: `(nil, nil)` when absent; a body that does not decode strictly, has another schema, or names another account or app is an error (fail closed, never skipped) |
| - `func establishShipKind(ctx context.Context, s Store, acct string, m *Manifest) error`: the kind check at publication, and the create-only marker write for an app's first ship |
| + - `func EstablishedKind(ctx context.Context, s Store, acct, app string) (AppKindInfo, error)` (Astra R2 finding 2): `AppKind`, and when it answers `Kind == ""`, the kind marker's `{Kind, Version, Hibernate}` (`readAppKindMarker`; a bad marker is an error, fail closed). The reader for a door that must honour a first ship whose publication is incomplete: Plan 4's instance create, placement and first-rootfs-ship refusal. `AppKind` itself still reads manifests only (deviation 5) |
| + - `func EstablishFirstKind(ctx context.Context, s Store, acct string, m *Manifest) error` (Astra R2 finding 2): `establishShipKind`, exported, for a caller that must fix an app's first kind inside its own mutation boundary before a slow publication (Plan 4 Task 5, under the console's per-app lock). The publication that follows (`ShipSpooled`, `Ship`) runs `establishShipKind` again, finds the marker of its own kind and passes; nothing is written twice |
| - `func ShipSpooled(ctx context.Context, s Store, acct string, m Manifest, paths map[string]string) error` (artifact key to local file path; runs as action `"release.ShipSpooled"`) |
| - `func IsRootfsArtifactKey(key string) bool` (`apps/<acct>/<app>/releases/<version>/rootfs.squashfs`, each part valid by `ValidAccount`, `ValidApp`, `ValidVersion`) |
| @@ -580,6 +667,8 @@ and, in the same shape: |
| - `TestCheckShipKindReadsTheMarkerOfAnUnpublishedFirstShip`: a marker naming binary `0.1.0-1` and no manifest: a rootfs manifest is a `KindConflictError` with `FirstShip`, `HaveVersion == "0.1.0-1"`, and a message starting `shop's first ship, 0.1.0-1, made it an app for binary releases, so it cannot take a rootfs release`; a binary `0.1.0-1` or `0.1.0-2` is nil (shipping the first kind again works); a rootfs marker with `Hibernate` true refuses an always-on rootfs release with C83, and the reverse with C82. |
| - `TestCheckShipKindFailsClosedOnABadMarker`: a marker body naming another app, another schema, or not JSON makes `CheckShipKind` and `establishShipKind` return an error, and nothing is written. |
| - `TestFirstShipsOfTwoKindsRaceOneWinner` (finding 9): over a wrapper store whose `Put` of the marker key blocks until both ships have reached it (a two-party barrier: both have read no history and no marker, the window the old plan accepted), and which counts every `Put` and `PutFrom` per key, run `Ship` of a binary `0.1.0-1` and `ShipSpooled` of a rootfs `0.1.0-2` in two goroutines, then release the barrier. Exactly one returns nil; the other returns a `KindConflictError` with `FirstShip` naming the winner's kind and version; the marker names the winner; `VersionCandidates` lists only the winner's version, and the loser wrote no artifact and no manifest (its marker put lost before its first artifact write). The test loops 50 times (`for i := range 50`, a fresh store each time) and runs under `-race` in the plan's gate. |
| +- `TestEstablishedKindHonoursAnUnpublishedFirstShip` (Astra R2 finding 2): an app with no release and no marker gives `AppKindInfo{}`; after `EstablishFirstKind` of a hibernating rootfs `0.1.0-1` and before any artifact or manifest write, `EstablishedKind` gives `{rootfs, 0.1.0-1, true}` while `AppKind` still gives `AppKindInfo{}`; once a binary release exists for another app, its `EstablishedKind` equals its `AppKind`; a marker naming another app is an error. |
| +- `TestEstablishFirstKindThenShipSpooledWritesTheMarkerOnce`: `EstablishFirstKind` then `ShipSpooled` of the same manifest: nil, nil; one `Put` of the marker key (the counter of `TestFirstShipWritesTheKindMarker`); after `EstablishFirstKind` of rootfs, a `Ship` of a binary release is a `KindConflictError` with `FirstShip`. |
| - `TestJITIsNotPartOfTheKind`: after a rootfs release without `JIT`, a rootfs release with `JIT` (and the reverse) passes `CheckShipKind` and `establishShipKind`; the marker carries no `jit`. |
| In `internal/hostops/mutation_boundary_putfrom_test.go`: |
| @@ -589,14 +678,14 @@ In `internal/hostops/mutation_boundary_putfrom_test.go`: |
| - [ ] **Step 2: Run them to see them fail** |
| -Run: `nice -n 10 go test ./internal/release/ ./internal/hostops/ -run 'TestAppKind|TestCheckShipKind|TestShipSpooled|TestFirstShip|TestJITIsNotPartOfTheKind|TestMutationBoundaryStreams|TestMutationBoundaryStillBuffers' -count=1` |
| +Run: `nice -n 10 go test ./internal/release/ ./internal/hostops/ -run 'TestAppKind|TestCheckShipKind|TestShipSpooled|TestFirstShip|TestEstablish|TestJITIsNotPartOfTheKind|TestMutationBoundaryStreams|TestMutationBoundaryStillBuffers' -count=1` |
| Expected: FAIL to compile. |
| - [ ] **Step 3: Implement** |
| `AppKind` reads `VersionCandidates(ctx, s, acct, app, 20)`, newest first, reads each manifest through `GetQualified(ctx, s, ManifestKey(...))` the way `PreviousProgram` does (`program.go:120-165`: not-exist skipped, decode or `validateManifest` failures logged and skipped, store errors returned), and answers the first readable one. `CheckShipKind` calls `AppKind(ctx, s, acct, m.App)`. When it answers another release (`Kind != ""` and `info.Version != m.Version`), that release decides, as before: it refuses when rootfs-ness differs (`KindConflictError` from the info) or both are rootfs with different `Hibernate` (`HibernateConflictError`), else nil. Otherwise (no release, or only the version being re-shipped) it reads the marker: absent is nil; present, the same two comparisons against the marker, with `FirstShip` set. There is no version skip against the marker: a re-ship of the marker's own version with the other kind is still the other kind. `JIT` is compared nowhere (deviation 11). Kind is fixed by the first ship, so the newest release, or before any release the marker, speaks for the app. |
| -`establishShipKind` is the authoritative check. It runs `CheckShipKind`'s two reads (an unexported `shipKindState` returns the info and the marker so nothing is read twice) and returns its refusal unwrapped, so `Error()` is the deck sentence. When the app has no other release and no marker, it puts the marker create-only (`s.Put(ctx, AppKindMarkerKey(acct, m.App), body, "")`, the `claimRawTrashApp` pattern). `ErrCASConflict` means another first ship won: it reads the marker again and compares (a conflict is refused; the same kind is nil). Any other store error is returned as `fmt.Errorf("ship: app kind: %w", err)`, and nothing is published. `claimRawShip` and `claimRawShipSpooled` call it after `validateManifest` and before their first artifact write, so a ship that loses the race writes no bytes to the bucket; their manifest is still written last. Apps whose history predates the marker never get one: their manifests already fix their kind, and two first ships cannot race on an app that has a release. |
| +`establishShipKind` is the authoritative check. It runs `CheckShipKind`'s two reads (an unexported `shipKindState` returns the info and the marker so nothing is read twice) and returns its refusal unwrapped, so `Error()` is the deck sentence. When the app has no other release and no marker, it puts the marker create-only (`s.Put(ctx, AppKindMarkerKey(acct, m.App), body, "")`, the `claimRawTrashApp` pattern). `ErrCASConflict` means another first ship won: it reads the marker again and compares (a conflict is refused; the same kind is nil). Any other store error is returned as `fmt.Errorf("ship: app kind: %w", err)`, and nothing is published. `claimRawShip` and `claimRawShipSpooled` call it after `validateManifest` and before their first artifact write, so a ship that loses the race writes no bytes to the bucket; their manifest is still written last. Apps whose history predates the marker never get one: their manifests already fix their kind, and two first ships cannot race on an app that has a release. `EstablishFirstKind` is `establishShipKind` exported; `EstablishedKind` is `AppKind` plus one `readAppKindMarker` when `AppKind` answers no release. |
| `claimRawShipSpooled` is `claimRawShip` (`release.go:631-678`) over files on disk: the same account, swap and file-set checks; each artifact hashed and sized by streaming the file once; a set `SHA256` must match; `validateManifest`; then `putImmutableFrom` per artifact and `putImmutable` for the manifest, manifest last. `putImmutableFrom(ctx, s, key, path string, sha string, size int64) error` opens the file and calls `PutObjectFrom(..., "")`; on `ErrCASConflict` it streams the existing object (`OpenObject`) through sha256 and answers nil only for the same digest, else the `putImmutable` wording. |
| @@ -611,7 +700,7 @@ Expected: FAIL naming the units and their digests. Read each unit, then copy the |
| - [ ] **Step 5: Run everything this task touched** |
| -Run: `nice -n 10 go test ./internal/release/ ./internal/hostops/ ./internal/provision/ -run 'TestAppKind|TestCheckShipKind|TestShipSpooled|TestFirstShip|TestJITIsNotPartOfTheKind|TestMutationBoundary|TestPlan9B8' -count=1`, then the existing ship tests, `nice -n 10 go test ./internal/release/ -run 'Ship|Trash|Purge' -count=1`. |
| +Run: `nice -n 10 go test ./internal/release/ ./internal/hostops/ ./internal/provision/ -run 'TestAppKind|TestCheckShipKind|TestShipSpooled|TestFirstShip|TestEstablish|TestJITIsNotPartOfTheKind|TestMutationBoundary|TestPlan9B8' -count=1`, then the existing ship tests, `nice -n 10 go test ./internal/release/ -run 'Ship|Trash|Purge' -count=1`. |
| Expected: PASS. An existing test that pins the exact key set a first `Ship` writes gains the marker key; say so in the commit message for each one changed. |
| - [ ] **Step 6: Commit** |
| @@ -1028,6 +1117,7 @@ The door's order after this task, new steps in bold: |
| - The next part must be named `rootfs-linux-arm64`, else 400 C27. It is copied to `<dir>/rootfs.squashfs` through sha256, under `io.LimitReader(part, RootfsSize+1)`; `shipMaxBytesRefused` first, then a length other than `RootfsSize` is 400 C28. The part after it must be `io.EOF`, else the existing `too many file parts (max %d for a %s release)` with 1. |
| - The manifest: kind rootfs, the one artifact (file `rootfs.squashfs`, the digest and size computed here), processes, hibernate, jit, label, notes, swap, `BuiltBy: "console-api " + version.Build`. |
| - An existing manifest at this version: artifacts that differ are the existing 409 (`:2204`); processes, hibernate or jit that differ are 409 C29; otherwise 200 with the existing body shape (`result`, `version`, `artifacts`, build probe). |
| +- **The first-ship slot** (Astra R2 findings 2 and 3): nothing in Plan 1. Plan 4 Task 5 inserts its first-rootfs-ship refusal and the first-kind boundary at exactly this point: after the re-ship short circuit above (a re-ship stays a 200) and before `ShipSpooled`. Step 8 returns before the binary path's `release.Ship`, so this is the only place a rootfs publication can be refused. Keep the short circuit and the `ShipSpooled` call adjacent, so the slot stays one obvious place. |
| - `release.ShipSpooled` under a `apiShipRootfsStoreTimeout` context; a kind or hibernation refusal is 409 with its text (step 9); a version conflict is the existing 409 wording via `shipImmutableConflict`; another error is the existing 502. |
| - Audit `ship` with `Detail: "shipped " + ver + " (cli, rootfs)"`; 201 with the same body shape. |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-2-box-isolation.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-2-box-isolation.md| index 4f877dc6b..189f6d4f9 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-2-box-isolation.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-2-box-isolation.md |
| @@ -34,6 +34,18 @@ No new user-facing string: every text these fixes add is a 502 body, a probe che |
| - **Index, "Environment the processes get":** add "an explicit argv (`carlos run`, `carlos run -it`) inherits `web`'s `env` and `working_dir`". **Index, "Box facts":** `rootfs_ready` also requires the probe's `box-addr` and `mdwe` lines, which are in the binary probe too. |
| - **Plan 1:** `Manifest.JIT` is read here as `m.JIT` (R11); nothing else. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, each checked against the plan and the code first. Findings 2, 3 and 5 belong to other plans. |
| + |
| +| Finding | Disposition | What changed | |
| +|---|---|---| |
| +| 1 | accepted | Checked: step 5 reloaded only when `writeJITDropIn` changed a file, and every cleanup path (stop, sweep, `cleanCmdExe`, the mismatch path) deletes the jit drop-in without a reload; the run, worker, cmd and tty unit names are reused (only generation names are not, `gen.go:273`), so a non-jit start after a jit one could start on systemd's cached `MemoryDenyWriteExecute=no` and be killed by the post-start check. Fixed "before reuse", not "after removal", so stop paths stay reload-free: removals bump `jitRemovals`, a start reloads when its drop-in changed or `jitReloadOwed()`, the owed state clears only on a reload that succeeded, and a restarted service owes one. Task 5; tests `TestRootfsNonJITStartAfterJITCleanupReloads` (JIT start, cleanup, non-JIT start with the unit kept loaded, modelled by `loadedJIT`) and `TestRootfsJITReloadStaysOwedUntilItSucceeds`; mutation check 9. Plan 6's tty start inherits it. | |
| +| 4 | accepted (Plan 2 half) | Checked: `startRootfs` is its own function, not `spawnUnitFrom`, where Plan 5 hooks binary starts. Task 9 names the slot (after the launch file, before `runExecGate`); Plan 5 Task 7 fills it and tests the order. | |
| +| 6 | accepted (Plan 2 half) | Checked: `adoptHost` (`adopt.go:1394`) is where the row meets the manifest; staging picked the artifact by the manifest's kind alone, so a binary row whose channel moved to a rootfs release would stage the image and point it live. Task 3: `stageCheck.runtime`, `runtimeMatches` in `stageFrom` before any download, and a re-check in `adoptHost` for the retained-copy path. Tests `TestStageFromRefusesAReleaseOfTheOtherRuntime`, `TestAdoptPassRefusesARootfsReleaseForABinaryRow`, `TestAdoptPassStagesARootfsReleaseForARootfsRow`. Plan 1's old-adopter test now runs against a frozen copy of the old pick. | |
| + |
| +No new user-facing string: the reload failure is a 502 body and the runtime refusal an adopt-pass log line. |
| + |
| ## Contract deviations |
| Each is decided here, once, and should be copied into the index's "Where the code differs from the spec" list. |
| @@ -489,7 +501,7 @@ git commit -m "registry: Route.Runtime column (rootfs apps plan 2, task 2; contr |
| **Files:** |
| - Modify: `internal/release/release.go` (beside `Artifact`, `:111`) |
| -- Modify: `internal/release/adopt.go:29-31, 1696, 1716, 1724, 2107, 2327, 2384` (every `m.Artifacts[adoptPlatform]` reader) |
| +- Modify: `internal/release/adopt.go:29-31, 1696, 1716, 1724, 2107, 2327, 2384` (every `m.Artifacts[adoptPlatform]` reader), `:2209` (`stageCheck.runtime`), `:1614-1640` (`adoptHost` passes the row's runtime and re-checks the manifest it holds; Astra R2 finding 6) |
| - Modify: `internal/release/versions.go:121` (`stageArtifactFrom` gains a mode) |
| - Create: `internal/release/adopt_rootfs_test.go` |
| - Modify: `internal/host/versionsweep_test.go` |
| @@ -504,6 +516,10 @@ git commit -m "registry: Route.Runtime column (rootfs apps plan 2, task 2; contr |
| // BoxArtifact is the one artifact a box of this platform stages: Artifacts[platform] for a binary, Artifacts[rootfsArtifactKeyFor(platform)] for a rootfs. Never the other key. |
| func (m *Manifest) BoxArtifact(platform string) (Artifact, bool) |
| func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, mode os.FileMode, open func() (io.ReadCloser, error)) error |
| + // stageCheck gains `runtime string`: the runtime of the row this staging is for ("" binary, registry.RuntimeRootfsV1). The zero value is a binary row. |
| + // runtimeMatches is nil when a rootfs manifest meets a rootfs row or a binary/static manifest a binary row; else an error wrapping errRuntimeMismatch. |
| + func runtimeMatches(m *Manifest, runtime string) error |
| + var errRuntimeMismatch = errors.New("release: the release's kind does not match the row's runtime") |
| // The fleet owner record (contract deviation 2); Plan 4 writes it. |
| func FleetOwnerKey(acct, fleet string) string // "accounts/" + acct + "/fleets/" + fleet + ".json" |
| type FleetOwner struct { |
| @@ -512,7 +528,7 @@ git commit -m "registry: Route.Runtime column (rootfs apps plan 2, task 2; contr |
| CreatedAt int64 `json:"created_at,omitempty"` |
| } |
| ``` |
| - The image is staged at `VersionPath(optDir, acct, ver, app)` with mode `0o644`; a binary keeps `0o755`. |
| + The image is staged at `VersionPath(optDir, acct, ver, app)` with mode `0o644`; a binary keeps `0o755`. A row stages only the kind its runtime runs (Astra R2 finding 6): `stageFrom` calls `runtimeMatches(m, chk.runtime)` first, before the staged-copy check and before any download, and `adoptHost` re-checks the manifest it ends up holding, because `stageAuthorized`'s retained-copy recovery (`RetainedStaged`) returns a manifest without passing `stageFrom`. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -589,7 +605,7 @@ func TestStageFromStagesTheImageAtTheVersionPathReadOnly(t *testing.T) { |
| s := newMemStore(t) // the package's in-memory Store fixture |
| putArtifact(t, s, "acct1", "shop", "v1", "rootfs.squashfs", image) |
| raw, _ := json.Marshal(m) |
| - got, err := stageFrom(context.Background(), s, optDir, "acct1", "shop", "v1", "shop.example.com", stageCheck{}, false, m, raw, false) |
| + got, err := stageFrom(context.Background(), s, optDir, "acct1", "shop", "v1", "shop.example.com", stageCheck{runtime: registry.RuntimeRootfsV1}, false, m, raw, false) |
| if err != nil || got == nil { |
| t.Fatalf("stageFrom: %v", err) |
| } |
| @@ -608,7 +624,7 @@ func TestStageFromStagesTheImageAtTheVersionPathReadOnly(t *testing.T) { |
| m2 := stagedRootfsManifest(t, image) |
| m2.Version = "v2" |
| raw2, _ := json.Marshal(m2) |
| - if _, err := stageFrom(context.Background(), s, optDir, "acct1", "shop", "v2", "shop.example.com", stageCheck{}, false, m2, raw2, false); !errors.Is(err, errArtifactMismatch) { |
| + if _, err := stageFrom(context.Background(), s, optDir, "acct1", "shop", "v2", "shop.example.com", stageCheck{runtime: registry.RuntimeRootfsV1}, false, m2, raw2, false); !errors.Is(err, errArtifactMismatch) { |
| t.Fatalf("tampered image: err = %v, want errArtifactMismatch", err) |
| } |
| if _, err := os.Stat(VersionPath(optDir, "acct1", "v2", "shop")); err == nil { |
| @@ -616,6 +632,46 @@ func TestStageFromStagesTheImageAtTheVersionPathReadOnly(t *testing.T) { |
| } |
| } |
| +// Astra R2 finding 6, the stage half: a row stages only its own kind, and |
| +// the refusal comes before a byte is downloaded. |
| +func TestStageFromRefusesAReleaseOfTheOtherRuntime(t *testing.T) { |
| + old := adoptPlatform |
| + adoptPlatform = "linux-arm64" |
| + t.Cleanup(func() { adoptPlatform = old }) |
| + image := []byte("squashfs") |
| + rfs := stagedRootfsManifest(t, image) |
| + bin := &Manifest{Schema: 1, App: "shop", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: rfs.Artifacts[RootfsArtifactKey].SHA256, Size: int64(len(image))}}} |
| + for _, c := range []struct { |
| + name, file, runtime string |
| + m *Manifest |
| + }{{"rootfs release, binary row", "rootfs.squashfs", "", rfs}, {"binary release, rootfs row", "linux-arm64", registry.RuntimeRootfsV1, bin}} { |
| + optDir := t.TempDir() |
| + s, reads := countArtifactReads(newMemStore(t)) // the store, wrapped to count Get/Open of artifact keys |
| + putArtifact(t, s, "acct1", "shop", "v1", c.file, image) |
| + raw, _ := json.Marshal(c.m) |
| + if _, err := stageFrom(context.Background(), s, optDir, "acct1", "shop", "v1", "shop.example.com", stageCheck{runtime: c.runtime}, false, c.m, raw, false); !errors.Is(err, errRuntimeMismatch) { |
| + t.Errorf("%s: err = %v, want errRuntimeMismatch", c.name, err) |
| + } |
| + if reads() != 0 { |
| + t.Errorf("%s: the artifact was read before the refusal", c.name) |
| + } |
| + if _, err := os.Lstat(VersionPath(optDir, "acct1", "v1", "shop")); err == nil { |
| + t.Errorf("%s: something was staged", c.name) |
| + } |
| + } |
| +} |
| + |
| +// Astra R2 finding 6, at the layer that receives the row: the adopt pass |
| +// (adoptHost through PassFilter, adoptRefusesManifest's harness, |
| +// adopt_test.go:905). A binary row live on v1 whose channel moves to a |
| +// rootfs v2 stages nothing, points nothing and restarts nothing, also when |
| +// a verified copy of v2's image is already on disk (stageAuthorized's |
| +// retained-copy path, which never reaches stageFrom); a rootfs row moving |
| +// to a rootfs v2 stages it 0644 and points it live. |
| +func TestAdoptPassRefusesARootfsReleaseForABinaryRow(t *testing.T) |
| +func TestAdoptPassStagesARootfsReleaseForARootfsRow(t *testing.T) |
| + |
| func TestFleetOwnerKey(t *testing.T) { |
| if got := FleetOwnerKey("acct1", "pi"); got != "accounts/acct1/fleets/pi.json" { |
| t.Errorf("FleetOwnerKey = %q", got) |
| @@ -623,7 +679,7 @@ func TestFleetOwnerKey(t *testing.T) { |
| } |
| ``` |
| -Use the package's existing in-memory store and artifact helpers (grep `adopt_test.go` for the store fixture and the helper that puts an artifact; name them as they are named there rather than `newMemStore`/`putArtifact` if those differ). Add imports (`encoding/json`, `errors`, `strings`). |
| +Use the package's existing in-memory store and artifact helpers (grep `adopt_test.go` for the store fixture and the helper that puts an artifact; name them as they are named there rather than `newMemStore`/`putArtifact` if those differ; `countArtifactReads` wraps a store and returns the wrapper and a counter of `Get`/`OpenObject` of `.../releases/<v>/<file>` keys). The two adopt-pass tests use `shipRelease`, `addRoute` (plus an `Upsert` setting `Runtime` for the rootfs row), `Promote`, `PassFilter` and the printed-line capture of `adoptRefusesManifest`; Plan 1's `putRootfsRelease` seeds v2; the refused pass prints one line containing `runtime` and the host. Add imports (`encoding/json`, `errors`, `strings`, `registry`). |
| Append to `internal/host/versionsweep_test.go`: |
| @@ -663,21 +719,21 @@ func TestSweepStagedVersionsTreatsRootfsImagesAsVersions(t *testing.T) { |
| - [ ] **Step 2: Run the tests to verify they fail** |
| -Run: `GOFLAGS=-mod=mod GOPROXY=off go test ./internal/release -run 'TestRootfsManifest|TestBoxArtifact|TestStageFromStagesTheImage|TestFleetOwnerKey' -count=1 && GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -run TestSweepStagedVersionsTreatsRootfsImages -count=1` |
| +Run: `GOFLAGS=-mod=mod GOPROXY=off go test ./internal/release -run 'TestRootfsManifest|TestBoxArtifact|TestStageFromStagesTheImage|TestStageFromRefuses|TestAdoptPassRefuses|TestAdoptPassStagesARootfs|TestFleetOwnerKey' -count=1 && GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -run TestSweepStagedVersionsTreatsRootfsImages -count=1` |
| Expected: FAIL to build in `release` (`undefined: BoxArtifact`); the host test passes already (it is the pin), which is fine: it stays as the proof. |
| - [ ] **Step 3: Implement** |
| -In `release.go`: `rootfsArtifactKeyFor`, `IsRootfs`, `BoxArtifact`, `FleetOwnerKey`, `FleetOwner`. In `adopt.go`: `BoxPlatform`; replace every `m.Artifacts[adoptPlatform]` with `m.BoxArtifact(adoptPlatform)` (lines 1696, 1716, 1724, 2107, 2327; `grep -n 'Artifacts\[adoptPlatform\]' internal/release/*.go` must return nothing after). In `stageFrom` (`:2384`) pass `0o644` when `m.IsRootfs()`, else `0o755`. In `versions.go:121` add the `mode os.FileMode` parameter and use it in `safefile.Write`. The "no build, skipping" message at `:2375` stays as it is: for a rootfs manifest on a box of another platform it names the missing key, which is `rootfsArtifactKeyFor(adoptPlatform)` (use `%s` on the key `BoxArtifact` looked up; add a small helper `boxArtifactKey(m, platform) string`). |
| +In `release.go`: `rootfsArtifactKeyFor`, `IsRootfs`, `BoxArtifact`, `FleetOwnerKey`, `FleetOwner`. In `adopt.go`: `BoxPlatform`; replace every `m.Artifacts[adoptPlatform]` with `m.BoxArtifact(adoptPlatform)` (lines 1696, 1716, 1724, 2107, 2327; `grep -n 'Artifacts\[adoptPlatform\]' internal/release/*.go` must return nothing after). In `stageFrom` (`:2384`) pass `0o644` when `m.IsRootfs()`, else `0o755`. `stageCheck` gains `runtime string`; `stageFrom` begins with `if err := runtimeMatches(m, chk.runtime); err != nil { return nil, fmt.Errorf("%s/%s: %w", app, ver, err) }`. In `adoptHost`, the stage check passed to `ensureStagedDigest` and `stageAuthorized` is `chk := cfg.stage; chk.runtime = rt.Runtime` (a copy: `verified` stays the pass's shared map), and right after the staging switch, before `releaseIdentity(m)`, `if err := runtimeMatches(m, rt.Runtime); err != nil` prints ` <host>: <ver>: <err>; not staged for this row` and returns (a diagnostic line, not a deck row). In `versions.go:121` add the `mode os.FileMode` parameter and use it in `safefile.Write`. The "no build, skipping" message at `:2375` stays as it is: for a rootfs manifest on a box of another platform it names the missing key, which is `rootfsArtifactKeyFor(adoptPlatform)` (use `%s` on the key `BoxArtifact` looked up; add a small helper `boxArtifactKey(m, platform) string`). |
| - [ ] **Step 4: Run the tests to verify they pass** |
| -Run: `GOFLAGS=-mod=mod GOPROXY=off go test ./internal/release ./internal/host -run 'TestRootfsManifest|TestBoxArtifact|TestStageFrom|TestFleetOwnerKey|TestSweepStaged|TestEnsureStaged|TestAdopt' -count=1` |
| +Run: `GOFLAGS=-mod=mod GOPROXY=off go test ./internal/release ./internal/host -run 'TestRootfsManifest|TestBoxArtifact|TestStageFrom|TestFleetOwnerKey|TestSweepStaged|TestEnsureStaged|TestAdopt|TestTheOldStageRule' -count=1` |
| Expected: PASS. |
| - [ ] **Step 5: Mutation check** |
| -Temporarily make `BoxArtifact` return `m.Artifacts[platform]` for every kind; run `TestStageFromStagesTheImageAtTheVersionPathReadOnly`: it must fail with "no linux-arm64 build". Revert. |
| +Temporarily make `BoxArtifact` return `m.Artifacts[platform]` for every kind; run `TestStageFromStagesTheImageAtTheVersionPathReadOnly`: it must fail with "no linux-arm64 build". Revert. Make `runtimeMatches` return nil: `TestStageFromRefusesAReleaseOfTheOtherRuntime` and `TestAdoptPassRefusesARootfsReleaseForABinaryRow` fail. Keep it but drop `adoptHost`'s re-check: the retained-copy case of `TestAdoptPassRefusesARootfsReleaseForABinaryRow` fails. Revert. |
| - [ ] **Step 6: Commit** |
| @@ -1010,9 +1066,16 @@ git commit -m "units: rootfs templates and probe with the restated namespace blo |
| // The JIT opt-in (R11, spec §5.11, C10): a second per-start drop-in, by unit name, for every rootfs role (run, gen, worker, cmd, and Plan 6's tty). |
| const JITDropInName = "50-carlos-jit.conf" |
| const JITDropIn = "# Written by carlos instance-control for one start of a release that declares jit (rootfs apps design §5.11); removed when the unit stops.\n[Service]\nMemoryDenyWriteExecute=no\n" |
| - // writeJITDropIn writes <DropInDir>/<unit>.d/50-carlos-jit.conf when jit, removes it otherwise. It reports whether the file's presence changed, so the caller daemon-reloads only then. |
| + // writeJITDropIn writes <DropInDir>/<unit>.d/50-carlos-jit.conf when jit, removes it otherwise. It reports whether the file's presence changed; the caller daemon-reloads when it changed OR when jitReloadOwed says a reload is owed (Astra R2 finding 1). |
| func (s *Server) writeJITDropIn(unit string, jit bool) (changed bool, err error) |
| + // removeJITDropIn removes the unit's jit drop-in (stop, sweep, cleanCmdExe, the mismatch path). It does not daemon-reload, but a removal that deleted a file bumps s.jitRemovals, so the next rootfs start of ANY unit reloads before it starts: systemd may still hold the unit loaded with MemoryDenyWriteExecute=no (systemd.unit(5): a loaded unit's configuration changes only on reload). |
| func (s *Server) removeJITDropIn(unit string) |
| + // The owed-reload state, under s.mu: jitRemovals counts removals that deleted a file (and mismatch-path marks); jitReloadedThrough is the count a successful daemon-reload began after; jitReloadedOnce is false until this service's first successful reload, because a restarted service cannot know what systemd has loaded. |
| + // jitRemovals, jitReloadedThrough uint64; jitReloadedOnce bool |
| + // jitReloadOwed: !jitReloadedOnce || jitRemovals != jitReloadedThrough. |
| + func (s *Server) jitReloadOwed() bool |
| + // jitDaemonReload snapshots jitRemovals, runs daemon-reload, and on success records the snapshot as jitReloadedThrough and sets jitReloadedOnce. A removal that lands during the reload stays owed. |
| + func (s *Server) jitDaemonReload(ctx context.Context) error |
| // startRootfsCmd is handleCmd's rootfs branch: the rootfs cmd unit's state check, root's validation, the rootfs/cmd layout, the tier rule, start --no-block and the invocation poll, all on RootfsUnitName(RoleCmd, host). |
| func (s *Server) startRootfsCmd(w http.ResponseWriter, r *http.Request, req request) |
| func (s *Server) ReadyRootfs() (ProbeReport, bool) |
| @@ -1032,12 +1095,12 @@ git commit -m "units: rootfs templates and probe with the restated namespace blo |
| 2. `rep, ok := s.ReadyRootfs(); !ok` → 503 with RFS-2-C02 and the first failing check's name. The box check that counts (spec §5.7): the probe must have passed on this boot. |
| 3. `launch, err := s.validateStagedImage(host, role, img)` → 403 with the reason (the agent maps 403 to `ErrStagedImage`: nothing was laid out). |
| 4. `refuseStart(disk)`; `ensureRootfsExe(key, role, img.Path, launch)` where key is `InstanceID(host)` (run, gen 0), `GenKey(host, n)` (gen), `WorkerKey(host, name)` (worker). |
| -5. Gen or worker: write the drop-in (`RootfsGenDropIn` or `WorkerDropIn`), then for every role `writeJITDropIn(unit, launch.JIT)` (R11: present only when the root-staged manifest declares `jit`; a stale one from an earlier jit release is removed here before a non-jit start), `daemon-reload` when any drop-in was written or removed, then prove the drop-ins applied: `systemctl show -p Slice --value` equals `SliceName(host)` (gen and worker) AND `systemctl show -p RootImage --value` equals the drop-in's image path (gen and worker); either mismatch is a 502 and the files are removed (`startGen`'s pattern, `gen.go:157`). |
| +5. Gen or worker: write the drop-in (`RootfsGenDropIn` or `WorkerDropIn`), then for every role `writeJITDropIn(unit, launch.JIT)` (R11: present only when the root-staged manifest declares `jit`; a stale one from an earlier jit release is removed here before a non-jit start), `jitDaemonReload` when any drop-in was written or removed **or `jitReloadOwed()`** (Astra R2 finding 1: a cleanup path already deleted the jit drop-in without a reload, so this start finds nothing to remove while systemd may still hold `MemoryDenyWriteExecute=no` for the unit); a failed reload is a 502 and nothing starts (`gen.go:153`'s pattern), and the reload stays owed, then prove the drop-ins applied: `systemctl show -p Slice --value` equals `SliceName(host)` (gen and worker) AND `systemctl show -p RootImage --value` equals the drop-in's image path (gen and worker); either mismatch is a 502 and the files are removed (`startGen`'s pattern, `gen.go:157`). |
| 6. Tier (Astra R1 finding 5): only a gen-0 **run** start sets the host slice's tier (`setTier`), as `handleStart` does for a cold instance. A generation start (`gen > 0`), a worker start and a cmd start set it only when `!s.slicePopulated(host)`, exactly as `startGen` (`gen.go:161`), the sidecar arm (`server.go:511`) and `handleCmd` (`cmd.go:84`) do: a populated slice may be carrying the swap's raised incumbent-plus-successor limit (`swap.go:517`, `RaiseGenParent`), and lowering it to one tier mid-overlap would OOM two healthy generations. |
| -7. `systemctl start`; then `show -p InvocationID,Slice,ControlGroup,MemoryDenyWriteExecute`: the ControlGroup must be under `/carlos.slice/carlos-instances.slice/<SliceName(host)>/` (C5: nothing the image starts escapes the host slice), and `MemoryDenyWriteExecute` must be `no` when `launch.JIT` and `yes` otherwise (C10: the effective property is checked after every start); on either mismatch the unit is stopped, waited empty, its jit drop-in removed, and the request answered 502 naming the property. |
| +7. `systemctl start`; then `show -p InvocationID,Slice,ControlGroup,MemoryDenyWriteExecute`: the ControlGroup must be under `/carlos.slice/carlos-instances.slice/<SliceName(host)>/` (C5: nothing the image starts escapes the host slice), and `MemoryDenyWriteExecute` must be `no` when `launch.JIT` and `yes` otherwise (C10: the effective property is checked after every start); on either mismatch the unit is stopped, waited empty, its jit drop-in removed, a reload marked owed (`jitRemovals++` even when no file was there: the loaded config was stale), and the request answered 502 naming the property, so the retry reloads. |
| 8. On a `start` failure whose output contains `Failed to set up mount namespacing` or `No such file or directory` with a path, answer 502 with RFS-2-C10 naming the path (`rootfsMountFailure(out string) (path string, ok bool)`), so the agent's event line says which mount point the image lacks (Review Focus 1). |
| -`startRootfsCmd` (`handleCmd`'s branch when `req.runtime == "rootfs"`) follows `handleCmd` (`cmd.go:41-112`) on `RootfsUnitName(RoleCmd, host)`: the per-host cmd lock; the loaded-state refusal (409) and `reset-failed` on `failed`, both on the rootfs cmd unit; steps 1 to 3 above with `RoleCmd`; `ensureRootfsExe(InstanceID(host), RoleCmd, ...)`; the jit drop-in and its reload; the tier rule of step 6 (cmd: populated slice left alone); `start --no-block`; the invocation poll of `cmd.go:94-112` on the rootfs unit; then the step-7 property check on the first `show` that returns the new invocation. `handleCmdStatus` picks `RootfsUnitName(RoleCmd, host)` when the query carries `runtime=rootfs`. `cleanCmdExe(ctx, host)` (`cmd.go:165`) learns the rootfs unit: when the rootfs cmd unit is `inactive` or `failed` it removes `rootfs/cmd` under the host's exe key and the unit's jit drop-in; the binary cmd layout is handled as today. |
| +`startRootfsCmd` (`handleCmd`'s branch when `req.runtime == "rootfs"`) follows `handleCmd` (`cmd.go:41-112`) on `RootfsUnitName(RoleCmd, host)`: the per-host cmd lock; the loaded-state refusal (409) and `reset-failed` on `failed`, both on the rootfs cmd unit; steps 1 to 3 above with `RoleCmd`; `ensureRootfsExe(InstanceID(host), RoleCmd, ...)`; the jit drop-in and its reload (step 5's rule: changed or `jitReloadOwed()`); the tier rule of step 6 (cmd: populated slice left alone); `start --no-block`; the invocation poll of `cmd.go:94-112` on the rootfs unit; then the step-7 property check on the first `show` that returns the new invocation. `handleCmdStatus` picks `RootfsUnitName(RoleCmd, host)` when the query carries `runtime=rootfs`. `cleanCmdExe(ctx, host)` (`cmd.go:165`) learns the rootfs unit: when the rootfs cmd unit is `inactive` or `failed` it removes `rootfs/cmd` under the host's exe key and the unit's jit drop-in; the binary cmd layout is handled as today. |
| `validateStagedImage`'s digest cache: `type imageDigests struct{ mu sync.Mutex; m map[string]digestEntry }` keyed by path, `digestEntry{dev, ino uint64; size int64; mtime time.Time; sum string}`; a hit requires all four to match the fresh `Lstat`/`Stat` of the opened fd (`unix.Fstat`); a miss hashes and stores. |
| @@ -1515,6 +1578,47 @@ func TestRootfsJITComesOnlyFromTheStagedManifest(t *testing.T) { |
| } |
| } |
| +// Astra R2 finding 1: cleanup deletes the jit drop-in without a reload, so |
| +// the next non-jit start of the same unit name finds nothing to remove. |
| +// systemd keeps the unit loaded with MemoryDenyWriteExecute=no until a |
| +// reload (systemd.unit(5)); without one the post-start check would stop a |
| +// healthy non-jit release. f.loadedJIT models exactly that: the effective |
| +// property changes only on daemon-reload. |
| +func TestRootfsNonJITStartAfterJITCleanupReloads(t *testing.T) { |
| + for _, role := range []string{"run", "worker", "cmd"} { |
| + t.Run(role, func(t *testing.T) { |
| + f := rootfsFixture(t) |
| + f.loadedJIT(t) |
| + unit := f.unitFor(role) |
| + f.restageManifest(t, func(m *release.Manifest) { m.JIT = true }) |
| + if status, body := f.startRole(t, role); status != 200 { |
| + t.Fatalf("jit start: %d %s", status, body) |
| + } |
| + f.ctl.answers["show -p ActiveState "+unit] = "inactive\n" |
| + if status, body := f.stopRole(t, role); status != 204 { |
| + t.Fatalf("stop: %d %s", status, body) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.srv.DropInDir, unit+".d", JITDropInName)); err == nil { |
| + t.Fatal("the stop left the jit drop-in") |
| + } |
| + mark := len(f.ctl.calls) |
| + f.restageManifest(t, func(m *release.Manifest) { m.JIT = false }) |
| + if status, body := f.startRole(t, role); status != 200 { |
| + t.Fatalf("non-jit start after cleanup: %d %s (it started on the cached jit setting)", status, body) |
| + } |
| + f.ctl.mustHaveCallsInOrderFrom(t, mark, "daemon-reload", "start "+unit) |
| + }) |
| + } |
| +} |
| + |
| +// The owed reload is cleared only by a reload that succeeds. After a jit |
| +// start and its stop: daemon-reload fails once, so the non-jit start answers |
| +// 502 and no "start" follows; the next start reloads and answers 200. A new |
| +// Server over the same DropInDir and the same loaded model (a restarted |
| +// control service) reloads before its first rootfs start although no file |
| +// changed. |
| +func TestRootfsJITReloadStaysOwedUntilItSucceeds(t *testing.T) |
| + |
| func TestRootfsVerbsAnswer503UnderClaimControl(t *testing.T) { |
| f := rootfsFixture(t) |
| f.srv.ClaimStop = http.NotFoundHandler() |
| @@ -1572,7 +1676,7 @@ func TestClientRootfsVerbsReachTheRealParser(t *testing.T) { |
| } |
| ``` |
| -Write `rootfsFixture` with the helpers the tests name (`post`, `get`, `client`, `startQuery`, `cmdQuery`, `img`, `restageManifest`, `symlinkVersionDir`, `rewriteImage`, `countReads`, `readLaunch`, `setRootfsReport`, `populate`, `plantJITDropIn`, `answerStart` (the `show` answers one role's start path reads, with `MemoryDenyWriteExecute=no` when asked for jit), `keyFor`/`roleOf` (the exe key and role for `run`, `gen`, `worker`, `cmd`), and `mustHaveCall`/`mustNotHaveCall`/`mustHaveCallsInOrder` on `fakeCtl` if they do not exist). `countReads` needs one seam: `Server.openBeneath func(dir, name string, flag int, perm os.FileMode) (*os.File, error)` defaulting to `instancefs.OpenBeneath`. If `Server` has no shrinkable long-poll bound (`cmdWaitBound` above is a placeholder name), use the seam `cmdWait()` already reads (`cmd.go`, grep `cmdWait`). |
| +Write `rootfsFixture` with the helpers the tests name (`post`, `get`, `client`, `startQuery`, `cmdQuery`, `img`, `restageManifest`, `symlinkVersionDir`, `rewriteImage`, `countReads`, `readLaunch`, `setRootfsReport`, `populate`, `plantJITDropIn`, `answerStart` (the `show` answers one role's start path reads, with `MemoryDenyWriteExecute=no` when asked for jit), `loadedJIT` (Astra R2 finding 1: a `fakeCtl.dynamic func(args []string) (string, bool)` hook, consulted before `answers`; add the field if absent. On `daemon-reload` it snapshots, per unit, whether `<DropInDir>/<unit>.d/50-carlos-jit.conf` exists; the post-start `show -p InvocationID,Slice,ControlGroup,MemoryDenyWriteExecute <unit>` answers `MemoryDenyWriteExecute=no` when that unit's last snapshot had the file and `yes` otherwise, so a unit not reloaded keeps its loaded setting), `unitFor`/`startRole`/`stopRole` (one role's unit, its start request and its `runtime=rootfs` stop), `keyFor`/`roleOf` (the exe key and role for `run`, `gen`, `worker`, `cmd`), and `mustHaveCall`/`mustNotHaveCall`/`mustHaveCallsInOrder`/`mustHaveCallsInOrderFrom` (the same, over `calls[from:]`) on `fakeCtl` if they do not exist). `countReads` needs one seam: `Server.openBeneath func(dir, name string, flag int, perm os.FileMode) (*os.File, error)` defaulting to `instancefs.OpenBeneath`. If `Server` has no shrinkable long-poll bound (`cmdWaitBound` above is a placeholder name), use the seam `cmdWait()` already reads (`cmd.go`, grep `cmdWait`). |
| - [ ] **Step 2: Run the tests to verify they fail** |
| @@ -1585,7 +1689,7 @@ In `rootfs.go`: the functions in the Interfaces block, `startRootfs`, `rootfsMou |
| In `server.go`: `request` gains `runtime string`, `worker string`, `image StagedImage`; `verbShape` gains `rootfs bool` (the launch verbs `/start` and `/cmd`: `runtime=rootfs` and the image fields come together or not at all), `rootfsStop bool` (`/stop`, `/reset` and `GET /cmd/{host}`: `runtime=rootfs` alone; `image`, `version`, `digest` or `process` is a 400) and `worker bool`; `parseRequest` reads `runtime`, `image`/`version`/`digest`/`process`, `worker` (only with `role=worker`, `release.ValidWorkerName`), applies the two rules above, and for `/cmd` decodes the optional JSON body into `req.image.Argv` (`http.MaxBytesReader` 64 KiB). `handleStart`: after `parseRequest`, `if req.runtime == "rootfs" { s.startRootfs(w, r, req); return }` (roles for rootfs: run, worker; a worker without `worker=` is 400). `handleCmd` (`cmd.go:41`): after `parseRequest`, `if req.runtime == "rootfs" { s.startRootfsCmd(w, r, req); return }`. `handleCmdStatus` (`cmd.go:118`): `unit := UnitName(RoleCmd, host)`, or `RootfsUnitName(RoleCmd, host)` when the query carries `runtime=rootfs` (parsed through the `rootfsStop` rule). `handleStop`/`handleReset`: with `runtime=rootfs` the unit is `RootfsUnitName`, `RootfsGenUnitName` or `WorkerUnitName`; the `RoleRun` legacy fence (`fenceLegacyStarts`) runs for the rootfs run unit too (same host, same semantics); a stopped rootfs cmd removes `rootfs/cmd` and its jit drop-in (`cleanCmdExe` learns the rootfs unit), a stopped worker removes its key directory and jit drop-in, a stopped gen removes its files (`removeGenFiles` learns `ParseRootfsGenUnitName`; the `.d` directory it removes holds the jit drop-in too), a stopped rootfs run unit removes its jit drop-in. `handleSweep` lists the four rootfs prefixes too and, per swept unit, removes rootfs files by kind (`IsRootfsUnit`) and the unit's jit drop-in; `sweepGenLeftovers` checks rootfs-gen drop-ins as well and gains a sibling pass over `ExeRoot` for `ParseWorkerKey` directories whose unit is inactive or failed with an empty cgroup. `Handler` registers `GET /ready/rootfs` (not claim-aware, like `/ready`). `RunProbe` runs the rootfs probe after the binary one (Task 7 fills it in; here, leave `runRootfsProbe` as a stub that records "not yet run"). `RunWithRegistryView` replaces `RunWithRegistry`'s body; keep `RunWithRegistry` as a one-line wrapper so `internal/host/claimstop_run.go:36` compiles, then change that caller to pass a view whose `HostRuntime` reads `rt.Runtime` from the registry (beside `instanceRegistryHostApp`). |
| -In `rootfs.go` for R11: `writeJITDropIn` is `writeGenDropIn`'s shape (`gen.go:60-69`: mkdir `<unit>.d`, temp file, rename) with `JITDropInName` and the fixed `JITDropIn` body when `jit`, and `os.Remove` of that one file when not (never the directory, which a gen or worker drop-in shares); it reports whether anything changed. `startRootfs` and `startRootfsCmd` call it before `daemon-reload` and read `MemoryDenyWriteExecute` in the same `show` as `ControlGroup`; the mismatch path is the C5 path (`stop`, `WaitCgroupEmpty`, `removeJITDropIn`, 502 `not serving <unit>: MemoryDenyWriteExecute is <got>, the release's jit is <jit>`). The probe units never get the drop-in: `runRootfsProbe` and `RunProbe` do not call `writeJITDropIn`, and Task 7's `mdwe` check proves the line from inside. |
| +In `rootfs.go` for R11: `writeJITDropIn` is `writeGenDropIn`'s shape (`gen.go:60-69`: mkdir `<unit>.d`, temp file, rename) with `JITDropInName` and the fixed `JITDropIn` body when `jit`, and `os.Remove` of that one file when not (never the directory, which a gen or worker drop-in shares); it reports whether anything changed. `removeJITDropIn` bumps `s.jitRemovals` when its `os.Remove` deleted a file (never on `ErrNotExist`). `startRootfs` and `startRootfsCmd` call `writeJITDropIn`, then `jitDaemonReload` when it changed or `jitReloadOwed()`, before `start`, and read `MemoryDenyWriteExecute` in the same `show` as `ControlGroup`; the mismatch path is the C5 path (`stop`, `WaitCgroupEmpty`, `removeJITDropIn`, 502 `not serving <unit>: MemoryDenyWriteExecute is <got>, the release's jit is <jit>`). The probe units never get the drop-in: `runRootfsProbe` and `RunProbe` do not call `writeJITDropIn`, and Task 7's `mdwe` check proves the line from inside. |
| In `client.go`: the six verbs (`CmdStatusRootfs` is `CmdStatus`'s body with `runtime=rootfs` in the query); `ReadyRootfs` is `Ready`'s body against `RootfsReadyPath`. |
| @@ -1606,6 +1710,7 @@ Expected: PASS (every existing server test still green: binary requests carry no |
| 6. Make a gen start call `setTier` unconditionally: `TestRootfsGenStartLeavesAPopulatedSliceAlone` fails. |
| 7. Make the argv branch leave `Env` and `WorkingDir` empty: `TestRootfsCmdTakesAProcessOrAnArgvNeverBoth` fails on the inherited `working_dir`. |
| 8. Drop the `MemoryDenyWriteExecute` comparison: `TestRootfsJITMismatchStopsTheUnit` fails. Read `jit` from the query instead of the manifest: `TestRootfsJITComesOnlyFromTheStagedManifest` fails. |
| +9. Reload only when `writeJITDropIn` reports a change (drop `jitReloadOwed()`): `TestRootfsNonJITStartAfterJITCleanupReloads` fails with a 502 naming `MemoryDenyWriteExecute`. Clear the owed state before the reload instead of after it succeeds: `TestRootfsJITReloadStaysOwedUntilItSucceeds` fails. |
| Revert each. |
| - [ ] **Step 6: Commit** |
| @@ -2582,7 +2687,7 @@ git commit -m "host: the box-side rootfs placement refusal, the single-tenant se |
| func unitNameFor(rt registry.Route, role instancectl.Role, gen uint64) string |
| func (b *ExecBackend) stopRole(ctx context.Context, rt registry.Route, role instancectl.Role, gen uint64) error // signature change: rt instead of host |
| ``` |
| - `Start` (`:1834`): before the layout switch, `if rt.Runtime == registry.RuntimeRootfsV1 { return b.startRootfsFromLive(ctx, rt, rt.Socket, 0) }`; a rootfs row whose layout is not nested refuses with RFS-2-C01's text and `ErrRootfsPlacement` semantics (never the `childProc` path: spec anti-goal "a rootfs app running flat"). `StartGenExe` dispatches the same way with `id` and the generation's own socket (`g.Socket`, the `sock` `rootfsVars` puts in `CARLOS_SOCKET`, Plan 3 D1). The gates, in order, before anything is prepared: `nestedGate`; `RootfsOK` (nil → refuse `errNoRootfsControl`); `RootfsPlacement(rt)`; `b.Cfg.HostWrites.Enabled` → `ErrClaimIsolationUnavailable` (as `startRole`, `containment.go:329`); `unitGen(n)`. Then `prepareKeyedAt(rt, RoleRun, sock, gen)`; `childVars := rootfsVars(b.agentOwnedVars(rt, sock+".control", fileEnv, ctl != nil), sock, p.dbPath)`; `writeRootfsLaunchFor(p, RoleRun, gen, rt.Host, release.ProcessWeb, childEnv(childVars))` writing `Launch{Schema: LaunchSchemaRootfs, Host, Runtime: RuntimeRootfsV1, Process: web, Env}` with no `Argv`; `runExecGate`; `rc.StartRootfs(ctx, host, gen, tier.MemoryMax, StagedImage{Path: id.StagedPath, Version: id.Version, Digest: id.Digest, Process: release.ProcessWeb})` with `tier = b.GenTier(rt, id.Version)`; the failure handling of `spawnUnitFrom` (`:2457-2510`) applied with `instancectl.ErrStagedImage` in `ErrStagedExe`'s place; `newUnitProcFor` with the rootfs unit name; `waitHealthyCtx(ctx, sock, rootfsHealthTimeout)`; on failure the stop-and-return shape of `startUnitAt` (`:2558-2585`), the error carrying RFS-2-C09. `startRootfsFromLive` is `LiveIdentity` + `VerifyStaged` + `startRootfs`. `LegacyRunGone` stops `unitNameFor(rt, RoleRun, 0)` through `StopRootfs` when the row is rootfs. |
| + `Start` (`:1834`): before the layout switch, `if rt.Runtime == registry.RuntimeRootfsV1 { return b.startRootfsFromLive(ctx, rt, rt.Socket, 0) }`; a rootfs row whose layout is not nested refuses with RFS-2-C01's text and `ErrRootfsPlacement` semantics (never the `childProc` path: spec anti-goal "a rootfs app running flat"). `StartGenExe` dispatches the same way with `id` and the generation's own socket (`g.Socket`, the `sock` `rootfsVars` puts in `CARLOS_SOCKET`, Plan 3 D1). The gates, in order, before anything is prepared: `nestedGate`; `RootfsOK` (nil → refuse `errNoRootfsControl`); `RootfsPlacement(rt)`; `b.Cfg.HostWrites.Enabled` → `ErrClaimIsolationUnavailable` (as `startRole`, `containment.go:329`); `unitGen(n)`. Then `prepareKeyedAt(rt, RoleRun, sock, gen)`; `childVars := rootfsVars(b.agentOwnedVars(rt, sock+".control", fileEnv, ctl != nil), sock, p.dbPath)`; `writeRootfsLaunchFor(p, RoleRun, gen, rt.Host, release.ProcessWeb, childEnv(childVars))` writing `Launch{Schema: LaunchSchemaRootfs, Host, Runtime: RuntimeRootfsV1, Process: web, Env}` with no `Argv`; **the egress readiness slot** (Astra R2 finding 4: Plan 5 Task 7 calls `b.EgressReady(ctx, rt.Host)` here, after the launch file and before the exec gate, so a granted destination is open before the image's `web` runs; nothing in this plan); `runExecGate`; `rc.StartRootfs(ctx, host, gen, tier.MemoryMax, StagedImage{Path: id.StagedPath, Version: id.Version, Digest: id.Digest, Process: release.ProcessWeb})` with `tier = b.GenTier(rt, id.Version)`; the failure handling of `spawnUnitFrom` (`:2457-2510`) applied with `instancectl.ErrStagedImage` in `ErrStagedExe`'s place; `newUnitProcFor` with the rootfs unit name; `waitHealthyCtx(ctx, sock, rootfsHealthTimeout)`; on failure the stop-and-return shape of `startUnitAt` (`:2558-2585`), the error carrying RFS-2-C09. `startRootfsFromLive` is `LiveIdentity` + `VerifyStaged` + `startRootfs`. `LegacyRunGone` stops `unitNameFor(rt, RoleRun, 0)` through `StopRootfs` when the row is rootfs. |
| - [ ] **Step 1: Write the failing tests** |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-3-lifecycle.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-3-lifecycle.md| index fd4e05efc..c160ece7b 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-3-lifecycle.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-3-lifecycle.md |
| @@ -30,6 +30,15 @@ Names this plan now needs from Plan 2 (the orchestrator checks Plan 2 provides t |
| - **Lease seams (finding 2).** None from Plan 2. This plan adds `SafeUntil` and `Lost` to `execLease` (forwarding `*lease.Held`); `*lease.Replicator` already has both (`internal/lease/lease.go:466`, `:470`). |
| - **Placeholder database (finding 8).** None from Plan 2. Plan 2's `rootfsVars` already sets `CARLOS_DB` to the nested path this plan's placeholder sits at. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, checked against the plans and the code first. |
| + |
| +- **Finding 4: accepted (Plan 3 half).** Checked: Task 11's table runs `release` before `web` at every site, through `StartRelease` and `CmdRootfs`, and Plan 5's hook sat only in the binary `spawnUnitFrom`, so a migration could run before a fresh grant reached the slice and fail the build. `StartRelease` (Task 10 step 3), `StartRootfsWorker` and the rootfs `StartCmd` now carry an egress readiness slot before the pending record, the exec gate and root's call; Plan 5 Task 7 fills it and owns the ordering tests (`TestEveryRootfsLaunchWaitsForEgressReady`). Nothing else here changes: the release's timeout and lease supervision already cover the bounded wait. |
| +- **Finding 5 (consumer): taken in.** RFS-3-C28 stays live (deck status `pending`) until Plan 6 Task 16 marks it struck under Plan 1 Task 1's rule. |
| + |
| +String IDs added: none. Changed: RFS-3-C28 (deck status `struck` back to `pending`; text unchanged). |
| + |
| ## Contract deviations |
| Each is decided here and proposed for the index's "Where the code differs from the spec" list. |
| @@ -593,6 +602,7 @@ git commit -m "activator: always-on rootfs instances: boot start, crash restart |
| type RootfsWorkerBackend interface { |
| Processes(id release.StagedIdentity) (map[string]release.Process, error) // A2 |
| StartRootfsWorker(ctx context.Context, rt registry.Route, name string, g Gen, genEnv map[string]string) (Proc, error) |
| + // (its first call is the egress readiness slot Plan 5 Task 7 fills, before rc.StartRootfsWorker; Astra R2 finding 4) |
| RootfsWorkerGone(ctx context.Context, rt registry.Route, name string) error // RootfsControl.StopRootfs(ctx, host, RoleWorker, 0, name), then the unit's cgroup proven empty |
| } |
| func workerNames(procs map[string]release.Process) []string // sorted; not web, release or console; ValidWorkerName; at most 4 |
| @@ -669,6 +679,8 @@ git commit -m "activator: rootfs workers start after web is healthy, restart wit |
| Processes(id release.StagedIdentity) (map[string]release.Process, error) |
| ReleaseEnv(rt registry.Route) (map[string]string, error) // the delivered files a successor would launch with, composed by rootfsCmdVars |
| // StartRelease runs runExecGate(ctx) immediately before CmdRootfs; a refusal there is launchRefused and nothing reached root. |
| + // Its first call is the egress readiness slot (Astra R2 finding 4): Plan 5 Task 7 puts b.EgressReady(ctx, rt.Host) there, before |
| + // runPendingRecord and runExecGate, so a migration reaches a database granted with this deploy; nothing in this plan. |
| StartRelease(ctx context.Context, rt registry.Route, id release.StagedIdentity, env map[string]string, out io.Writer) (CmdUnit, error) |
| } |
| // leaseWatch is the held lease a release runs under: the wake's Lease on a cold wake (execLease, before it becomes a |
| @@ -726,7 +738,7 @@ own := func() error { |
| `runRelease`, in order: |
| 1. `procs := Processes(id)`; not due: return nil. `held == nil`: `errReleaseNoLease`, nothing started (a caller whose `Lease` or `Rep` is not a `leaseWatch` passes nil). |
| 2. `own()`; a refusal returns it and starts nothing. Under `a.mu`: refuse with `errSwapBusy` when `cmdBusyLocked(e)` (a member's run holds the host's one cmd unit); else attach `e.releaseHold = newCmdHold("release", cancel, a.now)` with `starting` set, so every stop path settles it before replication closes (`settleCmds` through `cmdHoldsLocked`) and `RunCmd` starts nothing beside it. |
| -3. `recordBuild(ctx, rt.Host, id.Version)`: the build state names the release's build before it touches the data (settings §4.2). `StartRelease` then calls `runPendingRecord(ctx)` and `runExecGate(ctx)` immediately before `CmdRootfs`, as the exec seam does, so a cold wake's pending record is written before `release` runs, not at `web`'s exec, and ownership is checked at the last moment before root is asked. |
| +3. `recordBuild(ctx, rt.Host, id.Version)`: the build state names the release's build before it touches the data (settings §4.2). `StartRelease` then runs its egress readiness slot (empty here; Plan 5 Task 7 fills it, Astra R2 finding 4), then `runPendingRecord(ctx)` and `runExecGate(ctx)` immediately before `CmdRootfs`, as the exec seam does, so a cold wake's pending record is written before `release` runs, not at `web`'s exec, and ownership is checked at the last moment before root is asked. |
| 4. Event `RFS-3-C03`. `rctx, trip := context.WithCancelCause(ctx)` with `releaseTimeout(procs[release.ProcessRelease])` over it. |
| 5. `StartRelease(withExecGate(rctx, own), rt, id, ReleaseEnv(rt), out)`: `startCmdUnit` with Plan 2's `writeRootfsLaunchFor(p, RoleCmd, 0, rt.Host, release.ProcessRelease, childEnv(env))` (A3: schema 2, no argv) and `RootfsControl.CmdRootfs(ctx, host, rootfsTier.MemoryMax, stagedImageOf(id, release.ProcessRelease, nil))` (A5); the unit it returns, with or without an error, is Plan 2's runtime-aware `cmdUnit` (A8): it polls `RootfsUnitName(RoleCmd, host)` and stops and proves that unit, never the binary `carlos-instance-cmd@`. `out` is a line writer into the app log sink (`b.logEmitter(rt)`), both streams, at most `runs.MaxOutputBytes`, after which lines are dropped and one diagnostic logged. A `launchRefused` start returns the gate's error with the hold detached: nothing ran. |
| 6. **Lease supervision**, from the moment the unit may run until it is proven gone: a goroutine re-reads `held.SafeUntil()` every `releaseLeaseRecheck` and wakes at once on `held.Lost()`. It trips (`trip(errReleaseLeaseLost)`) when `Lost()` closes, when `SafeUntil` errors, or when `time.Until(until) <= releaseStopLead`, keeping the last `until` it read as the deadline. It uses the real clock, as the lease does (`SafeUntil` is monotonic), not `a.now`. |
| @@ -824,6 +836,8 @@ Where `release` runs, under which lease and gate, and what its failure means: |
| In `doWake`'s start-error switch, `errReleaseUnproven` (with or without the lease-loss error) leaves the lease to its TTL (as `ErrSeedUnproven` does, `activator.go:2376`): the unit may still write the database. The lease-loss error with the unit proven gone gives the lease back (`giveBack`, which a lost lease answers harmlessly) and starts nothing after it: no `web`, no fallback, no worker. Restart and config attempts re-run the serving build, which is the good build, so `releaseDue` is false (D3). A rootfs row is never seeded (D5), so on a first boot `release` runs after the restore and the placeholder (Task 7) and before `web`. A release stopped by lease loss leaves `GoodVersion` unchanged, so the next attempt, on whichever box then holds the lease, runs it again. |
| +Every site in the table reaches root through `StartRelease`, so each `release` waits for the bounded egress readiness pass first once Plan 5 lands (its slot, Task 10 step 3; Astra R2 finding 4): a release that migrates a database granted with this deploy finds it open, and is never failed for a connection the periodic pass had not opened yet. Workers (`StartRootfsWorker`, Task 9) and `carlos run` (the rootfs `StartCmd`, Task 12) carry the same slot before their root call. |
| + |
| `reasonWord` answers `status.ReasonReleaseTimeout` for a reason starting `release timed out` and `status.ReasonRelease` for one starting `release`, before its other arms (`exited` would otherwise match `ReasonExit`). `rolloutwords.Reason` renders them as `RFS-3-C01` and `C02`, so the existing `swap.failed` event and the REASON column say why without new code. |
| Worker handover (spec §5.2): at step 8, beside `replaceSidecar`, `a.handOverRootfsWorkers(e)`. By then the new `web` has passed its health check and is serving. It stops and proves every worker of the retiring generation, then starts the new generation's set. A worker it cannot prove stays stray and the new set waits; the tick starts it once every stray is proven. A config attempt with the same build hands over too, so workers pick up new config. Two worker sets never run at once. |
| @@ -898,7 +912,7 @@ git commit -m "rootfs: release gates every move onto a new build; a failure keep |
| ``` |
| Rules: |
| -- `StartCmd` on a rootfs row: the tenant launch file is Plan 2's schema 2 with `Process: "cmd"` and no argv; the argv is `spec.Args` as given (no live-link prefix), sent to root as `StagedImage.Argv` through `RootfsControl.CmdRootfs(ctx, host, rootfsTier.MemoryMax, stagedImageOf(id, "", spec.Args))` (A3, A5); env `rootfsCmdVars(rt, spec.genEnv, p.dbPath, spec.RunID, spec.RunDir)`; the image identity is required (a rootfs command always names its image; none refuses with `errNoCmdExe`). The `CmdUnit` it returns, on success and on a failed start alike, is Plan 2's runtime-aware unit (A8, through Task 10's `startCmdUnit`): `RunCmd`'s `Poll`, its `Stop`, `interruptCmdsLocked`, `settleCmds` and the sweep's `retryCmdSettlesLocked` all reach `RootfsUnitName(RoleCmd, host)` and prove that unit's cgroup empty, never the binary cmd unit's (Astra R1 finding 1). A binary row is unchanged. |
| +- `StartCmd` on a rootfs row (its first call is the egress readiness slot Plan 5 Task 7 fills, before `CmdRootfs`; Astra R2 finding 4): the tenant launch file is Plan 2's schema 2 with `Process: "cmd"` and no argv; the argv is `spec.Args` as given (no live-link prefix), sent to root as `StagedImage.Argv` through `RootfsControl.CmdRootfs(ctx, host, rootfsTier.MemoryMax, stagedImageOf(id, "", spec.Args))` (A3, A5); env `rootfsCmdVars(rt, spec.genEnv, p.dbPath, spec.RunID, spec.RunDir)`; the image identity is required (a rootfs command always names its image; none refuses with `errNoCmdExe`). The `CmdUnit` it returns, on success and on a failed start alike, is Plan 2's runtime-aware unit (A8, through Task 10's `startCmdUnit`): `RunCmd`'s `Poll`, its `Stop`, `interruptCmdsLocked`, `settleCmds` and the sweep's `retryCmdSettlesLocked` all reach `RootfsUnitName(RoleCmd, host)` and prove that unit's cgroup empty, never the binary cmd unit's (Astra R1 finding 1). A binary row is unchanged. |
| - `RunCmd` with `spec.Build != ""`, at the point it sets `spec.serving` (`cmd.go:600`): `id, err := b.BuildIdentity(rt, spec.Build)`; an error ends the run before anything starts with `ErrCmdVersion` (wrapped). Otherwise `spec.serving = stagedExeOf(id)` (a rootfs row's `StartCmd` turns that identity into `stagedImageOf`), `h.build = id`, and `recordBuild` names `spec.Build`, which is the build about to touch the data. The environment stays the serving generation's (merge ruling M3). `entryGenerationsLocked` adds `h.build` for every hold not proven settled, so the sweep keeps the image until the run ends. This works for binary rows too: `CmdExe` already mounts a staged binary. |
| - Console: `runClassRefusal` passes a rootfs record that does not hibernate (`rec.Runtime == provision.RuntimeRootfsV1`), and keeps the fleet refusal for every record (D4): a `carlos run` on a fleet box is refused with today's sentence until Plan 6 Task 16 carries it over the box-dials-the-console door (R7), and that task removes the fleet arm. The CLI page says so (RFS-3-C28). With `meta.Version != ""`: `release.ValidVersion` or 400 `RFS-3-C19`; `release.GetManifest(ctx, store, acct, app, version)` not found, 409 `RFS-3-C20`; written to `request.json`'s `Version` and the `run-start` audit row's version. |
| - Box: the runs worker sets `CmdSpec.Build = req.Version`. `runResultForLaunch` maps `ErrCmdVersion` to `Result{Refused: runs.RefusedVersion, Detail: req.Version}`; `runRefusedWords` says `RFS-3-C22`; the CLI's `runExit` prints `RFS-3-C21` with `r.Detail` and exits 3. |
| @@ -1112,7 +1126,7 @@ Every row goes to the deck in Task 1 and reaches code only once approved. `%s` a |
| | RFS-3-C28 | `docs/site/cli.md` (`carlos run`, the same paragraph) | carlos run does not reach instances on a customer fleet yet. | |
| | RFS-3-C29 | `internal/rolloutwords/words.go` (`EventReleaseLeaseLost`) | the release command of build %s was stopped because this box no longer holds the instance. it runs again on the next attempt | |
| -Count: 29 (RFS-3-C29 added in the Astra R1 fix round; Task 1 Step 2 takes it to the deck and Paul). (RFS-3-C28 is struck from the deck by Plan 6 Task 16 when runs reach fleet boxes, R7.) |
| +Count: 29 (RFS-3-C29 added in the Astra R1 fix round; Task 1 Step 2 takes it to the deck and Paul). (RFS-3-C28 is live until Plan 6 Task 16 marks it struck when runs reach fleet boxes, R7.) |
| ## Hand-offs to other plans |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-4-console-and-docs.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-4-console-and-docs.md| index 1e8b2d65e..71da1b436 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-4-console-and-docs.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-4-console-and-docs.md |
| @@ -27,13 +27,22 @@ String IDs added: RFS-4-C136, C137, C138. Changed: RFS-4-C54, C55, C81, C112. No |
| - **Plan 2** (R11): C136 says every process of a `--jit` release runs without the protection, interactive sessions and `carlos run` included; Plan 2's per-start rendering must cover every rootfs role for that to be true. |
| - None for finding 13: `writeFleetOwner`, `backfillFleetOwners` and the record's key and body keep their names. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, each checked against the plans and the code first. |
| + |
| +- **Finding 2: accepted.** Checked: `rootfsHeadFor` fell back to `AppKind`, which reads manifests only, so a create during a first rootfs ship's publication minted a binary record (`apiinstances.go:1421`); and with a create that read nothing before the ship's marker and wrote after it, no check saw the other. `release.WithAppMutation` is a plain call with host claims off (`app_mutation.go:12-17`), so it is not a boundary by itself; the console's per-app lock `lockApp` (`applocks.go:59`, already the lifecycle lock) is. Both doors take it while the app has no kind (`rootfsHead.Unfixed`) and recheck under it: the ship counts binary records and writes the marker (`EstablishFirstKind`) inside it, the create rereads the head and writes its record inside it, and every door reads `EstablishedKind`, so a marker whose manifest is unpublished already decides. Instance records are written only through the console and one console serves a bucket, so the in-process lock covers both doors; the direct-bucket ship never ran the refusal. Tasks 2, 3, 4 and 5; tests `TestRootfsHeadReadsAnUnpublishedFirstShip`, `TestCreateHonoursTheMarkerOfAnUnpublishedFirstShip`, `TestCreateDuringAnUnpublishedFirstRootfsShipMintsRootfs`, `TestFirstRootfsShipWaitsForAnInFlightCreate`. |
| +- **Finding 3: accepted.** Checked: Plan 1 Task 8 step 8 returns from `shipRootfsParts` before `release.Ship` (`apicli.go:2264`), where Task 5 had wired the refusal. Task 5 now fills Plan 1's slot in `shipRootfsParts`, after the re-ship short circuit and before `ShipSpooled`; `apicli.go` is untouched. `TestShipFirstRootfsReleaseOverBinaryInstances` stays a real-door test and now also checks that no marker is left and that shipping after deleting the instance works. |
| + |
| +String IDs added or changed: none (C08 and C11 are reused; a conflict at the marker is Plan 1's C80 to C83 through `Error()`). |
| + |
| --- |
| ## Contract deviations |
| The shared contract fixes most names this plan uses. The items below were proposals in this plan's first draft; the consistency pass settled each against Plans 1 to 3 (index, "Where the code differs from the spec"). None changes a contract name. |
| -- **D1. The app-kind reader is Plan 1's `release.AppKind`** (index decision 9). `AppKind(ctx, s, acct, app) (AppKindInfo, error)`, `AppKindInfo{Kind, Version string; Hibernate bool}`, in `internal/release/rootfs_ship.go`: the newest readable, valid release among the 20 newest; `Kind == ""` means no release; store errors fail closed. Plan 1's `CheckShipKind` is built on it, so the ship door, instance create and placement can never disagree. This plan writes no `kind.go`. |
| +- **D1. The app-kind reader is Plan 1's `release.AppKind`** (index decision 9). `AppKind(ctx, s, acct, app) (AppKindInfo, error)`, `AppKindInfo{Kind, Version string; Hibernate bool}`, in `internal/release/rootfs_ship.go`: the newest readable, valid release among the 20 newest; `Kind == ""` means no release; store errors fail closed. Plan 1's `CheckShipKind` is built on it, so the ship door, instance create and placement can never disagree. This plan's doors read Plan 1's `release.EstablishedKind`, which is `AppKind` falling back to the app's kind marker when no release is readable (Astra R2 finding 2), so an app whose first ship has written its marker and not yet its manifest is already that kind here. This plan writes no `kind.go`. |
| - **D2. `rootfs_ready` sits directly on `status.BoxMetrics`** (Plan 2 Task 8): `BoxMetrics.RootfsReady bool \`json:"rootfs_ready,omitempty"\`` beside `Layout`, not inside `LayoutReport`. It is true only when every box-local rule of the box's placement predicate holds (`host.RootfsBoxReady`: claim control off, nested-ready, the rootfs probe passed this boot, overlap swaps configured). `boxRootfsReady(b status.Box) bool` is the one function that reads it. |
| - **D3. Worker and release reports are Plan 3 Task 14's, confirmed.** Workers: `status.HostRollout.Workers []status.WorkerState`, `WorkerState{Name string \`json:"name"\`; State string \`json:"state"\`; Restarts int \`json:"restarts,omitempty"\`}`, `State` one of `"running"`, `"restarting"`, `"crash-loop"`, `"stopped"`; the rollout report carries account and app, so the console's tenant check (`boxJoin.rollout`, `internal/console/apiinstances.go:447`) covers it, and fleet boxes' reports are folded in (`joinFleetRollouts`, `:724`). A failed or timed-out `release` is a rollout failure with Plan 3's reason words `status.ReasonRelease = "release"` and `status.ReasonReleaseTimeout = "release-timeout"`, rendered by `rolloutwords.Reason` as RFS-3-C01 and C02 in the REASON column: this plan adds no code for it. |
| - **D4. Processes are declared in a file passed to `carlos ship --processes`** (Plan 1, index decision 8). The file holds the processes object itself (`{"web": {...}, "worker": {...}}`, decoded strictly by `release.DecodeProcesses`), and hibernation is the `--hibernate` flag. The sample keeps it as `examples/rails-shop/processes.json`; there is no `carlos.json`. Rows RFS-4-C35, C37, C87, C89, C93 say so. |
| @@ -96,7 +105,7 @@ Plans-caps Plan 3b (never-sleep refusals, `docs/superpowers/specs/2026-10-20-pla |
| | `internal/hostops/` (classifier), `internal/provision/rootfs_apps_4_census_test.go` | the fleet owner key's classifier family and record-write census entries (Task 5a) | |
| | `internal/console/apiinstances.go` | create-door wiring; `rolloutView.Workers` | |
| | `internal/console/fleets.go` | `placeAppTarget` wiring | |
| -| `internal/console/apicli.go` | ship-door wiring | |
| +| `internal/console/apiship_rootfs.go` | the first-rootfs-ship refusal and the first-kind boundary, in Plan 1's `shipRootfsParts` slot (Task 5) | |
| | `apimode.go`, `instances_cli.go` | `Runtime`, `Hibernate`, `Workers` decoding; `runtimeCell`, `workersCell`, `instanceRuntimeLine`; the columns | |
| | `instances_rootfs_test.go`, `rootfs_instances_e2e_test.go` | the CLI cells and the through-the-console witness | |
| | `docs/site/rootfs.md`, `rails.md`, `laravel-and-django.md`, `nav.json`, `cli.md` | the docs | |
| @@ -193,13 +202,14 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - Create: `internal/console/rootfs.go`, `internal/console/rootfs_test.go` |
| **Interfaces:** |
| -- Consumes: `release.Channel` (`internal/release/release.go:1238`), `release.GetManifest` (`:710`), Plan 1's `release.AppKind` (D1), Plan 1's `Manifest.Kind == "rootfs"` and `Manifest.Hibernate`; `registry.FleetPlacement`, `consoleRegistry.GetFleet` (`internal/console/view.go:518`); `status.LoadBoxes` (`internal/status/fleet.go:150`), `status.BoxStale` (`:137`), `status.SnapshotPeriod`, `release.NewPrefixStore`; `(s *Server).homeRegionIn` / `homeRegionOut` (`internal/console/apiinstancemove.go:306,322`); `doorRefusal` (`internal/console/domainattach.go:74`); Plan 3's `provision.Record.Runtime` and the constant for `"rootfs-v1"` (written here as `provision.RuntimeRootfsV1`). |
| +- Consumes: `release.Channel` (`internal/release/release.go:1238`), `release.GetManifest` (`:710`), Plan 1's `release.EstablishedKind` (D1), Plan 1's `Manifest.Kind == "rootfs"` and `Manifest.Hibernate`; `registry.FleetPlacement`, `consoleRegistry.GetFleet` (`internal/console/view.go:518`); `status.LoadBoxes` (`internal/status/fleet.go:150`), `status.BoxStale` (`:137`), `status.SnapshotPeriod`, `release.NewPrefixStore`; `(s *Server).homeRegionIn` / `homeRegionOut` (`internal/console/apiinstancemove.go:306,322`); `doorRefusal` (`internal/console/domainattach.go:74`); Plan 3's `provision.Record.Runtime` and the constant for `"rootfs-v1"` (written here as `provision.RuntimeRootfsV1`). |
| - Produces (all in `rootfs.go`): |
| ```go |
| type rootfsHead struct { |
| Rootfs bool // the creating release is a rootfs release |
| Hibernate bool // its manifest's opt-in (spec §5.0, R5) |
| Version string // the release it was read from |
| + Unfixed bool // no release and no kind marker: no first ship has fixed the app's kind yet (Astra R2 finding 2); Rootfs is false |
| } |
| func (s *Server) rootfsHeadFor(ctx context.Context, sqid, app, channel string) (rootfsHead, *doorRefusal) |
| func (s *Server) rootfsPlacementRefusal(reg consoleRegistry, acct *Account, app, placed, pool string) *doorRefusal |
| @@ -212,8 +222,8 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| `rootfsHeadFor`'s order (the algorithm the tests pin): |
| 1. Read the channel pointer and, when it names a version, that manifest. Kind is per app (§5.0), so a readable head answers alone: `{Rootfs: m.Kind == "rootfs", Hibernate: m.Hibernate, Version: v}`. Two reads, the same as `staticHeadRefusal` (`apiinstances.go:1838`). |
| -2. On no pointer, an empty version, or ANY pointer or manifest read error, fall back to `release.AppKind`. A failed head read never decides "binary" by itself (Review Focus 1). |
| -3. `AppKind` error: `&doorRefusal{http.StatusBadGateway, rootfsReleasesUnreadableMsg}` (C08). `Kind != "rootfs"`: `rootfsHead{}` (binary, today). `Kind == "rootfs"`: `{true, info.Hibernate, info.Version}`. |
| +2. On no pointer, an empty version, or ANY pointer or manifest read error, fall back to `release.EstablishedKind` (the release history, then the kind marker). A failed head read never decides "binary" by itself (Review Focus 1). |
| +3. `EstablishedKind` error: `&doorRefusal{http.StatusBadGateway, rootfsReleasesUnreadableMsg}` (C08). `Kind == ""`: `rootfsHead{Unfixed: true}` (no first ship yet: Task 3 rechecks under the first-kind boundary). `Kind` binary or static: `rootfsHead{}` (binary, today). `Kind == "rootfs"`: `{true, info.Hibernate, info.Version}`, whether it came from a manifest or from the marker of a first ship still publishing. |
| `rootfsPlacementRefusal` (spec §5.7, the ownership half of the box predicate, `host.OwnBoxAllowed`, mirrored; Plan 3 D6's no-pool rule first, D9): `pool != ""` → 409 C02; `DeploymentSingleTenant` → nil; `placed == ""` → 409 C01; `registry.FleetPlacement(placed)` not ok, or `GetFleet` nil or `Account != acct.Sqid` → 409 C01; `GetFleet` error → 502 with the existing text `"could not check the fleet"` (`fleets.go:606`, already shipped, not a new row). Otherwise nil. |
| @@ -276,7 +286,8 @@ func TestRootfsHeadFallsBackToHistory(t *testing.T) { |
| } |
| } |
| -func TestRootfsHeadIsBinaryForBinaryAndNewApps(t *testing.T) // kass (binary, buildFixtures) and an app with no release: rootfsHead{}, nil |
| +func TestRootfsHeadIsBinaryForBinaryAndNewApps(t *testing.T) // kass (binary, buildFixtures): rootfsHead{}, nil; an app with no release and no marker: rootfsHead{Unfixed: true}, nil |
| +func TestRootfsHeadReadsAnUnpublishedFirstShip(t *testing.T) // Astra R2 finding 2: release.EstablishFirstKind of a hibernating rootfs r1 for shop, no artifact, no manifest, no pointer: {Rootfs: true, Hibernate: true, Version: "r1"}, nil |
| func TestRootfsHeadRefusesWhenHistoryIsUnreadable(t *testing.T) // fx.srv.cfg.Releases = listFailStore{fx.rel}: 502, ref.Msg == rootfsReleasesUnreadableMsg |
| ``` |
| @@ -361,12 +372,12 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - Test: `internal/console/rootfs_test.go` |
| **Interfaces:** |
| -- Consumes: Task 2's functions; `placed` (`:1205`), `region` (`:1238-1331`), `m.Pool`, `seeded` (`:1073`), `probeRec.Role` (`:1305`), `provision.Record.DerivesPaths` (`internal/provision/provision.go:404`). |
| +- Consumes: Task 2's functions; `placed` (`:1205`), `region` (`:1238-1331`), `m.Pool`, `seeded` (`:1073`), `probeRec.Role` (`:1305`), `provision.Record.DerivesPaths` (`internal/provision/provision.go:404`); `(s *Server).lockApp` (`internal/console/applocks.go:59`), the first-kind boundary (index, App kind marker). |
| - Produces: a record created through this door for an app whose creating release is rootfs carries `Runtime: provision.RuntimeRootfsV1` and `Hibernate: &head.Hibernate`; constants `rootfsSeededMsg` (C09), `rootfsReplicaMsg` (C10). |
| The block, in this order (each a refusal through `jsonError(w, ref.Status, ref.Msg)`): |
| -1. `head, ref := s.rootfsHeadFor(ctx, acct.Sqid, app, channel)`. |
| +1. `head, ref := s.rootfsHeadFor(ctx, acct.Sqid, app, channel)`. **The first-kind boundary** (Astra R2 finding 2): when `head.Unfixed` and the record is socket-backed, `defer s.lockApp(acct.Sqid, app)()` and read `rootfsHeadFor` again under it. The lock is held to the handler's return, so until the door has written the record (`CreateRecord`, or the claim path's `PrepareInstance` has answered) or refused; the seed and import hand-offs the create door makes (`apiinstanceseed.go:341`, `apiinstanceimport.go:172`, the console's only other writers of a socket-backed record) are inside it too. Under it, a first ship that has fixed the kind (its marker, Plan 1's `EstablishFirstKind`, taken under the same lock in Task 5) answers rootfs here even while its manifest is unpublished, and a first rootfs ship that has not reached the boundary waits for this record and then counts it (Task 5). An app whose kind is already fixed never takes the lock: its kind cannot change. Before implementing, `grep -n lockApp` along the create path below this point (the claim branch included): nothing there may take it, or the door deadlocks; if something does, stop and report rather than nest. |
| 2. Only when `head.Rootfs` and the request is a socket-backed instance (`provision.Record{Kind: kind, Addrs: req.Addrs}.DerivesPaths()`): a static or addr-backed record of a rootfs app is not a process the platform starts, so it is minted as today. |
| 3. `seeded` → 422 C09 (copying a database into a new rootfs instance is not in v1; the seed doors write their own records from `rec` and are not reached). |
| 4. `probeRec.Role == "replica"` → 422 C10 (spec §2 non-goal: one box per instance). |
| @@ -426,13 +437,14 @@ func TestRootfsCreateOnASingleTenantDeployment(t *testing.T) // DeploymentSingle |
| func TestBinaryCreateIsUnchanged(t *testing.T) // a binary head on a placed and an unplaced app: 201, Runtime "", Hibernate nil, byte-identical record keys to before (no "runtime" key in the stored JSON) |
| func TestRootfsCreateStaticRecordStaysPlain(t *testing.T) // kind static on a rootfs app: no Runtime, no readiness read |
| func TestRootfsCreateUnderPlansMintsAlwaysOn(t *testing.T) // a plan catalog on (fx.srv.cfg.PlanCatalog with a paid plan whose limits include fleets): 201 and *Hibernate == false; pins today's door for plans-caps 3b |
| +func TestCreateHonoursTheMarkerOfAnUnpublishedFirstShip(t *testing.T) // Astra R2 finding 2, door half: fleet home placed with a ready box, release.EstablishFirstKind of rootfs r1 for shop and nothing else: create answers 201 and the record is rootfs-v1 with *Hibernate false; the same app unplaced answers 409 C01 and writes no record (never a binary record) |
| ``` |
| The setup helpers are compositions of Task 2's fixtures plus `enableInstances`, the `/place` door and `seedChannel`. |
| - [ ] **Step 2: Run to verify they fail** |
| -Run: `go test ./internal/console/ -run 'TestRootfsCreate|TestBinaryCreateIsUnchanged' -count=1` |
| +Run: `go test ./internal/console/ -run 'TestRootfsCreate|TestBinaryCreateIsUnchanged|TestCreateHonoursTheMarker' -count=1` |
| Expected: FAIL, the record has no runtime and the refusals are 201s. |
| - [ ] **Step 3: Implement** the block and the stamp as above; rows C09, C10 into `rootfsCopyBuilt`. |
| @@ -458,10 +470,10 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - Test: `internal/console/rootfs_test.go` |
| **Interfaces:** |
| -- Consumes: `release.AppKind` (D1), `(s *Server).appPlacement` (`fleets.go:490`), `(s *Server).appInstanceCount` (`plancaps.go:264`). |
| +- Consumes: `release.EstablishedKind` (D1), `(s *Server).appPlacement` (`fleets.go:490`), `(s *Server).appInstanceCount` (`plancaps.go:264`). |
| - Produces: `func (s *Server) rootfsPlaceRefusal(ctx context.Context, acct *Account, reg consoleRegistry, app, target string) *doorRefusal`; constants `rootfsPlaceSharedMsg` (C03), `rootfsPlaceMoveMsg` (C04). |
| -Order: `AppKind` error → 502 C08; kind not rootfs → nil (binary and release-less apps unchanged). `target == current placement` → nil (a no-op, with or without instances). `appInstanceCount > 0` → 409 C04 (moves refuse rootfs rows, spec §5.5; on every deployment). `!DeploymentSingleTenant && target == ""` → 409 C03. Otherwise nil: the target fleet's ownership was proven by the code above the call. Never `(404, "")`: that pair means the cross-account refusal to every caller. |
| +Order: `EstablishedKind` error → 502 C08; kind not rootfs → nil (binary and release-less apps unchanged; an app whose rootfs first ship is still publishing is rootfs here). `target == current placement` → nil (a no-op, with or without instances). `appInstanceCount > 0` → 409 C04 (moves refuse rootfs rows, spec §5.5; on every deployment). `!DeploymentSingleTenant && target == ""` → 409 C03. Otherwise nil: the target fleet's ownership was proven by the code above the call. Never `(404, "")`: that pair means the cross-account refusal to every caller. |
| The three doors (`handleAPIPlace` `:729`, `handleAppPlace` `:791`, `handleFleetPlacePage` `:834`) render the message unchanged: JSON for the CLI, a flash for the browser. |
| @@ -512,15 +524,22 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| An instance created before an app's first release is minted binary (Task 3: no release says otherwise), and its record never changes (§5.0). If the first release is then rootfs, the instance can never serve it: a box looks for a `linux-arm64` artifact and finds none. The ship door says so instead of letting the deploy look stuck. |
| **Files:** |
| -- Modify: `internal/console/apicli.go`, `handleAPIShip` (`:1812`): after Plan 1's kind-fixed refusal, which sits beside `release.CheckShipProgram` (`:2255`), and before `release.Ship` (`:2264`); after the identical-reship short circuit, so a re-ship stays idempotent |
| +- Modify: `internal/console/apiship_rootfs.go`, Plan 1's `shipRootfsParts`, at its first-ship slot (Plan 1 Task 8): after the identical-reship short circuit, so a re-ship stays idempotent, and before `release.ShipSpooled` (Astra R2 finding 3: `handleAPIShip` hands every rootfs ship to `shipRootfsParts` and returns, so the binary path's `release.Ship` at `apicli.go:2264` never sees one). `apicli.go` is not changed by this task. |
| - Modify: `internal/console/rootfs.go` (row C11) |
| - Test: `internal/console/rootfs_test.go` |
| **Interfaces:** |
| -- Consumes: `release.AppKind` (D1), `provision.ListRecords` (`instanceListMax`). |
| +- Consumes: `release.EstablishedKind` and `release.EstablishFirstKind` (D1, Plan 1 Task 3), `provision.ListRecords` (`instanceListMax`), `s.lockApp`, Plan 1 Task 8's step-9 mapping of `KindConflictError`/`HibernateConflictError` to 409. |
| - Produces: `func (s *Server) firstRootfsShipRefusal(ctx context.Context, acct *Account, app string) *doorRefusal`; constant `rootfsFirstShipMsg` (C11, `%s` app, `%d` count, `%s` the first host in sort order). |
| -Rule: only when the manifest's `Kind == "rootfs"` and `AppKind` says the app has no release (`Kind == ""`; Plan 1's rule governs every other case). The records counted are those with `DerivesPaths()` and `Runtime == ""`. A store error: 502 C08. The direct-bucket `carlos ship` (operator, no console) is not changed. |
| +Rule, in the slot (every ship reaching it is rootfs): |
| +1. `EstablishedKind` answers a kind (a release, or a first ship's marker): nothing here; `ShipSpooled`'s own publication check decides (Plan 1's rule). An error: 502 C08. |
| +2. No kind yet: **the first-kind boundary** (Astra R2 finding 2). `unlock := s.lockApp(acct.Sqid, app)`, then `EstablishedKind` again (another ship may have fixed it while this one waited: unlock and go to 1's answer). |
| +3. Still none: `firstRootfsShipRefusal` counts the app's records with `DerivesPaths()` and `Runtime == ""`. Any: unlock, 409 C11, and nothing is written, the kind marker included, so the remedy C11 names (delete them, ship again) works. A list error: unlock, 502 C08. |
| +4. None: `release.EstablishFirstKind(ctx, s.cfg.Releases, acct.Sqid, &m)` writes the marker; then unlock. A kind or hibernation conflict is 409 with `err.Error()` (Plan 1 Task 8 step 9); another error is the existing 502. The lock covers only the recheck, the record list and the marker put, never the image upload. |
| +5. `ShipSpooled` follows, finds the marker of its own kind and publishes. |
| + |
| +An instance create of the same app takes the same lock while the app has no kind (Task 3), so the two doors are ordered: a create first leaves a binary record the ship counts; a ship first leaves a marker the create reads. The direct-bucket `carlos ship` (operator, no console) is not changed: it never ran this refusal. Instance records are written only through the console, and one console serves a deployment bucket (`applocks.go`'s rule), so the in-process lock is the boundary both doors share. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -536,26 +555,43 @@ func TestShipFirstRootfsReleaseOverBinaryInstances(t *testing.T) { |
| if _, err := release.GetManifest(context.Background(), fx.rel, "bab", "shop", "r1"); err == nil { |
| t.Fatal("the refused release was stored") |
| } |
| + if _, _, err := fx.rel.Get(context.Background(), release.AppKindMarkerKey("bab", "shop")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the refused first ship fixed the app's kind: %v", err) |
| + } |
| + deleteInstance(t, fx, token, "shop.example.com") |
| + if status, body := shipRootfsThroughTheDoor(t, fx, token, "shop", "r1"); status != http.StatusCreated { |
| + t.Fatalf("ship after deleting the instance = %d %v", status, body) |
| + } |
| } |
| func TestShipFirstRootfsReleaseWithStaticRecordsOnly(t *testing.T) // a static record only: 201 |
| func TestShipLaterRootfsReleaseIsPlanOnesRule(t *testing.T) // r1 shipped and an instance made from it: r2 ships (201) |
| +// Astra R2 finding 2, both interleavings, through the real doors. Each runs 20 times (fresh fixture) and under -race in the gate. |
| +func TestCreateDuringAnUnpublishedFirstRootfsShipMintsRootfs(t *testing.T) // fleet home placed, ready box; fx.srv.cfg.Releases wrapped so the PutFrom of |
| + // ArtifactKey("bab","shop","r1",RootfsArtifactFile) blocks until the test releases it (the ship is past its marker, its manifest unwritten); |
| + // a create of shop.example.com meanwhile: 201, the stored record rootfs-v1 with *Hibernate from the ship; released, the ship answers 201 |
| +func TestFirstRootfsShipWaitsForAnInFlightCreate(t *testing.T) // the wrapper blocks the Put of shop.example.com's record key (the create is |
| + // inside the boundary, past its recheck); a rootfs ship of r1 meanwhile reaches the slot and waits: no Put of AppKindMarkerKey while the |
| + // create is blocked (the wrapper's ordered log); released: the create answers 201 with a binary record (no kind was fixed), then the ship |
| + // answers 409 C11 naming shop.example.com, with no marker, no artifact and no manifest written |
| ``` |
| +`deleteInstance` is the instance delete door through `rawReq`. The blocking wrapper is one `release.Store` that holds a named key's `Put` or `PutFrom` on a channel and logs every write in order; both interleaving tests use it. |
| + |
| - [ ] **Step 2: Run to verify they fail** |
| -Run: `go test ./internal/console/ -run 'TestShipFirstRootfs|TestShipLaterRootfs' -count=1` |
| +Run: `go test ./internal/console/ -run 'TestShipFirstRootfs|TestShipLaterRootfs|TestCreateDuringAnUnpublishedFirstRootfsShip|TestFirstRootfsShipWaitsForAnInFlightCreate|TestCreateHonoursTheMarker' -count=1` |
| Expected: FAIL, the first test's ship answers 201. |
| - [ ] **Step 3: Implement**; row C11 into `rootfsCopyBuilt`. |
| -- [ ] **Step 4: Run to verify they pass**, then `go test ./internal/console/ -run 'TestAPIShip|TestShip' -count=1`. Expected: PASS. |
| +- [ ] **Step 4: Run to verify they pass**, then `go test ./internal/console/ -run 'TestAPIShip|TestShip|TestRootfsCreate' -count=1` and `go test -race ./internal/console/ -run 'TestCreateDuringAnUnpublishedFirstRootfsShip|TestFirstRootfsShipWaitsForAnInFlightCreate' -count=1`. Expected: PASS. Mutation checks: read `AppKind` instead of `EstablishedKind` in `rootfsHeadFor`: the first interleaving test fails (a binary record); drop the lock from either door: the second fails (a binary record beside a published rootfs release). Revert. |
| - [ ] **Step 5: Commit** |
| ```bash |
| -git add internal/console/apicli.go internal/console/rootfs.go internal/console/rootfs_test.go approved_rootfs_copy_test.go |
| -git commit -m "console: refuse an app's first rootfs release while it has instances made for a binary app (rootfs spec §5.0) |
| +git add internal/console/apiship_rootfs.go internal/console/rootfs.go internal/console/rootfs_test.go approved_rootfs_copy_test.go |
| +git commit -m "console: refuse an app's first rootfs release while it has instances made for a binary app, ordered with instance create (rootfs spec §5.0) |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-5-egress-allow.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-5-egress-allow.md| index 7f3cad95a..24c397b5d 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-5-egress-allow.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-5-egress-allow.md |
| @@ -34,6 +34,15 @@ Names this plan needs from Plan 2 (its review-fix round owns them; if Plan 2 pic |
| Copy deck (`2026-10-24-rootfs-apps-copy.md`, not edited here): rows RFS-5-C36, C40, C41 change text and RFS-5-C42 changes its "Where" column (R8). RFS-5-C44 is new. All are `pending`. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, checked against Plans 2 and 3 and the code first. |
| + |
| +- **Finding 4: accepted.** Checked: Task 7 hooked `EgressReady` only into `spawnUnitFrom` (`backend_exec.go:2464`) and the nested seed (`:1567`); a rootfs `web` starts through Plan 2's `startRootfs`, and `release` runs before `web` through Plan 3's `StartRelease` and `CmdRootfs`, so a deploy introducing a grant could run its migration before the periodic pass opened the database and record a good build as failed. Task 7 now calls one helper before every rootfs launch (web and generations, workers, `release`, `carlos run`), each before the exec gate and root's call. Tests `TestEveryRootfsLaunchWaitsForEgressReady` (activator: each launch's root call comes after `EgressReady`, with no loop to open anything else) and `TestEgressReadyAppliesAFreshGrantWithThePeriodicPassDelayed` (host: with the periodic pass an hour away, `Ready` applies the new grant before it returns). |
| +- **Finding 5 (consumer): taken in.** Task 2a marks RFS-1-C15 `struck` under the gate's rule (Plan 1 Task 1) instead of deleting it. |
| + |
| +String IDs added or changed: none. |
| + |
| ## Contract deviations |
| 1. **The ownership half has its own name, settled (index decision 3).** Plan 2 exports `host.OwnBoxAllowed(f RootfsBoxFacts, rt registry.Route) error` (single-tenant, or a fleet box whose owner record names the row's account) beside `host.RootfsBoxReady(f) error`, and `RootfsPlacementAllowed` is the two in order. Egress calls `OwnBoxAllowed` alone: §5.9 covers binary nested rows too, and a box that is nested-ready but has not passed the rootfs probe must still be able to give a binary instance its database. The agent's facts are Plan 2's `(*agentRootfs).facts() RootfsBoxFacts` (no error), passed to this plan's pass as `Facts: rf.facts`. |
| @@ -377,7 +386,7 @@ git commit -m "caps: declare egress destinations; refuse egress with doc_engine |
| **Interfaces:** |
| - Consumes: Task 2's `caps.Set.Egress`; Plan 1's `validateRootfsShape`, `rootfsShipOpts`. |
| -- Produces: `func rootfsCapsAllowed(c *caps.Set) error` in `internal/release/rootfs.go`: nil for a nil set or one whose only non-zero fields are `V` and `Egress`; otherwise an error with RFS-5-C43. `validateRootfsShape` calls it where it refused any `Capabilities` (RFS-1-C15, which no code says any more once this lands: Task 2a strikes the row from the deck and from `rootfsCopyBuilt` in the same commit, the copy gate's struck-row rule). The ship door's rootfs branch and `carlos ship --rootfs --capabilities <file>` both go through it, so the CLI, the door and the adopt-side rule agree. |
| +- Produces: `func rootfsCapsAllowed(c *caps.Set) error` in `internal/release/rootfs.go`: nil for a nil set or one whose only non-zero fields are `V` and `Egress`; otherwise an error with RFS-5-C43. `validateRootfsShape` calls it where it refused any `Capabilities` (RFS-1-C15, which no code says any more once this lands: Task 2a marks the row `struck` in the deck, adds it to `rootfsCopyStruck` and removes it from `rootfsCopyBuilt` in the same commit, the copy gate's struck-row rule, Plan 1 Task 1). The ship door's rootfs branch and `carlos ship --rootfs --capabilities <file>` both go through it, so the CLI, the door and the adopt-side rule agree. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -392,7 +401,7 @@ func TestShipRootfsTakesCapabilitiesForEgress(t *testing.T) // carlos ship --roo |
| Run: `go test ./internal/release/ -run TestRootfsManifestMayDeclareEgressOnly -count=1`, `go test ./internal/console/ -run TestAPIShipRootfsAcceptsEgress -count=1`, `TMPDIR=/tmp/g go test . -run TestShipRootfsTakesCapabilitiesForEgress -count=1` |
| Expected: FAIL (RFS-1-C15's refusal). |
| -- [ ] **Step 3: Implement** `rootfsCapsAllowed` and the three call sites; strike RFS-1-C15 from the deck and `rootfsCopyBuilt`, add RFS-5-C43. |
| +- [ ] **Step 3: Implement** `rootfsCapsAllowed` and the three call sites; mark RFS-1-C15 `struck` (deck status, `rootfsCopyStruck`) and remove it from `rootfsCopyBuilt`, add RFS-5-C43. |
| - [ ] **Step 4: Run them to verify they pass**, plus Plan 1's rootfs families: `go test ./internal/release/ -run 'TestValidateManifestRootfsRules|TestRootfs' -count=1`, `go test ./internal/console/ -run TestAPIShipRootfs -count=1`, `TMPDIR=/tmp/g go test . -run 'TestShipRootfs|TestRootfsCopy' -count=1`. Expected: PASS (Plan 1's `capabilities` case in `TestValidateManifestRootfsRules` becomes a ports case). |
| @@ -745,6 +754,7 @@ git commit -m "host: egress hostname resolution with retention and fail-closed a |
| - Create: `internal/host/egresspass.go` |
| - Modify: `internal/host/agent.go:456-491` (construct, `Boot`, wire `eb.EgressReady`), `:2157-2170` (`fastDeps.egressLeg`), `:2690-2700` (call it on a serial move, beside `layoutLeg`), `:3085` (set it), `:4009` (`Probes.Egress`) |
| - Modify: `internal/activator/backend_exec.go:74-190` (`ExecBackend.EgressReady`), `:1567` (before the nested seed call), `:2464` (before `b.startRole` for the run role) |
| +- Modify (Astra R2 finding 4, every rootfs launch): `internal/activator/rootfs.go` (Plan 2's `startRootfs`, its egress readiness slot: web and generations), `internal/activator/backend_rootfs.go` (Plan 3's `StartRelease` and `StartRootfsWorker`), `internal/activator/cmdunit.go` (the rootfs branch of `StartCmd`, Plan 3 Task 12: `carlos run`) |
| - Modify: `internal/status/snapshot.go:153-170` (`Probes.Egress`), `:594-605` (`BoxMetrics.Egress`), `:1078` (`CollectBox` copies it) |
| - Test: `internal/host/egresspass_test.go`, `internal/host/agent_test.go` (fast tick), `internal/activator/backend_exec_test.go`, `internal/status/snapshot_test.go` |
| @@ -766,7 +776,7 @@ git commit -m "host: egress hostname resolution with retention and fail-closed a |
| func (p *egressPass) Boot(ctx context.Context) // Load, one pass with the persisted records and no bucket read, bounded by egressBootBound |
| func (p *egressPass) Run(ctx context.Context) // every egressEvery; record Refresh on Nudge or egressRecordBackstop |
| func (p *egressPass) Nudge() // non-blocking |
| - func (p *egressPass) Ready(ctx context.Context, host string) // one immediate pass, waited for at most egressReadyBound; never fails a start |
| + func (p *egressPass) Ready(ctx context.Context, host string) // one immediate pass, waited for at most egressReadyBound; never fails a start; it reads the records itself (a Refresh), so it does not depend on the periodic loop having run |
| func (p *egressPass) Report() map[string]status.EgressState |
| // activator: |
| // ExecBackend.EgressReady func(ctx context.Context, host string) // nil: nothing |
| @@ -814,11 +824,13 @@ func TestEgressPassReport(t *testing.T) // Report for ea |
| ``` |
| `agent_test.go`: `TestFastTickNudgesEgressOnSerialMove`: a serial change calls `egressLeg` once; no change, no call. |
| `backend_exec_test.go`: `TestNestedStartWaitsForEgressReady`: with a recording `Control`, `EgressReady` is called before `Start`/`StartGen` for the run role and before `Seed`; with `EgressReady` nil the order and calls are as today. |
| +`rootfs_test.go` (activator, Astra R2 finding 4): `TestEveryRootfsLaunchWaitsForEgressReady`: on Plan 2's `fakeControl` with an `EgressReady` that appends `egress-ready <host>` to the same ordered log, for each launch of a rootfs row (`Start`, `StartGenExe`, `StartRootfsWorker`, `StartRelease`, and `StartCmd` with an argv), `egress-ready shop.example.com` precedes the root call (`start-rootfs`, `start-rootfs-worker`, `cmd-rootfs`), and for `StartRelease` it also precedes the pending-record write and the exec gate, so the gate stays the last check before root; with `EgressReady` nil the calls are as before. The fake `EgressReady` stands for a grant the periodic pass has not applied yet: it sets `applied[host]`, nothing else does (no `Run` loop exists in the fixture), and `fakeControl` fails the test when a root call for a rootfs row arrives while `applied[host]` is false. |
| +`egresspass_test.go` (host, production-wired): `TestEgressReadyAppliesAFreshGrantWithThePeriodicPassDelayed`: the Step 2 fixture with `egressEvery` set to an hour and `Run` never started (the periodic pass delayed), `Boot` done, then a grant for `10.20.0.0/16` signed and published and a build declaring it staged: `pass.Ready(ctx, host)` returns only after root's recording `Systemctl` has run `set-property --runtime carlos-instances-<iid>.slice IPAddressAllow=10.20.0.0/16`; with root's `Systemctl` blocking, `Ready` returns after `egressReadyBound` (shortened) without it, and the caller goes on. With the activator test, this is the ordering proof: the launch waits for `Ready`, and `Ready` applies a grant the loop has not. |
| `snapshot_test.go`: `TestCollectBoxCarriesEgressPrivately`: `Probes.Egress` lands in `Box.Metrics.Egress` and in nothing `BuildFleet` publishes. |
| - [ ] **Step 3: Run them to make sure they fail** |
| -Run: `go test ./internal/host/... -run 'TestEgressPass|TestEgressReady|TestFastTickNudgesEgress'`, `go test ./internal/activator/... -run TestNestedStartWaitsForEgressReady`, `go test ./internal/status/... -run TestCollectBoxCarriesEgress` |
| +Run: `go test ./internal/host/... -run 'TestEgressPass|TestEgressReady|TestFastTickNudgesEgress'` (`TestEgressReadyAppliesAFreshGrant...` included), `go test ./internal/activator/... -run 'TestNestedStartWaitsForEgressReady|TestEveryRootfsLaunchWaitsForEgressReady'`, `go test ./internal/status/... -run TestCollectBoxCarriesEgress` |
| Expected: FAIL. |
| - [ ] **Step 4: Implement `internal/host/egresspass.go` and the wiring.** |
| @@ -829,17 +841,19 @@ One pass (under one mutex): |
| 3. Refresh records (only when nudged or the backstop is due, and only for the (account, app) pairs from step 2 that reached the grant step); refresh names (every pass) for the union of effective names. |
| 4. Each row's prefixes plus `/32`/`/128` of each name's retained addresses; agent-side `egress.Render(list, nil)` (refusals reported, the row sends an empty list). |
| 5. Send `EgressRequest{V:1, Hosts: <every qualifying row, empty lists included>}` when any of these holds: it differs from the last **settled** request; the box has any non-empty row (root re-reads box addresses on each request, F9a); or the last push did not settle. A push settles when root answered 200 and no host in the response, requested or not, carries `systemctl` (finding 4). A transport error, a non-200 or a `systemctl` result leaves it unsettled, so the same request, an all-empty one included, goes again on the next pass (5 s) until it settles. Once it settles, an unchanged all-empty state is not sent again. Boot's first push counts as unsettled until answered. Root's per-host results fill `Applied`/`Reasons` for the rows the agent names (results for hosts it no longer names only decide settling); a transport error sets `control-unreachable`. |
| +The readiness hook's sites (Astra R2 finding 4: the hook must precede every launch whose process may reach a granted destination, and a `release` migration is the first such process a deploy runs). One helper, `b.egressReady(ctx, rt)` (`if b.EgressReady != nil { b.EgressReady(ctx, rt.Host) }`), called: in `spawnUnitFrom` before `b.startRole` for the run role and before the nested seed (binary, as above); in Plan 2's `startRootfs` at its egress readiness slot (after the launch file, before `runExecGate` and `rc.StartRootfs`), which covers `Start` and `StartGenExe` of a rootfs row; in Plan 3's `StartRootfsWorker` before `rc.StartRootfsWorker`; in Plan 3's `StartRelease` first, before `runPendingRecord`, `runExecGate` and `CmdRootfs` (Plan 3 Task 10 step 3 keeps the gate last); and in the rootfs branch of `StartCmd` before `CmdRootfs`. It never fails a launch (bounded by `egressReadyBound`, 5 s); under a release, that time comes out of the release's own timeout and lease supervision, which already run. |
| + |
| Wiring in `RunAgent`: after Plan 2's `rf` (`agent.go:480`, which needs `eb.Control`), build the pass with `eb.Control`, `Facts: rf.facts`, `eb.LayoutFacts`, the fast loop's store and root public key, `dataDir()`; set `eb.EgressReady = pass.Ready`; call `pass.Boot(agentCtx)` before `activator.New` (`:491`); start `go pass.Run(agentCtx)`; set `d.egressLeg = pass.Nudge` (`:3085`); add `Egress: pass.Report()` to the `status.Probes` literal (`:4009`). Only when `cfg != nil` (no activator, no nested instances). On a claim-armed box nothing changes in this wiring (F8). |
| - [ ] **Step 5: Run them to verify they pass** |
| -Run: `go test ./internal/host/... -run 'TestEgress|TestFastTick|TestBuildPlans' -count=1`, `go test ./internal/activator/... -run 'TestNestedStart' -count=1`, `go test ./internal/status/... -count=1` |
| +Run: `go test ./internal/host/... -run 'TestEgress|TestFastTick|TestBuildPlans' -count=1`, `go test ./internal/activator/... -run 'TestNestedStart|TestEveryRootfsLaunchWaitsForEgressReady|TestRelease|TestRootfs' -count=1`, `go test ./internal/status/... -count=1`. Mutation check: drop the call from `StartRelease` (or from `startRootfs`): `TestEveryRootfsLaunchWaitsForEgressReady` fails for that launch. Make `Ready` wait for the loop's next tick instead of running a pass: `TestEgressReadyAppliesAFreshGrantWithThePeriodicPassDelayed` fails. Revert. |
| Expected: PASS. |
| - [ ] **Step 6: Commit** |
| ```bash |
| -git add internal/host internal/activator/backend_exec.go internal/activator/backend_exec_test.go internal/status |
| +git add internal/host internal/activator/backend_exec.go internal/activator/backend_exec_test.go internal/activator/rootfs.go internal/activator/rootfs_test.go internal/activator/backend_rootfs.go internal/activator/cmdunit.go internal/status |
| git commit -m "host: egress pass renders declared-and-granted destinations on own boxes, live (#366 plan 5)" |
| ``` |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-6-interactive.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-6-interactive.md| index 1f4e4c531..199119350 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-6-interactive.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-6-interactive.md |
| @@ -26,6 +26,15 @@ String IDs added or changed: none. Every new sentence in these fixes is a box or |
| - **Plan 2** (R11): its JIT rendering and the effective `MemoryDenyWriteExecute` check must sit in the shared rootfs start path that `resolveRootfsLaunch` serves, so they apply to `RoleTTY` as well as `RoleCmd`, `RoleWorker` and the web roles; Task 6's `TestRootfsTTYStartFollowsTheReleasesJIT` consumes whatever name Plan 2 gives the drop-in. |
| - **The index**, shared contract: `interactive.RunMaxFrame` and `RunHello` are Plan 6's names; nothing else in the series uses the relay. |
| +## Review dispositions (Astra R2) |
| + |
| +Findings from `CODEX-FINDINGS-ROOTFS-PLANS-R2.md` that touch this plan, checked against Plan 2's start path first. |
| + |
| +- **Finding 1: accepted (consumer).** Checked: the tty unit name is reused across sessions, and a session's end removes its jit drop-in through Plan 2's stop path, so a non-jit session after a jit one was the same stale-load case. Plan 2 Task 5's owed reload covers the tty start with no Plan 6 code (the rootfs `/tty` start is Plan 2's start path, D3); Task 6 adds `TestRootfsTTYNonJITSessionAfterAJITOneReloads` on Plan 2's `loadedJIT` model so the tty role cannot drift. |
| +- **Finding 5 (consumer): taken in.** Task 16 Step 5 marks RFS-3-C28 `struck` under the gate's rule (Plan 1 Task 1) instead of deleting it. |
| + |
| +String IDs added or changed: none. |
| + |
| --- |
| ## Contract deviations |
| @@ -536,11 +545,12 @@ func TestTTYStatusLongPoll(t *testing.T) // running / done / gone, as the cmd te |
| func TestTTYRefusedWithClaimControlArmed(t *testing.T) // ClaimStop set -> 503 for POST /tty and GET /tty/{host} |
| func TestSweepCoversTTYUnits(t *testing.T) // list-units pattern includes "carlos-instance-tty@*.service" and the rootfs one; tty exe removed |
| func TestRootfsTTYStartFollowsTheReleasesJIT(t *testing.T) // R11: a staged manifest with jit: the rootfs tty start carries Plan 2's JIT drop-in and passes Plan 2's effective-property check; without jit, neither; the same assertion Plan 2 makes for /cmd, so the two cannot drift |
| +func TestRootfsTTYNonJITSessionAfterAJITOneReloads(t *testing.T) // Astra R2 finding 1, on Plan 2's loadedJIT model: a jit session's tty start, its end (POST /stop role=tty runtime=rootfs removes the drop-in, no reload), then a non-jit session on the same unit name: "daemon-reload" precedes its "start", and the start answers 200 with MemoryDenyWriteExecute=yes; Plan 2's TestRootfsNonJITStartAfterJITCleanupReloads with role "tty" |
| ``` |
| - [ ] **Step 2: Run them and see them fail** |
| -Run: `go test ./internal/instancectl/ -run 'TestTTY|TestRootfsRowStartsTheRootfsTTYUnit|TestSweepCoversTTYUnits|TestRootfsTTYStartFollowsTheReleasesJIT' -count=1` |
| +Run: `go test ./internal/instancectl/ -run 'TestTTY|TestRootfsRowStartsTheRootfsTTYUnit|TestSweepCoversTTYUnits|TestRootfsTTYStartFollowsTheReleasesJIT|TestRootfsTTYNonJITSessionAfterAJITOneReloads' -count=1` |
| Expected: FAIL. |
| - [ ] **Step 3: Parametrize the handlers by role, register the routes, add the client methods** |
| @@ -1305,7 +1315,7 @@ Expected: FAIL. |
| Add `route GET /api/box/runs/{id}` to `claim-routes.txt` and a refused fixture (409, `claimInteractiveUnsupportedMsg`). The runs worker change is agent code; the console's record and `runs/` writes are new Put sites: add them to the record-write census as Plan 1 Task 3 Step 4 does, `PROPOSED-rootfs-apps-6`, and the proposal on #280. Run the Step 2 commands and `go test ./internal/console/ -run 'TestClaimConsoleRouteCensus|TestClaimConsoleMutationInvariant|TestAPIRuns|TestRunSweep' -count=1`. Expected: PASS. |
| -- [ ] **Step 5: Docs and copy.** In `docs/site/cli.md`'s `carlos run` section, remove RFS-3-C28's sentence and write RFS-6-C62 in its place; strike RFS-3-C28 from the deck and from `rootfsCopyBuilt` (the gate's struck-row rule), and add RFS-6-C62 to C64. Run `go test ./internal/docsite/ -count=1` and the series copy gate. Expected: PASS. |
| +- [ ] **Step 5: Docs and copy.** In `docs/site/cli.md`'s `carlos run` section, remove RFS-3-C28's sentence and write RFS-6-C62 in its place; mark RFS-3-C28 `struck` in the deck (it stays in `rootfsCopyIDs`, joins `rootfsCopyStruck`) and remove it from `rootfsCopyBuilt` (the gate's struck-row rule, Plan 1 Task 1), and add RFS-6-C62 to C64. Run `go test ./internal/docsite/ -count=1` and the series copy gate. Expected: PASS. |
| - [ ] **Step 6: The dev-box check** (ask Paul first: ruling R10 approved the dev fleet box for Plans 2 and 4's checks only, so using it, or any box, here needs his approval of that scope): `carlos run --app shop --instance <host> -- /rails/bin/rails runner 'puts ActiveRecord::Base.connection.tables.sort.join(" ")'` on the fleet instance prints the tables, `carlos logs` shows the run, and the box made no `runs/` write (the store door's refusal log is empty for it). |
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md| index 3f7b2b8be..b4f52b762 100644 |
| --- a/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md |
| +++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md |
| @@ -5,17 +5,23 @@ from the string tables of plans 1 to 6 (`2026-10-24-rootfs-apps-[1-6]-*.md`). |
| Paul reviews it with the `copy-review` skill before any task writes a string: |
| Plan 1 Task 1 stops for that review. The gate is `approved_rootfs_copy_test.go` |
| (Plan 1 Task 1), which reads the run deck's format: columns id, file, text, |
| -status. |
| +status. Ids are canonical: `RFS-<plan>-C01` to `C99`, then `C100` onward |
| +(Plan 4 runs to C138). |
| The rules: |
| - A row is `pending` until Paul approves it in the copy review. His edits are |
| applied to the text here verbatim and the row becomes `approved`. |
| -- A row marked `struck` was superseded by a later plan. It is kept here for |
| - history and is not reviewed. |
| +- A row marked `struck` was superseded. It is kept here for history, and it |
| + is never reviewed, never built and never checked as applied. A row a later |
| + plan supersedes stays live (and is reviewed) until that plan's task removes |
| + the string; that task sets it `struck` and takes it out of |
| + `rootfsCopyBuilt`. RFS-1-C15 (Plan 5 Task 2a) and RFS-3-C28 (Plan 6 |
| + Task 16) are such rows, and are live now. The gate reads `struck` for this |
| + deck only (Plan 1 Task 1, `parseCopyTableStatuses`). |
| - The file cell is the plan's "where it appears" cell. A note in parentheses |
| - after it is the context note the plan gives (or, on a struck row, the plan |
| - that strikes it). Plan 4's tables that have no file column take the file from |
| + after it is the context note the plan gives (or, on a row a later plan |
| + supersedes, that plan). Plan 4's tables that have no file column take the file from |
| their sub-heading. |
| - Rows are in id order within each plan. The text cell is the plan's text, |
| unedited. Where a plan wrote a whole cell as one code span, the surrounding |
| @@ -45,7 +51,7 @@ From the plan: Placeholders: `<app>` an app name; `<name>` a process name; `<var |
| | RFS-1-C12 | internal/release/rootfs.go | the processes are <n> bytes, over the limit of <max>. | pending | |
| | RFS-1-C13 | internal/release/rootfs.go | processes, hibernate and jit belong to rootfs releases only. | pending | |
| | RFS-1-C14 | internal/release/rootfs.go | a rootfs release carries one artifact, rootfs-linux-arm64, and nothing else. | pending | |
| -| RFS-1-C15 | internal/release/rootfs.go (struck by Plan 5 Task 2a, replaced by RFS-5-C43) | a rootfs release cannot declare capabilities. | struck | |
| +| RFS-1-C15 | internal/release/rootfs.go (live until Plan 5 Task 2a strikes it, replaced by RFS-5-C43) | a rootfs release cannot declare capabilities. | pending | |
| | RFS-1-C16 | internal/release/rootfs.go | malformed processes block. Want the processes object: web, any workers, release and console. | pending | |
| | RFS-1-C17 | internal/release/rootfs.go | processes: null. A rootfs release needs its processes, starting with web. | pending | |
| | RFS-1-C18 | internal/release/rootfs_ship.go | <app> ships <kind> releases (<version> is one), so it cannot take a rootfs release. Ship rootfs releases to an app of their own, made with carlos apps create. | pending | |
| @@ -171,10 +177,10 @@ From the plan: Every row goes to the deck in Task 1 and reaches code only once a |
| | RFS-3-C25 | `internal/host/systemapply.go` (`systemApplyRootfsRefusal`) | %s cannot run rootfs instances, and this box still has: %s. delete those instances before installing it | pending | |
| | RFS-3-C26 | `docs/site/cli.md` (`carlos run`, a paragraph) | --version runs another build of the app against the instance, for example the build a deploy is about to bring in. The build must already be on the instance's box. The command still gets the environment the instance serves with. | pending | |
| | RFS-3-C27 | `docs/site/cli.md` (`carlos run`, a paragraph) | For a rootfs app the command after -- runs inside the instance's image and is found on the image's PATH. | pending | |
| -| RFS-3-C28 | `docs/site/cli.md` (`carlos run`, the same paragraph) (struck by Plan 6 Task 16, replaced by RFS-6-C62) | carlos run does not reach instances on a customer fleet yet. | struck | |
| +| RFS-3-C28 | `docs/site/cli.md` (`carlos run`, the same paragraph) (live until Plan 6 Task 16 strikes it, replaced by RFS-6-C62) | carlos run does not reach instances on a customer fleet yet. | pending | |
| | RFS-3-C29 | `internal/rolloutwords/words.go` (`EventReleaseLeaseLost`) | the release command of build %s was stopped because this box no longer holds the instance. it runs again on the next attempt | pending | |
| -From the plan: Count: 29 (RFS-3-C29 added in the Astra R1 fix round; Task 1 Step 2 takes it to the deck and Paul). (RFS-3-C28 is struck from the deck by Plan 6 Task 16 when runs reach fleet boxes, R7.) |
| +From the plan: Count: 29 (RFS-3-C29 added in the Astra R1 fix round; Task 1 Step 2 takes it to the deck and Paul). (RFS-3-C28 is live until Plan 6 Task 16 marks it struck when runs reach fleet boxes, R7.) |
| ## Plan 4: console and docs (`2026-10-24-rootfs-apps-4-console-and-docs.md`) |
| @@ -498,18 +504,20 @@ other Plan 1 rows use `%s` or `%d`. |
| Checked in every plan and in the index: each full `RFS-<n>-C<nn>` id, and each bare `C<nn>` in the plan's own task steps. Every one is in the deck except these, none of which is a string: |
| -- `RFS-0-C01`, `RFS-1-C1`, `RFS-1-C001`, `RFS-7-C01`: the bad-id examples in Plan 1 Task 1's id-pattern test (they must be refused). |
| -- `RFS-6-C99`: the good-id example in the same test. |
| +- `RFS-0-C01`, `RFS-1-C1`, `RFS-1-C00`, `RFS-1-C001`, `RFS-4-C0100`, `RFS-4-C1000`, `RFS-7-C01`: the bad-id examples in Plan 1 Task 1's id-pattern test (they must be refused). |
| +- `RFS-6-C99`: a good-id example in the same test (the others, RFS-1-C01, RFS-4-C100 and RFS-4-C138, are deck rows). |
| - `RFS-2-C03a`: the index's note that Plan 2's first-draft id is now `RFS-2-C12`. |
| ## Counts |
| Live rows are `pending` (or `approved`); struck rows are kept for history. |
| +None is struck before the review: RFS-1-C15 and RFS-3-C28 are live until |
| +Plan 5 Task 2a and Plan 6 Task 16 strike them (Astra R2 finding 5). |
| -- Plan 1: 84 live, 1 struck (RFS-1-C15), 85 rows |
| +- Plan 1: 85 live, 0 struck, 85 rows |
| - Plan 2: 12 live, 0 struck, 12 rows |
| -- Plan 3: 28 live, 1 struck (RFS-3-C28), 29 rows |
| +- Plan 3: 29 live, 0 struck, 29 rows |
| - Plan 4: 138 live, 0 struck, 138 rows |
| - Plan 5: 44 live, 0 struck, 44 rows |
| - Plan 6: 64 live, 0 struck, 64 rows |
| -- Total: 370 live, 2 struck, 372 rows |
| +- Total: 372 live, 0 struck, 372 rows |