feat(recipe+operation): carve Recipe aggregate out of Capability; close replay loop (Stage-1.7-1.10)#24
Merged
Merged
Conversation
…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
Coverage reportClick to see where and how coverage changed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Splits the Capability aggregate's overloaded
template_bodyinto a siblingRecipeaggregate and closes the replay loop: a recipe-driven Procedure's step list is re-expanded deterministically at run time from a pinnedRecipeExpansionRecordedpayload rather than being re-stored.Four stage commits, each independently green at HEAD:
d3a70e6dcRecipeaggregate carved out ofCapability.template_body; FSM mirrors Capability (Defined / Versioned / Deprecated); 5th aggregate in Recipe BCae0b15955define_recipe/version_recipe/deprecate_recipe/get_recipe962424300register_procedure_from_recipeslice +RecipeExpansionRecordedprovenance event on Procedure stream77d8ef51drun_procedurerecipe-replay gate +canonical_json_bytessingle-source helper atcora.infrastructure.canonical_jsonAfter 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
template_body+ theCapabilityTemplateBodyDefinedevent. Per the [[capability-naming-split-lock]] design memo (Shape 2: 5 peers).RecipeExpansionRecordedis 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 expandedtuple[Step, ...]is NOT persisted; replay reconstructs it deterministically from these pins.run_procedurehandler now loads the Procedure stream at entry. Ifrecipe_id is not None, it walks the stream for the genesisRecipeExpansionRecorded, loads the Recipe at the pinned version, re-runsInMemoryRecipeExpansionPort.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_bytesis the single source for canonical-JSON byte production in the operation + recipe BC trees. Lives atcora.infrastructure.canonical_json(forced there by tach —cora.operation.aggregates.eventscannot import fromcora.operation._recipe_expansion). Architecture fitness AST-walksjson.dumps(sort_keys=True)co-occurrences in the scoped trees and rejects inline duplicates.load_recipe_at_versionis the firstload_*_at_versionhelper across 16 BCs. First-match-from-head semantics; tag re-use is legal (no UNIQUE constraint onRecipe.version_tagper Stage-1.9 Lock).load_procedure_with_eventsis the first read helper returning(state, raw StoredEvent list). Single underlyingevent_store.loadcall; existingload_procedurebecomes a thin wrapper.New error classes
ProcedureStepsForbiddenForRecipeDrivenErrorstepsfor recipe-driven ProcedureRecipeExpansionPortVersionMismatchErrorexpansion_port_versiondiffers from currently-wired port; placeholder until a v2 expansion port lands with its routing layerRecipeExpansionRecordNotFoundErrorrecipe_idset but the pinnedRecipeExpansionRecordedevent or the pinned Recipe stream cannot be locatedRecipeExpansionReplayMismatchErrorLiteral["bindings", "steps"]discriminator; bindings or steps hash drifted at replayRecipeVersionNotFoundErrorload_recipe_at_versionwalked a non-empty stream but found noRecipeVersionedwith the pinned tagPlus 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}/runand 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 newload_procedure_with_eventsraisesProcedureNotFoundErrorat handler tier, which finally aligns with the route-tier 404 mapping. This closes the pre-existingproject-conduct-procedure-test-contract-driftitem.Gate-review trajectory
Five gate-review cycles fired during the work, with 24 reviewer-agent runs total:
from_storeddict round-tripTest plan
-n 4 --timeout=60before commit)test_register_then_run_procedure_postgres+test_register_procedure_from_recipe_handler_postgres+test_list_procedures_handler_postgres)Memos shipped
memory/project_recipe_aggregate_design.md— Stage-1.7-1.9 lockmemory/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→*NotFoundErrorrename, and the run_procedure contract alignment🤖 Generated with Claude Code