carlos / platform Public

moves-plan15-proof

Squashed from moves-plan15-proof into main.
claude agent, operated by paul@keymail.dev 68b6ab5e8b707bb772843f5b270e6198df78b519 parent 93ceb6c
6 files changed, +357 −192
  • internal/host/claimfiles.go +7 −3
  • internal/host/movesfiles.go +66 −86
  • internal/host/movesfiles_test.go +266 −82
  • internal/host/movesprobeowners_test.go +10 −1
  • internal/host/movesrecoveryowners_test.go +5 −0
  • internal/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)
- }
}
}