Skip to content

feat(worker): per-slot pending_actions map, load fence, and revision CAS (C2 2 of 3) - #260

Merged
ruby-dlee merged 2 commits into
mainfrom
feat/c2-pending-actions-map
Aug 19, 2026
Merged

feat(worker): per-slot pending_actions map, load fence, and revision CAS (C2 2 of 3)#260
ruby-dlee merged 2 commits into
mainfrom
feat/c2-pending-actions-map

Conversation

@ruby-dlee

Copy link
Copy Markdown
Owner

What

C2's second change, per the judged design (C2-DESIGN.md): the durable state shape and tripwires that the lock-discipline change (3 of 3) requires, landed while the controller stays fully serialized so any defect surfaces under today's semantics.

  • pending_actions {slot: action} replaces the scalar pending_action. Stored claims are deep copies of the exact minted action (make_action aliases live queue/worker objects and apply mutates some in place); verify_state re-derives every claim's idempotency key at load, so a hand-edited or truncated claim refuses before it can replay. Bounded by MAX_WORKERS (not the operator-settable env limit); claims for slots without worker records stay loadable by design.
  • Legacy migration + poison sentinel: a populated scalar migrates onto the map at load; the scalar permanently holds 'superseded-by-pending-actions', which an OLD binary's verify_state refuses loudly instead of reading None and blind-overwriting a live claim. Rollback is a one-way door; the cutover procedure backs up controller.json first.
  • FencedState + load fence: save_state refuses any document not produced by load_state inside the committing lock hold.
  • Revision CAS: save_state re-reads the on-disk revision and refuses when it moved, naming both numbers.
  • controller_lock refuses re-entrant acquisition (previously a deadlock) and stamps the per-process epoch the fence checks.
  • status gains pending_mutations; withdraw's guard fans out over the map naming the slot; surrender/resume gate on the map; replay drains in slot order.

Verification

16 units green including three new/rewritten proofs: legacy_scalar_migration (real minted scalar -> map + sentinel + revision, through the real wrapper), state_fence_and_revision_cas (re-entrancy refusal, stale-hold refusal, unfenced-dict refusal, tampered-claim load refusal, CAS refusal naming both revisions), and restart replays per-slot keys exactly once with TWO parked claims (a shape the scalar could never hold). The e2e withdraw guard now plants two REAL minted claims and asserts the refusal names the second slot ('slot 9'), because verify_state rejects the old fake-key fixture at load, exactly as the design predicted. execute_action's durable claim is pinned at the provider boundary by reading the FILE before and after a modeled provider crash.

Mutations, each run against the committed tree and restored from it, each turning the suite red: migration keeps the dict scalar; revision CAS dropped; load fence dropped; self-hash dropped from verify_state; pending_mutations projection dropped; execute_action claims nothing durable (this one survived the first sweep and got the file-reading probe above); re-entrancy refusal dropped (deadlocks the suite - detected by timeout, it can never pass).

bin/fm-lint.sh and fm-behavior-shards.sh --check 8 clean. Deploy note: after merge, cp $FM_HOME/state/azure-workers/controller.json{,.pre-c2}, run status --live, verify revision advanced, pending_actions {}, sentinel present; the live file's scalar is currently null so the migration is a no-op on this machine.

…CAS (C2 change 2 of 3)

The scalar pending_action could hold exactly one in-flight provider mutation,
which is what forces the whole fleet to serialize behind one lock. This lands
the durable groundwork while everything stays fully serialized:

- pending_actions: {slot: action} replaces the scalar. The stored claim is a
  deep copy of the exact minted action (make_action aliases live queue and
  worker objects, and apply mutates some of them in place; a claim that can
  change after it was hashed turns verification into a random wedge), and
  verify_state re-derives every stored claim's idempotency key at load, so a
  hand-edited or truncated claim refuses before it can replay. Bounded by
  MAX_WORKERS, never the operator-settable env limit, so lowering
  FM_AZURE_WORKER_MAX cannot make an existing file unloadable; claims for
  slots without worker records stay loadable, because turning a recoverable
  wedge into a file that refuses even status helps nobody.
- The legacy scalar is migrated onto the map at load (the action always
  carried its own slot) and permanently holds a sentinel string an OLD
  binary's verify_state refuses loudly, so a rollback cannot read None, plan
  fresh work, and blind-overwrite a live claim. Rollback is a one-way door:
  cp controller.json controller.json.pre-c2 before deploying.
- FencedState + load fence: save_state refuses any document that was not
  produced by load_state inside the lock hold that is committing it. Moving a
  provider call between a load and its save fails on the first run.
- Revision CAS: save_state re-reads the on-disk revision and refuses when it
  moved, naming both numbers. Last-writer-wins here would not corrupt the
  file; it would silently forget another writer's cloud resource identities
  and re-admit a VM that exists and is billing.
- controller_lock refuses re-entrant acquisition (it deadlocked before) and
  stamps a per-process epoch the fence checks.
- status --json gains pending_mutations [{slot, type}]; the text mode prints
  it when non-empty, so an operator can finally see a slot mid-mutation.
- replay_pending drains the map in slot order; execute_action claims its own
  slot; the withdraw guard fans out over every entry naming the slot in its
  refusal; surrender and resume gate on the map.

The lock discipline itself (provider calls outside the fleet lock, slot
leases, drain-after-convergence) is deliberately NOT here; it ships as the
next change in one piece.
…n the claim-pop ordering

The review found the migration paving over a corrupt legacy scalar (a
non-dict, non-sentinel value the old binary refused loudly), silently
destroying whatever replay obligation the corrupted bytes used to be; it now
refuses before the sentinel overwrite, with a red-path test. The one
load-bearing surviving mutation - popping the slot's claim before the apply
instead of after, which would let a later refusal-handler save erase the
replay obligation and wedge the slot silently - is pinned by asserting the
claim on the CALLER'S object after a failed apply. Two more tripwires get
red-path tests: a legacy scalar disagreeing with its slot's map entry, and a
self-hashing claim naming a slot outside 1..MAX_WORKERS. The C2 requirement
text is updated to record which half landed and which blocker remains.
@ruby-dlee
ruby-dlee merged commit a8965ac into main Aug 19, 2026
13 checks passed
@ruby-dlee
ruby-dlee deleted the feat/c2-pending-actions-map branch August 19, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant