moves: refuse a cut whose files manifest is past the readers' cap (#236)
Relocation's a8cebad capped every entry path of a files manifest at 4095 bytes on the read side, and the readers refuse an over-cap manifest terminally. A manifest written before that commit can still hold such a path. If a move's recovery selected it (the src_gen bridge, or a revert's own lineage), Establish would fail with ErrTooDeep and the source could not recover after the cut. This is moves' part of Paul's ruling of 2026-10-15 on amadan #236 (Astra's P2): refuse at the cut, so a move that could not recover never starts. - sourceRefusal ends in sourceFilesRefusal: the source round, in every preparing round and in the cut's requested round, selects the files manifest the establishment would (movesFilesSelect, newestManifest's selection split out) over the holder's chain, and refuses at S1 with MV-N501 when hostfiles.RestorableWithinCap fails. That happens before Touch, any staging write, hold, stop or cut. A read that fails ends the round with an error, as the other reads do. - movesFilesRestorable wraps hostfiles.RestorableWithinCap (already on main; hostfiles is unchanged). The recovery's movesFilesAllowed now calls it first, so the cut and the recovery share one function. - MV-N501 is a new pending row in the moves copy record. Moves stays off. - Census: a new Plan 14 overlay, moves-overcap-cut-236, re-freezes movesWorker.sourceRefusal and movesWorker.sourceRound with main's dispositions and new digests, and re-pins the internal/host envelope. The legacy seal moves. The 9B8 record-write inventory re-digests sourceRound; its write sites are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+470
−16
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+8 −0internal/host/movesfiles.go+54 −12internal/host/movesfilescap_test.go+321 −0internal/host/movesphase1.go+64 −3internal/provision/plan14_root_reads_test.go+12 −0internal/provision/plan9_b8_census_test.go+3 −1internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv+7 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 296.728s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.746s
ok github.com/carlosframework/platform/cmd/moveprobe 92.208s
ok github.com/carlosframework/platform/cmd/zddprobe 0.701s
ok github.com/carlosframework/platform/internal/activator 249.641s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.463s
ok github.com/carlosframework/platform/internal/apperr 0.080s
ok github.com/carlosframework/platform/internal/applog 0.126s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.923s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.692s
ok github.com/carlosframework/platform/internal/changelog 0.316s
ok github.com/carlosframework/platform/internal/clamd 0.057s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 389.382s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.205s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.334s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.638s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.129s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.010s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/host 134.230s
ok github.com/carlosframework/platform/internal/hostactors 0.583s
ok github.com/carlosframework/platform/internal/hostbox 2.236s
ok github.com/carlosframework/platform/internal/hostclaim 0.489s
ok github.com/carlosframework/platform/internal/hostevidence 0.011s
ok github.com/carlosframework/platform/internal/hostexec 4.964s
ok github.com/carlosframework/platform/internal/hostfence 0.036s
ok github.com/carlosframework/platform/internal/hostfiles 10.567s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.014s
ok github.com/carlosframework/platform/internal/hostops 1.573s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.012s
ok github.com/carlosframework/platform/internal/hostquarantine 0.011s
ok github.com/carlosframework/platform/internal/hostrelocate 0.435s
ok github.com/carlosframework/platform/internal/hostresolve 0.133s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 4.169s
ok github.com/carlosframework/platform/internal/instanceexec 2.726s
ok github.com/carlosframework/platform/internal/instancefs 0.016s
ok github.com/carlosframework/platform/internal/instancekey 0.012s
ok github.com/carlosframework/platform/internal/instanceupdates 0.017s
ok github.com/carlosframework/platform/internal/layout 1.506s
ok github.com/carlosframework/platform/internal/lease 9.807s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.151s
ok github.com/carlosframework/platform/internal/maintenance 0.076s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.545s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.066s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.025s
ok github.com/carlosframework/platform/internal/moves/archive 0.771s
ok github.com/carlosframework/platform/internal/objstore 0.179s
ok github.com/carlosframework/platform/internal/ops 0.178s
ok github.com/carlosframework/platform/internal/park 0.203s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 16.972s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.887s
ok github.com/carlosframework/platform/internal/relay 7.223s
ok github.com/carlosframework/platform/internal/release 19.672s
ok github.com/carlosframework/platform/internal/rollout 3.809s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.010s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.572s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.010s
ok github.com/carlosframework/platform/internal/schedule 2.509s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.643s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.719s
ok github.com/carlosframework/platform/internal/status 0.040s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.192s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.557s
=== 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: 46399650 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.022s
=== RUN TestT4StorageProbeMinio
2026/10/15 13:37:07 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEB777DBB40955, 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.020s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index c4d349fce..7a28f80e9 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -89,6 +89,7 @@ var movesCopyIDs = []string{ |
| "MV-N440", "MV-N441", "MV-N442", "MV-N443", |
| "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456", |
| "MV-N500", |
| + "MV-N501", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index e44720f29..5a15e7881 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -448,3 +448,11 @@ Paul's copy review of 2026-10-12 (region HA stage B, final review minor 9). The |
| | MV-N500 | internal/console/apiinstancemove.go | the instance's last relocation is still finishing. try again in a few minutes | approved | |
| The five rows added after Plan 12 (MV-N450, MV-N451, MV-N454, MV-N455 and MV-N456) were approved by Paul on 2026-10-13 with no edits. |
| + |
| +## Files manifest past the path cap |
| + |
| +Paul's ruling of 2026-10-15 on amadan #236 (Astra's P2 on a8cebad). The files readers refuse, terminally, a manifest with an entry path longer than 4095 bytes (`hostfiles.ErrTooDeep`), and a manifest written before that cap can still hold one. A recovery after the cut that selected it could not restore the source, so the source's round refuses the move before any staging write, stop or cut, and writes MV-N501 into S1, where `carlos move status` shows it. MV-N501 is the first number past MV-N500. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N501 | internal/host/movesphase1.go | <host> has a files manifest with a path longer than 4095 bytes, so it cannot move. an operator must replace the manifest | pending | |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index 683d50f21..fa6c2138e 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -405,7 +405,14 @@ func movesFilesChainFrom(h *hostclaim.Holder, start, home, region string) []stri |
| // names it: "" when the key has no current object. A delete marker or an |
| // unversioned current object is an error. |
| func (f *movesFiles) manifestVersion(ctx context.Context, key string) (string, error) { |
| - vs, err := f.Data.ListVersions(ctx, f.Bucket, key) |
| + return movesManifestVersion(ctx, f.Data, f.Bucket, key) |
| +} |
| + |
| +// movesManifestVersion is manifestVersion over any versioned reader of the |
| +// data bucket: the cut's preflight reads with the moves worker's own |
| +// (sourceFilesRefusal), the establishment with the claim's. |
| +func movesManifestVersion(ctx context.Context, data lease.VersionedStore, bucket, key string) (string, error) { |
| + vs, err := data.ListVersions(ctx, bucket, key) |
| if err != nil { |
| return "", err |
| } |
| @@ -429,30 +436,58 @@ func (f *movesFiles) manifestVersion(ctx context.Context, key string) (string, e |
| // one in its place). found is false when no generation of the chain has a |
| // manifest. |
| func (f *movesFiles) newestManifest(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location string, chain []string) (hostfiles.Manifest, []string, bool, error) { |
| + m, key, version, found, err := movesFilesSelect(ctx, f.Data, f.Bucket, location, chain) |
| + if err != nil || !found { |
| + return hostfiles.Manifest{}, nil, false, err |
| + } |
| + allowed, err := f.movesFilesAllowed(ctx, o, in, location, in.NewGeneration, m) |
| + if err != nil { |
| + return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| + } |
| + return m, allowed, true, nil |
| +} |
| + |
| +// movesFilesSelect is newestManifest's selection, apart from its |
| +// validation: the first generation of chain whose manifest key has a |
| +// current version, read at exactly that version, with the key and the |
| +// version. found is false when no generation of chain has a manifest. The |
| +// establishment (newestManifest, the src_gen bridge included) and the |
| +// cut's preflight (sourceFilesRefusal) both select through it, so the cut |
| +// checks the manifest a recovery would restore, not a guess at it (#236). |
| +func movesFilesSelect(ctx context.Context, data lease.VersionedStore, bucket, location string, chain []string) (m hostfiles.Manifest, key, version string, found bool, err error) { |
| for _, g := range chain { |
| prefix := movesFilesPrefix(location, g) |
| key := hostfiles.ManifestKey(prefix) |
| - version, err := f.manifestVersion(ctx, key) |
| + version, err := movesManifestVersion(ctx, data, bucket, key) |
| if err != nil { |
| - return hostfiles.Manifest{}, nil, false, err |
| + return hostfiles.Manifest{}, "", "", false, err |
| } |
| if version == "" { |
| continue |
| } |
| - m, found, err := hostfiles.Load(ctx, versionStore{data: f.Data, bucket: f.Bucket, key: key, version: version}, prefix) |
| + m, found, err := hostfiles.Load(ctx, versionStore{data: data, bucket: bucket, key: key, version: version}, prefix) |
| if err != nil { |
| - return hostfiles.Manifest{}, nil, false, err |
| + return hostfiles.Manifest{}, "", "", false, err |
| } |
| if !found { |
| - return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s is not readable", key, version) |
| - } |
| - allowed, err := f.movesFilesAllowed(ctx, o, in, location, in.NewGeneration, m) |
| - if err != nil { |
| - return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| + return hostfiles.Manifest{}, "", "", false, fmt.Errorf("host: the files manifest %s@%s is not readable", key, version) |
| } |
| - return m, allowed, true, nil |
| + return m, key, version, true, nil |
| } |
| - return hostfiles.Manifest{}, nil, false, nil |
| + return hostfiles.Manifest{}, "", "", false, nil |
| +} |
| + |
| +// movesFilesRestorable is the reader's path cap as moves applies it |
| +// (hostfiles.RestorableWithinCap, the rule checkManifest applies to every |
| +// entry, live, tombstoned or skipped): a manifest that fails it is |
| +// hostfiles.ErrTooDeep, and no establishment restores it. The recovery's |
| +// establishment (movesFilesAllowed) and the cut's preflight |
| +// (sourceFilesRefusal) both call it, so a move whose recovery could not |
| +// restore the source's files never starts its cut (#236; Paul's ruling of |
| +// 2026-10-15). If region HA relaxes the readers' rule, RestorableWithinCap |
| +// follows it, and both sides here with it. |
| +func movesFilesRestorable(m hostfiles.Manifest) error { |
| + return hostfiles.RestorableWithinCap(m) |
| } |
| // movesOriginProof proves that generations of the holder's incarnation |
| @@ -532,6 +567,13 @@ func (f *movesFiles) proveOrigins(ctx context.Context, o hostclaim.Observed, in |
| // before; only unreferenced hints and unreferenced chain generations, which |
| // no entry depends on, are no longer asked about. |
| func (f *movesFiles) movesFilesAllowed(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location, admitted string, m hostfiles.Manifest) ([]string, error) { |
| + // The path cap first, by the rule the cut's preflight applies |
| + // (movesFilesRestorable): ValidateManifest below refuses the same |
| + // manifest with the same error, so this only refuses it before any |
| + // origin proof, and keeps the two sides on one function. |
| + if err := movesFilesRestorable(m); err != nil { |
| + return nil, err |
| + } |
| named, err := movesFilesOrigins(location, "", m) |
| if err != nil { |
| return nil, err |
diff --git a/internal/host/movesfilescap_test.go b/internal/host/movesfilescap_test.go| new file mode 100644 |
| index 000000000..61fecacbe |
| --- /dev/null |
| +++ b/internal/host/movesfilescap_test.go |
| @@ -0,0 +1,321 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The cut's files preflight (#236; Paul's ruling of 2026-10-15): a source |
| +// whose files manifest a recovery could not restore, because an entry path |
| +// is past the readers' cap, is refused at S1 before anything is staged, |
| +// stopped or cut. |
| + |
| +// capPath is a clean relative path of exactly n bytes: 200-byte segments |
| +// joined by "/", as hostfiles' own tests build them. |
| +func capPath(n int) string { |
| + var b strings.Builder |
| + for b.Len() < n { |
| + if b.Len() > 0 { |
| + b.WriteByte('/') |
| + } |
| + b.WriteString(strings.Repeat("x", min(200, n-b.Len()))) |
| + } |
| + return b.String() |
| +} |
| + |
| +// capCase is one manifest shape: its one entry's kind (live, tombstone or |
| +// skipped) at a path of the given length. |
| +type capCase struct { |
| + kind string |
| + n int |
| +} |
| + |
| +// capCases are the shapes every test here runs: an ordinary manifest, a |
| +// live entry at the cap, and each kind of entry one byte past it. |
| +var capCases = map[string]capCase{ |
| + "an ordinary manifest": {"live", len("a.txt")}, |
| + "a live entry at the cap": {"live", hostfiles.MaxPathBytes}, |
| + "a live entry past it": {"live", hostfiles.MaxPathBytes + 1}, |
| + "a tombstone past it": {"tombstone", hostfiles.MaxPathBytes + 1}, |
| + "a skipped entry past it": {"skipped", hostfiles.MaxPathBytes + 1}, |
| +} |
| + |
| +func (c capCase) path() string { |
| + if c.n == len("a.txt") { |
| + return "a.txt" |
| + } |
| + return capPath(c.n) |
| +} |
| + |
| +// manifest is the case's manifest for generation gen at location loc: a |
| +// live entry names an object under gen's files prefix, as the publisher |
| +// writes it. |
| +func (c capCase) manifest(loc, gen string) hostfiles.Manifest { |
| + var e hostfiles.Entry |
| + switch c.kind { |
| + case "live": |
| + sum := sha([]byte("A")) |
| + e = hostfiles.Entry{Object: movesFilesPrefix(loc, gen) + fmt.Sprintf("objects/%016d-%s", 1, sum), SHA256: sum, Size: 1, Mode: 0o600, Seq: 1} |
| + case "tombstone": |
| + e = hostfiles.Entry{Seq: 1, Deleted: true} |
| + case "skipped": |
| + e = hostfiles.Entry{Seq: 1, Skipped: "too-large"} |
| + } |
| + return hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 1, Entries: map[string]hostfiles.Entry{c.path(): e}, Platform: map[string]hostfiles.Entry{}} |
| +} |
| + |
| +// readerRefuses is what the files readers do with m today: ValidateManifest |
| +// (checkManifest, the establishment's reader) refuses it as ErrTooDeep. The |
| +// tests hold the cut to this verdict rather than to a fixed answer per |
| +// kind: today the readers cap every entry, tombstones and skipped entries |
| +// included, so the cut refuses all three past the cap. If region HA |
| +// relaxes the readers for tombstones and skipped entries (its part of |
| +// #236), RestorableWithinCap follows, and the cut with it. |
| +func readerRefuses(m hostfiles.Manifest, loc, gen string) bool { |
| + return errors.Is(hostfiles.ValidateManifest(m, []string{movesFilesPrefix(loc, gen)}), hostfiles.ErrTooDeep) |
| +} |
| + |
| +// putP1Manifest stores m as the current version of gen's files manifest in |
| +// the Phase 1 harness's data bucket, written directly (a manifest from |
| +// before the cap), never through the staging mirror. |
| +func putP1Manifest(t *testing.T, h *phase1Harness, loc, gen string, m hostfiles.Manifest) { |
| + t.Helper() |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := hostfiles.ManifestKey(movesFilesPrefix(loc, gen)) |
| + h.f.store("data-ie", key, fakeMoveObject{lease.MirrorObject{ETag: "manifest-" + gen, Size: int64(len(raw))}, string(raw)}) |
| + h.f.setVersion("data-ie", key, "ver-manifest-"+gen) |
| +} |
| + |
| +// A source whose files manifest has an entry past the cap is refused at S1 |
| +// with MV-N501, and nothing is staged: no staging request, no touch of the |
| +// target in the claim, the move still preparing. A manifest the readers |
| +// accept passes, and the round stages as before. |
| +func TestPreparingRefusesAFilesManifestPastTheCap(t *testing.T) { |
| + for name, c := range capCases { |
| + t.Run(name, func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + loc := "instances/" + p1Host + "/" + h.inc |
| + m := c.manifest(loc, "cur") |
| + putP1Manifest(t, h, loc, "cur", m) |
| + touched := h.claim(t).Touched |
| + h.pass(t) |
| + sr := h.sourceReport(t) |
| + if sr == nil { |
| + t.Fatal("no source report") |
| + } |
| + refuses := readerRefuses(m, loc, "cur") |
| + if c.kind == "live" && refuses != (c.n > hostfiles.MaxPathBytes) { |
| + t.Fatalf("the reader's verdict on a live entry of %d bytes is %v", c.n, refuses) |
| + } |
| + if !refuses { |
| + if !sr.S1.OK || len(h.f.stagingCalls()) == 0 { |
| + t.Fatalf("a manifest the readers accept: S1 %+v, %d staging calls", sr.S1, len(h.f.stagingCalls())) |
| + } |
| + return |
| + } |
| + if sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| + t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilesPastCap(p1Host)) |
| + } |
| + if calls := h.f.stagingCalls(); len(calls) != 0 { |
| + t.Fatalf("staged despite the refusal: %+v", calls) |
| + } |
| + if got := h.claim(t).Touched; !reflect.DeepEqual(got, touched) { |
| + t.Fatalf("the claim was touched: %+v, was %+v", got, touched) |
| + } |
| + if st := h.read(t, p1Host).Move.State; st != provision.MovePreparing { |
| + t.Fatalf("the move is %s", st) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A files manifest that cannot be read ends the round as the other read |
| +// errors do: no source report, nothing staged, the reason in the log. |
| +func TestPreparingEndsTheRoundWhenTheFilesManifestCannotBeRead(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + h.vs.deny = func(op, _, key string) error { |
| + if op == "list-versions" && strings.Contains(key, "/files/g-") { |
| + return errors.New("injected 403") |
| + } |
| + return nil |
| + } |
| + logs := captureLog(t) |
| + h.pass(t) |
| + if sr := h.sourceReport(t); sr != nil { |
| + t.Fatalf("a report was written: %+v", sr) |
| + } |
| + if calls := h.f.stagingCalls(); len(calls) != 0 { |
| + t.Fatalf("staged: %+v", calls) |
| + } |
| + if !strings.Contains(logs(), "read the files manifest") { |
| + t.Fatalf("no log of the read:\n%s", logs()) |
| + } |
| +} |
| + |
| +// The cut's requested round runs the same source round (G13): a manifest |
| +// past the cap that appeared after readiness sends the cut back to ready |
| +// before anything holds, fences or stops, with MV-N501 in S1, and the move |
| +// is not pinned again while it stands. Once an operator replaces the |
| +// manifest, a passing round pins it again and the cut admits. |
| +func TestCutRefusesAFilesManifestPastTheCap(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.ev.stopped = false // stop at stopping, to look at the admission |
| + past := capCases["a live entry past it"] |
| + putP1Manifest(t, h.phase1Harness, cutRoot, "cur", past.manifest(cutRoot, "cur")) |
| + h.cut(t) |
| + h.pass(t) |
| + m := h.move(t) |
| + if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil { |
| + t.Fatalf("not back to ready: %+v", m) |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 { |
| + t.Fatalf("something stopped: %d evidence calls, holds %v, %d fences", h.ev.calls, h.holds, h.fences) |
| + } |
| + if sr := h.sourceReport(t); sr == nil || sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| + t.Fatalf("S1 = %+v, want %q", sr, s1FilesPastCap(p1Host)) |
| + } |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil { |
| + t.Fatalf("pinned again while the manifest stands: %+v", m) |
| + } |
| + |
| + // The operator replaces the manifest. |
| + putP1Manifest(t, h.phase1Harness, cutRoot, "cur", capCases["an ordinary manifest"].manifest(cutRoot, "cur")) |
| + // A refused report carries no archive, so the source reports once, the |
| + // target checks that report, and the next round pins. |
| + h.pass(t) |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil { |
| + t.Fatalf("not pinned again after the replacement: %+v", m) |
| + } |
| + h.cut(t) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping { |
| + t.Fatalf("the cut did not admit after the replacement: %+v", m) |
| + } |
| +} |
| + |
| +// The cut's preflight and the recovery's establishment agree on every |
| +// manifest shape, so they cannot drift: the source's round before the flip |
| +// (sourceFilesRefusal over the holder at src) refuses exactly the |
| +// manifests a recovery's Establish refuses as ErrTooDeep, through the |
| +// src_gen bridge of a clean return and through a revert's own lineage, and |
| +// both agree with the readers (readerRefuses). Both call |
| +// movesFilesRestorable; a manifest the cut passes, the recovery |
| +// establishes from. |
| +func TestCutPreflightAndRecoveryAgreeOnTheCap(t *testing.T) { |
| + ctx := context.Background() |
| + recoveries := map[string]func(o *hostclaim.Observed){ |
| + "a clean return": func(*hostclaim.Observed) {}, |
| + "a revert": func(o *hostclaim.Observed) { |
| + o.Claim.Op.Kind = hostclaim.KindReverting |
| + delete(o.Claim.Op.Intent, "rollback") |
| + o.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "src", Region: "uk", Predecessor: "@root", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}, |
| + {Generation: "r1", Region: "uk", Predecessor: "src", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}} |
| + }, |
| + } |
| + for name, c := range capCases { |
| + for rname, shape := range recoveries { |
| + t.Run(name+", "+rname, func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + m := c.manifest(filesLoc, "src") |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "src")), raw) |
| + |
| + // The cut's side: the source's round before the flip, its |
| + // holder at src. |
| + w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| + Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| + pre := servingObserved("src", "src:uk:@root:uk") |
| + why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder) |
| + if err != nil { |
| + t.Fatalf("the preflight: %v", err) |
| + } |
| + if why != "" && why != s1FilesPastCap(filesHost) { |
| + t.Fatalf("the preflight's reason %q", why) |
| + } |
| + |
| + // The recovery's side: the first recovery admission after |
| + // the cut, whose src_gen is the holder's generation then. |
| + o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| + shape(&o) |
| + o = x.observe(o, "src", "r1") |
| + est := x.f.Establish(ctx, o, launchInputs("r1"), x) |
| + if est != nil && !errors.Is(est, hostfiles.ErrTooDeep) { |
| + t.Fatalf("the establishment failed for another reason: %v", est) |
| + } |
| + |
| + cut, recovery, reader := why != "", est != nil, readerRefuses(m, filesLoc, "src") |
| + if cut != recovery || cut != reader { |
| + t.Fatalf("the cut refuses %v, the recovery refuses %v (%v), the readers refuse %v", cut, recovery, est, reader) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// The establishment applies the cap before any origin proof: a manifest |
| +// past it is ErrTooDeep with no Proven call, by the same function the |
| +// cut's preflight calls. |
| +func TestEstablishRefusesPastTheCapBeforeTheProof(t *testing.T) { |
| + ctx := context.Background() |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + m := capCases["a live entry past it"].manifest(filesLoc, "src") |
| + if err := movesFilesRestorable(m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| + t.Fatalf("movesFilesRestorable: %v", err) |
| + } |
| + proof := &countingProof{proven: map[string]bool{"src": true, "r1": true}} |
| + x.f.Proof = proof |
| + if _, err := x.f.movesFilesAllowed(ctx, servingObserved("src"), launchInputs("r1").Admission, filesLoc, "r1", m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| + t.Fatalf("movesFilesAllowed: %v", err) |
| + } |
| + if len(proof.calls) != 0 { |
| + t.Fatalf("the proof was asked %v", proof.calls) |
| + } |
| +} |
| + |
| +// The preflight reads by exact version through the data bucket's versioned |
| +// reader; a holder with no location, or a chain with no manifest, has |
| +// nothing to check. |
| +func TestSourceFilesRefusalNeedsAManifestToRefuse(t *testing.T) { |
| + ctx := context.Background() |
| + x := newFilesFixture(t) |
| + w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| + Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| + pre := servingObserved("src", "src:uk:@root:uk") |
| + if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); why != "" || err != nil { |
| + t.Fatalf("no manifest: %q, %v", why, err) |
| + } |
| + noLoc := *pre.Claim.Holder |
| + noLoc.Location = "" |
| + if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, &noLoc); why != "" || err != nil { |
| + t.Fatalf("no location: %q, %v", why, err) |
| + } |
| + w.cfg.Versioned = nil |
| + if _, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); err == nil { |
| + t.Fatal("no versioned reader passed") |
| + } |
| +} |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 36a6b2a2b..5e104a11f 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/moves/archive" |
| @@ -77,6 +78,17 @@ func s1OverCap(host string) string { |
| return host + " keeps more than 256 MiB of files beside its database. Keep large files in object storage." |
| } |
| +// s1FilesPastCap is MV-N501 (#236): the files manifest a recovery of the |
| +// host would restore holds an entry path past the readers' cap, so no |
| +// recovery after the cut could restore it (sourceFilesRefusal). |
| +func s1FilesPastCap(host string) string { |
| + return host + " has a files manifest with a path longer than 4095 bytes, so it cannot move. an operator must replace the manifest" |
| +} |
| + |
| +// The row says 4095, so the sentence does; this fails to compile if |
| +// hostfiles.MaxPathBytes is ever changed without the row. |
| +const _ = uint(hostfiles.MaxPathBytes-4095) + uint(4095-hostfiles.MaxPathBytes) |
| + |
| // s1TooManyEntries and s1LongPath are MV-N79 and MV-N81, the archive's own |
| // rows, written here from data (the sentinel, the path the error carries) |
| // rather than taken from an error's text. s1ArchiveFailed is every archive |
| @@ -309,7 +321,7 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| // The box-step refusals (§7.1: every box step re-checks), and the |
| // one-box rule the door applied (D15, ruling G13): a region that has |
| // gained a second box since is refused here, every round. |
| - if why, err := w.sourceRefusal(ctx, s, rec); err != nil { |
| + if why, err := w.sourceRefusal(ctx, s, rec, obs.Claim.Holder); err != nil { |
| return nil, nil, err |
| } else if why != "" { |
| return report(&srcRound{s1: &moves.Check{Reason: why}}) |
| @@ -532,8 +544,9 @@ func (w *movesWorker) refreshCrossRate(ctx context.Context, gm *guardedMirror, s |
| // sourceRefusal is the box-step re-check (§6.2, §7.1, F14): the reason this |
| // source cannot move, or "". A registry or bucket read that fails is an |
| -// error and ends the round. |
| -func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec provision.Record) (string, error) { |
| +// error and ends the round. holder is the claim's, as the round read it |
| +// under the staging lease. |
| +func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec provision.Record, holder *hostclaim.Holder) (string, error) { |
| cfg := w.cfg |
| if cfg.Route == nil { |
| return "", errors.New("no registry to re-check the source against") |
| @@ -565,6 +578,54 @@ func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec pr |
| if cfg.SecretsPub == nil || cfg.SecretsKey == "" { |
| return s1NoSecretsKey, nil |
| } |
| + return w.sourceFilesRefusal(ctx, rec, holder) |
| +} |
| + |
| +// sourceFilesRefusal is the cut's files preflight (#236; Paul's ruling of |
| +// 2026-10-15): the files manifest a recovery of this host would restore |
| +// must pass the readers' path cap, or the move never reaches its cut. A |
| +// manifest written before the cap (a8cebad) can hold a path past it, and |
| +// every reader refuses that manifest terminally: a recovery that selected |
| +// it would fail its establishment and leave the source offline after the |
| +// cut. It runs in every preparing round and in the cut's requested round |
| +// (G13), before any staging write, stop or cut. |
| +// |
| +// The manifest is the one the establishment selects (movesFilesSelect): |
| +// the newest manifest of the holder's admitted chain in this region. That |
| +// is the one a revert restores (its holder never leaves the source), and |
| +// the one a clean return's src_gen bridge restores, since the flip |
| +// journals the holder's generation as src_gen and every later write of a |
| +// manifest is held to the cap by Commit. A dirty return restores from a |
| +// prepared manifest its install builds through the capped writers, so |
| +// nothing published before the cap reaches it. The rule is the |
| +// establishment's own (movesFilesRestorable). A host with no files |
| +// manifest has nothing to check here. A read that fails ends the round. |
| +func (w *movesWorker) sourceFilesRefusal(ctx context.Context, rec provision.Record, holder *hostclaim.Holder) (string, error) { |
| + cfg := w.cfg |
| + if holder == nil || holder.Location == "" { |
| + return "", nil // no location, no files manifest (a legacy holder) |
| + } |
| + if cfg.Versioned == nil { |
| + return "", errors.New("no versioned reader to check the files manifest with") |
| + } |
| + vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion) |
| + if err != nil { |
| + return "", fmt.Errorf("the data bucket, to check the files manifest: %w", err) |
| + } |
| + m, key, version, found, err := movesFilesSelect(ctx, vs, cfg.OwnBucket, holder.Location, movesFilesChain(holder, cfg.ParkHomeRegion, cfg.parkRegion())) |
| + if err != nil { |
| + return "", fmt.Errorf("read the files manifest: %w", err) |
| + } |
| + if !found { |
| + return "", nil |
| + } |
| + if err := movesFilesRestorable(m); err != nil { |
| + if !errors.Is(err, hostfiles.ErrTooDeep) { |
| + return "", fmt.Errorf("the files manifest %s@%s: %w", key, version, err) |
| + } |
| + log.Printf("moves: %s: the files manifest %s@%s cannot be restored (%v); an operator must replace it", rec.Host, key, version, err) |
| + return s1FilesPastCap(rec.Host), nil |
| + } |
| return "", nil |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 2618e1b5a..5f85dcb94 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -741,6 +741,18 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + // Census overlay moves-overcap-cut-236 (amadan #236, moves' part of |
| + // Paul's ruling of 2026-10-15), loaded last: the moves source round's |
| + // files manifest preflight. sourceRefusal and sourceRound are re-frozen |
| + // with main's class, task and basis and new digests, so the seal moves; |
| + // the internal/host envelope is re-pinned. Owed a census ruling. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "c169ea03f9a41ef3b761619fd61ce78eff59b40eaf5f71ad1765bcc13c2662e4" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 05b5aba92..a646ddca9 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -772,7 +772,9 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesphase1.go", "writeMoveLedger", "nonrecord", "sel:Put", "597a70ac06ceb4e9eb52bb9f61b28a4937e703fa4a0916f64bd95d9594f0556e"}, |
| // settings-4: added the build-state lane (mirrorBuildState: holder location's |
| // build-state.json to the move lane's state/ prefix in the data bucket); no record key. |
| - {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "9f87605eaeb58cb034c2147a2f54e02b542d1cf8930b6fe3a1b78868cc8e5345"}, |
| + // moves-overcap-cut (#236): sourceRefusal is passed the claim holder for the |
| + // files manifest preflight; the write sites are unchanged. |
| + {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7"}, |
| {"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"}, |
| {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"}, |
| {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv b/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv| new file mode 100644 |
| index 000000000..5dd88dda6 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv |
| @@ -0,0 +1,7 @@ |
| +# Plan 14 census overlay moves-overcap-cut-236 (amadan #236, moves' part of Paul's ruling of 2026-10-15; loaded after hostfiles-cap-236): the moves source round refuses, at S1, a source whose files manifest a recovery could not restore because an entry path is past the readers' cap (hostfiles.RestorableWithinCap). sourceRound passes the claim holder it already read under the staging lease to sourceRefusal, and sourceRefusal ends in a call to the new sourceFilesRefusal, which reads the holder's files manifest from this box's own data bucket by exact version through cfg.Versioned (the agent's moves worker, as carlos; no root process). |
| +# Both units keep main's class, task and basis unchanged; only their digests (and sourceRefusal's site offsets, moved by its new parameter and comment) change, so each is re-frozen here and the legacy seal moves. The new units (sourceFilesRefusal, movesFilesSelect, movesManifestVersion, movesFilesRestorable) carry no recognized root read. No registry, /data, release or claim read was added. This re-freeze is owed a census ruling. |
| +@legacy da46b0739f2d4ffa154ae94449a2345a6a90e122c5872f42a19d46f4446c0bfd internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| +internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| +@legacy 88e76ac8c57eab258dc8885283a919c0e90ee7e44f280f6be9828e34aafb6339 internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| +internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| +@coverage internal/host 3ba30e265e90918651dec3bee22803cbc618afb5d5184ca9e220d4482474e320 |