Skip to content

feat(recipe+operation): carve Recipe aggregate out of Capability; close replay loop (Stage-1.7-1.10)#24

Merged
xmap merged 6 commits into
mainfrom
worktree-stage-1-7-template-body
Jun 3, 2026
Merged

feat(recipe+operation): carve Recipe aggregate out of Capability; close replay loop (Stage-1.7-1.10)#24
xmap merged 6 commits into
mainfrom
worktree-stage-1-7-template-body

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 2, 2026

Summary

Splits the Capability aggregate's overloaded template_body into a sibling Recipe aggregate and closes the replay loop: a recipe-driven Procedure's step list is re-expanded deterministically at run time from a pinned RecipeExpansionRecorded payload rather than being re-stored.

Four stage commits, each independently green at HEAD:

Stage Commit Scope
1.7 d3a70e6dc Recipe aggregate carved out of Capability.template_body; FSM mirrors Capability (Defined / Versioned / Deprecated); 5th aggregate in Recipe BC
1.8 ae0b15955 4 Recipe slices: define_recipe / version_recipe / deprecate_recipe / get_recipe
1.9 962424300 register_procedure_from_recipe slice + RecipeExpansionRecorded provenance event on Procedure stream
1.10 77d8ef51d run_procedure recipe-replay gate + canonical_json_bytes single-source helper at cora.infrastructure.canonical_json

After Stage-1.10, the Procedure aggregate count goes from 28 → 29 (Recipe joins) and the Capability aggregate is no longer carrying the template body.

Architectural deltas

  • Recipe is a NEW aggregate, not a Capability rename. Capability stays in place, just loses template_body + the CapabilityTemplateBodyDefined event. Per the [[capability-naming-split-lock]] design memo (Shape 2: 5 peers).
  • RecipeExpansionRecorded is provenance-only on the Procedure stream. Pins (recipe_id, recipe_version, capability_id, capability_version, bindings, expansion_port_version, steps_hash, bindings_hash, step_count). The expanded tuple[Step, ...] is NOT persisted; replay reconstructs it deterministically from these pins.
  • run_procedure handler now loads the Procedure stream at entry. If recipe_id is not None, it walks the stream for the genesis RecipeExpansionRecorded, loads the Recipe at the pinned version, re-runs InMemoryRecipeExpansionPort.expand(), and verifies both hashes against the pins before handing fresh steps to the Conductor. Legacy (no-recipe) Procedures keep the caller-supplied-steps path unchanged.
  • canonical_json_bytes is the single source for canonical-JSON byte production in the operation + recipe BC trees. Lives at cora.infrastructure.canonical_json (forced there by tach — cora.operation.aggregates.events cannot import from cora.operation._recipe_expansion). Architecture fitness AST-walks json.dumps(sort_keys=True) co-occurrences in the scoped trees and rejects inline duplicates.
  • load_recipe_at_version is the first load_*_at_version helper across 16 BCs. First-match-from-head semantics; tag re-use is legal (no UNIQUE constraint on Recipe.version_tag per Stage-1.9 Lock).
  • load_procedure_with_events is the first read helper returning (state, raw StoredEvent list). Single underlying event_store.load call; existing load_procedure becomes a thin wrapper.

New error classes

Error BC HTTP Trigger
ProcedureStepsForbiddenForRecipeDrivenError operation 400 Caller sent non-empty steps for recipe-driven Procedure
RecipeExpansionPortVersionMismatchError operation 500 Pinned expansion_port_version differs from currently-wired port; placeholder until a v2 expansion port lands with its routing layer
RecipeExpansionRecordNotFoundError operation 500 Procedure has recipe_id set but the pinned RecipeExpansionRecorded event or the pinned Recipe stream cannot be located
RecipeExpansionReplayMismatchError operation 500 Closed Literal["bindings", "steps"] discriminator; bindings or steps hash drifted at replay
RecipeVersionNotFoundError recipe 404 load_recipe_at_version walked a non-empty stream but found no RecipeVersioned with the pinned tag

Plus existing Stage-1.9 errors: RecipeExpansionOverflowError, RecipeExpansionDeterminismError, RecipeBindingsStaleAgainstCurrentCapabilityError, InvalidRecipeBindingsError.

Atlas migrations

  • 20260602124500_init_proj_recipe_recipe_summary.sql (Stage-1.9)
  • 20260602124600_procedure_summary_add_recipe_id.sql (Stage-1.9; ALTER ADD COLUMN with partial index for audit-by-Recipe queries)

Atlas migrations are forward-only per project_forward_only_migrations.

