Skip to content

Record the design a steered run was given, not just the points it chose - #771

Merged
xmap merged 9 commits into
mainfrom
worktree-steering-design-pin
Sep 3, 2026
Merged

Record the design a steered run was given, not just the points it chose#771
xmap merged 9 commits into
mainfrom
worktree-steering-design-pin

Conversation

@xmap

@xmap xmap commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Why

CORA recorded every steering output and no steering input. A steered run's stream held the advised points, the measurements, the brain diagnostics and the order they arrived in, but nothing said what objective the brain was optimizing, what space it was allowed to search, what budget it was given, or which substrate resolved the advice. NewEvent.command_name is a bare string with no payload and there is no command log, so the design inputs were not recoverable from anywhere.

That makes a run describable but not interpretable. Rubin's ignorability condition (Biometrika 63(3):581, 1976) is the sharp form: a sampling rule is ignorable only if it can be checked, and it can only be checked if it was recorded. Replay sufficiency and inferential sufficiency are different criteria, and the second was missing.

What

A provenance-only SteeringDesignRecorded event on the Procedure stream, typed rather than blobbed so record export keeps the objective and the search space out of the opaque bucket. It has no evolver fold arm beyond require_state: it changes no aggregate state and gates nothing.

Written on the initial conduct beside the resolved-steps pin, in one append under one clock read, before the FSM opens. Written again on a resume when the design differs, suppressed when it is byte-identical.

A resume is refused only when the new space cannot represent a coordinate already measured: an axis dropped, an axis renamed, or a categorical choice removed. Bounds may narrow or widen, the objective may change, the budget and the brain may change, all recorded rather than refused. Recording is what buys that freedom.

Notable

  • Duplicate guard and continuity check read different pins. The guard reads the plain latest pin. The check reads the governing pin, the last one preceding the last ProcedureStarted/ProcedureResumed. Reading the latest in both places let a failed first resume brick a legacy stream permanently: the pin lands before the wire guard refuses, and the check then accepts only the design that was just rejected. Found in independent review, reproduced as a failing test, then fixed.
  • Pin count is not segment count. The suppression makes them diverge. A reader attributes a pass to the most recent pin at or before it. The event docstring now says so.
  • _conduct_preparation gained one stream-scan home. find_first_event / find_last_event hoisted to cora.infrastructure.event_payload; four call sites converted, each keeping the docstring that justifies its direction. The fourth site was written as a genexp, which is why the earlier rule-of-three note missed it.
  • A whole-system export defect fixed on the way through. Every tuple[ValueObject, ...] field published as {}: not a withheld field but a positive claim of emptiness, across thirteen fields in six contexts. Fixed in the generator rather than the redactor, which is what moves the redaction profile hash; a redactor-only fix would have widened fifteen fields under an unchanged hash. It also repaired two exports that aborted outright.

Verification

  • 33 unit tests on the decider and the continuity predicate, driving the pure function directly. Routing every continuity assertion through the handler had left three tests decorative, each surviving a mutation; the direct tests exist because of that.
  • 4 integration tests against real Postgres. Both value objects round-trip whole rather than spot-checked; the two pins land adjacent in one append; a refused resume leaves the stream byte-identical.
  • Contract test for the 422 mapping.
  • Full suite with the new files tracked: 37,244 passed, 639 skipped.

Recorded, not fixed

A principal denied ResumeProcedure can still append a pin before the Conductor's authz refuses; promoting the objective-capture-slot check to wire time; spend_agent_id may change across a hold unremarked; the read-side duplicate defect on the steps pin; three smaller export items.

🤖 Generated with Claude Code

xmap and others added 9 commits September 3, 2026 05:26
…actory

A steered run's design inputs are about to be recorded on the Procedure
stream so the adaptive design is recoverable for inference. That payload
must be typed rather than an opaque map: record-export dispositions are
generated from the annotation, so a Mapping field publishes nothing and
the pin would close none of the gaps it exists to close.

The brain selection is one of those fields, and it cannot be typed with
the DecideSubstrate Literal it names: that Literal is adapter-tier, and
cora.operation.aggregates reaches only cora.infrastructure and
cora.shared. So the enum lives here, beside the objective and space value
types that moved for the same tach reason.

Mirrors invite drift, so the fitness test is the point of this commit as
much as the enum is. It compares two genuinely independent sides, a
shared StrEnum and an adapter Literal with no common generator, and it
was proven by mutation in both directions rather than merely written.

