carlos / platform Public

rootfs: the series copy gate (Plan 1 Task 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev af41a68ea29b8ca52db78a1cb0e2d2aef0a7610f parent 88ac707
2 files changed, +255 −3
  • approved_moves_copy_test.go +13 −3
  • approved_rootfs_copy_test.go +242 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (18 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (176 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	4.066s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.861s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.796s
ok  	github.com/carlosframework/platform/internal/activator	59.700s
ok  	github.com/carlosframework/platform/internal/agentsock	0.041s
ok  	github.com/carlosframework/platform/internal/alerting	0.010s
ok  	github.com/carlosframework/platform/internal/analytics	0.044s
ok  	github.com/carlosframework/platform/internal/appconfig	3.232s
ok  	github.com/carlosframework/platform/internal/apperr	0.165s
ok  	github.com/carlosframework/platform/internal/applog	0.271s
ok  	github.com/carlosframework/platform/internal/awsinv	0.010s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.025s
ok  	github.com/carlosframework/platform/internal/boxid	0.811s
?   	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.019s
ok  	github.com/carlosframework/platform/internal/caps	0.029s
ok  	github.com/carlosframework/platform/internal/certwarm	0.008s
ok  	github.com/carlosframework/platform/internal/cgroup	0.806s
ok  	github.com/carlosframework/platform/internal/changelog	0.556s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.232s
ok  	github.com/carlosframework/platform/internal/clamd	0.068s
?   	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	8.583s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
ok  	github.com/carlosframework/platform/internal/dbquery	0.013s
ok  	github.com/carlosframework/platform/internal/dbreads	2.171s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.010s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.007s
ok  	github.com/carlosframework/platform/internal/docsite	0.010s
ok  	github.com/carlosframework/platform/internal/domains	2.987s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.010s
ok  	github.com/carlosframework/platform/internal/edge	45.664s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.133s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.157s
ok  	github.com/carlosframework/platform/internal/host	146.241s
ok  	github.com/carlosframework/platform/internal/hostactors	5.672s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.016s
ok  	github.com/carlosframework/platform/internal/hostbox	2.850s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.778s
ok  	github.com/carlosframework/platform/internal/hostenv	0.018s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.018s
ok  	github.com/carlosframework/platform/internal/hostexec	5.373s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.366s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.021s
ok  	github.com/carlosframework/platform/internal/hostguard	1.063s
ok  	github.com/carlosframework/platform/internal/hostops	1.950s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.021s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.016s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.650s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.161s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.015s
ok  	github.com/carlosframework/platform/internal/infraview	0.021s
ok  	github.com/carlosframework/platform/internal/instancectl	5.226s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.830s
ok  	github.com/carlosframework/platform/internal/instancefs	0.028s
ok  	github.com/carlosframework/platform/internal/instancekey	0.023s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.039s
ok  	github.com/carlosframework/platform/internal/layout	1.782s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.015s
ok  	github.com/carlosframework/platform/internal/lease	10.350s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.636s
ok  	github.com/carlosframework/platform/internal/ledger	0.049s
ok  	github.com/carlosframework/platform/internal/mail	0.208s
ok  	github.com/carlosframework/platform/internal/maintenance	0.098s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.573s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.095s
ok  	github.com/carlosframework/platform/internal/metrics	0.008s
ok  	github.com/carlosframework/platform/internal/moves	22.585s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.944s
ok  	github.com/carlosframework/platform/internal/objstore	0.307s
ok  	github.com/carlosframework/platform/internal/ops	0.189s
ok  	github.com/carlosframework/platform/internal/park	0.286s
ok  	github.com/carlosframework/platform/internal/plans	0.019s
ok  	github.com/carlosframework/platform/internal/privatefile	0.015s
ok  	github.com/carlosframework/platform/internal/provision	24.005s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.668s
ok  	github.com/carlosframework/platform/internal/relay	7.231s
ok  	github.com/carlosframework/platform/internal/release	26.246s
ok  	github.com/carlosframework/platform/internal/rollout	3.802s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.010s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.061s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.513s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.669s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.603s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.879s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.915s
ok  	github.com/carlosframework/platform/internal/status	0.084s
ok  	github.com/carlosframework/platform/internal/steering	0.008s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.294s
ok  	github.com/carlosframework/platform/internal/svcrole	0.004s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.004s
ok  	github.com/carlosframework/platform/internal/updatewords	0.007s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.694s
=== internal_console@0 (87 s)
ok  	github.com/carlosframework/platform/internal/console	84.808s
=== internal_console@1 (44 s)
ok  	github.com/carlosframework/platform/internal/console	41.644s
=== internal_console@2 (42 s)
ok  	github.com/carlosframework/platform/internal/console	39.901s
=== internal_console@3 (42 s)
ok  	github.com/carlosframework/platform/internal/console	39.858s
=== internal_console@4 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.447s
=== internal_console@5 (41 s)
ok  	github.com/carlosframework/platform/internal/console	39.298s
=== internal_console@6 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.776s
=== internal_console@7 (48 s)
ok  	github.com/carlosframework/platform/internal/console	45.744s
=== root@0 (106 s)
ok  	github.com/carlosframework/platform	104.065s
=== root@1 (81 s)
ok  	github.com/carlosframework/platform	79.145s
=== root@2 (100 s)
ok  	github.com/carlosframework/platform	97.596s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50659490 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/24 20:17:51 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E1908F401B0E0B, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.030s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN   TestDBForwardMinio
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 20:17:53 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 20:17:53 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 20:17:53 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 20:17:53 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 20:17:53 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 20:17:54 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/24 20:17:54 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A33519: could not determine target mailbox
    agent.go:135: dbreads: dbr_cfonhhj4k6hw3mssjemmykjoxi: planned 3 files (1544 bytes) and restored to 3 in 9ms
    agent.go:135: dbreads: dbr_cfonhhj4k6hw3mssjemmykjoxi: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 20:17:55 console: db read dbr_cfonhhj4k6hw3mssjemmykjoxi: relayed 1 chunks (102 bytes) in 1.004s, ended done
    agent.go:135: dbreads: dbr_7q6b6ptnbnlwj2buoatlsvg6vq: planned 1 files (720 bytes) and restored to 1 in 8ms
    agent.go:135: dbreads: dbr_7q6b6ptnbnlwj2buoatlsvg6vq: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 20:17:56 console: db read dbr_7q6b6ptnbnlwj2buoatlsvg6vq: relayed 1 chunks (102 bytes) in 1.003s, ended done
    agent.go:135: dbreads: dbr_bdvteg2nyqnaqxx4zfskhspav4: planned 3 files (1544 bytes) and held through 3 in 9ms
    agent.go:135: dbreads: dbr_bdvteg2nyqnaqxx4zfskhspav4: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/24 20:17:57 console: db read dbr_bdvteg2nyqnaqxx4zfskhspav4: relayed 1 chunks (1641 bytes) in 1.003s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_l2zai6kjn66gntwzjnqpksvr5i: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 20:17:57 console: db read dbr_l2zai6kjn66gntwzjnqpksvr5i: relayed 0 chunks (0 bytes) in 13ms, ended client gone
    agent.go:135: dbreads: dbr_l2zai6kjn66gntwzjnqpksvr5i: output 5 bytes in 1 confirmed chunks in 32ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_owitieekwold5erqunqn62z4za: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 20:18:17 console: db read dbr_owitieekwold5erqunqn62z4za: relayed 0 chunks (0 bytes) in 19.786s, ended no-result
    agent.go:135: dbreads: dbr_owitieekwold5erqunqn62z4za: output 5 bytes in 1 confirmed chunks in 19.776s
--- PASS: TestDBForwardMinio (23.14s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.15s)
    --- PASS: TestDBForwardMinio/by_timeout (19.89s)
PASS
ok  	github.com/carlosframework/platform/internal/console	23.156s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 7d15e7bba..812fb7b02 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
+ "slices"
"sort"
"strconv"
"strings"
@@ -145,8 +146,14 @@ func parseMovesCopyTable(src, name string) ([]copyRow, error) {
// parseCopyTable reads a copy record's tables: the moves record, the run
// deck and the seeded deck share the format. ids is the record's id pattern
-// and idKind names it in the refusal.
+// and idKind names it in the refusal. A row's status is pending or approved.
func parseCopyTable(src, name string, ids *regexp.Regexp, idKind string) ([]copyRow, error) {
+ return parseCopyTableStatuses(src, name, ids, idKind, "pending", "approved")
+}
+
+// parseCopyTableStatuses is parseCopyTable for a deck with its own statuses
+// (the rootfs deck adds struck).
+func parseCopyTableStatuses(src, name string, ids *regexp.Regexp, idKind string, statuses ...string) ([]copyRow, error) {
var rows []copyRow
seen := map[string]int{}
inTable := false
@@ -194,8 +201,11 @@ func parseCopyTable(src, name string, ids *regexp.Regexp, idKind string) ([]copy
if r.File == "" || r.Text == "" {
return nil, fmt.Errorf("%s: %s has an empty file or text cell", at, r.ID)
}
- if r.Status != "pending" && r.Status != "approved" {
- return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status)
+ if !slices.Contains(statuses, r.Status) {
+ if slices.Equal(statuses, []string{"pending", "approved"}) {
+ return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status)
+ }
+ return nil, fmt.Errorf("%s: %s has status %q, which is not one of %s", at, r.ID, r.Status, strings.Join(statuses, ", "))
}
if first, dup := seen[r.ID]; dup {
return nil, fmt.Errorf("%s: %s is already a row at line %d", at, r.ID, first)
diff --git a/approved_rootfs_copy_test.go b/approved_rootfs_copy_test.go
new file mode 100644
index 000000000..a8810501e
--- /dev/null
+++ b/approved_rootfs_copy_test.go
@@ -0,0 +1,242 @@
+// approved_rootfs_copy_test.go
+package main
+
+import (
+ "os"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// The copy gate of the rootfs apps series (Plan 1 Task 1), over the one deck
+// every rootfs plan's string table was assembled into:
+//
+// - TestRootfsCopyRecordIsComplete: the deck names exactly rootfsCopyIDs;
+// - TestRootfsCopyHasNoEmDash;
+// - TestRootfsBuiltCopyIsApproved: an id in rootfsCopyBuilt is an approved
+// row, so no pending string reaches code or docs;
+// - TestRootfsBuiltCopyIsApplied: a built row's text is in the file it
+// names.
+const rootfsCopyRecordPath = "docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md"
+
+// 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. A
+// struck row stays in it: the deck keeps the row for history.
+var rootfsCopyIDs = []string{
+ // Plan 1
+ "RFS-1-C01", "RFS-1-C02", "RFS-1-C03", "RFS-1-C04",
+ "RFS-1-C05", "RFS-1-C06", "RFS-1-C07", "RFS-1-C08",
+ "RFS-1-C09", "RFS-1-C10", "RFS-1-C11", "RFS-1-C12",
+ "RFS-1-C13", "RFS-1-C14", "RFS-1-C15", "RFS-1-C16",
+ "RFS-1-C17", "RFS-1-C18", "RFS-1-C19", "RFS-1-C20",
+ "RFS-1-C21", "RFS-1-C22", "RFS-1-C23", "RFS-1-C24",
+ "RFS-1-C25", "RFS-1-C26", "RFS-1-C27", "RFS-1-C28",
+ "RFS-1-C29", "RFS-1-C30", "RFS-1-C31", "RFS-1-C32",
+ "RFS-1-C33", "RFS-1-C34", "RFS-1-C35", "RFS-1-C36",
+ "RFS-1-C37", "RFS-1-C38", "RFS-1-C39", "RFS-1-C40",
+ "RFS-1-C41", "RFS-1-C42", "RFS-1-C43", "RFS-1-C44",
+ "RFS-1-C45", "RFS-1-C46", "RFS-1-C47", "RFS-1-C48",
+ "RFS-1-C49", "RFS-1-C50", "RFS-1-C51", "RFS-1-C52",
+ "RFS-1-C53", "RFS-1-C54", "RFS-1-C55", "RFS-1-C56",
+ "RFS-1-C57", "RFS-1-C58", "RFS-1-C59", "RFS-1-C60",
+ "RFS-1-C61", "RFS-1-C62", "RFS-1-C63", "RFS-1-C64",
+ "RFS-1-C65", "RFS-1-C66", "RFS-1-C67", "RFS-1-C68",
+ "RFS-1-C69", "RFS-1-C70", "RFS-1-C71", "RFS-1-C72",
+ "RFS-1-C73", "RFS-1-C74", "RFS-1-C75", "RFS-1-C76",
+ "RFS-1-C77", "RFS-1-C78", "RFS-1-C79", "RFS-1-C80",
+ "RFS-1-C81", "RFS-1-C82", "RFS-1-C83", "RFS-1-C84",
+ "RFS-1-C85",
+ // Plan 2
+ "RFS-2-C01", "RFS-2-C02", "RFS-2-C03", "RFS-2-C04",
+ "RFS-2-C05", "RFS-2-C06", "RFS-2-C07", "RFS-2-C08",
+ "RFS-2-C09", "RFS-2-C10", "RFS-2-C11", "RFS-2-C12",
+ // Plan 3
+ "RFS-3-C01", "RFS-3-C02", "RFS-3-C03", "RFS-3-C04",
+ "RFS-3-C05", "RFS-3-C06", "RFS-3-C07", "RFS-3-C08",
+ "RFS-3-C09", "RFS-3-C10", "RFS-3-C11", "RFS-3-C12",
+ "RFS-3-C13", "RFS-3-C14", "RFS-3-C15", "RFS-3-C16",
+ "RFS-3-C17", "RFS-3-C18", "RFS-3-C19", "RFS-3-C20",
+ "RFS-3-C21", "RFS-3-C22", "RFS-3-C23", "RFS-3-C24",
+ "RFS-3-C25", "RFS-3-C26", "RFS-3-C27", "RFS-3-C28",
+ "RFS-3-C29",
+ // Plan 4
+ "RFS-4-C01", "RFS-4-C02", "RFS-4-C03", "RFS-4-C04",
+ "RFS-4-C05", "RFS-4-C06", "RFS-4-C07", "RFS-4-C08",
+ "RFS-4-C09", "RFS-4-C10", "RFS-4-C11", "RFS-4-C12",
+ "RFS-4-C13", "RFS-4-C14", "RFS-4-C15", "RFS-4-C16",
+ "RFS-4-C17", "RFS-4-C18", "RFS-4-C19", "RFS-4-C20",
+ "RFS-4-C21", "RFS-4-C22", "RFS-4-C23", "RFS-4-C24",
+ "RFS-4-C25", "RFS-4-C26", "RFS-4-C27", "RFS-4-C28",
+ "RFS-4-C29", "RFS-4-C30", "RFS-4-C31", "RFS-4-C32",
+ "RFS-4-C33", "RFS-4-C34", "RFS-4-C35", "RFS-4-C36",
+ "RFS-4-C37", "RFS-4-C38", "RFS-4-C39", "RFS-4-C40",
+ "RFS-4-C41", "RFS-4-C42", "RFS-4-C43", "RFS-4-C44",
+ "RFS-4-C45", "RFS-4-C46", "RFS-4-C47", "RFS-4-C48",
+ "RFS-4-C49", "RFS-4-C50", "RFS-4-C51", "RFS-4-C52",
+ "RFS-4-C53", "RFS-4-C54", "RFS-4-C55", "RFS-4-C56",
+ "RFS-4-C57", "RFS-4-C58", "RFS-4-C59", "RFS-4-C60",
+ "RFS-4-C61", "RFS-4-C62", "RFS-4-C63", "RFS-4-C64",
+ "RFS-4-C65", "RFS-4-C66", "RFS-4-C67", "RFS-4-C68",
+ "RFS-4-C69", "RFS-4-C70", "RFS-4-C71", "RFS-4-C72",
+ "RFS-4-C73", "RFS-4-C74", "RFS-4-C75", "RFS-4-C76",
+ "RFS-4-C77", "RFS-4-C78", "RFS-4-C79", "RFS-4-C80",
+ "RFS-4-C81", "RFS-4-C82", "RFS-4-C83", "RFS-4-C84",
+ "RFS-4-C85", "RFS-4-C86", "RFS-4-C87", "RFS-4-C88",
+ "RFS-4-C89", "RFS-4-C90", "RFS-4-C91", "RFS-4-C92",
+ "RFS-4-C93", "RFS-4-C94", "RFS-4-C95", "RFS-4-C96",
+ "RFS-4-C97", "RFS-4-C98", "RFS-4-C99", "RFS-4-C100",
+ "RFS-4-C101", "RFS-4-C102", "RFS-4-C103", "RFS-4-C104",
+ "RFS-4-C105", "RFS-4-C106", "RFS-4-C107", "RFS-4-C108",
+ "RFS-4-C109", "RFS-4-C110", "RFS-4-C111", "RFS-4-C112",
+ "RFS-4-C113", "RFS-4-C114", "RFS-4-C115", "RFS-4-C116",
+ "RFS-4-C117", "RFS-4-C118", "RFS-4-C119", "RFS-4-C120",
+ "RFS-4-C121", "RFS-4-C122", "RFS-4-C123", "RFS-4-C124",
+ "RFS-4-C125", "RFS-4-C126", "RFS-4-C127", "RFS-4-C128",
+ "RFS-4-C129", "RFS-4-C130", "RFS-4-C131", "RFS-4-C132",
+ "RFS-4-C133", "RFS-4-C134", "RFS-4-C135", "RFS-4-C136",
+ "RFS-4-C137", "RFS-4-C138",
+ // Plan 5
+ "RFS-5-C01", "RFS-5-C02", "RFS-5-C03", "RFS-5-C04",
+ "RFS-5-C05", "RFS-5-C06", "RFS-5-C07", "RFS-5-C08",
+ "RFS-5-C09", "RFS-5-C10", "RFS-5-C11", "RFS-5-C12",
+ "RFS-5-C13", "RFS-5-C14", "RFS-5-C15", "RFS-5-C16",
+ "RFS-5-C17", "RFS-5-C18", "RFS-5-C19", "RFS-5-C20",
+ "RFS-5-C21", "RFS-5-C22", "RFS-5-C23", "RFS-5-C24",
+ "RFS-5-C25", "RFS-5-C26", "RFS-5-C27", "RFS-5-C28",
+ "RFS-5-C29", "RFS-5-C30", "RFS-5-C31", "RFS-5-C32",
+ "RFS-5-C33", "RFS-5-C34", "RFS-5-C35", "RFS-5-C36",
+ "RFS-5-C37", "RFS-5-C38", "RFS-5-C39", "RFS-5-C40",
+ "RFS-5-C41", "RFS-5-C42", "RFS-5-C43", "RFS-5-C44",
+ // Plan 6
+ "RFS-6-C01", "RFS-6-C02", "RFS-6-C03", "RFS-6-C04",
+ "RFS-6-C05", "RFS-6-C06", "RFS-6-C07", "RFS-6-C08",
+ "RFS-6-C09", "RFS-6-C10", "RFS-6-C11", "RFS-6-C12",
+ "RFS-6-C13", "RFS-6-C14", "RFS-6-C15", "RFS-6-C16",
+ "RFS-6-C17", "RFS-6-C18", "RFS-6-C19", "RFS-6-C20",
+ "RFS-6-C21", "RFS-6-C22", "RFS-6-C23", "RFS-6-C24",
+ "RFS-6-C25", "RFS-6-C26", "RFS-6-C27", "RFS-6-C28",
+ "RFS-6-C29", "RFS-6-C30", "RFS-6-C31", "RFS-6-C32",
+ "RFS-6-C33", "RFS-6-C34", "RFS-6-C35", "RFS-6-C36",
+ "RFS-6-C37", "RFS-6-C38", "RFS-6-C39", "RFS-6-C40",
+ "RFS-6-C41", "RFS-6-C42", "RFS-6-C43", "RFS-6-C44",
+ "RFS-6-C45", "RFS-6-C46", "RFS-6-C47", "RFS-6-C48",
+ "RFS-6-C49", "RFS-6-C50", "RFS-6-C51", "RFS-6-C52",
+ "RFS-6-C53", "RFS-6-C54", "RFS-6-C55", "RFS-6-C56",
+ "RFS-6-C57", "RFS-6-C58", "RFS-6-C59", "RFS-6-C60",
+ "RFS-6-C61", "RFS-6-C62", "RFS-6-C63", "RFS-6-C64",
+}
+
+// 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 mustParseRootfsRecord(t *testing.T) []copyRow {
+ t.Helper()
+ raw, err := os.ReadFile(rootfsCopyRecordPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows, err := parseCopyTableStatuses(string(raw), rootfsCopyRecordPath, rootfsCopyIDPattern, "an RFS-<plan>-C<nn>", rootfsCopyStatuses...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rows
+}
+
+func TestRootfsCopyIDPattern(t *testing.T) {
+ 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-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)
+ }
+ // The variadic form names its statuses in the refusal.
+ src = "| id | file | text | status |\n|---|---|---|---|\n| RFS-1-C01 | a.go | one | maybe |\n"
+ if _, err := parseCopyTableStatuses(src, "x.md", rootfsCopyIDPattern, "an RFS-<plan>-C<nn>", rootfsCopyStatuses...); err == nil || !strings.Contains(err.Error(), "not one of pending, approved, struck") {
+ t.Errorf("parseCopyTableStatuses accepted maybe: %v", err)
+ }
+}
+
+func TestRootfsCopyRecordIsComplete(t *testing.T) {
+ reportAll(t, checkMovesInventory(mustParseRootfsRecord(t), rootfsCopyIDs))
+}
+
+func TestRootfsCopyHasNoEmDash(t *testing.T) {
+ reportAll(t, checkMovesNoEmDash(mustParseRootfsRecord(t)))
+}
+
+func TestRootfsBuiltCopyIsApproved(t *testing.T) {
+ reportAll(t, checkRunBuilt(mustParseRootfsRecord(t), rootfsCopyBuilt))
+}
+
+// rootfsAppliedText is a row's text as the code prints it: the deck writes a
+// newline as a literal backslash-n, and Markdown needs a backslash before a
+// pipe inside a table cell (RFS-1-C34, RFS-5-C36, RFS-6-C41), which Go must
+// not print.
+func rootfsAppliedText(s string) string {
+ return strings.NewReplacer(`\n`, "\n", `\|`, "|").Replace(s)
+}
+
+func TestRootfsAppliedTextUnescapes(t *testing.T) {
+ if got, want := rootfsAppliedText(`a \| b\nc`), "a | b\nc"; got != want {
+ t.Errorf("rootfsAppliedText = %q, want %q", got, want)
+ }
+}
+
+func TestRootfsBuiltCopyIsApplied(t *testing.T) {
+ rows := builtRows(mustParseRootfsRecord(t), rootfsCopyBuilt)
+ for i := range rows {
+ rows[i].Text = rootfsAppliedText(rows[i].Text)
+ }
+ reportAll(t, checkRunApplied(rows, "."))
+}