moves-plan15-proof
Squashed from moves-plan15-proof into main.
6 files changed,
+357
−192
internal/host/claimfiles.go+7 −3internal/host/movesfiles.go+66 −86internal/host/movesfiles_test.go+266 −82internal/host/movesprobeowners_test.go+10 −1internal/host/movesrecoveryowners_test.go+5 −0internal/host/plan15_originproof_test.go+3 −20
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
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]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 340.119s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.298s
ok github.com/carlosframework/platform/cmd/moveprobe 92.258s
ok github.com/carlosframework/platform/internal/activator 230.473s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.006s
ok github.com/carlosframework/platform/internal/analytics 0.021s
ok github.com/carlosframework/platform/internal/appconfig 2.347s
ok github.com/carlosframework/platform/internal/apperr 0.077s
ok github.com/carlosframework/platform/internal/applog 0.128s
ok github.com/carlosframework/platform/internal/awsinv 0.008s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.621s
? 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.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.191s
ok github.com/carlosframework/platform/internal/changelog 0.243s
ok github.com/carlosframework/platform/internal/clamd 0.056s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.147s
? 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 5.856s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 2.151s
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.138s
? 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.128s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.024s
ok github.com/carlosframework/platform/internal/geo 0.028s
ok github.com/carlosframework/platform/internal/geoip 0.035s
ok github.com/carlosframework/platform/internal/host 61.008s
ok github.com/carlosframework/platform/internal/hostactors 0.387s
ok github.com/carlosframework/platform/internal/hostbox 1.261s
ok github.com/carlosframework/platform/internal/hostclaim 0.296s
ok github.com/carlosframework/platform/internal/hostevidence 0.012s
ok github.com/carlosframework/platform/internal/hostexec 0.305s
ok github.com/carlosframework/platform/internal/hostfence 0.029s
ok github.com/carlosframework/platform/internal/hostfiles 4.129s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.134s
ok github.com/carlosframework/platform/internal/hostops 1.358s
? 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.012s
ok github.com/carlosframework/platform/internal/hostresolve 0.123s
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 1.717s
ok github.com/carlosframework/platform/internal/instanceexec 2.698s
ok github.com/carlosframework/platform/internal/instancefs 0.015s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.388s
ok github.com/carlosframework/platform/internal/lease 9.452s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.017s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.157s
ok github.com/carlosframework/platform/internal/maintenance 0.076s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.505s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.070s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 20.984s
ok github.com/carlosframework/platform/internal/moves/archive 0.757s
ok github.com/carlosframework/platform/internal/objstore 0.167s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.189s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 3.286s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.948s
ok github.com/carlosframework/platform/internal/relay 7.227s
ok github.com/carlosframework/platform/internal/release 19.413s
ok github.com/carlosframework/platform/internal/rollout 3.258s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
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.268s
ok github.com/carlosframework/platform/internal/sim/hostlife 8.344s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.924s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.227s
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.003s
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.519s
=== 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
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/13 00:26:27 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDEF2961C28E72, 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/internal/host/claimfiles.go b/internal/host/claimfiles.go| index 153ddc682..eb007e325 100644 |
| --- a/internal/host/claimfiles.go |
| +++ b/internal/host/claimfiles.go |
| @@ -116,16 +116,20 @@ func movesFilesDirFor(dataDir string) func(host string) (string, string) { |
| } |
| } |
| -func (s *movesFilesSource) Open(ctx context.Context, _ release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) { |
| +func (s *movesFilesSource) Open(ctx context.Context, claims release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) { |
| region := park.PlacementRegion(actor.Region) |
| - if s == nil || s.DataDir == "" || s.newFiles == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" { |
| + if s == nil || s.DataDir == "" || s.newFiles == nil || claims == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" { |
| return nil, hostwrite.ErrUnavailable |
| } |
| files, err := s.newFiles(ctx, actor.Bucket, actor.Cloud.Region) |
| if err != nil { |
| return nil, err |
| } |
| - return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir)}, nil |
| + // The origin proof is claim Plan 15's, over the deployment store the |
| + // claims live in, with the home alias from the verified roster binding |
| + // (actor.HomeRegion) and this box's engine region. |
| + proof := ClaimOriginProof{Claims: claims, HomeRegion: actor.HomeRegion, Region: region} |
| + return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir), Proof: proof}, nil |
| } |
| func (s *movesFilesSource) Close(context.Context) error { return nil } |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index d694da7b3..22c9a7fe0 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -13,7 +13,6 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| - "slices" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -78,10 +77,11 @@ type movesFiles struct { |
| // FilesDir is the host's files directory and env file on this box |
| // (moveCfg.FilesDir). |
| FilesDir func(host string) (dir, envPath string) |
| - // Proof proves an origin against admissions the claim has recorded |
| - // (platform-1e's ruling, fix round 3): nil means chainOriginProof, the |
| - // retained admission chain, until claim Plan 15's durable |
| - // per-incarnation record slots in behind movesOriginProof. |
| + // Proof proves origins against admissions the claim has recorded |
| + // (platform-1e's ruling, fix round 3; claim Plan 15): in production |
| + // ClaimOriginProof over the deployment store and the verified home |
| + // alias, one batch call per establishment. nil refuses: there is no |
| + // other route, and retained admissions are never evidence. |
| Proof movesOriginProof |
| // Evidence reads the claim pin's files evidence for a host, when the |
| // claim carries a pin: region HA's ReadPin over the op journal's pin |
| @@ -450,74 +450,64 @@ func (f *movesFiles) newestManifest(ctx context.Context, o hostclaim.Observed, i |
| return hostfiles.Manifest{}, nil, false, nil |
| } |
| -// movesOriginProof proves that a generation of the holder's incarnation |
| -// was admitted, by the claim's own records (platform-1e's ruling, fix |
| -// round 3): provenance comes from the claim, never from a manifest, whose |
| -// writer holds the bucket credentials that could write its Origins too. |
| -// Today's implementation is chainOriginProof, the retained admission |
| -// chain; claim Plan 15's durable per-incarnation record (platform-1e's, |
| -// with a read API) slots in behind this interface. Anything unprovable |
| -// refuses: a host past the pruning window refuses until Plan 15 lands, |
| -// and that is accepted. |
| +// movesOriginProof proves that generations of the holder's incarnation |
| +// were admitted, by the claim's own records (platform-1e's ruling, fix |
| +// round 3; claim Plan 15): provenance comes from the claim, never from a |
| +// manifest, whose writer holds the bucket credentials that could write |
| +// its Origins too. The implementation is Plan 15's ClaimOriginProof: a |
| +// batch Proven over the whole wanted set, which authenticates the bound |
| +// observation against the stored claim (the CAS result the caller holds, |
| +// never a bare Admission.NewGeneration), walks the claim's recorded |
| +// admissions once from its committed head, stops as soon as every wanted |
| +// generation is proven, bounds each node read, and takes home aliases only |
| +// through the verified roster binding. The chain starts empty: nothing is |
| +// seeded from retained admissions, which are never evidence, and a claim |
| +// with no recorded head proves nothing, so every existing host refuses |
| +// until the #250 files backfill has run. That is intended. A false answer |
| +// is ErrOrigin; an error is the error. |
| type movesOriginProof interface { |
| - Proven(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, generation string) (bool, error) |
| + Proven(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, generations []string) (map[string]bool, error) |
| } |
| -// chainOriginProof proves a generation by the retained admission chain of |
| -// the bound observation alone (movesFilesChain: the observed holder's |
| -// generation and each cold admission's predecessor in this region). It |
| -// never accepts a bare Admission.NewGeneration supplied on its own |
| -// (platform-1e's condition, fix round 4): the generation an admission |
| -// opens is proven only once the observation the caller holds records it, |
| -// which is the observation Materialize is given (the CAS result |
| -// Writer.Admit returns, BindWakeRetrySession's result, or the resumed |
| -// recovery's current observation checked against its journal), where the |
| -// holder's generation is that one. A free-standing generation with no such |
| -// observation is not proven. |
| -type chainOriginProof struct{ home, region string } |
| - |
| -func (p chainOriginProof) Proven(_ context.Context, o hostclaim.Observed, _ hostclaim.Admission, generation string) (bool, error) { |
| - if generation == "" || o.Absent || o.Claim == nil || o.Claim.Holder == nil { |
| - return false, nil |
| - } |
| - if slices.Contains(movesFilesChain(o.Claim.Holder, p.home, p.region), generation) { |
| - return true, nil |
| - } |
| - // A recovery's source generation (item 3): the claim journalled it at |
| - // the flip, and the recovery restores from it; the chain through its |
| - // own admissions follows. |
| - if src, ok := recoverySourceGeneration(o.Claim); ok && src != "" { |
| - return slices.Contains(movesFilesChainFrom(o.Claim.Holder, src, p.home, p.region), generation), nil |
| - } |
| - return false, nil |
| -} |
| - |
| -func (f *movesFiles) proof() movesOriginProof { |
| - if f.Proof != nil { |
| - return f.Proof |
| +// proveOrigins is the one route to origin proof (fix round 4; Plan 15): |
| +// one Proven call over the deduplicated set of generations, and a |
| +// generation's files prefix under location enters the allowed set only |
| +// when that call proves it. A generation the proof does not prove is |
| +// ErrOrigin, naming the first; an error is the error; no proof wired |
| +// refuses. No per-origin calls (Plan 15's review: one walk per |
| +// establishment). |
| +func (f *movesFiles) proveOrigins(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location string, generations []string) ([]string, error) { |
| + if f.Proof == nil { |
| + return nil, fmt.Errorf("%w: no origin proof is wired", hostwrite.ErrUnavailable) |
| } |
| - return chainOriginProof{home: f.HomeRegion, region: f.Region} |
| -} |
| - |
| -// proveOrigin is the one route to origin proof (fix round 4): the |
| -// generation's files prefix under location enters an allowed set only |
| -// when movesOriginProof proves the generation. A proof that answers false |
| -// is ErrOrigin; an error is the error. Nothing enters a set any other way, |
| -// so a different proof (claim Plan 15's) has the final say over the |
| -// chain's generations and the prepared generation as over every other. |
| -func (f *movesFiles) proveOrigin(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location, gen string) (string, error) { |
| - if !movesFilesGeneration(gen) { |
| - return "", fmt.Errorf("%w: %q is not a generation name", hostfiles.ErrOrigin, gen) |
| + set := make([]string, 0, len(generations)) |
| + seen := map[string]bool{} |
| + for _, gen := range generations { |
| + if !movesFilesGeneration(gen) { |
| + return nil, fmt.Errorf("%w: %q is not a generation name", hostfiles.ErrOrigin, gen) |
| + } |
| + if !seen[gen] { |
| + seen[gen] = true |
| + set = append(set, gen) |
| + } |
| } |
| - origin := movesFilesPrefix(location, gen) |
| - proven, err := f.proof().Proven(ctx, o, in, gen) |
| + proven, err := f.Proof.Proven(ctx, o, in, set) |
| if err != nil { |
| - return "", fmt.Errorf("prove the origin %s: %w", origin, err) |
| + return nil, fmt.Errorf("prove the origins of %s: %w", location, err) |
| } |
| - if !proven { |
| - return "", fmt.Errorf("%w: %s is not proven by the claim's recorded admissions", hostfiles.ErrOrigin, origin) |
| + origins := make([]string, 0, len(set)) |
| + for _, gen := range set { |
| + if !proven[gen] { |
| + return nil, fmt.Errorf("%w: %s is not proven by the claim's recorded admissions", hostfiles.ErrOrigin, movesFilesPrefix(location, gen)) |
| + } |
| + origins = append(origins, movesFilesPrefix(location, gen)) |
| } |
| - return origin, nil |
| + // The prefixes are sorted, not the generation names: the two do not |
| + // order alike ("mv1" before "mv1-old", "g-mv1-old/" before "g-mv1/"), |
| + // and this is the carried set SetOrigins stores and establishedOver |
| + // compares (Astra's finding on the Plan 15 switch). |
| + sort.Strings(origins) |
| + return origins, nil |
| } |
| // movesFilesAllowed is the origin set an admission allows for m, the |
| @@ -537,36 +527,26 @@ func (f *movesFiles) proveOrigin(ctx context.Context, o hostclaim.Observed, in h |
| // 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) { |
| - set := map[string]bool{} |
| - own, err := f.proveOrigin(ctx, o, in, location, admitted) |
| - if err != nil { |
| - return nil, err |
| - } |
| - set[own] = true |
| named, err := movesFilesOrigins(location, "", m) |
| if err != nil { |
| return nil, err |
| } |
| base := strings.TrimSuffix(location, "/") + "/files/g-" |
| + generations := []string{admitted} |
| for _, origin := range named { |
| - if set[origin] { |
| - continue |
| - } |
| rest, under := strings.CutPrefix(origin, base) |
| gen, slash := strings.CutSuffix(rest, "/") |
| if !under || !slash || !movesFilesGeneration(gen) { |
| return nil, fmt.Errorf("%w: %s is not a generation's files prefix of %s", hostfiles.ErrOrigin, origin, location) |
| } |
| - if _, err := f.proveOrigin(ctx, o, in, location, gen); err != nil { |
| - return nil, err |
| - } |
| - set[origin] = true |
| + generations = append(generations, gen) |
| } |
| - allowed := make([]string, 0, len(set)) |
| - for origin := range set { |
| - allowed = append(allowed, origin) |
| + // One Proven call over the deduplicated referenced origins plus the |
| + // admitted generation (Plan 15). |
| + allowed, err := f.proveOrigins(ctx, o, in, location, generations) |
| + if err != nil { |
| + return nil, err |
| } |
| - sort.Strings(allowed) |
| if err := hostfiles.ValidateManifest(m, allowed); err != nil { |
| return nil, err |
| } |
| @@ -898,7 +878,7 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| return err |
| } |
| case in.Empty: |
| - own, err := f.proveOrigin(ctx, o, in.Admission, h.Location, gen) |
| + own, err := f.proveOrigins(ctx, o, in.Admission, h.Location, []string{gen}) |
| if err != nil { |
| return err |
| } |
| @@ -906,7 +886,7 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| if err != nil { |
| return err |
| } |
| - p.SetOrigins([]string{own}) |
| + p.SetOrigins(own) |
| default: |
| // The chain is the holder's: the newest authorized manifest through |
| // it wins, a recovery's own included (the prepared rollback |
| @@ -940,11 +920,11 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| if !never { |
| return fmt.Errorf("%w: no files manifest in the admitted predecessor chain of %s (%s)", hostfiles.ErrNoEvidence, o.Host, h.Generation) |
| } |
| - own, err := f.proveOrigin(ctx, o, in.Admission, h.Location, gen) |
| + own, err := f.proveOrigins(ctx, o, in.Admission, h.Location, []string{gen}) |
| if err != nil { |
| return err |
| } |
| - base, origins = emptyManifest(), []string{own} |
| + base, origins = emptyManifest(), own |
| } |
| p, err = hostfiles.NewFromCopy(files, prefix, base, origins) |
| if err != nil { |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index 1086feb07..fd428db89 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -95,6 +95,7 @@ func (r *recordingReader) GetVersion(ctx context.Context, bucket, key, version s |
| type filesFixture struct { |
| t *testing.T |
| + claims release.Store |
| bucket *leasetest.VersionedBucket |
| data *recordingReader |
| files casBucketStore |
| @@ -111,9 +112,21 @@ type filesFixture struct { |
| workErrAt int |
| } |
| -const ( |
| - filesHost = "shop.example.com" |
| - filesLoc = "instances/" + filesHost + "/" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| +const filesHost = "shop.example.com" |
| + |
| +// filesCreated and filesInc are the fixture host's creation and incarnation |
| +// (hostclaim.Incarnation), so its location is a valid origin tuple |
| +// (instances/<host>/<incarnation>) for claim Plan 15's proof. |
| +var ( |
| + filesCreated = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + filesInc = func() string { |
| + inc, err := hostclaim.Incarnation("bab", "jam", filesHost, filesCreated) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return inc |
| + }() |
| + filesLoc = "instances/" + filesHost + "/" + filesInc |
| ) |
| func (x *filesFixture) With(_ context.Context, _ hostwrite.Identity, fn func() error) error { |
| @@ -128,10 +141,14 @@ func (x *filesFixture) With(_ context.Context, _ hostwrite.Identity, fn func() e |
| func newFilesFixture(t *testing.T) *filesFixture { |
| t.Helper() |
| + // Claim Plan 15: the proof reads the claim's recorded admissions from |
| + // the claims store under both switches. |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| b := leasetest.NewVersionedBucket() |
| b.AddBucket("data") |
| - x := &filesFixture{t: t, bucket: b, data: &recordingReader{VersionedStore: b}, files: casBucketStore{b: b, bucket: "data"}, dir: t.TempDir()} |
| - x.f = &movesFiles{Files: x.files, Data: x.data, Bucket: "data", HomeRegion: "ie", Region: "uk", Box: "uk-box", DataDir: x.dir, |
| + x := &filesFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: b, data: &recordingReader{VersionedStore: b}, files: casBucketStore{b: b, bucket: "data"}, dir: t.TempDir()} |
| + x.f = &movesFiles{Files: x.files, Data: x.data, Bucket: "data", HomeRegion: "ie", Region: "uk", Box: "uk-box", DataDir: x.dir, Proof: ClaimOriginProof{Claims: x.claims, HomeRegion: "ie", Region: "uk"}, |
| FilesDir: func(host string) (string, string) { |
| return filepath.Join(x.dir, host), filepath.Join(x.dir, host+".env") |
| }} |
| @@ -175,16 +192,123 @@ func (x *filesFixture) manifest(gen string) (hostfiles.Manifest, bool) { |
| return m, found |
| } |
| -// serving is a serving claim on uk-box whose holder is at gen with the |
| -// cold admission chain given, newest last: each entry is |
| -// "<generation>:<region>:<predecessor>:<predecessor region>". |
| +// servingObserved is a serving claim on uk-box whose holder is at gen with |
| +// the cold admission chain given, newest last: each entry is |
| +// "<generation>:<region>:<predecessor>:<predecessor region>". It is a |
| +// hand-built claim: nothing in it is proven until observe stores it with |
| +// recorded admissions (recordOrigins). |
| func servingObserved(gen string, chain ...string) hostclaim.Observed { |
| - h := &hostclaim.Holder{Family: "instance", Key: "k", Account: "bab", App: "jam", Incarnation: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Location: filesLoc, Generation: gen, Session: 3, WriterBox: "uk-box", Stamp: 7} |
| - for _, c := range chain { |
| + h := &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + filesHost + ".json", Account: "bab", App: "jam", CreatedAt: filesCreated, Incarnation: filesInc, Location: filesLoc, Generation: gen, Session: 3, WriterBox: "uk-box", Stamp: 7} |
| + for i, c := range chain { |
| p := strings.Split(c, ":") |
| - h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: p[0], Region: p[1], Predecessor: p[2], PredecessorRegion: p[3], Mode: "cold", Session: 3, Box: "uk-box"}) |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: p[0], Region: p[1], Predecessor: p[2], PredecessorRegion: p[3], Mode: "cold", Session: uint64(3 + i), Box: "uk-box"}) |
| + } |
| + if n := len(h.Admissions); n > 0 { |
| + h.Session = h.Admissions[n-1].Session |
| + } |
| + return hostclaim.Observed{Host: filesHost, ETag: "e1", Claim: &hostclaim.Claim{V: 2, Host: filesHost, Gen: 9, Holder: h, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk-box"}}} |
| +} |
| + |
| +// recordOrigins records tuples as the claim's admitted origins, as the |
| +// claim's CAS records an admission and the #250 files backfill records a |
| +// live origin into an admitted prefix: one immutable node per tuple under |
| +// control/host-origins/<host>/, chained from the claim's current head, and |
| +// the head written on the stored claim. The claim must already be stored. |
| +func recordOrigins(t *testing.T, claims release.Store, host string, tuples ...hostclaim.OriginTuple) { |
| + t.Helper() |
| + ctx := context.Background() |
| + o, err := hostclaim.Read(ctx, claims, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.Absent { |
| + t.Fatalf("recordOrigins: %s has no stored claim", host) |
| + } |
| + c := o.Claim |
| + head := hostclaim.OriginHead{} |
| + if c.Origins != nil { |
| + head = *c.Origins |
| + } |
| + type node struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Count uint64 `json:"count"` |
| + Previous string `json:"previous,omitempty"` |
| + Origin hostclaim.OriginTuple `json:"origin"` |
| + } |
| + for _, tuple := range tuples { |
| + body, err := json.Marshal(node{V: 1, Host: host, Count: head.Count + 1, Previous: head.Digest, Origin: tuple}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.New() |
| + sum.Write([]byte("carlos-admitted-origin-v1\x00")) |
| + sum.Write(body) |
| + digest := hex.EncodeToString(sum.Sum(nil)) |
| + key := "control/host-origins/" + host + "/" + digest + ".json" |
| + if err := hostclaim.ValidateOriginObject(key, body); err != nil { |
| + t.Fatalf("recordOrigins: %v", err) |
| + } |
| + if _, err := claims.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) |
| + } |
| + head = hostclaim.OriginHead{Digest: digest, Count: head.Count + 1} |
| + } |
| + c.Origins = &head |
| + raw, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := hostclaim.Key(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := claims.Put(ctx, key, raw, o.ETag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// observe stores the hand-built observation's claim in the fixture's claims |
| +// store, records the named generations as admitted origins of the holder's |
| +// incarnation and location in this region (the admitted generation among |
| +// them, as its admission records it), and returns the stored observation, |
| +// which is what Materialize is given and what the proof authenticates. |
| +func (x *filesFixture) observe(o hostclaim.Observed, recorded ...string) hostclaim.Observed { |
| + x.t.Helper() |
| + ctx := context.Background() |
| + c := o.Claim |
| + if err := c.Validate(); err != nil { |
| + x.t.Fatalf("observe: %v", err) |
| + } |
| + key, err := hostclaim.Key(o.Host) |
| + if err != nil { |
| + x.t.Fatal(err) |
| + } |
| + etag := "" |
| + if stored, err := hostclaim.Read(ctx, x.claims, o.Host); err == nil && !stored.Absent { |
| + // A re-observation keeps the chain the store already records: the |
| + // head is the claim's, never a hand-built claim's. |
| + etag, c.Origins = stored.ETag, stored.Claim.Origins |
| + } |
| + raw, err := json.Marshal(c) |
| + if err != nil { |
| + x.t.Fatal(err) |
| + } |
| + if _, err := x.claims.Put(ctx, key, raw, etag); err != nil { |
| + x.t.Fatal(err) |
| + } |
| + tuples := make([]hostclaim.OriginTuple, 0, len(recorded)) |
| + for _, gen := range recorded { |
| + tuples = append(tuples, hostclaim.OriginTuple{Incarnation: c.Holder.Incarnation, Location: c.Holder.Location, Region: "uk", Generation: gen}) |
| + } |
| + if len(tuples) > 0 { |
| + recordOrigins(x.t, x.claims, o.Host, tuples...) |
| } |
| - return hostclaim.Observed{Host: filesHost, ETag: "e1", Claim: &hostclaim.Claim{V: 1, Host: filesHost, Gen: 9, Holder: h, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk-box"}}} |
| + stored, err := hostclaim.Read(ctx, x.claims, o.Host) |
| + if err != nil { |
| + x.t.Fatal(err) |
| + } |
| + return stored |
| } |
| func launchInputs(newGen string) ClaimLaunchInputs { |
| @@ -450,7 +574,7 @@ func TestEstablishCopiesTheNewestValidManifestByExactVersion(t *testing.T) { |
| if g1.Seq != 3 { |
| t.Fatalf("g1 at seq %d", g1.Seq) |
| } |
| - o := servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk") |
| + o := x.observe(servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk"), "g1", "g3") |
| if err := x.f.Establish(ctx, o, launchInputs("g3"), x); err != nil { |
| t.Fatal(err) |
| } |
| @@ -533,23 +657,24 @@ func TestEstablishKeepsOriginsPastAdmissionPruning(t *testing.T) { |
| for i := 4; i <= 11; i++ { |
| chain = append(chain, fmt.Sprintf("g%d:uk:g%d:uk", i, i-1)) |
| } |
| - o := servingObserved("g11", chain...) |
| - if len(o.Claim.Holder.Admissions) != 8 { |
| - t.Fatalf("%d admissions", len(o.Claim.Holder.Admissions)) |
| - } |
| - // Provenance comes from the claim (platform-1e's ruling, fix round 3): |
| - // the carried set is a hint, and every origin must be proven against |
| - // admissions the claim has recorded. Today that is the retained chain, |
| - // which no longer names g1, so the establishment refuses; it passes |
| - // only with a proof that records g1, as claim Plan 15's durable |
| - // per-incarnation record will. |
| + c := servingObserved("g11", chain...) |
| + if len(c.Claim.Holder.Admissions) != 8 { |
| + t.Fatalf("%d admissions", len(c.Claim.Holder.Admissions)) |
| + } |
| + // Provenance comes from the claim (platform-1e's ruling, fix round 3; |
| + // claim Plan 15): the carried set is a hint, retained admissions are |
| + // never evidence, and every origin must be proven by the claim's |
| + // recorded admissions. g1 is retained-only (pruned from the chain and |
| + // never recorded), so the establishment refuses; once the claim records |
| + // g1 (its admission, or the #250 files backfill), it passes. |
| + o := x.observe(c, "g11") |
| if err := x.f.Establish(ctx, o, launchInputs("g11"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| - t.Fatalf("past pruning with the retained chain alone: %v", err) |
| + t.Fatalf("past pruning, g1 retained only: %v", err) |
| } |
| if _, found := x.manifest("g11"); found { |
| t.Fatal("a manifest was written over an unproven ancestor") |
| } |
| - x.f.Proof = recordedOrigins{"g1": true, "g2": true, "g3": true, "g4": true, "g5": true, "g6": true, "g7": true, "g8": true, "g9": true, "g10": true, "g11": true} |
| + o = x.observe(c, "g1") |
| if err := x.f.Establish(ctx, o, launchInputs("g11"), x); err != nil { |
| t.Fatalf("establish past pruning with the claim's record: %v", err) |
| } |
| @@ -623,7 +748,8 @@ func preparedFixture(t *testing.T) (*filesFixture, preparedManifest, hostclaim.O |
| x.journal("mv1", pm) |
| x.writeEnv("K=1\nCARLOS_INSTANCE_KEY=abc\n") |
| o := servingObserved("mv1", "cur:ie:@root:ie", "mv1:uk:cur:ie") |
| - o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box"} |
| + o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box"}} |
| + o = x.observe(o, "mv1") |
| in := launchInputs("mv1") |
| in.Prepared = true |
| return x, pm, o, in |
| @@ -776,7 +902,7 @@ func TestEstablishPublishesOutsideTheStartsWork(t *testing.T) { |
| }) |
| st := &stallingStore{Store: x.files, key: hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "g2")), stalled: make(chan struct{}), release: make(chan struct{})} |
| x.f.Files = st |
| - o := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk") |
| + o := x.observe(servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk"), "g1", "g2") |
| done := make(chan error, 1) |
| go func() { done <- x.f.Establish(ctx, o, launchInputs("g2"), x) }() |
| select { |
| @@ -834,7 +960,7 @@ func TestEstablishRefusesANeverAdmittedOrigin(t *testing.T) { |
| t.Fatal(err) |
| } |
| }) |
| - o := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk") |
| + o := x.observe(servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk"), "g1", "g2") |
| never := movesFilesPrefix(filesLoc, "never") + "objects/0000000000000001-" + sha([]byte("N")) |
| x.bucket.Put("data", never, []byte("N")) |
| for name, origins := range map[string][]string{"no carried set": nil, "a carried set without it": {movesFilesPrefix(filesLoc, "g1")}} { |
| @@ -880,7 +1006,7 @@ func TestEstablishRefusesANeverAdmittedOrigin(t *testing.T) { |
| hint := m |
| hint.Origins = []string{movesFilesPrefix(filesLoc, "g1"), movesFilesPrefix(filesLoc, "g2"), movesFilesPrefix(filesLoc, "never")} |
| x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "g2")), mustJSON(t, hint)) |
| - o3 := servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk") |
| + o3 := x.observe(servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk"), "g3") |
| if err := x.f.Establish(ctx, o3, launchInputs("g3"), x); err != nil { |
| t.Fatalf("carried only: %v", err) |
| } |
| @@ -917,6 +1043,7 @@ func TestEstablishSeedsALegacyLocationsOriginSet(t *testing.T) { |
| } |
| o := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk") |
| o.Claim.Holder.Location = legacy |
| + o = x.observe(o, "g1", "g2") |
| if err := x.f.Establish(ctx, o, launchInputs("g2"), x); err != nil { |
| t.Fatal(err) |
| } |
| @@ -933,20 +1060,12 @@ func TestEstablishSeedsALegacyLocationsOriginSet(t *testing.T) { |
| x.bucket.Put("data", hostfiles.ManifestKey(loc("g2")), mustJSON(t, bad)) |
| o3 := servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk") |
| o3.Claim.Holder.Location = legacy |
| + o3 = x.observe(o3, "g3") |
| if err := x.f.Establish(ctx, o3, launchInputs("g3"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| t.Fatalf("never admitted under a legacy location: %v", err) |
| } |
| } |
| -// recordedOrigins is a claim-backed origin proof that records the named |
| -// generations: what claim Plan 15's durable per-incarnation record will |
| -// answer. |
| -type recordedOrigins map[string]bool |
| - |
| -func (r recordedOrigins) Proven(_ context.Context, _ hostclaim.Observed, _ hostclaim.Admission, generation string) (bool, error) { |
| - return r[generation], nil |
| -} |
| - |
| // answeringProof is a claim-backed origin proof with an answer per |
| // generation: proven, not proven, or an error; a generation it does not |
| // name is not proven. |
| @@ -955,11 +1074,15 @@ type answeringProof struct { |
| errs map[string]error |
| } |
| -func (a answeringProof) Proven(_ context.Context, _ hostclaim.Observed, _ hostclaim.Admission, generation string) (bool, error) { |
| - if err := a.errs[generation]; err != nil { |
| - return false, err |
| +func (a answeringProof) Proven(_ context.Context, _ hostclaim.Observed, _ hostclaim.Admission, generations []string) (map[string]bool, error) { |
| + out := map[string]bool{} |
| + for _, generation := range generations { |
| + if err := a.errs[generation]; err != nil { |
| + return nil, err |
| + } |
| + out[generation] = a.proven[generation] |
| } |
| - return a.proven[generation], nil |
| + return out, nil |
| } |
| // movesOriginProof is the only route to origin proof (fix round 4): the |
| @@ -1025,30 +1148,15 @@ func TestEstablishProvesEveryOriginThroughTheProof(t *testing.T) { |
| } |
| } |
| -// chainOriginProof never accepts a bare Admission.NewGeneration supplied |
| -// on its own (platform-1e's condition, fix round 4): a generation is |
| -// proven only when the bound observation's retained chain records it, as |
| -// the post-admission observation Materialize is given does. A |
| -// free-standing generation, named by the admission but by no observation, |
| -// is refused at the proof and by Establish, and nothing is written. |
| -func TestChainProofRefusesAFreeStandingGeneration(t *testing.T) { |
| +// The proof never accepts a bare Admission.NewGeneration supplied on its |
| +// own (platform-1e's condition, fix round 4; claim Plan 15): a generation |
| +// is proven only when the bound observation's claim records its |
| +// admission, as the post-admission observation Materialize is given does. |
| +// A free-standing generation, named by the admission but recorded by no |
| +// observation, is refused at the proof and by Establish, and nothing is |
| +// written; an observation of no claim is an error. |
| +func TestClaimProofRefusesAFreeStandingGeneration(t *testing.T) { |
| ctx := context.Background() |
| - proof := chainOriginProof{home: "ie", region: "uk"} |
| - before := servingObserved("g1", "g1:uk:@root:uk") // the observation before g2's admission |
| - in := hostclaim.Admission{Box: "uk-box", Mode: "cold", NewGeneration: "g2", Region: "uk"} |
| - if ok, err := proof.Proven(ctx, before, in, "g2"); ok || err != nil { |
| - t.Fatalf("a free-standing generation proven: %v %v", ok, err) |
| - } |
| - if ok, err := proof.Proven(ctx, hostclaim.Observed{Host: filesHost, Absent: true}, in, "g2"); ok || err != nil { |
| - t.Fatalf("proven with no claim: %v %v", ok, err) |
| - } |
| - if ok, _ := proof.Proven(ctx, before, in, "g1"); !ok { |
| - t.Fatal("the observed holder's generation is not proven") |
| - } |
| - after := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk") // the CAS result the writer's admission returns |
| - if ok, _ := proof.Proven(ctx, after, in, "g2"); !ok { |
| - t.Fatal("the admitted generation the observation records is not proven") |
| - } |
| x := newFilesFixture(t) |
| x.writeEnv("K=1\n") |
| x.publish("g1", func(p *hostfiles.Publisher) { |
| @@ -1056,27 +1164,41 @@ func TestChainProofRefusesAFreeStandingGeneration(t *testing.T) { |
| t.Fatal(err) |
| } |
| }) |
| + proof := ClaimOriginProof{Claims: x.claims, HomeRegion: "ie", Region: "uk"} |
| + before := x.observe(servingObserved("g1", "g1:uk:@root:uk"), "g1") // the observation before g2's admission |
| + in := hostclaim.Admission{Box: "uk-box", Mode: "cold", NewGeneration: "g2", Region: "uk"} |
| + if got, err := proof.Proven(ctx, before, in, []string{"g1", "g2"}); err != nil || !got["g1"] || got["g2"] { |
| + t.Fatalf("a free-standing generation: %v %v", got, err) |
| + } |
| + if _, err := proof.Proven(ctx, hostclaim.Observed{Host: filesHost, Absent: true}, in, []string{"g2"}); err == nil { |
| + t.Fatal("proven with no claim") |
| + } |
| if err := x.f.Establish(ctx, before, launchInputs("g2"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| t.Fatalf("establish with an observation that does not record the admission: %v", err) |
| } |
| if _, found := x.manifest("g2"); found { |
| t.Fatal("a manifest was written for a free-standing generation") |
| } |
| + after := x.observe(servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk"), "g2") // the CAS result the writer's admission returns |
| if err := x.f.Establish(ctx, after, launchInputs("g2"), x); err != nil { |
| t.Fatalf("establish with the bound observation: %v", err) |
| } |
| } |
| -// countingProof records every generation it is asked about, in order, |
| +// countingProof records every Proven call and the set it was asked about, |
| // answering from proven. |
| type countingProof struct { |
| proven map[string]bool |
| - asked []string |
| + calls [][]string |
| } |
| -func (c *countingProof) Proven(_ context.Context, _ hostclaim.Observed, _ hostclaim.Admission, generation string) (bool, error) { |
| - c.asked = append(c.asked, generation) |
| - return c.proven[generation], nil |
| +func (c *countingProof) Proven(_ context.Context, _ hostclaim.Observed, _ hostclaim.Admission, generations []string) (map[string]bool, error) { |
| + c.calls = append(c.calls, append([]string(nil), generations...)) |
| + out := map[string]bool{} |
| + for _, generation := range generations { |
| + out[generation] = c.proven[generation] |
| + } |
| + return out, nil |
| } |
| // Only referenced origins are proven and carried (Task 6b, the moves side |
| @@ -1117,9 +1239,16 @@ func TestEstablishProvesAndCarriesOnlyReferencedOrigins(t *testing.T) { |
| if !found || !reflect.DeepEqual(m.Origins, want) { |
| t.Fatalf("g2 origins %v, want %v", m.Origins, want) |
| } |
| - sort.Strings(proof.asked) |
| - if !reflect.DeepEqual(proof.asked, []string{"g1", "g2", "g5"}) { |
| - t.Fatalf("Proven asked %v, want each referenced origin and the admitted generation once", proof.asked) |
| + // One Proven(set) call per Establish (Plan 15): the deduplicated |
| + // referenced origins plus the admitted generation, in one batch (the |
| + // set's order is the caller's; the prefixes are what get sorted). |
| + if len(proof.calls) != 1 { |
| + t.Fatalf("Proven calls %v, want one call over the referenced origins and the admitted generation", proof.calls) |
| + } |
| + asked := append([]string(nil), proof.calls[0]...) |
| + sort.Strings(asked) |
| + if !reflect.DeepEqual(asked, []string{"g1", "g2", "g5"}) { |
| + t.Fatalf("Proven asked %v, want the referenced origins and the admitted generation", proof.calls[0]) |
| } |
| // A referenced origin the proof refuses still refuses the whole. |
| x.f.Proof = &countingProof{proven: map[string]bool{"g1": true, "g2": true, "g3": true}} |
| @@ -1164,8 +1293,8 @@ func TestRecoveryEstablishesFromTheRecordedSourceGeneration(t *testing.T) { |
| for name, shape := range map[string]func(o *hostclaim.Observed){ |
| "a clean return": func(*hostclaim.Observed) {}, |
| "a declared-loss return": func(o *hostclaim.Observed) { |
| - o.Claim.Op.Journal["loss.l1"] = "1" |
| - o.Claim.LossRefs = []string{"control/loss-records/l1.json"} |
| + o.Claim.Op.Journal["loss.l1"] = `{"kind":"move-target-lost","target":"ie-box"}` |
| + o.Claim.LossRefs, o.Claim.Lost = []string{"l1"}, []string{"ie-box"} |
| }, |
| "a fenced return": func(o *hostclaim.Observed) { |
| o.Claim.Op.Journal["fence.ie-box"] = `{"enrollment":"enr","key":"control/box-fences/enr.json","sha256":"ab"}` |
| @@ -1190,6 +1319,7 @@ func TestRecoveryEstablishesFromTheRecordedSourceGeneration(t *testing.T) { |
| }) |
| o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| shape(&o) |
| + o = x.observe(o, "src", "r1") |
| if err := x.f.Establish(ctx, o, launchInputs("r1"), x); err != nil { |
| t.Fatalf("establish from src_gen: %v", err) |
| } |
| @@ -1263,7 +1393,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| publish(x, "src", "A") |
| } |
| rb := publish(x, "rb1", "B") |
| - o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| + o := x.observe(recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2"), "rb1", "r2") |
| if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| t.Fatalf("the restart: %v", err) |
| } |
| @@ -1280,7 +1410,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| x.writeEnv("K=1\n") |
| publish(x, "src", "A") |
| newer := publish(x, "r1", "C") // the earlier recovery admission's publication |
| - o := recoveryRestartObserved(kind, "r1", "r2") |
| + o := x.observe(recoveryRestartObserved(kind, "r1", "r2"), "r1", "r2") |
| if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| t.Fatalf("the restart: %v", err) |
| } |
| @@ -1299,6 +1429,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| rb := publish(x, "rb1", "B") |
| o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| o.Claim.Op.Journal[moves.JournalReturnSeal] = "sealed" |
| + o = x.observe(o, "rb1", "r2") |
| if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| t.Fatalf("the second admission: %v", err) |
| } |
| @@ -1308,6 +1439,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| r2 := publish(x, "r2", "D") // the second admission's own publication |
| o3 := servingObserved("r3", "src:uk:@root:uk", "mv1:ie:src:uk", "rb1:uk:mv1:ie", "r2:uk:rb1:uk", "r3:uk:r2:uk") |
| o3.Claim.Op = o.Claim.Op |
| + o3 = x.observe(o3, "r3") |
| if err := x.f.Establish(ctx, o3, launchInputs("r3"), x); err != nil { |
| t.Fatalf("the third admission: %v", err) |
| } |
| @@ -1324,6 +1456,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| publish(x, "src", "A") |
| o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| o.Claim.Op.Journal[moves.JournalReturnSeal] = "sealed" |
| + o = x.observe(o, "src", "r2") |
| if err := x.f.Establish(ctx, o, launchInputs("r2"), x); !errors.Is(err, hostfiles.ErrNoEvidence) { |
| t.Fatalf("a dirty return without its rollback manifest: %v", err) |
| } |
| @@ -1335,7 +1468,7 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| x := newFilesFixture(t) |
| x.writeEnv("K=1\n") |
| src := publish(x, "src", "A") |
| - o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| + o := x.observe(recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1"), "src", "r1") |
| if err := x.f.Establish(ctx, o, launchInputs("r1"), x); err != nil { |
| t.Fatalf("the first admission: %v", err) |
| } |
| @@ -1345,6 +1478,56 @@ func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| }) |
| } |
| +// The proven set and the carried set order the same way (Astra's finding |
| +// on the Plan 15 switch): generation names and their files prefixes do |
| +// not sort alike ("mv1" before "mv1-old", but "g-mv1-old/" before |
| +// "g-mv1/"), so the prefixes, not the names, are sorted everywhere the |
| +// sets are compared. A prepared manifest for mv1 that also references an |
| +// object under mv1-old (a same-region retained reference) is established |
| +// once; a crash before the local effect leaves the key at sequence 1 |
| +// with the carried set; the genuine replay is accepted and writes nothing. |
| +func TestPreparedReplayOrdersOriginsLikeTheCarriedSet(t *testing.T) { |
| + x := newFilesFixture(t) |
| + ctx := context.Background() |
| + x.writeEnv("K=1\n") |
| + oldObj := movesFilesPrefix(filesLoc, "mv1-old") + "objects/0000000000000001-" + sha([]byte("O")) |
| + x.bucket.Put("data", oldObj, []byte("O")) |
| + newObj := movesFilesPrefix(filesLoc, "mv1") + "objects/0000000000000000-" + sha([]byte("A")) |
| + x.bucket.Put("data", newObj, []byte("A")) |
| + envObj := movesFilesPrefix(filesLoc, "mv1") + "objects/0000000000000000-" + sha([]byte("K=1\n")) |
| + x.bucket.Put("data", envObj, []byte("K=1\n")) |
| + prepared := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 0, |
| + Entries: map[string]hostfiles.Entry{"a.txt": {Object: newObj, SHA256: sha([]byte("A")), Size: 1, Mode: 0o600, Seq: 0}, "old.txt": {Object: oldObj, SHA256: sha([]byte("O")), Size: 1, Mode: 0o600, Seq: 1}}, |
| + Platform: map[string]hostfiles.Entry{hostfiles.EnvItem: {Object: envObj, SHA256: sha([]byte("K=1\n")), Size: 4, Mode: 0o600, Seq: 0}}, |
| + Origins: []string{movesFilesPrefix(filesLoc, "mv1-old"), movesFilesPrefix(filesLoc, "mv1")}} |
| + raw := mustJSON(t, prepared) |
| + key := hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "mv1")) |
| + version := x.bucket.Put("data", key, raw) |
| + x.journal("mv1", preparedManifest{Key: key, SHA256: sha(raw), Version: version, Op: "mv1", Stamp: 7}) |
| + x.writeEnv("K=1\nCARLOS_INSTANCE_KEY=abc\n") |
| + o := servingObserved("mv1", "cur:ie:@root:ie", "mv1:uk:cur:ie") |
| + o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box"}} |
| + o = x.observe(o, "mv1", "mv1-old") |
| + in := launchInputs("mv1") |
| + in.Prepared = true |
| + x.workErr, x.workErrAt = errors.New("crash before the local effect"), 3 |
| + if err := x.f.Establish(ctx, o, in, x); err == nil { |
| + t.Fatal("the crash was not reported") |
| + } |
| + x.workErr, x.workErrAt = nil, 0 |
| + m, found := x.manifest("mv1") |
| + if !found || m.Seq != 1 { |
| + t.Fatalf("after the crashed attempt: %+v %v", m, found) |
| + } |
| + seq1 := x.bucket.LatestID("data", key) |
| + if err := x.f.Establish(ctx, o, in, x); err != nil { |
| + t.Fatalf("the genuine replay: %v", err) |
| + } |
| + if x.bucket.LatestID("data", key) != seq1 { |
| + t.Fatal("the replay wrote a new revision") |
| + } |
| +} |
| + |
| // The instance key rule (O1'): the manifest Establish leaves carries the |
| // env file with exactly the bytes on disk when it runs, staged and |
| // committed before it returns, and no adoption item; the local adoption |
| @@ -1366,7 +1549,7 @@ func TestEstablishCarriesTheEnvBytesAndDropsTheAdoptionItem(t *testing.T) { |
| if err := os.WriteFile(adoption, []byte(`{"v":2}`), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| - o := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk") |
| + o := x.observe(servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk"), "g1", "g2") |
| x.workErr = hostbox.ErrChanged |
| if err := x.f.Establish(ctx, o, launchInputs("g2"), x); !errors.Is(err, hostbox.ErrChanged) { |
| t.Fatalf("refused work: %v", err) |
| @@ -1483,7 +1666,7 @@ func TestEstablishHostThatNeverPublishedFiles(t *testing.T) { |
| x.f.Evidence = func(context.Context, hostclaim.Observed) (hostfiles.PinnedFiles, bool, error) { |
| return hostfiles.PinnedFiles{}, true, nil |
| } |
| - o := servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk") |
| + o := x.observe(servingObserved("g3", "g1:uk:@root:uk", "g2:uk:g1:uk", "g3:uk:g2:uk"), "g3") |
| if err := x.f.Establish(ctx, o, launchInputs("g3"), x); err != nil { |
| t.Fatal(err) |
| } |
| @@ -1494,7 +1677,7 @@ func TestEstablishHostThatNeverPublishedFiles(t *testing.T) { |
| // The claim's own empty bootstrap: a created incarnation before its |
| // first writer, admitted after EmptyAuthority. |
| x.f.Evidence = nil |
| - empty := servingObserved("g1", "g1:uk:@root:uk") // admitted: the holder at g1 after EmptyAuthority |
| + empty := x.observe(servingObserved("g1", "g1:uk:@root:uk"), "g1") // admitted: the holder at g1 after EmptyAuthority |
| in := launchInputs("g1") |
| in.Empty = true |
| if err := x.f.Establish(ctx, empty, in, x); err != nil { |
| @@ -1561,7 +1744,8 @@ func TestEstablishOnThePreparedGenerationContinuesFromSequenceZero(t *testing.T) |
| x.journal("mv1", pm) |
| x.writeEnv("K=1\nCARLOS_INSTANCE_KEY=abc\n") |
| o := servingObserved("mv1", "cur:ie:@root:ie", "mv1:uk:cur:ie") |
| - o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box"} |
| + o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box"}} |
| + o = x.observe(o, "mv1") |
| in := launchInputs("mv1") |
| in.Prepared = true |
| // The first attempt crashes after the commit, before the local effect: |
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go| index 97eb4d60f..d72f19b56 100644 |
| --- a/internal/host/movesprobeowners_test.go |
| +++ b/internal/host/movesprobeowners_test.go |
| @@ -91,6 +91,10 @@ func (f *poFixture) poFlipClaim() *hostclaim.Claim { |
| func newPOFixture(t *testing.T) *poFixture { |
| t.Helper() |
| + // Claim Plan 15: admissions through the real CAS record their origin |
| + // under both switches; the real C8's proof reads that record. |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| inc, err := hostclaim.Incarnation("bab", "jam", poHost, at) |
| if err != nil { |
| @@ -349,7 +353,12 @@ func TestProbeIsAColdAdmissionIntoThePreparedGeneration(t *testing.T) { |
| // directory and files paths, as the claim assembly binds it: the probe |
| // owners' Files is this, not a fake (whole-branch fixes, item 1). |
| func (f *poFixture) realC8() *movesFiles { |
| - return &movesFiles{Files: casBucketStore{b: f.bucket, bucket: "data"}, Data: f.bucket, Bucket: "data", HomeRegion: "ie", Region: f.owners.Region, Box: f.owners.Box, DataDir: f.dataDir, FilesDir: f.owners.FilesDir} |
| + return &movesFiles{Files: casBucketStore{b: f.bucket, bucket: "data"}, Data: f.bucket, Bucket: "data", HomeRegion: "ie", Region: f.owners.Region, Box: f.owners.Box, DataDir: f.dataDir, FilesDir: f.owners.FilesDir, |
| + // Plan 15's proof over the fixture's claim store: the admissions the |
| + // fixture makes through the real CAS are recorded (both switches |
| + // on), and recordOrigins records what a hand-built claim or the |
| + // #250 backfill would. |
| + Proof: ClaimOriginProof{Claims: f.claims, HomeRegion: "ie", Region: f.owners.Region}} |
| } |
| // preparedManifestJournalled builds the prepared manifest local writes for |
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go| index 004ff67e2..b7f30518f 100644 |
| --- a/internal/host/movesrecoveryowners_test.go |
| +++ b/internal/host/movesrecoveryowners_test.go |
| @@ -172,6 +172,11 @@ func newRCFixture(t *testing.T, kind string) *rcFixture { |
| t.Fatal(err) |
| } |
| f.putClaim(c) |
| + // Plan 15: the source generation the clean kinds bridge from is proven |
| + // only when the claim records it; record it as the #250 backfill does. |
| + if kind != rcLegacy { |
| + recordOrigins(t, f.claims, poHost, hostclaim.OriginTuple{Incarnation: f.inc, Location: "instances/" + poHost, Region: "ie", Generation: "src"}) |
| + } |
| return f |
| } |
diff --git a/internal/host/plan15_originproof_test.go b/internal/host/plan15_originproof_test.go| index 62858930f..1cbf81fca 100644 |
| --- a/internal/host/plan15_originproof_test.go |
| +++ b/internal/host/plan15_originproof_test.go |
| @@ -1,7 +1,6 @@ |
| package host |
| import ( |
| - "context" |
| "testing" |
| "time" |
| @@ -9,20 +8,9 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -var _ interface { |
| - Proven(context.Context, hostclaim.Observed, hostclaim.Admission, []string) (map[string]bool, error) |
| -} = ClaimOriginProof{} |
| - |
| -// The moves session will adopt the batch signature after Plan 15 lands. |
| -// This test-only bridge checks compatibility with its current singleton API. |
| -type plan15SingleOriginAdapter struct{ ClaimOriginProof } |
| - |
| -var _ movesOriginProof = plan15SingleOriginAdapter{} |
| - |
| -func (p plan15SingleOriginAdapter) Proven(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, generation string) (bool, error) { |
| - proven, err := p.ClaimOriginProof.Proven(ctx, o, in, []string{generation}) |
| - return proven[generation], err |
| -} |
| +// ClaimOriginProof is the moves provider's proof (movesOriginProof's batch |
| +// contract), adopted with the Plan 15 switch. |
| +var _ movesOriginProof = ClaimOriginProof{} |
| func TestPlan15MovesProof(t *testing.T) { |
| t.Setenv("CARLOS_HOST_CLAIM", "1") |
| @@ -58,10 +46,5 @@ func TestPlan15MovesProof(t *testing.T) { |
| if got[tc.generation] != tc.want || err != nil { |
| t.Fatalf("%+v: %v %v", tc, got, err) |
| } |
| - var adapter movesOriginProof = plan15SingleOriginAdapter{p} |
| - one, err := adapter.Proven(t.Context(), o, hostclaim.Admission{NewGeneration: tc.generation}, tc.generation) |
| - if one != tc.want || err != nil { |
| - t.Fatalf("singleton adapter %+v: %v %v", tc, one, err) |
| - } |
| } |
| } |