Skip to content

feat(equipment): add Model aggregate (5th Equipment-BC aggregate, 6 slices, projection)#17

Merged
xmap merged 11 commits into
mainfrom
worktree-model-aggregate
Jun 2, 2026
Merged

feat(equipment): add Model aggregate (5th Equipment-BC aggregate, 6 slices, projection)#17
xmap merged 11 commits into
mainfrom
worktree-model-aggregate

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 2, 2026

Summary

Adds the Model aggregate as the 5th aggregate in the Equipment BC, sitting between Family (the rigid-kind taxonomy) and Asset (the deployed instance) in the equipment ladder. A Model is a vendor-catalog entry: it pins together a Manufacturer (name plus optional ROR/GRID/ISNI identifier in a closed StrEnum scheme), a part_number (case-sensitive vendor SKU), and a declared_families: frozenset[UUID] pointing at one or more registered Family aggregates that the catalog entry satisfies.

The 5-slice mutation surface (define, version, deprecate, add_family, remove_family) + 1 read slice (get) ships with full REST + MCP parity, a backing proj_equipment_model_summary projection, and a forward-only Atlas migration. Closes the Lock 6 + Lock 9 deferral from the Model design memo on the Asset-side binding via the parallel project_asset_model_binding_design.md Stage 1 memo (implementation in a follow-up PR).

Commits (10, oldest first)

  1. eb810de80 Model aggregate core (state + events + evolver + 14 error classes in routes.py)
  2. 2a33d75a8 define_model slice (POST /models, with cross-BC family lookup)
  3. d35fe7d99 version_model slice (POST /models/{id}/versions, update-style)
  4. 643a72e55 deprecate_model slice (POST /models/{id}/deprecation)
  5. a3ea37f5b add_model_family slice (POST /models/{id}/families, targeted-mutation)
  6. f595a3920 remove_model_family slice (DELETE /models/{id}/families/{family_id})
  7. 43c3a3e56 get_model read slice + proj_equipment_model_summary projection + Atlas migration
  8. 10a875ebc Fix (P0): drop vendor-key UNIQUE INDEX (gate-review caught the same bookmark-poison foot-gun the Capability aggregate fixed in 20260518210000_drop_proj_recipe_capability_summary_code_unique.sql)
  9. 78e8acd4f Fix (P1-1/P1-2): deprecated-Family lookup via list_all_family_ids + per-verb error classes (ModelCannotAddFamilyError / ModelCannotRemoveFamilyError)
  10. 53291cf8e Fix (P1-3/5/6/7/8): test discipline (aggregate-shape fitness, payload split tests, padded-text PBT, list_model_ids tests) + wire hygiene (5-aggregate field ordering)

Commits 8/9/10 close every P0 + P1 finding from the 7-axis Stage 3 gate review (architecture / test-coverage / cross-BC-consistency baseline panel + standards-alignment specialist + security + migration-safety + design-memo-conformance axes).

