carlos / platform Public

console: a keyed run whose key is gone but whose record holds it is kept, not deleted (PRO-6214)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f9a52e3df8217d2bba1c9da4d24b15f4bc602521 parent a333a94
3 files changed, +52 −1
  • docs/superpowers/specs/2026-10-24-service-runs-design.md +1 −1
  • internal/console/apiruns.go +6 −0
  • internal/console/apiruns_key_test.go +45 −0
diff --git a/docs/superpowers/specs/2026-10-24-service-runs-design.md b/docs/superpowers/specs/2026-10-24-service-runs-design.md
index 26488812d..3aef2a33b 100644
--- a/docs/superpowers/specs/2026-10-24-service-runs-design.md
+++ b/docs/superpowers/specs/2026-10-24-service-runs-design.md
@@ -314,5 +314,5 @@ run there answers "no such run", not the host-claim refusal (checked
- The exact shape of `run_keys` entries.
- The 4xx code list.
-- The 24-hour and 128-entry bounds, if the builder finds a reason to change
+- The 24-hour and 32-per-caller bounds, if the builder finds a reason to change
them, stated in the docs.
diff --git a/internal/console/apiruns.go b/internal/console/apiruns.go
index c126528ca..a6412e2ed 100644
--- a/internal/console/apiruns.go
+++ b/internal/console/apiruns.go
@@ -609,6 +609,12 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) {
return
case found:
err = provision.ErrRunKeyReused
+ case cur.ActiveRun != nil && cur.ActiveRun.ID == id:
+ // The key is gone (a record writer dropped run_keys between
+ // the admit and now) but the record still holds this run:
+ // it is admitted, and deleting its objects would leave a
+ // queued run no box can read.
+ state, err = cur.ActiveRun.State, nil
}
case cur.ActiveRun != nil && cur.ActiveRun.ID == id:
state, err = cur.ActiveRun.State, nil
diff --git a/internal/console/apiruns_key_test.go b/internal/console/apiruns_key_test.go
index 98d4ba761..c4b1e21ef 100644
--- a/internal/console/apiruns_key_test.go
+++ b/internal/console/apiruns_key_test.go
@@ -699,6 +699,51 @@ func TestRunKeyedLostAdmissionSettledByItsKey(t *testing.T) {
}
}
+// keysDroppedStore commits the admission's record write, then has a record
+// writer drop run_keys (a box rolled back to code that does not know them)
+// while the record still holds the run, and answers err.
+type keysDroppedStore struct {
+ release.Store
+ recordKey string
+ err error
+ once sync.Once
+ t *testing.T
+}
+
+func (m *keysDroppedStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ etag, err := m.Store.Put(ctx, key, body, ifMatch)
+ if err != nil || key != m.recordKey {
+ return etag, err
+ }
+ m.once.Do(func() {
+ drop := func(r *provision.Record) error { r.RunKeys = nil; return nil }
+ if _, e := provision.SetRecordRun(context.Background(), m.Store, release.OpsAccount, "shop", runTestHost, drop); e != nil {
+ m.t.Errorf("keysDroppedStore: %v", e)
+ }
+ })
+ return etag, m.err
+}
+
+// A keyed admission whose answer was lost, and whose key is gone from the
+// record while the record still names the run, is admitted: the objects stay
+// and the answer names the run, as the unkeyed settle would have.
+func TestRunKeyedSettleKeepsARunTheRecordHoldsWhenItsKeyIsGone(t *testing.T) {
+ fx := keyedFixture(t)
+ fx.srv.cfg.Releases = &keysDroppedStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost),
+ err: errors.New("connection reset by peer"), t: t}
+ c := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c"))
+ rec := fx.instRecord(t)
+ if rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID || len(rec.RunKeys) != 0 {
+ t.Fatalf("answer %+v; record run %+v keys %+v, want the run held with no key", c, rec.ActiveRun, rec.RunKeys)
+ }
+ if c.State != provision.RunQueued {
+ t.Errorf("answered %+v, want the record's queued state", c)
+ }
+ if keys, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", c.ID)); len(keys) != 2 {
+ t.Fatalf("the held run's objects are %q, want request.json and in/x.csv", keys)
+ }
+}
+
// twinOnRetryStore loses both of updateRecord's attempts: the first record
// write is refused as a conflict without landing, and before the second a
// twin's keyed admission lands, so the second conflicts for real.