carlos / platform Public

activator: the replica gate leaves the claim's identity check and runs in the launch sources only (final review I4)

The claim's input policy compares a Work's identity to a snapshot's and
launches nothing, yet it snapshotted through the replica gate: every
guarded Work on an admitted configured-key replica cost a bucket read
with a 10 s bound, logged "replica <host> not started" with nothing
starting, and answered ErrReplicaKey where the policy's contract is
ErrChanged. SnapshotClaimLaunch is now the pure read, used by the policy;
SelectClaimLaunch is the same snapshot followed by the gate on the env and
layer of that one read (ruling 29), used by the serving and move launch
sources. The policy closure is lifted out of OpenClaimOwners as
claimInputPolicy so a test can hold it: a configured-key replica whose
bucket reader is a tripwire gets nil for its own identity and ErrChanged
for another with no read, while the launch source on the same backend
trips it and refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 92e6d5c3ccf4fc4b7ba546d4ca082709c1ebeeb6 parent 96a5ac0
5 files changed, +145 −44
  • internal/activator/claim_snapshot.go +50 −18
  • internal/activator/replicakey.go +4 −2
  • internal/host/claim_replica_instancekey_test.go +52 −0
  • internal/host/claimmoveaccess.go +3 −2
  • internal/host/claimproduction.go +36 −22