SteeringDesignSource ships with a single value so the later across-Run
arm is purely additive and no recorded pin is ever retroactively
ambiguous about where its design came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The record holds what a steered run DID and nothing about what it was
allowed to do. A downstream reader gets the points the brain chose and
the values that came back, but not the objective, the search space, the
budget or the brain's own settings. Rubin's criterion is that a sampling
rule is ignorable for likelihood-based inference only if it depends on
observed data, and that is verifiable only if the rule was written down.
An unrecorded rule is not benign, it is unverifiable.

So this adds the event that pins those inputs. Nothing emits it yet, on
purpose: the risky part is not the emission, it is whether the record can
carry the payload at all, and that is worth proving on its own.

The typing is the whole point. Dispositions are generated from the
annotation, so an opaque map would have exported as nothing and the pin
would have closed none of the gaps it exists for. Objective and space are
therefore the shared value objects, and the budget and brain config are
flattened to scalars, because the aggregate tier may reach cora.shared
but not the ports or adapters where their runtime types live.

Campaign already serialized these two value objects privately. Those
helpers move to cora.shared beside the types, so one object cannot be
carried in two shapes on two streams; Campaign's payload is unchanged.

One gap is pinned rather than fixed, with a test that fails when someone
closes it: a populated search space still redacts to an empty dict,
because the generator describes a collection of value objects with a
dict-shaped rule while the stored value is a list. That is not specific
to steering and it changes what several other contexts disclose, so it
needs its own diff. It must land before anything emits this event, since
an empty space reads as a zero-dimensional search rather than a withheld
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five places said a conducted Procedure carries exactly one
ResolvedStepsRecorded, including both resume handlers at the point where
they read it. It is not true. The pin decider guards on status alone, so
any conduct that fails after pinning and before start_procedure leaves the
Procedure Defined and the retry pins again; three such paths exist on the
steered entry point alone.

Usually the second pin is identical and nothing is harmed, but the recipe
hash check that would guarantee that runs BEFORE pseudoaxis expansion, and
that stage resolves constituents from the parent Run's live wiring. Rewire
a Run-phase Procedure between a failed attempt and its retry and the two
pins genuinely differ, at which point the head-scanning reader replays the
attempt that was abandoned rather than the one that ran.

The finder shipped with no tests at all, so this characterizes it first:
what it returns for no match, one match, two matches, and that it really
does stop at the first. Those pin observed behaviour, and the two-match
case is expected to invert when the read side is corrected, which its
docstring says so a green result is not mistaken for a specification.

No fix here and no hoist. The fix is read-side, take the last pin before
ProcedureStarted, and it forfeits the early exit, so it wants its own
diff. The rule-of-three hoist of the finders belongs with the third one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redaction table is generated from field types, and for a collection of
value objects it kept only the element's rule. A rule shaped like one
object then met a list, the redactor withheld it, and the field published
as an empty object. That is not a withheld field. It is a positive claim
that there was nothing there, and thirteen fields across six contexts have
been making it.

The table can now say "collection of", so the redactor never has to guess
cardinality from the payload it is redacting, and a stored shape that
disagrees with the table fails closed again instead of publishing.

Nothing sensitive starts flowing. Every drop rule still drops, every id is
still tokenised, and what appears is bounds, counts, enums and numbers the
table already authorised. Sweeping a marker through every drop-classified
leaf of all 237 event types leaves none of them in an export.

Three fields whose serializer disagrees with its declared type are
withheld outright rather than reconciled, since changing a stored payload
shape is a migration question and not an export one. Asset owners are the
reason this matters: that serializer writes explicit nulls for absent
values, so per-field rules would have disclosed which of name, contact or
identifier is on file, with the reading inverted, over a field documented
as usually an email. The singular sibling is withheld with it.

Two exports that abort today stop aborting. A partition rule with any
number of parameters other than two used to raise, and with exactly two it
published the channel name verbatim; both are correct now, with withheld
slots as nulls so the surviving positions still line up.

The point of putting this in the generator rather than the redactor is
that the redaction profile hash covers the table. A consumer pinning that
hash sees this change; had the redactor learned to guess instead, fifteen
fields would have widened under an unchanged hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The objective, the search space, the budget and the brain configuration
reached the runtime as command arguments and were persisted nowhere. No
Procedure event pinned them, NewEvent carries command_name as a bare string
with no payload, and there is no command log. So a steered run's record
could describe every point that was chosen and nothing about the rule that
chose them, and a sampling rule that is not recorded is not thereby
harmless, it is unverifiable.