Contract change

POST /procedures/{procedure_id}/run and the equivalent MCP tool now return 404 (REST) / isError=true (MCP) when the Procedure does not exist, instead of the prior 200 + lifecycle-failure-payload behavior. Stage-1.10's new load_procedure_with_events raises ProcedureNotFoundError at handler tier, which finally aligns with the route-tier 404 mapping. This closes the pre-existing project-conduct-procedure-test-contract-drift item.

Gate-review trajectory

Five gate-review cycles fired during the work, with 24 reviewer-agent runs total:

  • Stage-1.9 pre-commit: 4/4 (1 changes_requested + 3 approve_with_nits) → fixed 2 P0 + 1 P1 before commit
  • Stage-1.10 design memo v1: 4/4 changes_requested (7 P0 + 17 P1) → memo v2 absorbed 10 must-fix items including a load-bearing canonicalizer-hoist bug that would have silently broken from_stored dict round-trip
  • Stage-1.10 design memo v2 re-gate: 4/4 (2 approve + 2 approve_with_nits) → green-lit
  • Stage-1.10 pre-commit: 4/4 (1 changes_requested + 3 approve_with_nits) → fixed 4 must-fix (alphabetical ordering + scrubbed-tag prose residue) before commit

Test plan

  • Unit + architecture + contract tier green at 16,906 passed, 574 skipped, 0 failed (full sweep with -n 4 --timeout=60 before commit)
  • Integration tests pass against real Postgres (test_register_then_run_procedure_postgres + test_register_procedure_from_recipe_handler_postgres + test_list_procedures_handler_postgres)
  • OpenAPI snapshot regenerated (no schema delta; only exception handler registrations changed)
  • tach edges unchanged
  • pre-commit hooks (ruff + pyright + tach + architecture fitness) green per commit
  • Reviewer-acked merge from CI

Memos shipped

  • memory/project_recipe_aggregate_design.md — Stage-1.7-1.9 lock
  • memory/project_run_procedure_replay_design.md — Stage-1.10 lock with implementation notes covering the tach-forced canonical_json_bytes hoist to infrastructure, the *Missing*Error*NotFoundError rename, and the run_procedure contract alignment

🤖 Generated with Claude Code

xmap and others added 5 commits June 2, 2026 10:02
…tage-1.7)

Extracts a new Recipe aggregate into cora/recipe/aggregates/recipe/
mirroring capability/'s per-aggregate layout (state, events, evolver,
read, body, steps_validation, __init__). The noun-overloaded Capability
surface splits cleanly into Capability (declarer) and Recipe (carrier
of the templated step body). TemplateBody wrapper retires; Recipe
carries the empty-steps invariant via __post_init__ EmptyRecipeStepsError.
capability_id is REQUIRED and IMMUTABLE across versions; replay
determinism comes from event-store sequence position, not version_tag
string lookup. from_stored arms wrap ValueError and
InvalidRecipeStepShapeError into the canonical Malformed Recipe<X>
envelope per the cross-aggregate wrap convention.

Routes register the 11 new Recipe error classes plus a new 422
_handle_unprocessable handler for the parse-shape and schema-cross-check
failure family (InvalidRecipeStepShapeError,
RecipeBindingReferencesUnknownParameterError,
RecipeRequiresCapabilityParametersSchemaError, UnboundRecipeBindingError).
The 400 Invalid<X> family is reserved for VO constructor failures.

Two prior gate-review cycles on the design memo plus the v3 verifier
pass closed 18 must-fix items before any code landed; this commit's
pre-commit gate review (4 reviewers plus adversarial skeptic) returned
3 approve plus 1 approve_with_nits and skeptic commit_ready=true. The
skeptic-elevated nit on from_stored extra= coverage was applied inline.

78 unit and PBT tests plus 2 architecture fitness files (3-assertion
RecipeStep arm-parity fitness with dispatch-coverage importorskip-deferred
to the downstream Operation BC commit; new test_http_422_handler_registered.py
asserting both Recipe and Operation routes files register a 422 handler).
Slices, routes, tools, wire, Operation BC rename, Atlas migrations, and
projection module remain out of scope for separate commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…/ get)

Closes the Recipe aggregate write+read surface per the v3 design memo.
Four feature slices ship with the standard 6-file layout
(command/decider/handler/route/tool/__init__): define_recipe is
idempotency-wrapped and runs cross-aggregate Capability fan-out
(BindingRef integrity validated against parameters_schema before
decider invocation); version_recipe re-validates BindingRefs against
the current Capability state on every call and is intentionally NOT
idempotency-wrapped (mirrors version_capability precedent;
re-attestation is the audit signal); deprecate_recipe is
strict-not-idempotent with no cross-aggregate load; get_recipe is the
read DTO (RecipeView) with projection-sourced lifecycle timestamps
gated on deps.pool.

