Skip to content

Give a Procedure hold claims, so a fault and a pause don't share one bit - #801

Merged
xmap merged 4 commits into
mainfrom
feat/procedure-hold-claims
Sep 11, 2026
Merged

Give a Procedure hold claims, so a fault and a pause don't share one bit#801
xmap merged 4 commits into
mainfrom
feat/procedure-hold-claims

Conversation

@xmap

@xmap xmap commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Split out of `port-procedure-hold-claims`, which mixed this with an
unrelated tach-audit/port-pinning thread (that half is #800). This half
stands on its own.

The gap

A paused Procedure said `HELD` and nothing else: one bit, not who paused
it or why. That collapsed two situations a beamline needs told apart:

  • An operator deliberately pausing to swap a sample. Fine to sit for a
    week; the ProcedureWatcher's staleness window assumed exactly this.
  • The Conductor parking a conduct on a step fault. Nothing will move it
    without a person, and a week of silence before anyone is told is wrong.

Worse, a Held Procedure that then hit a fault had nowhere to record the
second cause: "already held" refused the parking attempt outright, and
the fault vanished. An operator resuming afterwards walked straight back
into the unfixed fault.

What changes

  1. A Procedure now records which concerns hold it, not just that one
    does (`hold_claims.py`, append/dedupe on `Held`, remove on
    `HoldClaimReleased`).
  2. A resume is refused while another concern still holds the
    Procedure
    , instead of silently discharging a hold nobody cleared.
  3. The Conductor names which concern parked a conduct, and an operator
    can clear that claim directly rather than the watcher inferring it from
    a stopwatch.
  4. The ProcedureWatcher reads the claim list, and gives a
    Conductor-parked hold a separate, week-shorter staleness window than a
    deliberate operator pause. Both are recorded and both are cleared
    explicitly when the person resumes.

Denormalized onto `proj_operation_procedure_summary` as `hold_causes`
(migration `20260911193339_procedure_summary_add_hold_causes.sql`) rather
than a precomputed flag, because the "needs attention" classification
(`ATTENTION_HOLD_CAUSES`) can change independent of the data; baking it in
would leave old rows silently wrong the day that set does. A row Held
before this shipped backfills to `LEGACY_CAUSE` rather than an empty
array claiming nothing holds it.

Migration note

This branch's migration was originally timestamped
`20260910100000`, which sorted before `20260910222120_init_proj_data_shortfall_summary.sql`
(landed on main via #798 while this branch sat unpushed). Renamed to
`20260911193339` so it lands after the current tip, and regenerated
`atlas.sum` with `atlas migrate hash` rather than hand-editing the
checksum file. `EXPECTED_SCHEMA_VERSION` updated to match.

Verification

  • `uv run pyright`: 0 errors
  • `uv run tach check`: passes
  • `uv run pytest tests/unit tests/architecture -n 4`: 51,781 passed, 651 skipped
  • DB/contract/integration tiers (this touches a migration and Postgres
    projection tests) run in CI; no local Docker available in this session
    to pre-run them.

🤖 Generated with Claude Code

xmap and others added 4 commits September 11, 2026 14:32
…ne is

`ProcedureStatus.HELD` is one bit. That was adequate while a hold had a
single author, and became a fault once four independent concerns could
each park the same conduct: two operator surfaces, a recoverable step
failure, and a steering driver going non-ACTIVE. A second holder arriving
at an already-Held Procedure is refused and records nothing, and the first
holder's resume then restarts the conduct with the second's cause
unenforced. Which concern survives depends only on arrival order.

Run hit this exact fault, fixed it with cause-scoped claims, and wrote it
up in project_hold_claim_ordering_fault. This ports the aggregate half of
that design rather than inventing a second one: `ProcedureHeld` carries
`claim_id` + a closed `cause`, `ProcedureResumed` carries
`released_claim_id`, an audit-only `ProcedureHoldClaimReleased` discharges
a claim without resuming, and claims fold onto `Procedure.hold_claims`.

Causes are the three concerns that actually park a conduct today, not
Run's four: `operator`, `step-fault`, `driver-stand-down`. The last two
are separate because they are discharged by different things, the
equipment recovering versus the driver being reinstated, so they must be
able to hold at once.

Nothing changes behaviour yet. No decider enforces resume-only-when-last
and no writer passes a cause, so every hold still folds to the single
legacy claim and every resume still clears it. That is deliberate: this
slice makes the aggregate able to represent the truth, and the enforcement
follows in its own slice with its own review.

Legacy streams replay to exactly their old one-bit meaning. A claimless
hold folds to LEGACY_CLAIM_ID, repeated claimless holds collapse rather
than accumulate, and a bare resume clears everything.

Two guards earned their keep. The evolver carry-forward fitness test
caught the new state field immediately, which is the drift catcher working
as designed; its registry is updated with the arms that write the field.
And the generated record-export disposition table needed regenerating, so
the new keys are dispositioned exactly as Run's are rather than diverging.

The release arm uses `replace` rather than a hand-listed constructor,
deliberately: re-listing seventeen fields to change one is how a field
gets silently dropped, which is the bug class this slice's own field
nearly fell into.

Verified by mutation: making a release clear every claim fails three of
the new tests, including the one pinning that a Procedure stays Held while
another concern holds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
S1 let a Procedure record which concerns hold it. This makes the deciders
act on that record, which is the half that changes behaviour.

Holding: `Held` becomes a legal starting status, so a second concern can
record its intent on an already-parked conduct. That was previously
refused outright, which is the fault: the second holder's cause went
unrecorded and the first holder's resume then restarted the conduct with
it unenforced. The guard moves from "is this Procedure un-held" to "is
THIS CONCERN already holding it", so the alternation the old rule
protected survives, scoped to the concern rather than the Procedure.

Resuming: the caller discharges its own claim and the decider picks. Last
active claim, emit ProcedureResumed and run. Others still holding, emit
the audit-only ProcedureHoldClaimReleased and stay Held. No claim of its
own, raise ProcedureHoldClaimsRemainError naming who is still holding, so
the caller learns which concern to address rather than only that it was
refused.

Causes are not reachable from the wire. `cause` defaults to `operator` and
neither the REST route nor the MCP tool exposes it, so a caller cannot
label a machine-parked conduct an operator pause and inherit an operator's
authority to clear it. The Conductor will set its own causes when the
writers are wired; that is the next slice.

Legacy holds stay resumable. A hold placed before claims existed has no
owner, so no derived id matches it and every Procedure held at that moment
would be stuck forever. An operator resume owns the legacy claim in
addition to its own, since the operator is the authority that could always
clear such a hold; a machine concern deliberately does not, so a step
fault clearing cannot silently adopt a hold nobody can attribute.

THREE TESTS CHANGED THAT PINNED THE OLD RULE, and they are the ones worth
looking at rather than the payload churn: test_decide_rejects_non_running_status
asserted "re-holding a Held one too" raises, and the two property tests
derived their permitted set from `_HOLDABLE_SOURCES = (RUNNING,)`. All
three encoded exactly the behaviour this commit deliberately changes, the
same way seven Run tests did before its hold-claim fix. The replacement
protection is the per-claim guard, covered directly.

Two fitness tests earned their keep again: the decider-docstring invariants
block caught both new raises going undocumented, and the generated
disposition table needed regenerating for the release event's decision
link.

Verified by mutation: making a resume ignore other holders fails the test
pinning that a discharge leaves the conduct Held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ator clear it

S1 gave a Procedure somewhere to record which concerns hold it, and S2 made
the deciders act on that record. Every hold still folded to a single claim
because no writer passed a cause. This wires the Conductor's two.

Passing the causes alone would have wedged the system, which the change had
to answer before it could ship. `conduct_or_hold` parks a conduct on a
recoverable step failure; `_hold_driver_stood_down` parks a steered loop
whose driver was switched off. Under S2's rule that each concern discharges
its own claim, neither hold could ever be discharged: nothing in CORA
re-establishes a faulted step or reinstates a driver, and an operator's
resume derives a different claim id. Every fault-parked conduct would have
become permanently unresumable. Reproduced at the decider before writing
the fix:

    WEDGED: ProcedureHoldClaimsRemainError ... still held by step-fault.

The wedge exposes a distinction the claim algebra had been carrying
implicitly. A hold claim is one of two things. An AUTHORITY claim is held
on behalf of a rule the holder enforces, and letting anyone else clear it
is the bypass it exists to prevent: Run's ratification and kill-switch
claims are these, which is why `resume_run` refuses an operator outright.
An ATTENTION claim is the opposite. The Conductor is reporting a conduct it
cannot itself un-stick, the claim's job is to say what an operator would be
resuming past, and an operator resuming IS the discharge.

So `ATTENTION_HOLD_CAUSES` records which Procedure causes are which, and an
operator's resume clears its own claim, a legacy claim, and every attention
claim. The set is declared rather than assumed, with
`test_every_hold_cause_is_classified` failing until a newly added cause is
put on one side or the other. "May an operator clear this" is the question
a new concern exists to raise, and inheriting an answer is how it would go
unasked.

Clearing several claims at once needed the emission to change. The caller's
own claim rides the `ProcedureResumed`; each claim it clears on another
concern's behalf gets its own `ProcedureHoldClaimReleased` first. A resume
that answers three concerns then records three discharges rather than one
status change that silently absorbed two of them.

WHAT DOES NOT CHANGE TODAY: every resume in the system is an operator
resume, since `cause` defaults to `operator` and no wire surface exposes
it, and an operator may clear every Procedure cause. So
`ProcedureHoldClaimsRemainError` does not fire in production. It is the
guard that keeps a future machine resumer from clearing a hold it never
placed. What changes now is that a machine hold is RECORDED at all: before
this, a conduct faulting on an already-held Procedure filed its fault under
the operator's claim, was refused as a duplicate, and the cause went
nowhere.

A GUARD, because the widening rests on something outside the algebra.
`operator` is the DEFAULT cause, so it means "the caller named no other
concern", and nothing checks what kind of thing the caller is. Every
unmarked resume traces to a person today only because no agent is granted
`ResumeProcedure`; grant one tomorrow and it defaults into `operator` and
inherits authority over every machine hold with nobody having decided that.
`test_no_agent_is_granted_resume_procedure` puts that where it can be seen,
in the grant table rather than in a docstring, and says what to do instead
if an agent ever needs to resume.

The same pressure is why a machine is already an operator in one place:
`_experiment_coordinator` has an in-process `HoldProcedure` grant and sets
no cause, so an agent's own decision is filed as "a person asked".

A DELIBERATE DIVERGENCE FROM RUN, since the two now differ and a reader
will ask. `resume_run` exposes `cause` on both the REST route and the MCP
tool, added because a kill-switch hold otherwise had no exit but abort_run
and its destroyed beamtime; on a Run, naming the cause is how a person says
which claim they are clearing. `resume_procedure` does not expose it, and
an operator's ownership of attention claims does that job instead. Both let
a person un-stick a machine-parked thing. Run's records the intent more
precisely, this one takes one call. The difference is defensible because no
Procedure claim is an authority claim, and it stops being defensible the
moment one is, which is what the classification test exists to catch.

One interaction is deliberately left alone. `_experiment_coordinator` still
issues a follow-on hold under the default operator cause, so on the Held
path it now records a mislabelled operator claim where it previously
raised. That whole call is removed by 187c55455cc on
`fix-coordinator-holds-aborted-procedure`, which found it invalid on every
path that reaches it; wiring a cause into code being deleted would only
conflict with that fix, and `steer_experiment` has no production caller.

THREE INTEGRATION TESTS CHANGED, and one of them was already broken. The
two `conduct_from` soft-IOC tests pin the exact event stream, which now
carries the release between the hold and the resume; that path parking and
resuming against a real IOC and Postgres is the end-to-end proof the wedge
is gone. The third, `test_a_person_and_an_agent_hold_through_one_gate_and_
the_record_agrees`, compares two Procedures' ProcedureHeld payloads field by
field after stripping what must differ. S2 began writing a claim_id derived
from the procedure id, so it differs for the same reason procedure_id does,
and nothing stripped it: that test has been red since 479b9ae12d9 and was
reported green on a test count that did not cover it. Fixed by asserting the
field rather than dropping it, so both paths still have to file a hold under
the same cause, differing only in which Procedure the claim is on.

Verified by mutation, five separately: dropping the operator's ownership of
attention claims, dropping either Conductor cause, dropping the extra
release events, granting an agent `ResumeProcedure`, and deriving a claim
from a fixed id instead of the Procedure's each fail tests that name the
behaviour rather than the shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ek for both

The ProcedureWatcher clocked every Held procedure against one week-long
window because `status` is one bit and cannot say WHICH concern is holding.
That window exists for a real case: a deliberate operator pause (a bakeout,
waiting on beam, waiting on a collaborator) legitimately runs for days, and
an hour-long window false-flagged every one of them.

But `Held` never meant "deliberate pause". The Conductor parks a conduct on
a recoverable step fault or a stood-down steering driver, and that is the
opposite case: nothing in CORA will move it until a person comes, so a week
of silence is precisely wrong for it. The watcher could not tell the two
apart, so it gave every hold the benefit of the doubt and the case most
worth surfacing was the one it hid longest. Now that a hold records its
cause, it does not have to.

The long window is not retired, it is narrowed to what its own config
comment always described. What changed is the question that selects it.

THE RULE IS POSITIVE EVIDENCE, not absence of a fault. The long window
applies only when `hold_causes` is non-empty AND every cause in it is
`operator`. An empty set does not qualify, and neither does
`legacy-unscoped`: a Held row this watcher cannot vouch for takes the short
window, because treating "no evidence" as "deliberate" is exactly how the
case worth catching would go on hiding. Being wrong costs one advisory
Decision a person can ignore, which is the cheap direction for a flag-only
watcher. Two consequences worth stating: a deployment carrying pre-claim
holds gets one flag per procedure, which is the right answer to "nobody can
say why this is held"; and an operator pausing a conduct does not vouch for
a fault that arrived alongside it, so one non-operator cause loses the long
window.

The causes reach the watcher through the read model. `hold_causes text[]` is
denormed onto `proj_operation_procedure_summary`, maintained by the Held
(append) / HoldClaimReleased (remove) / Resumed and three terminal (clear)
arms. Denorming the CAUSES rather than a precomputed "needs attention" flag
keeps the classification in `ATTENTION_HOLD_CAUSES`: baking it into the
projection would leave old rows silently wrong the day that set changes, and
it makes "held by what" answerable on a plain list read. The alternative
considered was folding the aggregate per candidate, which `_run_supervisor`
already does for Runs; it was rejected for costing a full stream fold per
held procedure per tick and for leaving the fact invisible to every other
reader.

The append is deduplicated by cause rather than a bare `array_append`, since
a re-delivered `ProcedureHeld` must not stack a second copy. That mirrors the
aggregate, where `_with_claim` is idempotent on an already-active claim. A
pre-claim hold carries no cause and folds to `LEGACY_CAUSE` there, so it
lands as that string here too rather than as a Held row claiming nothing
holds it. The migration backfills currently-Held rows the same way.

THE UNIT TESTS COULD NOT HAVE CAUGHT A BROKEN ARRAY UPDATE: the projection's
tests mock the connection, so this SQL had never executed. The Postgres
integration test now drives the whole chain instead, a conduct parking
itself, a person pausing the same conduct, causes accumulating oldest-first,
and one resume draining both.

ONE MISS WORTH RECORDING. The architecture suite passed 51,665 tests with
`EXPECTED_SCHEMA_VERSION` stale, because `test_schema_version_pin` reads
`tracked_migration_files()` and the new migration was still untracked. The
integration gate, which reads the directory, failed 15 tests and errored 8
on one root cause. The pin's guard was never wrong; running it before
staging was. Confirmed by reverting the pin with the file tracked, which
fails it immediately.

PRE-COMMIT REJECTED THE FIRST ATTEMPT, on a boundary worth recording rather
than working around. `cora.api` may not import `cora.operation.aggregates`,
and the watcher was reaching in for `HOLD_CAUSE_OPERATOR` to restate the rule
locally. The rule is domain knowledge about causes, so it moved to
`is_deliberate_pause` beside the causes it classifies, where whoever adds the
next cause will see it; the watcher applies it rather than repeating it. That
still needs the dependency, which is now declared with its reason alongside
the six `*.aggregates` edges `cora.api` already carries, including the
`cora.access.aggregates` one this same runtime uses for `Actor.active`.

Stating the rule as positive evidence rather than as "no attention cause is
present" also settles a case that does not exist yet: a cause that is neither
`operator` nor an attention claim, which is what an authority claim on a
Procedure would be. Such a hold is not somebody taking a break, and under the
negative phrasing it would have inherited a pause's latitude by being
unclassified.

Verified by mutation: treating an empty cause set as a deliberate pause, and
selecting the window by status again, each fail tests that name the
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  _procedure_watcher.py
  apps/api/src/cora/infrastructure
  config.py
  schema_version.py
  apps/api/src/cora/operation
  conductor.py
  apps/api/src/cora/operation/aggregates/procedure
  __init__.py
  events.py
  evolver.py
  hold_claims.py
  state.py
  apps/api/src/cora/operation/features/hold_procedure
  command.py
  decider.py
  apps/api/src/cora/operation/features/list_procedures
  handler.py
  apps/api/src/cora/operation/features/resume_procedure
  command.py
  decider.py 130
  apps/api/src/cora/operation/projections
  procedure.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap
xmap merged commit e500f75 into main Sep 11, 2026
19 checks passed
@xmap
xmap deleted the feat/procedure-hold-claims branch September 11, 2026 20:14
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