Verification

  • 14500+ architecture fitness tests pass including the new test_equipment_aggregate_shape.py sibling-symmetry guard and the existing fitness suite (no em-dashes, projection-table-match, decider-paired-PBT, slice-contract, routes-completeness, test-names-carry-outcome).
  • ~280 new tests across the 7-slice scope: 77 unit (aggregate core) + 35 contract REST + 26 contract MCP + 27 integration (Postgres-backed) + paired Hypothesis PBT for every decider + projection PG fitness for every event handler.
  • ruff clean, pyright 0/0/0, tach clean across the full tree.
  • All cross-BC interactions exercised end-to-end against real Postgres: family lookup hit / miss / Deprecated-family / projection event-order independence / idempotency-key collision / vendor-key dual-write (the load-bearing test that pins commit 8's fix).

Standards alignment (specialist-reviewed)

  • Model.manufacturer is required because catalog-tier traditions (CMMS Equipment Type per ISO 14224, AAS Type-AAS DigitalNameplate IDTA 02006, OPC UA vendor profile, ECLASS-augmented Property) all treat manufacturer as required at the catalog tier. Explicitly NOT justified by PIDINST property 6 (which is instance-tier; transfers to Asset.alternate_identifiers in a future slice).
  • ManufacturerIdentifierType closed StrEnum (ROR | GRID | ISNI) per the Affordance closed-vocabulary precedent.
  • AAS HSBoM ArcheType correctly framed as hierarchy direction (Full | OneDown | OneUp), NOT a Type-vs-Instance discriminator (the Type-vs-Instance axis lives in the AAS metamodel AssetKind enum + Entity.entityType).

Deferred (tracked as Watch items)

  • Asset.model_id additive migration + AssetModelMismatch enforcement (Model memo Lock 6 + Lock 9). Stage 1 memo locked alongside this PR; implementation in a follow-up PR.
  • list_models_by_vendor_key + decider-tier vendor-key check — fires only when pilot operators surface a real collision.
  • Federation define_permit support for Model aggregate kind — fires when first federation consumer opens.
  • PIDINST DOI mint slice — unblocked by Asset.model_id + Asset.alternate_identifiers landing in sibling slices.
  • AssetVariant aggregate — fires under rule-of-three.
  • AssemblyTemplate aggregate — Stage 1 memo locked but not yet shipped.

Test plan

  • Local pytest: 14500+ architecture + 280 new Model tests pass
  • Local ruff + pyright clean across the full tree
  • Local pre-commit hooks pass on every commit
  • Atlas migration syntactically validated (forward-only ADD COLUMN + partial index)
  • CI run on push (this PR)
  • Manual smoke test of POST /models + GET /models/{id} against the dev API once merged

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

🤖 Generated with Claude Code

@xmap xmap force-pushed the worktree-model-aggregate branch from 53291cf to b1fd1c6 Compare June 2, 2026 05:54
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/equipment
  routes.py
  tools.py
  apps/api/src/cora/equipment/aggregates/family
  read.py 138
  apps/api/src/cora/equipment/aggregates/model
  events.py
  evolver.py
  read.py
  state.py
  apps/api/src/cora/equipment/features/add_model_family
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/features/define_model
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/features/deprecate_model
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/features/get_model
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/features/remove_model_family
  handler.py
  tool.py
  apps/api/src/cora/equipment/features/version_model
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/projections
  model.py
Project Total  

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

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

xmap and others added 11 commits June 2, 2026 10:18
Model is the Equipment BC's vendor-catalog tier between Family
(device-class kind) and Asset (deployed instance). A Model pins a
Manufacturer (name plus optional ROR/GRID/ISNI identifier) to a
part_number (vendor SKU, case-sensitive) and a declared_families
frozenset (at least one Family id the catalog entry satisfies).

This commit lands the aggregate core only: state.py (VOs,
ModelStatus enum, error classes, Model dataclass), events.py (5
events with payload round-trip), evolver.py (template-shaped FSM
mirroring Family), and routes.py exception-handler registration
for all 14 Model error classes. No slices, no projection, no API
surface yet, those land per slice in follow-up commits per the
design memo project_model_aggregate_design.md.

FSM mirrors Family verbatim:
  - Defined to Versioned (multi-source from Defined or Versioned)
  - (Defined | Versioned) to Deprecated

Five events:
  - ModelDefined / ModelVersioned / ModelDeprecated
  - ModelFamilyAdded / ModelFamilyRemoved (targeted-mutation;
    matches the operational pattern "vendor shipped firmware
    update, one extra Family declared" rather than wholesale
    re-author)

Manufacturer is a small VO with a pairing invariant (identifier
and identifier_type both set or both None). ManufacturerIdentifierType
is a closed StrEnum (ROR | GRID | ISNI) per the Affordance
closed-vocabulary precedent.

declared_families is REQUIRED with cardinality at least one;
empty rejected at the API boundary in the define_model slice.
The cross-BC subset invariant (Model.declared_families subset-of
Asset.families) is enforced by the Asset BC at register_asset
and add_asset_family time, not inside the Model aggregate.

The PIDINST property 6 (Manufacturer, 1-n Mandatory) mandate is
an instance-tier obligation per PIDINST v1.0 spec p.1 ("instrument
instances ... as opposed to instrument types or models") and
transfers to Asset.alternate_identifiers in a future slice, NOT
to Model.manufacturer at the catalog tier. Model.manufacturer is
required because catalog-tier traditions (CMMS Equipment Type per
ISO 14224, AAS Type-AAS DigitalNameplate IDTA 02006, OPC UA vendor
profile, ECLASS-augmented Property) all treat manufacturer as
required at the catalog tier.

Tests:
- 22 VO + dataclass + enum tests (test_model.py)
- 9 event payload round-trip tests (test_model_events.py)
- 10 FSM evolution tests (test_model_evolver.py)
- All 41 pass; 923 equipment unit tests + 14112 architecture
  tests pass overall; ruff clean; pyright 0/0/0.

Note on HTTP status mapping: validation errors follow the existing
equipment BC convention (Invalid* to 400) rather than the design
memo's 422 spec. The memo will be reconciled if a pilot needs 422
specifically; the existing convention takes precedence for the
first ship per established Equipment BC handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the writable POST /models REST endpoint and define_model MCP
tool, surfacing the Model aggregate's genesis command per the
design memo (project_model_aggregate_design.md).

Command shape: DefineModel(name, manufacturer, part_number,
declared_families, version_tag=None). The Manufacturer VO carries
a required name plus an optional (identifier, identifier_type)
pair (closed StrEnum scheme: ROR | GRID | ISNI; pairing invariant
enforced at the VO).

Cross-BC family lookup: the handler loads list_family_ids from
the Family read repo and rejects with FamilyNotFoundError (404)
if any declared_families element does not resolve to a registered
Family. Bulk single-query approach (cheap at pilot scale, <50
Families). Family.read.list_family_ids widened to accept Pool|None
mirroring the load_asset_lifecycle precedent so test/no-pool
environments short-circuit cleanly.

Slice files (10 new):
- src/cora/equipment/features/define_model/{command,decider,
  handler,route,tool,__init__}.py
- tests/unit/equipment/test_define_model_decider.py (8 decider
  tests, value-object validation + invariants)
- tests/unit/equipment/test_define_model_decider_properties.py
  (7 Hypothesis PBTs over name/part_number/version_tag/families)
- tests/unit/equipment/test_define_model_handler.py (6 handler
  tests, mocked Kernel; cross-BC list_family_ids monkeypatched)
- tests/contract/test_define_model_contract.py (10 REST contract
  tests; idempotency-key parity with define_family)
- tests/contract/test_define_model_mcp_tool.py (5 MCP tool
  contract tests; happy-path deferred to integration tier
  because in-memory MCP harness has no pool, mirrors
  inspect_plan_binding precedent)
- tests/integration/test_define_model_handler_postgres.py
  (4 PG integration tests; real cross-BC family lookup, real
  idempotency-key dedup, real event-store persistence)

Wiring (3 edits):
- equipment/routes.py: include define_model.router
- equipment/wire.py: EquipmentHandlers.define_model field +
  with_tracing(with_idempotency(...)) builder mirroring
  define_family
- equipment/tools.py: register define_model MCP tool

openapi.json regenerated to include POST /models. Architecture
fitness gates that previously blocked partial-slice commits
(paired PBT, slice-contract, REST contract, MCP contract,
integration test, decider docstring invariants) all pass.

Tests: 15072 pass + 566 skipped across the full architecture +
unit + contract + Model integration suite. ruff clean,
pyright 0/0/0.

Pre-existing failure noted: tests/contract/
test_conduct_procedure_endpoint.py::
test_post_conduct_against_unregistered_procedure_returns_200_with_lifecycle_failure
fails on origin/main too (verified by reproducing with this
branch's changes stashed). Unrelated to Model work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds POST /models/{model_id}/versions REST endpoint and
version_model MCP tool. Update-style command issuing a new
revision of an existing Model with replacement name,
manufacturer, part_number, declared_families, and version_tag.

Command shape: VersionModel(model_id, name, manufacturer,
part_number, declared_families, version_tag). version_tag is
required here (unlike define_model where it is optional).

Wholesale replacement semantics: ModelVersioned event replaces
the entire user-facing tuple. Per the design memo Lock,
version_model does NOT re-validate declared_families against
Family existence (that validation is the add_model_family
slice's job for incremental edits). Callers who want to add
or remove individual families should use the targeted-mutation
slices add_model_family / remove_model_family.

FSM: (Defined | Versioned) -> Versioned. Rejected from
Deprecated with ModelCannotVersionError (409). Update-style:
no idempotency wrapping (domain-idempotent via
ModelCannotVersionError on retry).

Slice files (10 new):
- src/cora/equipment/features/version_model/{command,decider,
  handler,route,tool,__init__}.py
- tests/unit/equipment/test_version_model_decider.py
  (decider tests covering happy path from Defined + Versioned,
  rejection from Deprecated, all VO-level validation failures,
  ModelNotFoundError on missing stream)
- tests/unit/equipment/test_version_model_decider_properties.py
  (Hypothesis PBT mirroring define_model_decider_properties.py
  pattern; properties over all valid commands + state transitions)
- tests/unit/equipment/test_version_model_handler.py
  (handler tests covering authorize denial, ModelNotFoundError,
  ModelCannotVersionError, happy path event-store append)
- tests/contract/test_version_model_endpoint.py (REST contract:
  204 on success, 422 on validation, 400 on domain invariant,
  404 on unknown model_id, 409 on Deprecated)
- tests/contract/test_version_model_mcp_tool.py (MCP contract:
  tool registered, description, missing-arg isError,
  not-found isError)
- tests/integration/test_version_model_handler_postgres.py
  (PG integration: seed Family + define Model + version Model,
  verify ModelVersioned event payload, verify failure paths)

Wiring (3 edits):
- equipment/routes.py: include version_model.router
- equipment/wire.py: EquipmentHandlers.version_model field
  (bare Handler, no IdempotentHandler) + with_tracing builder
  (no idempotency wrap; update-style)
- equipment/tools.py: register version_model MCP tool

openapi.json regenerated to include POST /models/{model_id}/
versions.

Tests: 38 new tests pass + 14178 architecture tests pass.
ruff clean, pyright 0/0/0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds POST /models/{model_id}/deprecation REST endpoint and
deprecate_model MCP tool. Update-style command marking a Model
as no longer recommended for new Assets.

Command shape: DeprecateModel(model_id, reason). Reason is a
1-500 char operator-supplied rationale (trimmed at the
ModelDeprecationReason VO).

FSM: (Defined | Versioned) -> Deprecated. Rejected from
Deprecated with ModelCannotDeprecateError (409). Update-style:
no idempotency wrapping; domain-idempotent via the Cannot
error on retry.

Deprecation is an AUTHORING signal, not a runtime gate. Existing
Assets with model_id pointing at a Deprecated Model continue to
function (mirrors the Family-deprecation posture per the design
memo Lock). Once Deprecated, no further ModelVersioned,
ModelFamilyAdded, or ModelFamilyRemoved events accepted
(Genesis-NoOp-or-Reject pattern; declared_families preserved
across deprecation for audit).

Slice files (10 new):
- src/cora/equipment/features/deprecate_model/{command,decider,
  handler,route,tool,__init__}.py
- tests/unit/equipment/test_deprecate_model_decider.py
- tests/unit/equipment/test_deprecate_model_decider_properties.py
  (Hypothesis PBT)
- tests/unit/equipment/test_deprecate_model_handler.py
- tests/contract/test_deprecate_model_endpoint.py
- tests/contract/test_deprecate_model_mcp_tool.py
- tests/integration/test_deprecate_model_handler_postgres.py

Wiring (3 edits): routes.py + wire.py + tools.py mirroring
the version_model wiring shape (bare Handler, no idempotency).

openapi.json regenerated to include POST /models/{model_id}/
deprecation.

Tests: 35 new tests pass + 14242 architecture tests pass.
ruff clean, pyright 0/0/0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds POST /models/{model_id}/families REST endpoint and
add_model_family MCP tool. Update-style targeted-mutation
command that appends a single Family to a Model's
declared_families set.

Command shape: AddModelFamily(model_id, family_id). Both
required. Strict-not-idempotent: re-adding a present family
raises ModelFamilyAlreadyPresentError (409).

Cross-BC family lookup: the handler loads list_family_ids from
the Family read repo and rejects with FamilyNotFoundError (404)
if family_id does not resolve to a registered Family. Same
pattern as define_model handler.

FSM: status preserved on success (Defined stays Defined;
Versioned stays Versioned). Rejected from Deprecated with the
existing ModelCannotVersionError (Deprecated catalog entries
cannot be mutated; mirrors the design memo Lock posture
"Genesis-NoOp-or-Reject" for ModelFamilyAdded after deprecation).

Operational rationale per the design memo: targeted-mutation
events are preferred over re-emitting ModelVersioned with the
full new set because the beamline pattern is "vendor shipped
firmware update, one extra Family declared" rather than
wholesale re-author. Mirrors the Caution / Safety amend_* event
precedent.

Slice files (10 new):
- src/cora/equipment/features/add_model_family/{command,
  decider, handler, route, tool, __init__}.py
- tests/unit/equipment/test_add_model_family_decider.py
- tests/unit/equipment/test_add_model_family_decider_properties.py
- tests/unit/equipment/test_add_model_family_handler.py
- tests/contract/test_add_model_family_endpoint.py
- tests/contract/test_add_model_family_mcp_tool.py
- tests/integration/test_add_model_family_handler_postgres.py

Wiring (3 edits): routes.py + wire.py + tools.py mirroring
the version_model / deprecate_model update-style wiring shape
(bare Handler, no idempotency).

openapi.json regenerated to include POST /models/{model_id}/
families.

Tests: 31 new tests pass + 14306 architecture tests pass.
ruff clean, pyright 0/0/0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds DELETE /models/{model_id}/families/{family_id} REST endpoint
and remove_model_family MCP tool. Update-style targeted-mutation
command that removes a single Family from a Model's
declared_families set.

Command shape: RemoveModelFamily(model_id, family_id). Both
required. Strict-not-idempotent: removing an absent family raises
ModelFamilyNotPresentError (409).

NO cross-BC family lookup: removing only requires the family_id
to be in declared_families; it does not need to be a registered
Family (Family may have been deprecated/deleted; removal proceeds
anyway). This is the symmetric counterpart to add_model_family
which DOES validate the family is registered.

FSM: status preserved on success. Rejected from Deprecated with
ModelCannotVersionError (mirrors add_model_family posture).

Does NOT cascade through existing Assets bound to this Model per
the design memo Lock: removing a family from the Model's catalog
declaration does not invalidate Asset instances; the subset
invariant (Model.declared_families subset-of Asset.families) is
satisfied automatically by the removal (the left set got smaller).

Slice files (10 new):
- src/cora/equipment/features/remove_model_family/{command,
  decider, handler, route, tool, __init__}.py
- tests/unit/equipment/test_remove_model_family_decider.py
- tests/unit/equipment/test_remove_model_family_decider_properties.py
- tests/unit/equipment/test_remove_model_family_handler.py
- tests/contract/test_remove_model_family_endpoint.py
- tests/contract/test_remove_model_family_mcp_tool.py
- tests/integration/test_remove_model_family_handler_postgres.py

Wiring (3 edits): routes.py + wire.py + tools.py mirroring
the add_model_family wiring shape.

openapi.json regenerated to include DELETE /models/{model_id}/
families/{family_id}.

Tests: 27 new tests pass + 14371 architecture tests pass.
ruff clean, pyright 0/0/0.

With this slice the Model aggregate's 5 mutation slices are
complete: define_model, version_model, deprecate_model,
add_model_family, remove_model_family. Remaining: get_model
read slice + proj_equipment_model_summary projection +
architecture BOUNDED_CONTEXTS sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds GET /models/{model_id} REST endpoint and get_model MCP tool
backed by the new ModelSummaryProjection. Closes out the Model
aggregate's first writable + readable surface.

Read shape (event-store fold, NOT projection-row read):
- get_model.bind(deps) -> Handler that loads the Model stream,
  folds via cora.equipment.aggregates.model.fold, returns the
  current Model state or None. The route maps None -> 404
  ModelNotFoundError, mirroring get_family.

Projection: proj_equipment_model_summary table for the future
list_models slice and vendor-key uniqueness guard.

  - ModelDefined       -> INSERT (status, manufacturer_*, part_number,
                          declared_families JSONB, version_tag if set)
  - ModelVersioned     -> wholesale UPDATE of the identity block
  - ModelDeprecated    -> UPDATE status + deprecation_reason; vendor-
                          key columns preserved (audit trail)
  - ModelFamilyAdded   -> UPDATE declared_families (append + re-sort
                          to canonical event-payload ordering)
  - ModelFamilyRemoved -> UPDATE declared_families (remove)

manufacturer is split into 3 flat columns
(manufacturer_name, manufacturer_identifier,
manufacturer_identifier_type) for queryability rather than a JSONB
blob, with a CHECK enforcing identifier and identifier_type
both-set-or-both-null (the value-object pairing invariant lifted
to SQL).

declared_families stored as JSONB array of UUID strings (the
event-payload-as-stored shape) since the read slice returns it
verbatim.

UNIQUE (manufacturer_name, part_number) index enforces the
vendor-key uniqueness guard from the design memo Lock 4.

Slice + projection files (11 new):
- infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql
- src/cora/equipment/projections/model.py (ModelSummaryProjection)
- src/cora/equipment/aggregates/model/read.py (load_model, list_model_ids)
- src/cora/equipment/features/get_model/{query,handler,route,tool,
  __init__}.py
- tests/unit/equipment/test_get_model_handler.py
- tests/unit/equipment/test_model_summary_projection.py
- tests/contract/test_get_model_endpoint.py
- tests/contract/test_get_model_mcp_tool.py
- tests/integration/test_get_model_handler_postgres.py

Wiring (5 edits):
- equipment/wire.py: get_model handler bundled
- equipment/tools.py: get_model MCP tool registered
- equipment/routes.py: get_model.router included
- equipment/_projections.py: ModelSummaryProjection registered
- equipment/projections/__init__.py: export ModelSummaryProjection

openapi.json regenerated to include GET /models/{model_id}.

Tests: 26 new tests pass + 14490 architecture tests pass.
ruff clean, pyright 0/0/0.

This commit closes out the 6-slice Model aggregate scope.
Architecture wiring (BOUNDED_CONTEXTS sync + sibling-symmetry
fitness) is the next and final commit.

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

Gate-review P0 fix. The initial Model summary migration shipped a
projection-tier UNIQUE INDEX on (manufacturer_name, part_number)
intended to fail the second commit on vendor-key collision.

The Stage 3 migration-safety review flagged the same bookmark-
poison foot-gun the Capability aggregate hit (migration
20260518210000_drop_proj_recipe_capability_summary_code_unique):
the decider does NOT enforce vendor-key uniqueness (define_model
only checks stream non-existence by model_id), so two concurrent
define_model calls carrying the same (manufacturer_name,
part_number) but distinct model_id values both succeed at event-
append. The second projection apply trips UniqueViolation on the
projection index, the projection worker's batch rolls back, the
_advance_loop exponentially backs off forever, and the
proj_equipment_model_summary bookmark is poisoned. Every
downstream consumer that reads through this projection blocks
indefinitely, and aggregate streams diverge from the read model
with no automatic recovery.

The Capability migration dropped its (code) UNIQUE INDEX for
exactly this reason, and Model follows the same posture:
vendor-key uniqueness is decider-tier operator-curation
discipline the operator owns at v1, NOT a projection-tier hard
constraint. Both Family and Capability take this stance for their
analogous (name, version_tag) and (code) pairs respectively.

Changes:
- New migration: 20260602100000_drop_proj_equipment_model_summary_
  vendor_key_unique.sql drops the UNIQUE INDEX and re-creates the
  columns as a non-unique secondary index for queryability (the
  manufacturer-keyed lookup path stays index-backed for the future
  list_models_by_vendor_key read repo).
- projections/model.py: aligned with the new constraint shape.
- test_model_summary_projection.py: aligned with the new constraint
  shape (existing AsyncMock-based unit tests).
- NEW test_postgres_model_summary_projection.py: real-PG integration
  test exercising every event handler end-to-end. Includes the
  load-bearing test_two_models_with_same_vendor_key_both_persist_
  and_advance_bookmark case that pins the fix and prevents
  regression. Resolves gate-review P1-4 (projection PG fitness
  gap) as a side effect.
- design memo Lock 4 reframed to eventual-consistency convention;
  Watch items section gains a "vendor-key collision trigger" entry
  for the future list_models_by_vendor_key + decider-tier guard
  followup (fired when pilot operators surface a real collision).

Tests: 9 new PG integration tests pass; existing 10 projection
unit tests pass; 14491 architecture tests pass. ruff clean,
pyright 0/0/0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gate-review P1-1 + P1-2 fix.

P1-1 — Deprecated-Family lookup contradicted memo anti-hook:
list_family_ids filtered WHERE deprecated_at IS NULL (the
discovery-side helper for inspect_plan_binding), so declaring or
adding a Deprecated Family failed with FamilyNotFoundError (404),
contradicting the design memo lock that Asset-to-Deprecated-Family
binding is permitted.

Fix: introduce list_all_family_ids in equipment/aggregates/family/
read.py that includes Deprecated Families. Swap define_model and
add_model_family handlers to use it. The discovery-side
list_family_ids (Deprecated-excluded) stays available for
inspect_plan_binding which intentionally excludes Deprecated
candidates. Documented in the family/read.py module docstring.

P1-2 — add/remove_model_family reused ModelCannotVersionError for
the Deprecated-state guard, emitting an operator-facing diagnostic
that named the wrong verb.

Fix: introduce ModelCannotAddFamilyError + ModelCannotRemoveFamilyError
in equipment/aggregates/model/state.py mirroring the Asset BC
precedent (AssetCannotAddFamilyError + AssetCannotRemoveFamilyError).
Swap add_model_family.decider + remove_model_family.decider to
raise the per-verb classes. Wire into routes.py _handle_cannot
_transition tuple (both map to 409 as before, just with the
verb-correct diagnostic). Update affected tests across decider +
PBT + handler + REST contract + MCP contract + integration tiers
for both slices.

Files changed:
- 5 src files: family/read.py + family/__init__.py + model/state.py
  + model/__init__.py + 4 handlers/deciders + routes.py
- ~20 test files: updated expectations for the renamed errors +
  2 new regression integration tests covering the
  Deprecated-Family binding case

Tests: 38 Model-related tests pass, 14492 architecture tests pass.
ruff clean, pyright 0/0/0.

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

Final post-review fix commit. Resolves five P1 findings from the
Stage 3 gate review.

P1-3: sibling-symmetry architecture fitness was specified in the
design memo but not shipped. NEW test_equipment_aggregate_shape.py
asserts every non-private aggregate directory under
equipment/aggregates/ ships the same core file set (__init__.py,
state.py, events.py, evolver.py, read.py). Locks the pattern
before the next aggregate clone; passes today against all 5
existing aggregates (family, asset, frame, mount, model).

P1-5: list_model_ids shipped untested. NEW test_list_model_ids.py
covers the Pool=None branch + NEW test_postgres_list_model_ids.py
covers the Deprecated-exclusion filter, canonical sort order, and
empty-projection cases against real Postgres.

P1-6: event payload tests were round-trip-only (a key rename on
both writer + reader sides would pass silently while every
historical event becomes unloadable). Extended
test_model_events.py with to_payload-only + from_stored-only
tests for each of the 5 Model events. Each new test asserts
against an explicit dict literal (pins WIRE shape on one side
and READ shape on the other); the existing round-trip tests
stay for end-to-end defense-in-depth.

P1-7: PBT did not exercise trim semantics on bounded-text VOs
through the decider boundary. The printable_ascii_text strategy
excluded whitespace, so the decider could store command.reason
raw instead of ModelDeprecationReason(command.reason).value and
the PBT would still pass. Added _padded_text helper strategy +
trim-semantics property tests to the 3 PBT files with bounded-
text inputs (define_model, version_model, deprecate_model).
add_model_family + remove_model_family PBTs carry only UUIDs so
no trim assertion applies.

P1-8: EquipmentHandlers docstring was stale (said "Two aggregates:
Family and Asset") and the 6 new Model fields wedged between
Family fields broke the per-aggregate grouping. Updated the
docstring to name all 5 aggregates (Family, Model, Asset, Frame,
Mount) and reordered fields by aggregate (Family then Model then
Asset then Frame then Mount), grouping create-style commands
first within each aggregate, then update-style, then queries.
Mirrored the reorder in routes.py include_router calls and
tools.py register() calls. Behaviour unchanged; purely cosmetic.

The wire.py reorder also let us drop wire.py from the
EMDASH_ALLOWLIST (the rewritten docstring uses commas/colons
instead of the em-dashes that lived in the prior version).

Tests: 1846 Model + architecture tests pass, no failures. ruff
clean, pyright 0/0/0.

With this commit landed, the Stage 3 review verdict flips from
NO_GO (P0 blocking) to GO_WITH_NITS: every P0 + P1 finding has
been resolved across commits A + B + C. P2 + Watch items remain
documented in the gate review output for future followup.

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

Post-rebase fixup. While this PR was open, main shipped
c47c7d3 "refactor(naming): rename Asset.families to
Asset.family_ids". The Model aggregate's state.py docstrings
referenced the old field name in two places (the cross-BC
subset invariant explanation at line 17, and the aggregate-level
Why text at line 448). Both are documentation-only; the deferred
Asset.model_id slice (Stage 1 memo locked at
project_asset_model_binding_design.md) will use the new name.

No source code references state.families or asset.families
because the Model BC reads from the Family read repo
(list_all_family_ids), not from Asset state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@xmap xmap force-pushed the worktree-model-aggregate branch from b1fd1c6 to b5c8806 Compare June 2, 2026 07:23
@xmap xmap merged commit e45c372 into main Jun 2, 2026
2 of 3 checks passed
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