Wire updates add 4 fields to RecipeHandlers with with_tracing applied
across all four and with_idempotency on define_recipe only. Routes
and MCP tool registrations land in stable BC order (after the 4
Capability slices, before inspect_plan_binding). The 422 handler
maps 4 parse/shape errors (InvalidRecipeStepShapeError,
RecipeBindingReferencesUnknownParameterError,
RecipeRequiresCapabilityParametersSchemaError,
UnboundRecipeBindingError); the last is forward-registered for the
Stage-1.9 Operation BC expansion path. Architecture fitness adds
deprecate_recipe + version_recipe to GRANDFATHERED_DECIDERS_WITHOUT_PBT
(mirrors version_capability + deprecate_capability); define_recipe
has paired property-based coverage. The 422 handler test now accepts
both HTTP_422_UNPROCESSABLE_CONTENT (newer) and the deprecated
HTTP_422_UNPROCESSABLE_ENTITY alias to avoid forcing a cross-BC
rewrite.

WHY now: the Recipe aggregate was scaffolded in Stage-1.7 (commit
d3a70e6) but only the genesis source landed; without write+read
slices the aggregate is unreachable from REST/MCP and Stage-1.9
(Operation BC port + register_procedure_from_recipe) has no surface
to integrate against. Shipping all 4 slices in one commit keeps the
wire+routes+tools fan-out atomic and matches Capability slice
precedent.

Pre-commit gate review (4 reviewers + adversarial skeptic) returned
4 approve_with_nits; skeptic caught a real bug in the new integration
test (seed_capability_postgres does not thread parameters_schema, so
a BindingRef step on the seeded Capability fails validation). Fix
applied: integration test now uses literal step values; BindingRef
wire-format round-trip is exercised at the unit tier
(test_recipe_body.py + the body roundtrip PBT).

Tests: 3 decider + 4 handler + 4 endpoint contract + 4 MCP tool
contract + 1 integration + 1 PBT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ion provenance (Stage-1.9)

Adds the second Procedure-registration verb, driven by a Recipe +
operator bindings rather than an inline step list. The expanded
tuple[Step, ...] is computed locally to run the executor-shape,
bindings, overflow, and determinism gates but is NOT persisted at
v1: a paired ProcedureRegistered + RecipeExpansionRecorded genesis
block records (recipe_id, recipe_version, capability_id,
capability_version, bindings, expansion_port_version, steps_hash,
bindings_hash, step_count), enough to re-expand deterministically
at run time.

The handler loads Recipe then Capability (Recipe owns steps,
Capability owns parameters_schema + executor_shapes) and raises
RecipeBindingsStaleAgainstCurrentCapabilityError before any event
lands if the Capability re-versioned since the operator chose
their bindings, closing the cross-BC race window.
ProcedureRegistered gains an additive recipe_id key folded via
payload.get for pre-rewrite streams; the evolver's
RecipeExpansionRecorded arm is provenance-only and leaves state
unchanged. steps_hash content-addresses the expanded
tuple[Step, ...] via the new shared steps_to_wire serializer, so
downstream re-expansion (run-time replay) can recompute and verify
the pin.

Default InMemoryRecipeExpansionPort wires the 2-arg pure
RecipeExpansionPort(steps, bindings) -> tuple[Step, ...] signature
at v1. Routes map InvalidRecipeBindingsError, the stale-Capability
error, and RecipeExpansionOverflowError to 422;
RecipeExpansionDeterminismError (a real bug surface, not user
input) to 500. Atlas migrations add the Recipe projection table
plus the procedure_summary.recipe_id column with a partial index
for audit-by-Recipe queries; the projection's ProcedureRegistered
INSERT populates the new column from payload.get('recipe_id').

A new architecture fitness pins the 4-field denorm payload shape
(anti-hook 15); register_procedure_from_recipe.decider is added to
GRANDFATHERED; the recipe-step dispatch-coverage test now
exercises _recipe_expansion._expand_step.

BC map: 28 -> 29 aggregates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Recorded pin (Stage-1.10)

run_procedure must produce byte-identical steps across replays for
recipe-driven Procedures or the event log is unsafe to fold. The
handler now re-expands at runtime against the live RecipeExpansionPort,
verifies the bindings_hash and steps_hash against the pin in
RecipeExpansionRecorded, and asserts the port's version matches the
recorded one. Caller-supplied steps are rejected up front for
recipe-driven Procedures rather than silently overridden.

