feat(equipment): add Model aggregate (5th Equipment-BC aggregate, 6 slices, projection)#17
Merged
Conversation
53291cf to
b1fd1c6
Compare
Coverage reportClick to see where and how coverage changed
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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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>
b1fd1c6 to
b5c8806
Compare
This was referenced Jun 2, 2026
Merged
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
Adds the Model aggregate as the 5th aggregate in the Equipment BC, sitting between
Family(the rigid-kind taxonomy) andAsset(the deployed instance) in the equipment ladder. AModelis a vendor-catalog entry: it pins together aManufacturer(name plus optional ROR/GRID/ISNI identifier in a closed StrEnum scheme), apart_number(case-sensitive vendor SKU), and adeclared_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 backingproj_equipment_model_summaryprojection, 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 parallelproject_asset_model_binding_design.mdStage 1 memo (implementation in a follow-up PR).Commits (10, oldest first)
eb810de80Model aggregate core (state + events + evolver + 14 error classes in routes.py)2a33d75a8define_modelslice (POST /models, with cross-BC family lookup)d35fe7d99version_modelslice (POST /models/{id}/versions, update-style)643a72e55deprecate_modelslice (POST /models/{id}/deprecation)a3ea37f5badd_model_familyslice (POST /models/{id}/families, targeted-mutation)f595a3920remove_model_familyslice (DELETE /models/{id}/families/{family_id})43c3a3e56get_modelread slice +proj_equipment_model_summaryprojection + Atlas migration10a875ebcFix (P0): drop vendor-key UNIQUE INDEX (gate-review caught the same bookmark-poison foot-gun the Capability aggregate fixed in20260518210000_drop_proj_recipe_capability_summary_code_unique.sql)78e8acd4fFix (P1-1/P1-2): deprecated-Family lookup vialist_all_family_ids+ per-verb error classes (ModelCannotAddFamilyError/ModelCannotRemoveFamilyError)53291cf8eFix (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
test_equipment_aggregate_shape.pysibling-symmetry guard and the existing fitness suite (no em-dashes, projection-table-match, decider-paired-PBT, slice-contract, routes-completeness, test-names-carry-outcome).ruffclean,pyright0/0/0,tachclean across the full tree.Standards alignment (specialist-reviewed)
Model.manufactureris 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).ManufacturerIdentifierTypeclosed StrEnum (ROR | GRID | ISNI) per the Affordance closed-vocabulary precedent.ArcheTypecorrectly framed as hierarchy direction (Full | OneDown | OneUp), NOT a Type-vs-Instance discriminator (the Type-vs-Instance axis lives in the AAS metamodelAssetKindenum +Entity.entityType).Deferred (tracked as Watch items)
Asset.model_idadditive migration +AssetModelMismatchenforcement (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.define_permitsupport for Model aggregate kind — fires when first federation consumer opens.Asset.model_id+Asset.alternate_identifierslanding in sibling slices.AssetVariantaggregate — fires under rule-of-three.AssemblyTemplateaggregate — Stage 1 memo locked but not yet shipped.Test plan
POST /models+GET /models/{id}against the dev API once mergedCo-Authored-By: Claude Opus 4.7 noreply@anthropic.com
🤖 Generated with Claude Code