Skip to content

Stop the conduct machinery from silently dropping fields - #744

Merged
xmap merged 7 commits into
mainfrom
conduct-field-fidelity
Aug 29, 2026
Merged

xmap merged 7 commits into
mainfrom
conduct-field-fidelity

Conversation

@xmap

@xmap xmap commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Three fields have shipped silently dropped by a hand-written copy, two of them
this week. Each was invisible to a green suite. This fixes them and adds the
two guards that would have caught them.

Groundwork for a conduct teardown phase: that work adds a field to exactly this
machinery, and the Recipe side had no carry-forward guard at all.

Keep the write ledger on the arms an operator triages from

Four wrapper terminals rebuilt a ConductorResult by hand-copying a field list.
All four omitted substrate_writes; three also omitted actuation_kind.

The held arm is the one that matters. A Held Procedure is parked mid-flight
waiting for an operator decision, which is exactly when "what did CORA leave
set" is the question, and it reported nothing at all.

dataclasses.replace instead, which cannot drift again by construction. The
conduct_from site now copies merged_result, so its complete-rejected arm
reports the same folded kind its success arm does.

Let a check's deadline reach the hash that decides recipe identity

RecipeCheckStep.timeout_s documents that it "rides expansion and the
determinism hash like any other authored value". Neither step serializer emitted
it and _step_from_payload never read it back, so two recipes differing only in
their deadline hashed identically, and a resumed conduct rebuilt the check with
no wait and re-ran it as an instantaneous read.

Emitted only when set, so every recipe authored before the field existed keeps
its wire form and its pinned steps_hash. A test hashes a deadlineless check
against the literal three-key form to pin that.

Guard the class

The step-serializer round-trips asserted rebuilt == instance, which looks like
full fidelity but only exercises a field the representative SETS. Every
representative left its optional fields at defaults, so a dropped field rebuilt
the default and compared equal. Requiring representatives to set every defaulted
field gives the existing round-trips their teeth back.

Recipe had no evolver carry-forward guard, unlike Procedure and Run. Added
the twin, stricter: only status is structural.

Verification

Every change verified by mutation, not just by passing: reverting each of the
four result sites fails that site's test and no other; re-dropping timeout_s
from either serializer fails that serializer's test; unsetting a representative
field and adding an unregistered Recipe field each fail the intended guard.

No wire change, no OpenAPI change, no event-payload change, no deployment step.

Deliberately out of scope: the convergence and decide loops drop the ledger too.
Their observer is per-execute, so a correct answer there is a union across
passes rather than a field copy, which is a design question for the teardown memo.

🤖 Generated with Claude Code

xmap added 3 commits August 29, 2026 08:58
Four wrapper terminals rebuilt a ConductorResult by hand-copying a field
list, so every field the list omitted was silently dropped. All four
omitted substrate_writes; three also omitted actuation_kind.

The held arm is the one that matters. A Held Procedure is parked
mid-flight waiting for an operator decision, which is precisely when
"what did CORA leave set" is the question, and it reported nothing at
all. Yesterday's 2-BM conduct is the concrete case: a halt returns from
the step loop, so a recipe's own closing steps never run and whatever
was opened stays open.

Use dataclasses.replace, which cannot drift again by construction and
was already the idiom fifteen lines above the conduct_from site. That
site now copies merged_result rather than result, so its complete-
rejected arm reports the same folded kind its success arm does and the
response agrees with the terminal event.

Each of the four is pinned by a test verified by mutation: reverting one
site fails that site's test and no other.

The convergence and decide loops drop the ledger too, deliberately left
alone. Their observer is per-execute, so a correct answer there is a
union across passes rather than a field copy, and that is a design
question for the teardown memo.
RecipeCheckStep.timeout_s promises it "rides expansion and the
determinism hash like any other authored value, so a recipe whose
deadline changed is a different recipe". Neither step serializer emitted
it and _step_from_payload never read it back, so the promise was false
twice over: two recipes differing only in their deadline hashed
identically, and a resumed conduct rebuilt the check with no wait at all
and re-ran it as an instantaneous read.

That second one is the dangerous half. A deadline exists because the
value takes time to arrive; a check that stops waiting reads the
pre-move state and passes or fails on it.

Emit the key only when set. Every recipe authored before the field
existed has None, so its wire form stays byte-identical and its pinned
steps_hash stays valid; a test hashes a deadlineless check against the
literal three-key form to pin that. Recipes that do carry a deadline
change hash, which is the point.

Verified by mutation: dropping the key from either serializer fails that
serializer's test and no other.

The HTTP conduct body still has no timeout_s arm, so a deadline remains
recipe-authored only. Left alone; widening the wire is a separate call.
Three fields have now shipped silently dropped by a hand-written copy,
two of them this week: substrate_writes and actuation_kind off the
Conductor's wrapper terminals, and CheckStep.timeout_s off both step
serializers. Each was invisible to a green suite.

The step-serializer round-trips already asserted `rebuilt == instance`,
which looks like full fidelity but only exercises a field the
representative actually SETS. Every representative left its optional
fields at their defaults, so a serializer that dropped one rebuilt the
default and compared equal. That is precisely how timeout_s got through,
and why ComputeStep.capture_name needed a hand-written test of its own.