The pin rides the existing pre-conduct pipeline and goes in the SAME append
as ResolvedStepsRecorded, off one clock reading. A second append would open
a window in which the steps are pinned and the design that chose them is
not, manufacturing exactly the absence this event exists to remove.

The duplicate guard is load-bearing here in a way it is not for the steps
pin. Two failure paths fire after the pin and leave the Procedure Defined,
so a retry pins again. For the steps that repetition is byte-identical and
harmless, but here the operator has usually corrected the space in between
and the second design is the one that ran. An identical re-pin is therefore
suppressed and a corrected one is not, and the reader scans from the tail,
where a head-scan would compare against the abandoned attempt. The two
existing finders scan from the head and return that abandoned attempt; that
is a recorded read-side defect, not a convention to follow.

Comparison is over the serialized payload rather than the rebuilt event, so
a row written before a field existed makes the two designs differ instead of
raising, and differing means pin again, which is the safe direction. A
stored payload carrying a key this code cannot read counts as different for
the same reason.

Unsteered conduct is untouched: steering_design defaults to None and the
three unsteered callers of the shared pipeline pass nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sites across two contexts wrote the same four-line loop to pull one
event out of a loaded stream, and the fourth was written as a genexp in the
Data context rather than a loop, so a grep for the pattern found three. The
recorded rule-of-three note asked for the hoist once a third landed.

What the loop was hiding is worth more than the duplication it caused. Each
site had already made a decision about DIRECTION, and none of them said so
where a reader would look. The genesis record is the first of its type and
cannot be superseded. The steps pin is re-emitted by a retry, so the first
one is the abandoned attempt, and reading from the head is a known
read-side defect. The steering design pin is re-emitted the same way and
reads from the tail for exactly that reason.

find_first_event and find_last_event now carry the loop, and the named
domain finders keep their docstrings and say which direction they picked
and why, since that is the part a caller needs. The two finders disagreeing
on a stream where a type repeats is itself a test: a single scan wired to
both would pass every assertion made about either one alone.

No behaviour changes. The existing characterization tests for both head
scanners still pass unchanged, and they go red together with the new tests
when find_first_event is made to scan backwards, which is what shows the
wrappers really delegate rather than keeping a private copy.

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

A steered Procedure can be held and resumed, and until now the resume carried
its objective, space, budget and brain config in as command arguments and left
no trace of any of them. A Procedure held and resumed under a different
substrate ended its life with a record describing only its first half.

So the resume pins its own design. It appends alone rather than beside a steps
pin, because a resume replays the already-pinned step list instead of resolving
a new one, and that is the only event there is to write. The status guard is
now a required argument rather than a default: the forward path pins while
Defined and the resume while Held, and accepting both at once would let a
forward conduct against a Held Procedure emit a design pin with no steps beside
it and only then fail in the Conductor.

A resume whose objective, capture name or space left the pinned design behind
is refused with 422 before any FSM event fires. Only the space has a
first-principles argument: it is the support the recorded observations were
drawn from, and resuming a brain over that history while it proposes elsewhere
asks it to extrapolate. The other two are a conservative default and the
docstrings now say so rather than borrowing the space's argument, because
neither survives it: the objective is applied when the brain fits, not when the
data was recorded, and the capture name's real condition is agreement with the
pinned steps. The asymmetry that leaves is stated outright. The substrate is
part of the sampling rule and may change freely, which is defensible only
because this event records it.

The check reads the pin the last segment actually STARTED under, not the newest
pin on the stream, and that distinction is a bug fix rather than a refinement.
A pin is written before the Conductor runs, and the wire guard, the brain
factory and the resume's own authorization can all still refuse. On a stream
with no earlier pin, a first attempt that mistypes an axis leaves a pin nothing
started under; measuring against it accepts only the design the wire guard just
rejected, and the Procedure can never be resumed again. Both remedies the error
names are wrong in that state: one is the rejected design, the other discards
every accumulated observation. A pin no segment started under governed nothing.

Absence stays silent. Every Procedure conducted before this event existed
carries steps and no design, and refusing those would make a record-keeping
improvement retroactively break resumes that were fine.

Two segments under one design leave one pin, because the duplicate guard
suppresses an unchanged resume. Counting pins does not count segments; a reader
attributes a pass to the most recent pin at or before it, and the FSM events
delimit the segments. The event docstring said otherwise and now does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was that objective, capture name and space must all survive a hold
unchanged, on the grounds that the observations already recorded were drawn
under them. That reason holds for none of the three as stated, and checking
what three widely used optimisation libraries actually do says so.