Adds two read helpers behind the cross-aggregate fetch:
load_procedure_with_events returns (state, raw StoredEvent list) from
a single event_store.load so the handler can scan for the genesis
RecipeExpansionRecorded payload without doubling IO; load_procedure
becomes a thin wrapper that preserves every existing call site.
load_recipe_at_version walks the Recipe event stream and folds to
the first-match-from-head version_tag snapshot per the replay-design
Locks (re-tagging is allowed and the earlier RecipeVersioned binds
the earlier RecipeExpansionRecorded by construction).

Hoists canonical_json_bytes to cora.infrastructure.canonical_json
so events.py (blocked by tach from importing cora.operation
._recipe_expansion) can preserve the bindings dict shape via
json.loads(canonical_json_bytes(...)) at to_payload time. A new
architecture fitness scoped to cora/operation/ + cora/recipe/
trees AST-walks json.dumps(sort_keys=True) calls and rejects any
inline duplication of the canonicalizer.

Four new Procedure error classes cover the replay failure modes:
ProcedureStepsForbiddenForRecipeDrivenError (caller bug, 400);
RecipeExpansionPortVersionMismatchError (pinned port v drift, 500,
placeholder until a v2 expansion port lands with its routing layer);
RecipeExpansionRecordNotFoundError (data corruption guard: missing
event, corrupt payload, or empty Recipe stream, 500);
RecipeExpansionReplayMismatchError (closed Literal[bindings, steps]
discriminator, 500). Recipe BC adds RecipeVersionNotFoundError (404)
when load_recipe_at_version cannot resolve a tag.

run_procedure handler also now loads the Procedure stream and raises
ProcedureNotFoundError when missing, aligning the handler tier with
the route tier's 404 mapping (closes the prior 200+lifecycle-failure
drift on unregistered Procedures across REST + MCP).

Memo updated at memory/project_run_procedure_replay_design.md with
SHIPPED status + implementation notes covering the tach-forced hoist
to infrastructure, the Missing -> NotFound rename, and the
contract-test alignment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…mplate-body

# Conflicts:
#	apps/api/openapi.json
#	apps/api/src/cora/operation/features/conduct_procedure/handler.py
#	apps/api/src/cora/operation/routes.py
#	apps/api/src/cora/operation/tools.py
#	apps/api/src/cora/operation/wire.py
#	apps/api/tests/contract/test_conduct_procedure_endpoint.py
#	apps/api/tests/contract/test_conduct_procedure_mcp_tool.py
#	infra/atlas/migrations/atlas.sum
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/infrastructure
  idempotency.py
  apps/api/src/cora/operation
  _recipe_expansion.py 48-56, 67-74, 99-101, 116-122
  _recipe_replay.py
  routes.py 172-173
  apps/api/src/cora/operation/aggregates/procedure
  events.py
  state.py
  apps/api/src/cora/operation/features/conduct_procedure
  handler.py
  apps/api/src/cora/operation/features/register_procedure_from_recipe
  decider.py
  handler.py
  route.py
  apps/api/src/cora/recipe
  routes.py
  tools.py
  apps/api/src/cora/recipe/aggregates/recipe
  body.py
  events.py
  read.py 126-130
  state.py
  steps_validation.py 97
  apps/api/src/cora/recipe/features/define_recipe
  handler.py
  apps/api/src/cora/recipe/features/deprecate_recipe
  handler.py
  apps/api/src/cora/recipe/features/get_recipe
  handler.py 105
  route.py
  tool.py 65-66
  apps/api/src/cora/recipe/features/version_capability
  decider.py
  apps/api/src/cora/recipe/features/version_recipe
  handler.py 116
  apps/api/src/cora/recipe/projections
  recipe.py 67-73, 93-122
Project Total  

The report is truncated to 25 files out of 59. To see the full report, please visit the workflow summary page.

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

…payload

Stage-1.9 added an additive `recipe_id: UUID | None` field to the
`ProcedureRegistered` event payload (default None for legacy
register_procedure that does not bind to a Recipe). The unit-tier
test for the legacy slice was updated then, but this integration test
asserts the persisted payload by full equality and was missed in the
sweep, so it caught the additive key after the merge with origin/main.
CI run 26874299577 surfaced it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@xmap xmap merged commit 0524b16 into main Jun 3, 2026
4 checks passed
@xmap xmap deleted the worktree-stage-1-7-template-body branch June 3, 2026 12:58
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