diff --git a/internal/activator/claim_snapshot.go b/internal/activator/claim_snapshot.go
index 21dfe1332..149f6e494 100644
--- a/internal/activator/claim_snapshot.go
+++ b/internal/activator/claim_snapshot.go
@@ -19,6 +19,13 @@ import (
// ClaimLaunchSnapshot is read-only launch selection. The actual launch still
// opens and hashes the executable under the captured work immediately before exec.
+//
+// Two readers take one. SnapshotClaimLaunch is the pure read: the claim's
+// input policy compares its Identity to a Work's, and nothing is launched,
+// so no gate runs and no bucket is read. SelectClaimLaunch is the launch
+// sources' read: the same snapshot, then the replica gate on the env and
+// the layer that read produced (ruling 29), with its fresh bucket read for
+// a configured key (ruling 27).
type ClaimLaunchSnapshot struct {
Identity hostwrite.Identity
Executable, SHA256, Version string
@@ -115,74 +122,99 @@ func (b *ExecBackend) EnsureClaimAdminToken(ctx context.Context, rt registry.Rou
})
}
+// SnapshotClaimLaunch is the read-only snapshot with no gate: what the
+// claim's input policy compares identities with. It reads the env files
+// and the live build; it never reads the bucket, and a replica row that
+// the launch would refuse still snapshots here, because the question is
+// "is the identity still this one", not "may this start".
func (b *ExecBackend) SnapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) {
+ out, _, _, err := b.snapshotClaimLaunch(ctx, rt)
+ return out, err
+}
+
+// SelectClaimLaunch is the launch sources' snapshot: SnapshotClaimLaunch,
+// then the replica gate on the env and the layer of that same read
+// (ruling 29). A claim launch has no control socket until O6, so a replica
+// is selected only with its home's key, verified (ruling 26); the gate's
+// fresh bucket read for a configured key (ruling 27) runs here and only
+// here on the claim path.
+func (b *ExecBackend) SelectClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) {
+ out, vars, layer, err := b.snapshotClaimLaunch(ctx, rt)
+ if err != nil {
+ return out, err
+ }
+ if err := b.replicaKeyGate(rt, vars, layer, false); err != nil {
+ return ClaimLaunchSnapshot{}, err
+ }
+ return out, nil
+}
+
+// snapshotClaimLaunch composes the snapshot and hands back, from the one
+// read that composed its env, the env and the layer its key came from, for
+// SelectClaimLaunch's gate.
+func (b *ExecBackend) snapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, map[string]string, instancekey.Layer, error) {
var out ClaimLaunchSnapshot
if ctx.Err() != nil {
- return out, ctx.Err()
+ return out, nil, instancekey.LayerNone, ctx.Err()
}
if !claimLaunchRoute(rt) {
- return out, hostwrite.ErrUnavailable
+ return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable
}
if err := b.claimLaunchShape(rt); err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
if err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept
if vars["CARLOS_ADMIN_TOKEN"] == "" {
- return out, hostwrite.ErrUnavailable
+ return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable
}
// The key this launch delivers and the file that supplied it, from
// the same read as vars: what the replica gate decides on (ruling 29)
// and what the process's adoption report is checked against (ruling
// 21, O6). Not in the identity: the env already is.
key, layer := instancekey.Effective(cfgEnv, ownEnv)
- // A claim launch has no control socket until O6, so a replica is
- // selected only with its home's key, verified (ruling 26).
- if err := b.replicaKeyGate(rt, vars, layer, false); err != nil {
- return out, err
- }
if key != "" {
out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer)
}
out.Executable = b.liveLink(rt.Host)
f, err := os.Open(out.Executable)
if err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
f.Close()
- return out, hostwrite.ErrUnavailable
+ return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable
}
digest := sha256.New()
_, err = io.Copy(digest, f)
closeErr := f.Close()
if err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
if closeErr != nil {
- return out, closeErr
+ return out, nil, instancekey.LayerNone, closeErr
}
out.SHA256 = hex.EncodeToString(digest.Sum(nil))
out.Version, err = b.LiveVersion(rt.Host)
if err != nil || out.Version == "" {
- return out, hostwrite.ErrUnavailable
+ return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable
}
out.Roles, err = b.ClaimLaunchRoles(rt)
if err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
out.Args = []string{"--socket", rt.Socket, "--db", rt.DBPath}
out.Env = childEnv(b.agentOwnedVars(rt, "", vars, false))
raw, err := json.Marshal(out.Env)
if err != nil {
- return out, err
+ return out, nil, instancekey.LayerNone, err
}
config := sha256.Sum256(raw)
out.Identity = hostwrite.Identity{Config: hex.EncodeToString(config[:]), Release: out.SHA256}
out.Limit = b.tier(rt)
- return out, nil
+ return out, vars, layer, nil
}
diff --git a/internal/activator/replicakey.go b/internal/activator/replicakey.go
index 515cd8f18..186b3163b 100644
--- a/internal/activator/replicakey.go
+++ b/internal/activator/replicakey.go
@@ -7,8 +7,10 @@ package activator
// every replica launch passes replicaKeyGate before anything runs: the
// flat seed and spawn (seedChild, spawnChildFrom), the nested run, seed
// and sidecar roles (prepareAt), the worker (StartWorkerStaged), the
-// legacy co-spawned sidecar (startSidecarChildFrom) and the claim launch's
-// snapshot (SnapshotClaimLaunch, which has no control socket until O6).
+// legacy co-spawned sidecar (startSidecarChildFrom) and the claim launch
+// sources' selection (SelectClaimLaunch, which has no control socket until
+// O6; the claim's input policy snapshots without the gate, because it
+// launches nothing).
//
// - a replica whose env files deliver a key starts only when that key's
// digest is its entry in the box's verified list (ReplicaHomeKeys, which
diff --git a/internal/host/claim_replica_instancekey_test.go b/internal/host/claim_replica_instancekey_test.go
index aa5bb0ff3..4b7207ca2 100644
--- a/internal/host/claim_replica_instancekey_test.go
+++ b/internal/host/claim_replica_instancekey_test.go
@@ -9,6 +9,9 @@ import (
"testing"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/registry"
)
@@ -60,3 +63,52 @@ func TestClaimReplicaLaunchIsGated(t *testing.T) {
})
}
}
+
+// Final review I4: the claim's input policy asks whether a Work's identity
+// still names this host's inputs, and launches nothing. It snapshots
+// without the replica gate, so a configured-key replica costs it no bucket
+// read, and its answer keeps the policy's contract (nil or ErrChanged),
+// never a launch refusal. The launch source on the same backend runs the
+// gate, and its fresh read, as before.
+func TestClaimInputPolicyNeverReadsTheBucket(t *testing.T) {
+ homeKey := strings.Repeat("H", 43) + "="
+ f := newAdminTokenFixture(t, "box-1")
+ f.rt.Replica = true
+ f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
+ reads := 0
+ f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) {
+ reads++
+ return "", errors.New("bucket unreachable") // a gate that ran here would refuse
+ }
+ if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"="+homeKey+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ snap, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt)
+ if err != nil {
+ t.Fatalf("snapshot: %v", err)
+ }
+ o, err := hostclaim.Read(t.Context(), f.claims, adminTokenHost)
+ if err != nil || o.Claim == nil || o.Claim.Holder == nil {
+ t.Fatalf("claim: %+v, %v", o, err)
+ }
+ h := o.Claim.Holder
+ sel := hostbox.Selection{Host: adminTokenHost, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}}
+ policy := claimInputPolicy(func(string) (*registry.Route, error) { return &f.rt, nil }, f.claims, nil, f.backend)
+ if err := policy(t.Context(), o.Claim, sel, snap.Identity); err != nil {
+ t.Fatalf("the policy refused the snapshot's own identity: %v", err)
+ }
+ changed := snap.Identity
+ changed.Config = "0000"
+ if err := policy(t.Context(), o.Claim, sel, changed); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("another identity = %v, want ErrChanged", err)
+ }
+ if reads != 0 {
+ t.Fatalf("the policy read the bucket %d times", reads)
+ }
+ if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); !errors.Is(err, activator.ErrReplicaKey) || reads != 1 {
+ t.Fatalf("the launch source = %v after %d bucket reads, want ErrReplicaKey after one", err, reads)
+ }
+}
diff --git a/internal/host/claimmoveaccess.go b/internal/host/claimmoveaccess.go
index 1177759a2..9794cf1dc 100644
--- a/internal/host/claimmoveaccess.go
+++ b/internal/host/claimmoveaccess.go
@@ -157,8 +157,9 @@ func (a *claimMoveReadAccess) Launch(ctx context.Context, host string) (ClaimSer
func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
- // Snapshot refuses a missing token. This path must never mint one.
- snapshot, err := backend.SnapshotClaimLaunch(ctx, rt)
+ // The selection refuses a missing token, and gates a replica. This
+ // path must never mint one.
+ snapshot, err := backend.SelectClaimLaunch(ctx, rt)
if err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
}
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 82cdfa57c..f44eec178 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -2,6 +2,7 @@ package host
import (
"context"
+ "crypto/ed25519"
"errors"
"os"
"path/filepath"
@@ -164,11 +165,41 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
return out, err
}
bindClaimLegacyOwners(&out)
- out.InputPolicy = func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
+ out.InputPolicy = claimInputPolicy(r.Registry.Get, store, pub, backend)
+ out.MovesPolicy = MovesClaimPolicy(MovesRecordsFrom(store))
+ bindProductionClaimInputPolicy(claimEntryEnv(ctx), &out, MovesRecordsFrom(store))
+ regional := productionClaimRegional{Identity: out.Identity, Parks: parks, Local: local}
+ out.Evidence = regional
+ out.LeaseSet = regional.LeaseSet
+ out.View = regional.View
+ out.Authorize = regional.Authorize
+ out.Close = func(ctx context.Context) error {
+ err := local.Close(ctx)
+ if closeRelocation != nil {
+ err = errors.Join(err, closeRelocation(ctx))
+ }
+ if external != nil {
+ return errors.Join(err, external.Close(ctx))
+ }
+ return err
+ }
+ return out, nil
+}
+
+// claimInputPolicy is the serving claim's input policy: does a Work's
+// identity still name this host's inputs? A config-only identity is checked
+// against the bucket's rendered config, a release-only one against the
+// route's release, and a full one against the launch snapshot's identity.
+// The snapshot is the gateless read (SnapshotClaimLaunch): nothing is
+// launched here, so the replica gate does not run, no bucket is read for a
+// configured key, and the answer is hostbox.ErrChanged or nil, never a
+// launch refusal (final review I4).
+func claimInputPolicy(route func(string) (*registry.Route, error), store release.Store, pub ed25519.PublicKey, backend *activator.ExecBackend) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
+ return func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || s.Host != c.Host || s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp {
return hostbox.ErrChanged
}
- rt, err := r.Registry.Get(s.Host)
+ rt, err := route(s.Host)
if err != nil {
return err
}
@@ -197,24 +228,6 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
}
return nil
}
- out.MovesPolicy = MovesClaimPolicy(MovesRecordsFrom(store))
- bindProductionClaimInputPolicy(claimEntryEnv(ctx), &out, MovesRecordsFrom(store))
- regional := productionClaimRegional{Identity: out.Identity, Parks: parks, Local: local}
- out.Evidence = regional
- out.LeaseSet = regional.LeaseSet
- out.View = regional.View
- out.Authorize = regional.Authorize
- out.Close = func(ctx context.Context) error {
- err := local.Close(ctx)
- if closeRelocation != nil {
- err = errors.Join(err, closeRelocation(ctx))
- }
- if external != nil {
- return errors.Join(err, external.Close(ctx))
- }
- return err
- }
- return out, nil
}
// bindClaimLegacyOwners fills the legacy owner fields the constructor uses
@@ -417,13 +430,14 @@ func (t claimAdminTokens) With(ctx context.Context, host string, effect func() e
}
// claimServingLaunchSource is the production serving launch source: the
-// authorized token initialization, then the read-only snapshot.
+// authorized token initialization, then the read-only snapshot with the
+// replica gate (SelectClaimLaunch).
func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminTokens) func(context.Context, registry.Route) (ClaimServingLaunch, error) {
return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
if err := backend.EnsureClaimAdminToken(ctx, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
return ClaimServingLaunch{}, err
}
- snapshot, err := backend.SnapshotClaimLaunch(ctx, rt)
+ snapshot, err := backend.SelectClaimLaunch(ctx, rt)
return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: snapshot.Env, Roles: snapshot.Roles, Version: snapshot.Version, Limit: snapshot.Limit}, err
}
}