Optuna records the distribution per trial and refuses a change of kind, of log
scale, or of categorical choices. It deliberately does not compare low, high or
step, and its FAQ says outright that a study may alter a range between trials.
Ax lets the objective be replaced while trials exist, and lets the search space
be replaced provided parameter names and types survive, demanding backfill
values before a new parameter may be added. Ax also stores the search space and
the objective on every generator run, and the flag that turns those copies off
is the same flag that makes both immutable, because once the change is not
recorded it can no longer be permitted. That is the entailment, and this
codebase now sits on the recorded side of it.

So the refusal narrows to what actually strands data. Each recorded pass holds
one coordinate per axis, and only three changes leave a coordinate with nowhere
to live: an axis that disappears, an axis that appears, and a categorical axis
that drops a value already drawn. Those still refuse. Bounds, objective,
capture name, budget and brain now travel on the resumed segment's own pin
instead, where a reader can see them.

The argument for freezing bounds was mine and it was backwards. Narrowing them
leaves the model fitted on a wider set than it proposes within, which is
interpolation; widening leaves the new region empty, where the model reports
high variance and goes to look, which is exploration. Neither strands anything.
The failure is dimensional, not geometric, which is what Ax's backfill demand
and Optuna's categorical rule were both pointing at.

The refusal now names the axis rather than the field, because a renamed axis
reports as both a disappearance and an appearance, and that is what a typo
looks like from the inside.

One thing is deliberately left undone. The capture name's real requirement is
that the block deposits that slot, and the Conductor already enforces it, but
only once a pass has run. Promoting it to a wire-time refusal would help both
steered paths, and it would also leave the runtime branch unreachable from
either of them, which matters because that branch carries the only cover for a
recorded ledger-drop bug. Renaming the slot on a resume therefore costs one
pass, exactly as it already does going forwards.

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

Every test of this event so far ran against the in-memory store, which answers
whether the right events get appended but not whether the payload comes back
the shape it went in. That gap matters more here than for most events. The
objective and the search space are nested value objects, the space itself a
list of axes, and the entire reason the event is typed rather than blobbed was
to keep them out of the opaque bucket on export. A structure that serializes
cleanly in process and returns from asyncpg flattened would have defeated that
silently.

Nothing covered it. The rotation-characterization scenario drives the Conductor
directly and so never reaches the handler that writes the pin, which means a
pin broken only against Postgres would have shipped with the suite green.

Four tests now drive the real steered handlers against a real database: both
value objects round-tripped whole rather than spot-checked, the two pins
adjacent in one append before the FSM opens, a resume recording the narrowed
bounds it actually ran under, and a refused resume leaving the stream
byte-identical, since a guard that appends before it decides is not a refusal.

The held fixture runs a genuine forward conduct and then appends the hold
directly. A conduct that has returned is already terminal, so there is no
moment in a test at which hold_procedure would be accepted; the helper says so
rather than leaving the shortcut to be discovered.

The event also reaches the module documentation, whose table no fitness test
guards and which therefore rots in silence. The surrounding prose claimed a
steered run was reconstructable from its recorded decision trail, which is true
of what the brain did and says nothing about what it was asked, so that
paragraph now carries the distinction and the rule for what a resume may
change. While there, the sibling row for the resolved-steps pin regains the
closing-steps field it has been missing since that field shipped.

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

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/campaign/aggregates/campaign
  events.py
  apps/api/src/cora/data
  _bootstrap.py 281, 284
  apps/api/src/cora/infrastructure
  event_payload.py
  apps/api/src/cora/infrastructure/record_export
  _redact_tier1.py 115
  apps/api/src/cora/operation
  _conduct_preparation.py 264, 364, 367, 371, 375
  errors.py
  apps/api/src/cora/operation/_recipe_expansion
  _replay.py
  _resolved_steps_replay.py
  apps/api/src/cora/operation/aggregates/procedure
  events.py
  evolver.py
  apps/api/src/cora/operation/features/conduct_from_procedure
  handler.py
  apps/api/src/cora/operation/features/conduct_until_advised
  handler.py
  apps/api/src/cora/operation/features/conduct_until_advised_from
  handler.py 191
  apps/api/src/cora/shared
  steering.py
Project Total  

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

@xmap
xmap merged commit 734f583 into main Sep 3, 2026
19 checks passed
@xmap
xmap deleted the worktree-steering-design-pin branch September 3, 2026 21:05
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