So require the representatives to set every defaulted field, and the
existing round-trips gain their teeth for free: with timeout_s set,
re-dropping it from step_to_payload now fails the round-trip test that
previously stayed green.

Recipe had no evolver carry-forward guard at all, unlike Procedure and
Run, so a new field would be wiped by the RecipeVersioned and
RecipeDeprecated arms on replay with nothing failing. Add the twin,
stricter than Procedure's: only `status` is structural, so id, name and
capability_id are checked as real carry-forwards. Both arms already pass;
this is prevention, and the aggregate is where the next additive field
lands.

Verified by mutation: unsetting a representative field, re-dropping
timeout_s, dropping a prior.* thread, and adding an unregistered field
each fail the intended guard and no other.
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/operation
  _conduct_wire.py
  conductor.py 1627, 2017, 2683, 2804
  apps/api/src/cora/operation/_recipe_expansion
  _expand.py
  apps/api/src/cora/operation/features/conduct_from_procedure
  command.py
  handler.py
  route.py
  tool.py
  apps/api/src/cora/operation/features/conduct_or_hold_procedure
  command.py
  handler.py
  route.py
  tool.py
  apps/api/src/cora/operation/features/conduct_procedure
  route.py
  tool.py
Project Total  

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

xmap added 4 commits August 29, 2026 12:42
conduct_until_converged and conduct_until_advised had the same
field-drop bug #744 fixed on conduct() / conduct_or_hold() /
conduct_from(): five branches built a fresh ConductorResult next to a
`result` (or `merged`) already in scope, copying only completed_count
and measurements, so substrate_writes / artifacts / outputs silently
read empty on an abort or complete-rejected terminal that followed
real hardware writes. Two more branches (_abort_unconverged_cap,
_abort_absolute_ceiling) take an Optional last_result and had already
threaded completed_count/measurements through a None-safe ternary but
not the rest of the ledger.

Found while scoping the ConductorResult construction-site guard for
the upcoming closing-steps field: writing the guard meant reading
every construction site, which is exactly how the four wrapper-level
instances surfaced in #744 too.

Four of the six byte-frozen convergence-loop methods changed as a
result; the digest pin is updated deliberately in this commit per its
own docstring.
…ound

Three field-drops have now shipped from the same shape: a fresh
ConductorResult built next to an existing result already in scope,
hand-copying only some of its fields. #744 fixed four sites by
reading the file; the prior commit found five more the same way. That
process does not scale to the next reviewer, and it is exactly the
guard the closing-steps design needs before it adds a
closing_failures field to this type.

Registers every direct ConductorResult(...) construction (14 remain
after both fixup commits; replace() sites are complete by
construction and need no entry) against the exact set of fields it is
known to leave at their default, with the reason. A construction that
newly drops something, drops something different, or a stale registry
entry from a renamed/removed site, both fail loud. Adding a field to
ConductorResult will make every registered site "wrong" the moment
that field is not threaded, forcing a decision at each one rather
than a silent default.
…re has

ConductOrHoldProcedureResult and ConductFromProcedureResult never
carried measurements / artifacts / outputs / substrate_writes at all,
despite both docstrings claiming to mirror ConductProcedureResult:
conduct_or_hold_procedure/handler.py hand-built its result from six
fields when the Conductor handed back nine (the fifth field-drop
instance in this tree), and conduct_from_procedure's dataclass never
had the fields to drop in the first place. Worst arm on each: a Held
Procedure's closing steps have not run, and an acquisition halt
leaves the Procedure Running with a setpoint re-driven, so
substrate_writes is exactly the ledger an operator needs on precisely
these two outcomes.

Also fixes the #740 MCP omission for real this time: conduct_procedure's
own _ToolResult never carried substrate_writes despite the REST route
having it since #740, proven by grepping the tool result construction
directly. All three slices now carry substrate_writes on REST response
+ MCP tool, sharing one `substrate_writes_to_wire` helper (a third
inlined copy would have been the next field-drop-by-copy site).

measurements / artifacts / outputs stop at the application-layer
dataclasses for now (matching conduct_procedure's existing wire scope,
which never surfaced them either) -- extending the wire surface to all
three fields is a separate, unscoped decision.

openapi.json regenerated (two additive substrate_writes properties).
The conduct verb-family is the one place an MCP tool hand-copies a
REST response into a separate _ToolResult class instead of returning
the response model directly (failure needs a plain dict on the MCP
side, a typed sub-model on the REST side). That hand-copy is exactly
the shape #740 shipped from: conduct_procedure's own _ToolResult
omitted substrate_writes for a full cycle after the REST route gained
it, and no test compared the two field sets to notice.

Discovers every _ToolResult under cora.operation.features by walking
the filesystem (six today) rather than a hardcoded list, finds its
sibling route.py Response class, and asserts the field NAMES match
(types may differ; that divergence is the documented reason
_ToolResult exists at all). Verified by mutation: dropping
substrate_writes from conduct_procedure's _ToolResult again reproduces
the exact #740 diff and fails loud.
@xmap
xmap merged commit 8e6b65a into main Aug 29, 2026
19 checks passed
@xmap
xmap deleted the conduct-field-fidelity branch August 29, 2026 21:41
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