Equipment module stable¶
Purpose & Scope¶
The Equipment module owns CORA's record of what physical devices the facility runs, how they are catalogued and composed, where they are mounted, and what operational state each one is in. Eight aggregates carry the responsibility. Family is the device-class abstraction (what kind of thing a rotary stage, camera, or scintillator is). Asset is one physical instance the facility commissions, maintains, moves, and eventually decommissions. Model is the vendor catalog entry that pins a manufacturer plus part number to a set of declared Families. Mount is a named installation slot the facility provisions in advance; an Asset is later installed into that slot. Frame is a coordinate frame in the placement hierarchy that Mounts use to anchor their position. Assembly is a reusable composition blueprint that declares a slot map and wiring for a cluster of Assets, and presents the cluster through the functional Roles it satisfies. Fixture is the materialization of an Assembly into concrete Asset instances on a particular Trust Surface. Role is the global functional binding contract (Detector, Positioner, Controller, Sensor, Regulator) that names WHAT operational shape a Method needs without pinning the anatomical Family that provides it; Families and Assemblies advertise which Roles they satisfy through a presents_as set.
Equipment is Foundation: every other module that needs to point at a specific piece of hardware references an Asset.id. The recipe ladder binds Methods to Assets through Plans, and a Method may now require not only Families but specific Assembly blueprints. Procedures target Assets. Calibrations key off an Asset.id plus a quantity. Supplies advertise resources whose physical delivery infrastructure lives as Assets. Trust groups Assets into Zones for security policy.
Out of scope
- High-frequency telemetry. Motor positions during a scan, frame-trigger timestamps, sub-microsecond timing edges. Those are substream records keyed on the Run plus the Asset, not Asset state.
- DataCite minting and the persistent-id write path. The PIDINST serializer (
to_pidinst_record) lives at the module root and is reachable over HTTP / MCP throughget_asset_pidinst. The DataCite mint surface that posts the serialized record, plus the slice that writes the resulting persistent id back onto Asset state, are deferred to follow-on slices. - Hierarchy descendant projection. The asset-summary table answers direct-parent queries. Transitive-closure queries ("every device under this beamline") are deferred until the use case lands.
- Per-Family settings-schema versioning history. The current schema replaces the prior schema on every
update_family_settings_schema; the event log carries the history, but no separate projection exposes "schema at time T". - Wiring at the Plan tier. Which Asset port connects to which other Asset port across the whole experiment lives in
Plan.wiringin the Recipe module. Assembly carries the wiring that is INTRINSIC to one composition blueprint; Plan layers experiment-specific wiring on top. - Composite affordances. An Assembly does not declare new affordances that only emerge from the composition; it presents through the Roles it satisfies (
presents_as), whose required affordances are the contract. Composite-only affordances are reserved for a future revision when a real case appears. - AssetVariant tier. Some traditions add a fourth rung (Family / Model / Variant / Asset) for configured-but-not-instantiated specialisations. CORA ships the three-rung Family / Model / Asset cluster today.
Aggregates¶
| Name | Identity | State summary | FSM |
|---|---|---|---|
Family |
id: UUID |
id, name: FamilyName, status: FamilyStatus, version: str?, affordances: frozenset[Affordance], presents_as: frozenset[RoleId], settings_schema: dict? |
yes (3-state) |
Model |
id: UUID |
id, name, manufacturer: Manufacturer, part_number, declared_family_ids: frozenset[UUID], status: ModelStatus, version: str? |
yes (3-state) |
Asset |
id: UUID |
id, name, tier, parent_id?, lifecycle, condition, family_ids: frozenset[UUID], settings: dict, ports: frozenset[AssetPort], model_id?, owners: frozenset[AssetOwner], alternate_identifiers: frozenset[AlternateIdentifier], fixture_id?, drawing?, commissioned_at?, decommissioned_at?, controller_id?, facility_code?, partition_rule: PartitionRule? (PseudoAxis only) |
yes (4-state lifecycle, 3-state condition) |
Frame |
id: UUID |
id, name, parent_id?, placement: Placement?, supersedes: FrameRevisionLink?, status |
yes (2-state) |
Mount |
id: UUID |
id, slot_code, parent_id?, placement: Placement, drawing?, installed_asset_id?, status |
yes (2-state) |
Assembly |
id: UUID |
id, name, presents_as: frozenset[RoleId], required_slots: frozenset[TemplateSlot], required_sub_assemblies: frozenset[SubAssemblyLink], required_wires: frozenset[TemplateWire], parameter_overrides_schema: dict?, drawing?, status: AssemblyStatus, version: str?, content_hash: str? |
yes (3-state) |
Fixture |
id: UUID |
id, assembly_id, assembly_content_hash, surface_id, slot_asset_bindings: frozenset[SlotAssetBinding], parameter_overrides: dict, persistent_id?, registered_at |
no (genesis + set-once persistent-id assign) |
Role |
id: RoleId |
id, name: RoleName, docstring: str, required_affordances: frozenset[Affordance], optional_affordances: frozenset[Affordance], produces: frozenset[SignalType], consumes: frozenset[SignalType] |
no (terminal at genesis; status implicit Defined) |
Family is the device-class abstraction: "RotaryStage", "Camera", "Hexapod", "Mirror", "TimingController". It carries the affordance set (what device-level primitives this class supports) and the JSON Schema that constrains the operational settings any Asset of that class may carry. Model is the vendor catalog entry that names a specific manufacturer plus part number and declares which Families that catalog row belongs to. Asset is one physical instance: a beamline, a detector, a sample changer. Assets form a single-parent tree through parent_id; an Asset may belong to multiple Families simultaneously, and each membership widens what the Asset's settings dict may contain. An Asset may optionally bind to a Model, in which case the Asset's family set must be a subset of the Model's declared families.
Frame and Mount add the placement axis the facility uses to provision physical space ahead of installation. A Frame is a named coordinate frame; Frames form a parent tree, and a child Frame carries its placement relative to its parent. A Mount is a named installation slot the facility provisions in advance, carries a 6-DoF placement relative to its parent Frame or Mount, and may eventually have an Asset installed into it. Slot codes are facility-unique across active Mounts.
Assembly and Fixture form a template-realization pair. An Assembly is a reusable composition blueprint that declares a slot map (which Families must occupy which named positions), an intra-cluster wiring map (which slot port connects to which other slot port), and an optional parameter-overrides schema. An Assembly may also compose smaller reusable Assemblies through required_sub_assemblies: each SubAssemblyLink includes a child Assembly in a named position, version-pinned at the child's content_hash (snapshot semantics, so a later child revision does not silently change a parent built today). At register_fixture time the child's own leaf slots expand into the parent's slot set (the union), so the materialization still binds only concrete Assets. The Assembly presents through the Roles it declares (presents_as) for the purposes of Capability binding, even though the cluster contains several underlying Assets. A Fixture is the materialization of an Assembly into concrete Assets on a particular Trust Surface; the Fixture binds named slots to specific Asset.id values, freezes the Assembly's content hash at registration time for federation matching, and carries any parameter overrides applied at this materialization.
Role is the global functional binding contract: WHAT operational shape a Method needs (Detector, Positioner, Controller, Sensor, Regulator) without pinning the anatomical Family that provides it. Role is a sister aggregate to Family, not a kind of Family: it carries its own registry, events, and identifier (RoleId). A Role is a lightweight contract, required_affordances plus optional_affordances plus produces plus consumes plus a docstring; it has no settings schema, no instances, and no ports. A Family or Assembly declares it satisfies a Role through presents_as: frozenset[RoleId], and a Method binds a positional role slot to a Role through its RoleRequirement in the Recipe module. RoleId values are uuid5-derived from a fixed namespace (role_stream_id(name) = uuid5(_ROLE_NAMESPACE, name.value.lower()), _ROLE_NAMESPACE = uuid5(NAMESPACE_DNS, 'cora.role')) so the same Role gets the same id across deployments, which federation portability requires. Five seed Roles (Detector, Positioner, Controller, Sensor, Regulator) are auto-seeded at lifespan by bootstrap_equipment; defining a Role whose case-insensitive name collides with an existing one (a seed or any prior Role) returns 409 Conflict via the deterministic stream id. Role has no FSM: status is implicit (Defined) on every Role; versioning is deferred until a satisfaction-breaking affordance change forces it.
Family and Model ids follow the same deterministic scheme as RoleId, for the same federation-portability reason. family_stream_id(name) = uuid5(_FAMILY_NAMESPACE, name.value.lower()), so the same-named Family converges across facilities and a duplicate define_family collides on the deterministic stream, returning 409. model_stream_id(manufacturer, part_number) derives a Model id as uuid5 over a canonical (lowercased manufacturer name, case-preserved part number) key, so the same vendor product converges and a second define_model on the same real vendor key returns 409. The one exception is the unknown-pending-confirmation placeholder part number: a Model recorded with it falls back to a random id, so two genuinely-unconfirmed units stay distinct rather than collapsing onto one identity. Uniqueness is enforced at the deterministic-stream tier (expected_version=0), not a projection unique index, so Family deliberately carries no LOWER(name) projection index (unlike Role's proj_equipment_role_summary_name_lower_uq): the deterministic stream id already makes a duplicate-name Family unreachable on the write path.
Value Objects¶
| Name | Shape | Where used |
|---|---|---|
FamilyName |
trimmed string, 1-200 chars | Family.name |
FamilyStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Family.status |
Affordance |
closed StrEnum, ~28 values in three patterns (motion, signal, lifecycle) | members of Family.affordances |
AssetName |
trimmed string, 1-200 chars | Asset.name |
AssetTier |
closed StrEnum: Unit | Component | Device (ISA-88 equipment tiers) |
Asset.tier (set once at registration, never mutated; the intrinsic-tier facet, orthogonal to the structural Facility binding) |
AssetLifecycle |
closed StrEnum: Commissioned | Active | Maintenance | Decommissioned |
Asset.lifecycle |
AssetCondition |
closed StrEnum: Nominal | Degraded | Faulted |
Asset.condition |
AssetPort |
(name, direction, signal_type) triple; direction is Input | Output; signal_type is free text, 1-50 chars |
members of Asset.ports |
AssetOwner |
(name, contact?, identifier?, identifier_type?); identifier and identifier_type are paired (both present or both absent) |
members of Asset.owners |
AlternateIdentifier |
(kind, value); kind is closed StrEnum SerialNumber | InventoryNumber | Other |
members of Asset.alternate_identifiers |
Drawing |
(system, number, revision?); system is closed StrEnum ICMS | EDMS | DOI; revision None means "latest" |
Asset.drawing, Mount.drawing, Assembly.drawing |
FacilityCode |
trimmed slug [a-z0-9-]{1,32} from cora.shared.facility_code; cross-deployment convergent identity for the owning Federation Facility |
Asset.facility_code (optional, set-once at register_asset; mirrors the Supply binding shape) |
Manufacturer |
(name, identifier?, identifier_type?); identifier_type is closed StrEnum ROR | GRID | ISNI and is paired with identifier |
Model.manufacturer |
ModelStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Model.status |
Placement |
6-DoF rigid-body transform: (translation_mm, rotation_deg) where translation is (x, y, z) and rotation is (rx, ry, rz) extrinsic Tait-Bryan |
Frame.placement, Mount.placement |
FrameRevisionLink |
(previous_frame_id, reason) audit pointer to the Frame this one supersedes |
Frame.supersedes |
FrameStatus |
closed StrEnum: Active | Decommissioned |
Frame.status |
MountStatus |
closed StrEnum: Active | Decommissioned |
Mount.status |
AssemblyStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Assembly.status |
SlotName |
trimmed string, 1-50 chars, lowercase alphanumeric plus underscore | TemplateSlot.slot_name, SlotAssetBinding.slot_name, both endpoints of TemplateWire |
SlotCardinality |
closed StrEnum: Exactly1 | ZeroOrOne | OneOrMore | ZeroOrMore |
TemplateSlot.cardinality |
TemplateSlot |
(slot_name, required_family_ids, cardinality) per-slot Family requirement |
members of Assembly.required_slots |
TemplateWire |
(source_slot, source_port, target_slot, target_port) intra-Assembly wiring |
members of Assembly.required_wires |
SubAssemblyLink |
(slot_name, sub_assembly_id, content_hash) version-pinned link to a child Assembly |
members of Assembly.required_sub_assemblies |
SlotAssetBinding |
(slot_name, asset_id) materialization pair |
members of Fixture.slot_asset_bindings |
RoleId |
NewType over UUID; uuid5-derived from a fixed namespace so a Role gets the same id across deployments; lives at cora/equipment/aggregates/_value_types.py |
Role.id, members of Family.presents_as and Assembly.presents_as |
RoleName |
trimmed string, 1-200 chars | Role.name |
SignalType |
NewType over str; open-vocabulary label, trimmed 1-50 chars at decider time (matches AssetPort.signal_type) |
members of Role.produces, Role.consumes |
The three AssetTier values are ISA-88 equipment tiers with single-word names. The tier is intrinsic to one Asset, set once at registration and never mutated; the decider does not enforce that a Device parents to a Component. Smart instruments with addressable sub-modules legitimately put a Device under another Device.
Facility-envelope scope (institution, site, area) is NOT a tier. It is owned by the Federation Facility aggregate, and an Asset binds its owning Facility through Asset.facility_code rather than through a tier value. The anchoring rule is XOR, enforced by the register_asset decider: a root Asset has parent_id = None and binds facility_code; a non-root Asset has a parent_id and does NOT bind facility_code, inheriting facility scope through the parent_id tree.
AssetTier is an intrinsic-shape label, not the aggregate ladder. The aggregate ladder (Family, Model, Assembly, Fixture, Asset) answers what kind of identity each row carries; AssetTier answers what equipment shape one registered Asset has. The two axes are orthogonal: a Family has no tier, a Fixture has no tier, only an Asset carries one. ISA-88 and ISA-95 readers should note that in those traditions the equipment hierarchy is the type ladder, but in CORA the two are kept apart.
Whether a composite vendor unit is one Asset with a wide settings dict or a parent Asset with several child Assets follows three tests. Any one is sufficient to spawn a child Asset:
- Lifecycle independence. The sub-component can be commissioned, swapped, or decommissioned without retiring the parent.
- External addressability. Another module needs to reference the sub-component by
asset_id:Plan.wiring,Calibration.target_id,Procedure.target_asset_ids,Caution.AssetTarget,Clearance.facility_asset_id,AssetBinding.asset_id, Trust Zone membership, or Supply infrastructure linkage. - Settings-schema divergence. The sub-component needs settings keys that would collide with the parent's, or that need independent write-and-validate cycles.
Addressability wins ties: if any other module references the sub-component by id, it is its own Asset. Per-component Calibration is the most common trigger at imaging beamlines (each lens position in a turret carries its own magnification calibration). Each Asset is its own event stream, so the choice is irreversible without a migration. A sub-component that fails all three is structural detail on the parent: a value in its settings dict or an entry in its ports set.
AssetCondition is orthogonal to AssetLifecycle. A Decommissioned Asset can be discovered Faulted on inventory check; an Active Asset can be Degraded without being pulled out of service. The two enums move independently through their own slices.
AssetPort declares what ports the equipment HAS. The connection between two ports lives in Plan.wiring for whole-experiment wiring, or in Assembly.required_wires for wiring that is intrinsic to a composition blueprint.
AssetOwner and AlternateIdentifier are sortable VOs whose ordering follows the field that uniquely keys them (owner_name for owners, (kind, value) for alternate identifiers). Both feed the PIDINST serializer (to_pidinst_record at the module root) that maps Asset state into the external PIDINST record shape, which the get_asset_pidinst slice exposes at GET /assets/{asset_id}/pidinst. The PIDINST property numbers are: 5 for owners, 13 for alternate identifiers.
Assembly.content_hash is a SHA-256 over the canonical serialization of the structural content (name, presents_as, required_slots, required_wires, required_sub_assemblies, parameter_overrides_schema); engineering metadata (drawing, version label) is intentionally excluded so two Assemblies with the same structural intent share a hash even when sourced from different facilities. A pinned child's content_hash is folded into the parent's, so a structural change deep in a composition ripples up one deliberate re-pin at a time. This cross-facility convergence holds because both the ids the hash folds in are deterministic: the Role ids in presents_as (the Role-contract set) come from role_stream_id, and each slot's required_family_ids come from family_stream_id, each deriving its id as uuid5 over the lowercased name. So two facilities that define the same-named Role and the same-named Family arrive at the same ids and therefore the same hash. (Until these ids were made deterministic, the hash silently did NOT converge, the random per-facility ids broke it even though the canonical serialization was identical.) Fixture.assembly_content_hash snapshots that hash at registration time, decoupling the materialization from any later Assembly revision.
Composition axes¶
Three mechanisms express "this is made of that", on three different layers, and they stay orthogonal:
- Physical containment lives on
Asset.parent_id: a single-parent tree of real, deployed objects. The camera inside the microscope body is a child Asset of the body. This is where the genuine "what sits inside what" fact lives, and it nests to any depth (aDevicemay parent anotherDevice). - Blueprint reuse lives on
Assembly.required_sub_assemblies: a parent blueprint can include a whole child blueprint (version-pinned) as a named position, so anOpticsblueprint is authored once and reused across microscopes. One composing level is supported today: a child that itself declares sub-assemblies is rejected atdefine_assembly/version_assemblytime (the same limitregister_fixtureenforces), so a defined Assembly is always instantiable. Because a non-leaf child is refused, an A-includes-B-includes-A indirect cycle is also impossible for the two-node case. Deeper nesting is deferred until a real case appears. - Materialization lives on
Fixture.slot_asset_bindings: a flat set of(slot_name, asset_id)pairs. Atregister_fixturethe parent blueprint's leaf slots and every child blueprint's leaf slots expand into ONE flat namespace, so the Fixture binds only concrete leaf Assets. Leaf slot names must therefore be unique across a composition; a clash is rejected at authoring time rather than at the end of the install-then-register choreography.
The three axes at a glance:
flowchart LR
subgraph BP["Blueprint reuse: Assembly.required_sub_assemblies"]
A["Assembly"] -- "@content_hash" --> C["child Assembly"]
end
subgraph MAT["Materialization: Fixture.slot_asset_bindings"]
F(["Fixture"]) -- "flat (slot_name, asset_id)" --> L["leaf Assets"]
end
subgraph CON["Physical containment: Asset.parent_id"]
P["parent Asset"] --> Ch["child Asset"]
end
A == "register_fixture (leaf slots flatten to one namespace)" ==> F
The materialization stays flat on purpose. Everything that points at equipment (a Calibration, a Plan.wiring entry, a Caution, a Trust Zone) wants the real leaf Asset directly; the PIDINST owners and manufacturers record is the honest union over those leaves; and the physical "inside what" fact is already carried by Asset.parent_id. A nested-Fixture tree would duplicate that fact and complicate every reader.
The split between the type side and the instance side is deliberate. Assembly (a composite blueprint) earns its own materialization aggregate, Fixture, because a materialization carries state of its own: the slot bindings, the parameter overrides, and the content-hash snapshot. Model (a single-device catalog entry) does NOT get a separate instance aggregate, because a model-realization carries no state beyond a pointer: an Asset simply sets model_id. For the same reason Model does not compose; a catalog row is atomic. A composite vendor product, a bundle SKU whose sub-SKUs operators reference as a unit, would be modeled as an Assembly, not as a Model of sub-Models. A Model.sub_models tier symmetric with Assembly.sub_assemblies is not modelled today.
Virtual axes and partition rules¶
A PseudoAxis is a Family whose Assets are virtual axes: the operator commands one value (a beam energy, a hexapod degree of freedom, a foil slot, a table tilt) and the runtime resolves it into setpoints on the real motors underneath. The resolving math is Asset.partition_rule, a typed value object that only PseudoAxis Assets carry. The operator writes the virtual port; the evaluator in the Operation module reads the rule and writes the resolved setpoints to the constituent motors through ControlPort. The point is one consistent positioner-axis vocabulary across devices whose internal kinematics differ.
PartitionRule is a closed discriminated union of five shapes, each naming who owns the math:
| Kind | Shape | Who owns the math | Example |
|---|---|---|---|
Affine |
out = gain * in + offset |
CORA (a linear map) | pitch tracking in the linear approximation, lever-arm couplings |
Aggregation |
out = aggregator(c1, ..., cN) over Sum / Difference / MidRange / Product |
CORA (deterministic arithmetic) | slit center and gap from two blades |
LookupTable |
out = interpolate(in, calibration revision) |
a pinned Calibration revision |
energy to optic position, foil slot to motor position |
CompositePartition |
out = sum(constituents), split by a partition discipline |
CORA (a split rule) | sample-stack Y shared between hexapod and table |
SolverReference |
out = solver(in), the solver named by id |
the device firmware or IOC | hexapod 6-DoF, six-circle HKL |
Two points are load-bearing. First, a PseudoAxis need not carry a rule at all: when an edge IOC already computes the geometry (a detector optical table's virtual pose axes, for example), CORA names the axis and writes its value directly, recording the move without re-implementing the kinematics. This is the spine and edge seam: coordinated actuation can live at the edge, and the thing to avoid is a second, drift-prone source of truth. Second, the LookupTable shape references its table by Calibration revision id rather than inlining it, so the calibration history stays the source of truth and the rule survives recalibration by pinning a new revision. The five-shape catalog grows by PR, the same closed-with-paired-migration discipline as Affordance and the Calibration quantities.
Family naming conventions¶
Family names are cross-facility vocabulary. Three conventions are load-bearing today:
Anatomical names, not vendor or hardware-substrate names¶
A Family names what something IS, device-agnostic across facilities. Camera, Mirror, Scintillator, Hexapod are anatomical. Vendor designations live in Asset.metadata.vendor_designation and on the bound Model row; they never enter the Family name. Hardware-substrate names (FPGA, VME card, PCIe board, the chip technology behind the function) likewise stay out of the Family name. A MotionController Family covers an Aerotech Ensemble drive, an Oregon Micro Systems VME58 card, and a hypothetical PCIe servo board with equal honesty; the substrate distinction lives in MotionController.settings.protocol or on the bound Model. The same convention rules out names like TriggerFPGA: the role is timing-signal generation and distribution, not "FPGA"; the right Family name is TimingController.
<Domain>Controller for separately-modelled active-control electronics¶
Any field-replaceable, firmware-versioned active-control-electronics box gets a <Domain>Controller Family with a <stage>.controller_id back-reference from the driven stage. Two <Domain>Controller Families are anchored today:
| Family | Drives | Anchored at |
|---|---|---|
MotionController |
Stages, hexapods, sample motors | Aerotech Ensemble (RotaryDrive), Aerotech Hexapod drive, Aerotech 2bmbAERO drive, OMS VME58 a-station + b-station drives at 2-BM |
TimingController |
Hardware timing signal generation (triggers, gates, sync pulses) | softGlueZynq FPGA (Timing) at 2-BM; further trigger sources to follow. Replaces the pre-rename TriggerFPGA candidate. Presents_as Controller, which carries Pulsing. |
Plausible siblings not yet modelled: PressureController (MKS Baratron + PID), DAQController (Quantum Detectors Merlin, FPGA frame grabbers), HVPSU / BiasController (CAEN HV crate). The suffix comes last, matching the Family-naming rule above. (TemperatureController and FlowController were such candidates but have graduated as catalog Families presenting the Regulator Role, not Controller: they perform rather than supervise, per the Controller-versus-Regulator note in the naming reference.)
Function × anatomy matrix¶
A useful cross-check on Family naming and gap detection: the function-and-anatomy matrix collects each Family by what it DOES (operational role) and what it IS (substrate). Empty cells are either real gaps or evidence the axes need refinement; cross-anatomy entries flag Families that straddle two substrates.
| Optical | Mechanical | Electronic | Composite (Assembly) | |
|---|---|---|---|---|
| Defining | - | Mask, Slit, Collimator, Baffle | - | Slits-with-Filters |
| Modifying | Mirror, Monochromator, Filter, Window | Foil | - | DMM |
| Sensing | Camera, Scintillator, Objective, Screen | - | - | Microscope (presents_as Detector) |
| Positioning | - | LinearStage, RotaryStage, Hexapod, Blade, PseudoAxis | - | SampleStack |
| Terminating | - | BeamStop | Shutter | - |
| Controlling | - | - | MotionController, TimingController, <Domain>Controller siblings |
- |
| Containing | Window | BeamPipe, Housing | - | - |
Window straddles Modifying + Containing (attenuates AND bounds vacuum); Shutter straddles Electronic + Mechanical (electromechanical). Both are intentional single-Family typings today; either may split if a future use case forces it. The matrix is the operator-side companion to the beam-path inventory in docs/deployments/<id>/inventory.md.
FSM¶
The Family, Model, and Assembly aggregates run a shared three-state lifecycle (Defined / Versioned / Deprecated). Asset runs a four-state lifecycle plus an orthogonal three-state condition. Frame and Mount each run a two-state lifecycle (Active / Decommissioned). Fixture has no FSM: a Fixture is registered in a genesis event and can later have a persistent identifier assigned once; otherwise it is never updated, and if a materialization needs to change, a new Fixture is registered against the same Assembly. Role also has no FSM today: it is terminal at genesis with status implicit (Defined), and the versioning slices that would add a Versioned / Deprecated lifecycle are deferred.
Family¶
stateDiagram-v2
[*] --> Defined: define_family
Defined --> Versioned: version_family
Versioned --> Versioned: version_family
Defined --> Deprecated: deprecate_family
Versioned --> Deprecated: deprecate_family
Deprecated --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Defined |
define_family |
FamilyDefined |
Defined |
Versioned |
version_family |
FamilyVersioned |
Versioned |
Versioned |
version_family |
FamilyVersioned |
Defined |
Deprecated |
deprecate_family |
FamilyDeprecated |
Versioned |
Deprecated |
deprecate_family |
FamilyDeprecated |
Model¶
stateDiagram-v2
[*] --> Defined: define_model
Defined --> Versioned: version_model
Versioned --> Versioned: version_model
Defined --> Deprecated: deprecate_model
Versioned --> Deprecated: deprecate_model
Deprecated --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Defined |
define_model |
ModelDefined |
Defined |
Versioned |
version_model |
ModelVersioned |
Versioned |
Versioned |
version_model |
ModelVersioned |
Defined |
Deprecated |
deprecate_model |
ModelDeprecated |
Versioned |
Deprecated |
deprecate_model |
ModelDeprecated |
Assembly¶
stateDiagram-v2
[*] --> Defined: define_assembly
Defined --> Versioned: version_assembly
Versioned --> Versioned: version_assembly
Defined --> Deprecated: deprecate_assembly
Versioned --> Deprecated: deprecate_assembly
Deprecated --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Defined |
define_assembly |
AssemblyDefined |
Defined |
Versioned |
version_assembly |
AssemblyVersioned |
Versioned |
Versioned |
version_assembly |
AssemblyVersioned |
Defined |
Deprecated |
deprecate_assembly |
AssemblyDeprecated |
Versioned |
Deprecated |
deprecate_assembly |
AssemblyDeprecated |
Asset¶
stateDiagram-v2
[*] --> Commissioned: register_asset
Commissioned --> Active: activate_asset
Active --> Maintenance: enter_asset_maintenance
Maintenance --> Active: exit_asset_maintenance
Commissioned --> Decommissioned: decommission_asset
Active --> Decommissioned: decommission_asset
Maintenance --> Decommissioned: decommission_asset
Decommissioned --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Commissioned |
register_asset |
AssetRegistered |
Commissioned |
Active |
activate_asset |
AssetActivated |
Active |
Maintenance |
enter_asset_maintenance |
AssetMaintenanceEntered |
Maintenance |
Active |
exit_asset_maintenance |
AssetMaintenanceExited |
Commissioned |
Decommissioned |
decommission_asset |
AssetDecommissioned |
Active |
Decommissioned |
decommission_asset |
AssetDecommissioned |
Maintenance |
Decommissioned |
decommission_asset |
AssetDecommissioned |
Condition transitions move from any source to a fixed target through three slices: degrade_asset lands on Degraded, fault_asset lands on Faulted, restore_asset lands on Nominal. Condition transitions are allowed in any lifecycle state (including Decommissioned) so the model stays honest about device-state-in-storage.
Frame and Mount¶
stateDiagram-v2
[*] --> Active: register_frame / register_mount
Active --> Decommissioned: decommission_frame / decommission_mount
Decommissioned --> [*]
| Aggregate | From | To | Command | Event |
|---|---|---|---|---|
Frame |
[*] |
Active |
register_frame |
FrameRegistered |
Frame |
Active |
Decommissioned |
decommission_frame |
FrameDecommissioned |
Mount |
[*] |
Active |
register_mount |
MountRegistered |
Mount |
Active |
Decommissioned |
decommission_mount |
MountDecommissioned |
Guards. Beyond the source-state check, several slices enforce cross-state and cross-aggregate invariants:
relocate_asset- The target parent is not the Asset itself (no single-element cycle). The target parent differs from the current parent (no-op rejection). Root Assets cannot relocate (a root has
parent_id = Noneand bindsfacility_code). Decommissioned Assets cannot relocate. Cycle detection beyond the trivial self-loop case is deferred. add_asset_family/remove_asset_family- The Asset is not Decommissioned. The Family id is not already present (or is present, for remove). When the Asset has a
model_idset, the resultingfamily_idsset must remain a subset ofModel.declared_family_ids(cross-aggregate subset invariant; raisesAssetModelMismatchErrorotherwise). The referenced Family is not verified to exist in the Family event stream at write time, matching the eventual-consistency stance on cross-aggregate references. add_asset_port/remove_asset_port- The Asset is not Decommissioned. The port name is not already present (or is present, for remove). Port-name uniqueness is enforced Asset-wide.
update_asset_settings- The proposed settings dict is validated against the union of currently-assigned Families'
settings_schemadeclarations. Orphan keys (not declared by any assigned Family) are rejected. Conflicts between two Families that declare the same key with incompatible types are rejected. PATCH semantics follow RFC 7396 merge. attach_asset_to_fixture/detach_asset_from_fixture- Attach sets
Asset.fixture_idand fails if the Asset is already attached elsewhere or if the target Fixture does not list this Asset in itsslot_asset_bindings. Detach clearsAsset.fixture_idand defensively requires the caller to pass the expected currentfixture_id. Both transitions are lifecycle-agnostic so a Decommissioned Asset can still be unbound. add_asset_owner/remove_asset_owner/add_asset_alternate_identifier/remove_asset_alternate_identifier- The Asset is not Decommissioned. Owners are keyed on
owner_name; alternate identifiers are keyed on(kind, value). Strict-not-idempotent: re-adding the same key raises, removing an absent key raises. decommission_frame- The Frame has no active consumers. A consumer is any Mount whose
placementreferences this Frame or any child Frame whoseparent_idis this Frame. RaisesFrameInUseErrorlisting the offending consumers. decommission_mount- The Mount has no Asset currently installed (
installed_asset_idis NULL). The Mount has no active child Mounts whoseparent_idreferences this Mount. RaisesMountHasAssetInstalledErrororMountHasActiveChildrenError. install_asset/uninstall_asset- Install sets
Mount.installed_asset_idand fails if the Mount is already occupied, the Asset does not exist, the Asset is Decommissioned, or the Asset is already installed in any other active Mount. Uninstall clears the slot and fails if the Mount is already empty. define_assembly/version_assembly- the presented Role ids in
presents_asare verified against the Role registry at the handler edge (an unknown id raises Role not found), and each slot'srequired_family_idsare verified as Families that exist in the Equipment module (via a cross-aggregate load). EveryTemplateWire.source_slotandTemplateWire.target_slotmatches aTemplateSlot.slot_namedeclared on the same Assembly. The decider captures the canonicalcontent_hashfrom the new structural content; re-versioning with identical content yields the same hash by design. register_fixture- Every required slot in the Assembly is covered by exactly one
SlotAssetBindingwhoseasset_idreferences an Asset whosefamily_idsincludes the slot's required Family. When the Assembly references sub-assemblies, the validated slot set is the union of the Assembly's own leaf slots and each referenced sub-assembly's leaf slots (one composing level deep); a slot name that appears in both is rejected as a collision, and eachSubAssemblyLink's pinnedcontent_hashis re-checked against the child Assembly's current hash. The Assembly is inDefinedorVersioned(notDeprecated). Every bound Asset must not beDecommissioned(a terminal lifecycle disallows attachment) and must currently be installed in some Mount (so the choreography isinstall_asset->register_fixture, never the reverse). The Fixture'ssurface_idis read from the caller's authenticated Trust Surface; the Fixture is bound to that Surface for authorization scoping. add_model_family/remove_model_family- The Model is not Deprecated. The Family id is not already present (or is present, for remove). The referenced Family must exist in the Family event stream (verified at decide time via load).
Events¶
The eight aggregates emit fifty distinct event types, grouped by aggregate.
Family events¶
| Event | Payload sketch | When emitted |
|---|---|---|
FamilyDefined |
family_id, name, affordances, occurred_at |
define_family succeeds (genesis) |
FamilyVersioned |
family_id, version_tag, affordances, occurred_at |
version_family succeeds; affordance set is the replacement declared at this version |
FamilyDeprecated |
family_id, occurred_at |
deprecate_family succeeds |
FamilySettingsSchemaUpdated |
family_id, settings_schema?, occurred_at |
update_family_settings_schema succeeds; payload carries the full replacement schema |
FamilyPresentsAsAdded |
family_id, role_id, occurred_at |
add_family_presents_as succeeds; the Family advertises one additional Role contract |
FamilyPresentsAsRemoved |
family_id, role_id, occurred_at |
remove_family_presents_as succeeds; the Family withdraws one previously-advertised Role |
Model events¶
| Event | Payload sketch | When emitted |
|---|---|---|
ModelDefined |
model_id, name, manufacturer, part_number, declared_family_ids, occurred_at |
define_model succeeds (genesis) |
ModelVersioned |
model_id, version_tag, manufacturer, part_number, declared_family_ids, occurred_at |
version_model succeeds |
ModelDeprecated |
model_id, reason?, occurred_at |
deprecate_model succeeds |
ModelFamilyAdded |
model_id, family_id, occurred_at |
add_model_family succeeds |
ModelFamilyRemoved |
model_id, family_id, occurred_at |
remove_model_family succeeds |
Asset events¶
| Event | Payload sketch | When emitted |
|---|---|---|
AssetRegistered |
asset_id, name, tier, parent_id?, model_id?, facility_code?, owners, occurred_at |
register_asset succeeds (genesis); a root Asset carries facility_code and no parent_id, a non-root Asset carries parent_id and no facility_code; optional model_id and seed owners may be carried |
AssetActivated |
asset_id, occurred_at |
activate_asset succeeds |
AssetMaintenanceEntered |
asset_id, occurred_at |
enter_asset_maintenance succeeds |
AssetMaintenanceExited |
asset_id, occurred_at |
exit_asset_maintenance succeeds |
AssetDecommissioned |
asset_id, occurred_at |
decommission_asset succeeds from any of the three permitted lifecycles |
AssetRelocated |
asset_id, from_parent_id?, to_parent_id, reason, occurred_at |
relocate_asset succeeds; payload carries both source and target for audit |
AssetFamilyAdded |
asset_id, family_id, occurred_at |
add_asset_family succeeds |
AssetFamilyRemoved |
asset_id, family_id, occurred_at |
remove_asset_family succeeds |
AssetPortAdded |
asset_id, port_name, direction, signal_type, occurred_at |
add_asset_port succeeds |
AssetPortRemoved |
asset_id, port_name, occurred_at |
remove_asset_port succeeds |
AssetSettingsUpdated |
asset_id, settings, occurred_at |
update_asset_settings succeeds; payload carries the post-merge settings dict |
AssetDegraded |
asset_id, reason, occurred_at |
degrade_asset succeeds; reason is operator free text (for example "hot pixel detected") |
AssetFaulted |
asset_id, reason, occurred_at |
fault_asset succeeds |
AssetRestored |
asset_id, reason, occurred_at |
restore_asset succeeds |
AssetOwnerAdded |
asset_id, owner (full AssetOwner VO), occurred_at |
add_asset_owner succeeds |
AssetOwnerRemoved |
asset_id, owner_name, occurred_at |
remove_asset_owner succeeds |
AssetAlternateIdentifierAdded |
asset_id, alternate_identifier (full VO), occurred_at |
add_asset_alternate_identifier succeeds |
AssetAlternateIdentifierRemoved |
asset_id, alternate_identifier, occurred_at |
remove_asset_alternate_identifier succeeds; payload carries the full removed VO for audit |
AssetAttachedToFixture |
asset_id, fixture_id, occurred_at |
attach_asset_to_fixture succeeds; sets the back-reference |
AssetDetachedFromFixture |
asset_id, previous_fixture_id, occurred_at |
detach_asset_from_fixture succeeds; clears the back-reference, carrying the prior id for audit |
AssetFacilityCodeAssigned |
asset_id, facility_code, occurred_at, assigned_by |
bind_asset_to_facility succeeds; sets the post-genesis Facility binding (set-once; rebind requires decommission + re-register) |
Frame events¶
| Event | Payload sketch | When emitted |
|---|---|---|
FrameRegistered |
frame_id, name, parent_id?, placement?, supersedes?, occurred_at |
register_frame succeeds (genesis); a root Frame omits parent and placement |
FramePlacementUpdated |
frame_id, placement, occurred_at |
update_frame_placement succeeds |
FrameDecommissioned |
frame_id, occurred_at |
decommission_frame succeeds |
Mount events¶
| Event | Payload sketch | When emitted |
|---|---|---|
MountRegistered |
mount_id, slot_code, parent_id?, placement, drawing?, occurred_at |
register_mount succeeds (genesis) |
MountPlacementUpdated |
mount_id, placement, occurred_at |
update_mount_placement succeeds |
MountAssetInstalled |
mount_id, asset_id, occurred_at |
install_asset succeeds |
MountAssetUninstalled |
mount_id, previous_asset_id, occurred_at |
uninstall_asset succeeds |
MountDecommissioned |
mount_id, occurred_at |
decommission_mount succeeds |
Assembly events¶
| Event | Payload sketch | When emitted |
|---|---|---|
AssemblyDefined |
assembly_id, name, presents_as, required_slots, required_sub_assemblies, required_wires, parameter_overrides_schema?, drawing?, content_hash, occurred_at |
define_assembly succeeds (genesis) |
AssemblyVersioned |
assembly_id, version_tag, required_slots, required_sub_assemblies, required_wires, parameter_overrides_schema?, drawing?, content_hash, occurred_at |
version_assembly succeeds; payload carries the replacement structural content |
AssemblyDeprecated |
assembly_id, occurred_at |
deprecate_assembly succeeds |
AssemblyPresentsAsAdded |
assembly_id, role_id, occurred_at |
add_assembly_presents_as succeeds; the Assembly advertises one additional Role contract |
AssemblyPresentsAsRemoved |
assembly_id, role_id, occurred_at |
remove_assembly_presents_as succeeds; the Assembly withdraws one previously-advertised Role |
Fixture events¶
| Event | Payload sketch | When emitted |
|---|---|---|
FixtureRegistered |
fixture_id, assembly_id, assembly_content_hash, surface_id, slot_asset_bindings, parameter_overrides, occurred_at |
register_fixture succeeds (genesis) |
FixturePersistentIdAssigned |
fixture_id, persistent_id_scheme, persistent_id_value, occurred_at |
assign_fixture_persistent_id succeeds; set-once at the aggregate level |
Role events¶
| Event | Payload sketch | When emitted |
|---|---|---|
RoleDefined |
role_id, name, docstring, required_affordances, optional_affordances, produces, consumes, occurred_at |
define_role succeeds (genesis); the only Role event today, status is implicit Defined |
AssetRelocated is the only event in the Equipment module that carries source state in its payload, because parent_id is a mutable value across many possible prior states rather than a discrete enum. AssetDetachedFromFixture and MountAssetUninstalled follow the same pattern for their respective back-references. Assembly versioning events carry the full replacement structural content so a downstream consumer can fold to the post-version state without loading the prior version.
Slices¶
The eight aggregates expose sixty slices end to end.
| Command / query | REST | MCP tool |
|---|---|---|
ActivateAsset |
POST /assets/{asset_id}/activate |
activate_asset |
AddAssemblyPresentsAs |
POST /assemblies/{assembly_id}/add-presents-as |
add_assembly_presents_as |
AddAssetAlternateIdentifier |
POST /assets/{asset_id}/add-alternate-identifier |
add_asset_alternate_identifier |
AddAssetFamily |
POST /assets/{asset_id}/add-family |
add_asset_family |
AddAssetOwner |
POST /assets/{asset_id}/add-owner |
add_asset_owner |
AddAssetPort |
POST /assets/{asset_id}/add-port |
add_asset_port |
AddFamilyPresentsAs |
POST /families/{family_id}/add-presents-as |
add_family_presents_as |
AddModelFamily |
POST /models/{model_id}/families |
add_model_family |
AssignAssetPersistentId |
POST /assets/{asset_id}/assign-persistent-identifier |
assign_asset_persistent_id |
AssignFixturePersistentId |
POST /fixtures/{fixture_id}/assign-persistent-identifier |
assign_fixture_persistent_id |
AttachAssetToFixture |
POST /assets/{asset_id}/attach-to-fixture |
attach_asset_to_fixture |
BindAssetToFacility |
POST /assets/{asset_id}/bind-to-facility |
bind_asset_to_facility |
DecommissionAsset |
POST /assets/{asset_id}/decommission |
decommission_asset |
DecommissionFrame |
DELETE /frames/{frame_id} |
decommission_frame |
DecommissionMount |
DELETE /mounts/{mount_id} |
decommission_mount |
DefineAssembly |
POST /assemblies |
define_assembly |
DefineFamily |
POST /families |
define_family |
DefineModel |
POST /models |
define_model |
DefineRole |
POST /roles |
define_role |
DegradeAsset |
POST /assets/{asset_id}/degrade |
degrade_asset |
DeprecateAssembly |
POST /assemblies/{assembly_id}/deprecate |
deprecate_assembly |
DeprecateFamily |
POST /families/{family_id}/deprecate |
deprecate_family |
DeprecateModel |
POST /models/{model_id}/deprecation |
deprecate_model |
DetachAssetFromFixture |
POST /assets/{asset_id}/detach-from-fixture |
detach_asset_from_fixture |
EnterAssetMaintenance |
POST /assets/{asset_id}/enter-maintenance |
enter_asset_maintenance |
ExitAssetMaintenance |
POST /assets/{asset_id}/exit-maintenance |
exit_asset_maintenance |
FaultAsset |
POST /assets/{asset_id}/fault |
fault_asset |
GetAsset |
GET /assets/{asset_id} |
get_asset |
GetAssetIntegrationView |
GET /assets/{asset_id}/integration-view |
get_asset_integration_view |
GetAssetPidinst |
GET /assets/{asset_id}/pidinst |
get_asset_pidinst |
GetFamily |
GET /families/{family_id} |
get_family |
GetFixture |
GET /fixtures/{fixture_id} |
get_fixture |
GetModel |
GET /models/{model_id} |
get_model |
InstallAsset |
PUT /mounts/{mount_id}/installed-asset |
install_asset |
ListAssets |
GET /assets |
list_assets |
ListFamilies |
GET /families |
list_families |
ListFixtures |
GET /fixtures |
list_fixtures |
MintMissingAssetPersistentIds |
POST /assets/mint-missing-persistent-ids |
mint_missing_asset_persistent_ids |
RegisterAsset |
POST /assets |
register_asset |
RegisterFixture |
POST /assemblies/{assembly_id}/fixtures |
register_fixture |
RegisterFrame |
POST /frames |
register_frame |
RegisterMount |
POST /mounts |
register_mount |
RelocateAsset |
POST /assets/{asset_id}/relocate |
relocate_asset |
RemoveAssemblyPresentsAs |
POST /assemblies/{assembly_id}/remove-presents-as |
remove_assembly_presents_as |
RemoveAssetAlternateIdentifier |
POST /assets/{asset_id}/remove-alternate-identifier |
remove_asset_alternate_identifier |
RemoveAssetFamily |
POST /assets/{asset_id}/remove-family |
remove_asset_family |
RemoveAssetOwner |
POST /assets/{asset_id}/remove-owner |
remove_asset_owner |
RemoveAssetPort |
POST /assets/{asset_id}/remove-port |
remove_asset_port |
RemoveFamilyPresentsAs |
POST /families/{family_id}/remove-presents-as |
remove_family_presents_as |
RemoveModelFamily |
DELETE /models/{model_id}/families/{family_id} |
remove_model_family |
RestoreAsset |
POST /assets/{asset_id}/restore |
restore_asset |
UninstallAsset |
DELETE /mounts/{mount_id}/installed-asset |
uninstall_asset |
UpdateAssetPartitionRule |
POST /assets/{asset_id}/partition-rule |
update_asset_partition_rule |
UpdateAssetSettings |
PATCH /assets/{asset_id}/settings |
update_asset_settings |
UpdateFamilySettingsSchema |
POST /families/{family_id}/settings-schema |
update_family_settings_schema |
UpdateFramePlacement |
PATCH /frames/{frame_id}/placement |
update_frame_placement |
UpdateMountPlacement |
PATCH /mounts/{mount_id}/placement |
update_mount_placement |
VersionAssembly |
POST /assemblies/{assembly_id}/versions |
version_assembly |
VersionFamily |
POST /families/{family_id}/version |
version_family |
VersionModel |
POST /models/{model_id}/versions |
version_model |
Errors per slice. Beyond Pydantic boundary 422s, each slice raises:
DefineFamilyInvalidFamilyName,FamilyAlreadyExists,UnauthorizedVersionFamily/DeprecateFamilyFamilyNotFound,FamilyCannotVersion/FamilyCannotDeprecate,InvalidFamilyVersionTag(version only),UnauthorizedUpdateFamilySettingsSchemaFamilyNotFound,InvalidFamilySettingsSchema,UnauthorizedAddFamilyPresentsAs/RemoveFamilyPresentsAsFamilyNotFound,RoleNotFound(add only, therole_iddoes not resolve viaRoleLookup),FamilyCannotPresentAs(add only,Family.affordancesdoes not superset the Role'srequired_affordances),FamilyRolePresentsAsAlready(add only) /FamilyRolePresentsAsNotPresent(remove only),UnauthorizedDefineModelInvalidModelName,ModelAlreadyExists,InvalidDeclaredFamilies,FamilyNotFound,UnauthorizedVersionModel/DeprecateModelModelNotFound,ModelCannotVersion/ModelCannotDeprecate,InvalidDeclaredFamilies(version only),UnauthorizedAddModelFamily/RemoveModelFamilyModelNotFound,ModelCannotAddFamily/ModelCannotRemoveFamily,ModelFamilyAlreadyPresent/ModelFamilyNotPresent,FamilyNotFound(add only),UnauthorizedRegisterAssetInvalidAssetName,InvalidAssetParent(the XOR anchoring rule is violated: a root Asset that supplies aparent_id, or a non-root Asset that suppliesfacility_codeinstead of aparent_id),AssetAlreadyExists,ModelNotFound,AssetOwnerAlreadyPresent(seed owners with duplicate names),AssetFacilityNotFound(facility_code supplied but the Facility is not visible in the Federation projection),UnauthorizedActivateAsset/EnterAssetMaintenance/ExitAssetMaintenance/DecommissionAssetAssetNotFound,AssetCannot<Activate|EnterMaintenance|ExitMaintenance|Decommission>,UnauthorizedRelocateAssetAssetNotFound,AssetCannotRelocate(root Asset, Decommissioned, self-loop, or no-op),UnauthorizedAddAssetFamily/RemoveAssetFamilyAssetNotFound,AssetCannotAddFamily/AssetCannotRemoveFamily(Decommissioned, or duplicate-add / missing-remove),AssetModelMismatch(add only, when Model is bound and the new family set escapesModel.declared_family_ids),ModelNotFound(add only, when the Asset'smodel_idno longer resolves),UnauthorizedAddAssetPort/RemoveAssetPortAssetNotFound,AssetCannotAddPort/AssetCannotRemovePort(Decommissioned, or duplicate-add / missing-remove),InvalidAssetPortName/InvalidAssetPortSignalType(add only),UnauthorizedUpdateAssetSettingsAssetNotFound,InvalidAssetSettings(orphan key, schema violation, or cross-Family conflict),UnauthorizedDegradeAsset/FaultAsset/RestoreAssetAssetNotFound,UnauthorizedAddAssetOwner/RemoveAssetOwner/AddAssetAlternateIdentifier/RemoveAssetAlternateIdentifierAssetNotFound,AssetCannotAddOwner/AssetCannotAddAlternateIdentifier(Decommissioned),AssetOwnerAlreadyPresent/AssetOwnerNotPresent/AssetAlternateIdentifierAlreadyPresent/AssetAlternateIdentifierNotPresent,UnauthorizedAttachAssetToFixture/DetachAssetFromFixtureAssetNotFound,FixtureNotFound(attach only),AssetAlreadyAttachedToFixture(attach only),AssetCannotAttachToFixture(attach only, Asset is Decommissioned),AssetNotBoundInFixture(attach only, Asset is not listed in the Fixture's slot bindings),AssetNotAttachedToFixture(detach only),AssetAttachedToDifferentFixture(detach only, defensive id mismatch),UnauthorizedBindAssetToFacilityAssetNotFound,AssetFacilityNotFound(facility_code supplied but the Facility is not visible in the Federation projection),AssetFacilityCodeAlreadyAssigned(set-once: the target Asset already carries a facility_code from register_asset genesis OR a prior bind_asset_to_facility call; rebind path is decommission + re-register),UnauthorizedRegisterFrame/UpdateFramePlacement/DecommissionFrameFrameAlreadyExists(register only),FrameNotFound(update / decommission),FrameCannotSupersede(register only, supersedes pointer not in Active),InvalidFrameRoot(register or update; root Frame placement / parent constraint),FrameCannotUpdate(Decommissioned),FrameCannotDecommission(Decommissioned),FrameInUse(decommission only, active consumers exist),UnauthorizedRegisterMount/UpdateMountPlacement/InstallAsset/UninstallAsset/DecommissionMountMountAlreadyExists(register only),MountNotFound,MountCannotUpdate(Decommissioned),MountAlreadyOccupied(install only),MountIsEmpty(uninstall only),MountHasAssetInstalled(decommission only),MountHasActiveChildren(decommission only),MountCannotDecommission(Decommissioned),AssetNotFoundForMount(install only),AssetNotInstallable(install only, Asset is Decommissioned),AssetAlreadyInstalledElsewhere(install only, Asset is in another active Mount),UnauthorizedDefineAssembly/VersionAssembly/DeprecateAssemblyAssemblyAlreadyExists(define only),AssemblyNotFound,AssemblyCannotVersion/AssemblyCannotDeprecate,FamilyNotFoundForAssembly(define / version, when a slot'srequired_family_idsreferences a missing Family), Role not found (define / version / amend, when an id inpresents_asreferences a missing Role),WireReferencesUnknownSlot(define / version, when a TemplateWire endpoint cites a slot the same Assembly does not declare),UnauthorizedAddAssemblyPresentsAs/RemoveAssemblyPresentsAsAssemblyNotFound,RoleNotFound(add only, therole_iddoes not resolve viaRoleLookup),AssemblyRolePresentsAsAlready(add only) /AssemblyRolePresentsAsNotPresent(remove only),Unauthorized. No affordance-superset check at the template tier: Assembly affordances derive from the constituent Family union atregister_fixturetime.RegisterFixtureAssemblyNotFound,AssemblyCannotInstantiate(Assembly is Deprecated),FixtureAlreadyExists,FixtureAssetNotFound(a binding references a missing Asset),FixtureAssetNotAttachable(a binding's Asset is Decommissioned),FixtureAssetNotInstalled(a binding's Asset is not currently installed in any Mount),FixtureAssetFamilyMismatch(a binding's Asset does not include the slot's required Family in itsfamily_ids),FixtureMappingIncomplete(a required slot has no covering binding),UnauthorizedDefineRoleInvalidRoleName,InvalidRoleDocstring,RoleAffordanceOverlap(required and optional Affordance sets are not disjoint),InvalidSignalType(aproduces/consumesentry is empty or too long),RoleAlreadyExists(a Role with the same case-insensitive name already exists, including the five auto-seeded Roles),UnauthorizedGetFamily/GetModel/GetAsset/GetAssetIntegrationView/GetFixture<X>NotFoundGetAssetPidinstAssetNotFound,PidinstSerializationError(and its subclasses:AssetNameMissing,LandingPageMissing,OwnerStateNotAvailable,ManufacturerStateNotAvailable,PidinstRecordInvariant)ListFamilies/ListAssets/ListFixtures- (boundary 422 only)
GetAssetIntegrationView is a read-time composition slice that joins the Asset's current state with the schema declarations of its assigned Families, the Capabilities those Families' affordances satisfy, and the active Cautions targeting the Asset. The composition runs at query time; there is no integration-view projection today. The view is the read-side primitive that integration code (a control-system adapter, a measurement broker) uses to discover "what can this Asset actually do". The response carries asset_id, name, tier, lifecycle, condition, parent_id, families (each with its settings schema), ports, settings, active_cautions, applicable_capabilities, and an incomplete boolean that flags partial composition when a referenced Family failed to load (eventual-consistency tolerance).
GetAssetPidinst is a read-time serializer slice at GET /assets/{asset_id}/pidinst. A feature-local view assembler loads the Asset, its bound Model, the Families behind the Model, the Asset's owners, and its alternate identifiers, then hands the assembled view to the to_pidinst_record pure function at the module root (_pidinst_serializer.py plus _pidinst_types.py). The response body is the PIDINST record shape used by external persistent-identifier registries, returned directly so a downstream caller can post it to DataCite or another mint surface without further transformation. Errors map: AssetNotFound to 404, PidinstRecordInvariant to 422 (the assembled view violated a PIDINST schema invariant), the four pre-construction subclasses (AssetNameMissing, LandingPageMissing, OwnerStateNotAvailable, ManufacturerStateNotAvailable) to 409 (the input view was loaded but failed serializer preconditions), and any unexpected failure to 500.
Cross-aggregate invariants¶
The Mount, Fixture, and Asset aggregates compose to answer two different questions about a single piece of equipment: Mount answers "where is this Asset bolted in the beamline" (one physical bay, one Asset, a 6-DoF pose, mutable across re-installs), and Fixture answers "which Assets compose this Assembly recipe on this Trust Surface" (one snapshot, N Assets keyed by template slot names, frozen at registration). The two are complementary; a typical specimen is both physically installed in a Mount and logically bound into a Fixture for the run that consumes its Assembly.
The word "slot" carries two distinct meanings in this module, only differentiated by the carrier. Mount.slot_code is a facility-unique physical bay (for example, APS 2-BM's 02-BM-A-K-01); slot codes are facility-wide identifiers operators speak in. SlotAssetBinding.slot_name is a template-local name drawn from the Assembly's required_slots (for example, camera, rotary, detector); slot names are scoped to one materialization and may repeat under OneOrMore cardinality. The two never reference each other directly: a Mount slot and an Assembly template slot are independent axes that the same Asset can be reached through.
| Axis | Mount | Fixture |
|---|---|---|
| Cardinality | one Asset per Mount, facility-unique slot_code |
N Assets per Fixture, slot_name unique only within the materialization |
| Lifecycle | two-state FSM (Active, Decommissioned), in-place mutation via install / uninstall / update_mount_placement |
no FSM; single-event genesis; "if it needs to change, register a new Fixture" |
| Relation graph | inbound terminus of the placement tree (Mount.placement.parent_frame_id -> Frame; parent_id -> Mount) |
outbound hub of the composition tree (Fixture.assembly_id -> Assembly; Fixture.surface_id -> Trust Surface) |
| Back-reference shape | proj_equipment_asset_location projection (Asset deliberately carries NO installed_at field) |
denormalized Asset.fixture_id scalar set by attach_asset_to_fixture on the Asset stream |
The Mount.installed_asset_id field is the single source of truth for "what is bolted into this slot right now"; the proj_equipment_asset_location projection feeds the back-lookup. The Asset.fixture_id field is the back-reference for "what Fixture currently binds this Asset"; it is set and cleared by the attach_asset_to_fixture / detach_asset_from_fixture slices that mirror the Fixture's slot_asset_bindings set.
Four cross-aggregate guards keep the three aggregates from drifting:
| Slice | Source aggregate | Refuses when | Operator must first | Error class |
|---|---|---|---|---|
decommission_asset |
Asset | the Asset still carries a fixture_id back-reference |
detach_asset_from_fixture |
AssetHasFixtureBindingError |
decommission_asset |
Asset | the Asset is still installed in a Mount (proj_equipment_asset_location row exists) |
uninstall_asset from that Mount |
AssetIsInstalledError |
uninstall_asset |
Mount | the installed Asset still carries a fixture_id back-reference |
detach_asset_from_fixture |
MountHasFixtureBoundAssetError |
register_fixture |
Fixture | any bound Asset has lifecycle Decommissioned |
use a different Asset, or activate this one | FixtureAssetNotAttachableError |
All four mirror the same precedent: per-reason error class (not a discriminated *CannotTransition(reason=...)), HAS/IS micro-grammar for Asset/Mount, negation prefix for the FixtureAsset* sibling family, sorted-first deterministic identification of the offending Asset when more than one applies. The guards surface the cross-aggregate dependency at the natural choreography step rather than letting a stranded back-reference propagate into a downstream operation that fails far from its cause.
The recommended choreography for a typical specimen lifecycle is therefore:
register_asset(Asset entersCommissioned)activate_asset(Asset entersActive)register_frame(if no parent Frame exists yet) andregister_mount(Mount provisioned at a knownslot_code)install_asset(the Asset is bolted into the Mount;proj_equipment_asset_locationrecords the back-lookup)define_family,define_assembly,register_fixture(the Assembly blueprint is materialized into a Fixture binding this Asset by its template slot name)attach_asset_to_fixture(the Asset'sfixture_idback-reference is set)- Run consumes the Fixture for the duration of its execution
detach_asset_from_fixture(the Asset'sfixture_idis cleared)uninstall_asset(the Mount becomes vacant;proj_equipment_asset_locationclears the row)decommission_asset(the Asset entersDecommissioned)
Steps 5-9 may repeat many times across runs while a single physical install (step 4) persists; the Mount slot outlives the individual specimens that pass through it. The four guards above hold the order of the teardown side: 8 must precede 9 if the Mount-uninstall guard is not to fire, and 8 + 9 must both precede 10 if the Asset-decommission guards are not to fire.
Storage & Projections¶
Fourteen read-side tables back the Equipment module: per-aggregate summaries for the eight aggregates that have one (Asset, Family, Model, Frame, Mount, Assembly, Fixture, Role), join projections for membership and parent-child relations, and uniqueness helpers that back decide-time precondition checks.
CREATE TABLE proj_equipment_asset_summary (
asset_id UUID PRIMARY KEY,
name TEXT NOT NULL,
tier TEXT NOT NULL CHECK (
tier IN ('Unit', 'Component', 'Device')
),
lifecycle TEXT NOT NULL CHECK (
lifecycle IN ('Commissioned', 'Active', 'Maintenance', 'Decommissioned')
),
condition TEXT NOT NULL DEFAULT 'Nominal' CHECK (
condition IN ('Nominal', 'Degraded', 'Faulted')
),
parent_id UUID,
model_id UUID,
fixture_id UUID,
drawing_system TEXT,
drawing_number TEXT,
drawing_revision TEXT,
owners JSONB NOT NULL DEFAULT '[]'::jsonb,
alternate_identifiers JSONB NOT NULL DEFAULT '[]'::jsonb,
persistent_id JSONB,
commissioned_at TIMESTAMPTZ,
decommissioned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_asset_summary_keyset_idx
ON proj_equipment_asset_summary (created_at, asset_id);
CREATE INDEX proj_equipment_asset_summary_parent_idx
ON proj_equipment_asset_summary (parent_id)
WHERE parent_id IS NOT NULL;
The Asset summary is the canonical list source for GET /assets. The partial index on parent_id supports direct-children lookups. The tier, lifecycle, and condition CHECKs match the corresponding closed enums exactly. The summary surfaces every projection-friendly Asset facet: lifecycle and condition states, parent and model and fixture back-references, the optional Drawing VO unpacked into three columns, and the JSONB-folded owners and alternate-identifier sets that feed the PIDINST view. Asset.family_ids, Asset.settings, and Asset.ports remain off the summary; single-Asset reads fold the event stream and return them. The reserved persistent_id column is empty today and is wired for the future mint-write slice.
CREATE TABLE proj_equipment_family_summary (
family_id UUID PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('Defined', 'Versioned', 'Deprecated')
),
version_tag TEXT,
settings_schema_present BOOLEAN NOT NULL DEFAULT FALSE,
affordances TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
presents_as UUID[] NOT NULL DEFAULT ARRAY[]::UUID[],
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_family_summary_keyset_idx
ON proj_equipment_family_summary (created_at, family_id);
The Family summary backs GET /families. settings_schema_present is a boolean indicator of whether the Family has any settings schema declared (the schema content itself is folded from the event stream on demand); versioned_at and deprecated_at are lifecycle timestamps mirrored from the events. affordances mirrors the Family's Affordance value strings (wholesale-replaced on FamilyVersioned) and backs the affordance-superset check in the add_family_presents_as slice and Recipe's Role satisfaction path; presents_as is the array of Role ids the Family advertises, mutated incrementally by add_family_presents_as (append) and remove_family_presents_as (remove).
CREATE TABLE proj_equipment_asset_family_membership (
asset_id UUID NOT NULL,
family_id UUID NOT NULL,
added_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (asset_id, family_id)
);
CREATE INDEX proj_equipment_asset_family_membership_by_family_idx
ON proj_equipment_asset_family_membership (family_id, asset_id);
The membership join projection mirrors the Asset.family_ids relation as one row per (asset_id, family_id) pair, folded from AssetFamilyAdded (INSERT) and AssetFamilyRemoved (DELETE) events. The aggregate state is the canonical source; the projection exists to answer the reverse-direction query "which Assets belong to Family X" efficiently. The _by_family_idx secondary index supports the per-Family read used by Recipe's inspect_plan_binding candidate enumeration.
CREATE TABLE proj_equipment_model_summary (
model_id UUID PRIMARY KEY,
name TEXT NOT NULL,
manufacturer_name TEXT NOT NULL,
manufacturer_identifier TEXT,
manufacturer_identifier_type TEXT,
part_number TEXT NOT NULL,
declared_families JSONB NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('Defined', 'Versioned', 'Deprecated')
),
version_tag TEXT,
deprecation_reason TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT proj_equipment_model_summary_identifier_type_chk CHECK (
manufacturer_identifier_type IS NULL
OR manufacturer_identifier_type IN ('ROR', 'GRID', 'ISNI')
),
CONSTRAINT proj_equipment_model_summary_identifier_paired_chk CHECK (
(manufacturer_identifier IS NULL AND manufacturer_identifier_type IS NULL)
OR (manufacturer_identifier IS NOT NULL AND manufacturer_identifier_type IS NOT NULL)
)
);
CREATE INDEX proj_equipment_model_summary_vendor_key_idx
ON proj_equipment_model_summary (manufacturer_name, part_number);
CREATE INDEX proj_equipment_model_summary_keyset_idx
ON proj_equipment_model_summary (created_at, model_id);
The Model summary backs GET /models and the catalog-lookup pattern (find a model by manufacturer plus part number). The paired-identifier CHECK enforces that manufacturer_identifier and manufacturer_identifier_type are either both present or both absent; the type CHECK restricts the identifier scheme to ROR, GRID, or ISNI when present. declared_families is the JSONB array of family ids the catalog row declares.
CREATE TABLE proj_equipment_frame_summary (
frame_id UUID PRIMARY KEY,
name TEXT NOT NULL,
parent_id UUID,
placement_relative_to_parent JSONB,
status TEXT NOT NULL CHECK (
status IN ('Active', 'Decommissioned')
),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_frame_summary_parent_idx
ON proj_equipment_frame_summary (parent_id)
WHERE parent_id IS NOT NULL;
The Frame summary backs Frame queries and tree walks. Root Frames carry NULL parent and NULL placement.
CREATE TABLE proj_equipment_frame_children (
parent_id UUID NOT NULL,
child_frame_id UUID NOT NULL,
registered_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (parent_id, child_frame_id)
);
CREATE INDEX proj_equipment_frame_children_by_child_idx
ON proj_equipment_frame_children (child_frame_id);
CREATE TABLE proj_equipment_frame_consumers (
referenced_frame_id UUID NOT NULL,
consumer_id UUID NOT NULL,
consumer_type TEXT NOT NULL CHECK (
consumer_type IN ('Frame', 'Mount')
),
registered_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (referenced_frame_id, consumer_id, consumer_type)
);
CREATE INDEX proj_equipment_frame_consumers_by_consumer_idx
ON proj_equipment_frame_consumers (consumer_id);
The Frame children join projection materializes the parent-child edge for tree walks. The Frame consumers projection is the decide-time precondition source for decommission_frame: any active Mount whose placement references a Frame, plus any active child Frame, blocks decommission.
CREATE TABLE proj_equipment_mount_summary (
mount_id UUID PRIMARY KEY,
slot_code TEXT NOT NULL,
parent_id UUID,
placement JSONB NOT NULL,
drawing JSONB,
installed_asset_id UUID,
status TEXT NOT NULL CHECK (
status IN ('Active', 'Decommissioned')
),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_mount_summary_parent_idx
ON proj_equipment_mount_summary (parent_id)
WHERE parent_id IS NOT NULL;
CREATE INDEX proj_equipment_mount_summary_installed_asset_idx
ON proj_equipment_mount_summary (installed_asset_id)
WHERE installed_asset_id IS NOT NULL;
CREATE TABLE proj_equipment_mount_slot_code (
slot_code TEXT PRIMARY KEY,
mount_id UUID NOT NULL,
registered_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX proj_equipment_mount_slot_code_by_mount_idx
ON proj_equipment_mount_slot_code (mount_id);
CREATE TABLE proj_equipment_mount_children (
parent_id UUID NOT NULL,
child_mount_id UUID NOT NULL,
registered_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (parent_id, child_mount_id)
);
CREATE INDEX proj_equipment_mount_children_by_child_idx
ON proj_equipment_mount_children (child_mount_id);
CREATE TABLE proj_equipment_asset_location (
asset_id UUID PRIMARY KEY,
mount_id UUID NOT NULL,
installed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX proj_equipment_asset_location_by_mount_idx
ON proj_equipment_asset_location (mount_id);
The Mount summary backs Mount queries and the installed_asset_id lookup. The slot-code uniqueness table enforces slot_code global uniqueness across active Mounts at decide time. The Mount children projection backs the decommission precondition (parent-with-active-children blocks). The asset-location projection answers "where is Asset X installed right now" with a single-row read keyed on asset_id.
CREATE TABLE proj_equipment_assembly_summary (
assembly_id UUID PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('Defined', 'Versioned', 'Deprecated')
),
version TEXT,
content_hash TEXT,
presents_as UUID[] NOT NULL DEFAULT ARRAY[]::UUID[],
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_assembly_summary_keyset_idx
ON proj_equipment_assembly_summary (created_at, assembly_id);
CREATE INDEX proj_equipment_assembly_summary_content_hash_idx
ON proj_equipment_assembly_summary (content_hash)
WHERE content_hash IS NOT NULL;
The Assembly summary is the read-side anchor for content-hash and presents_as lookups. The content-hash index supports federation dedup queries (find the Assembly that materializes the same canonical content). The presents_as UUID[] column is the sole presents_as storage: it carries the Role ids the Assembly advertises (folded into content_hash at define and version), and is amended by add_assembly_presents_as (append) and remove_assembly_presents_as (remove). It backs the Role-based binding path. Read slices on Assembly itself (get_assembly, list_assemblies) are deferred today; operators consume Assembly state indirectly through get_fixture and list_fixtures, which carry the assembly_id and the frozen assembly_content_hash for each materialization.
CREATE TABLE proj_equipment_role_summary (
role_id UUID PRIMARY KEY,
name TEXT NOT NULL CHECK (
length(name) > 0
),
docstring TEXT NOT NULL CHECK (
length(docstring) > 0
),
required_affordances TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
optional_affordances TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
produces TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
consumes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX proj_equipment_role_summary_name_lower_uq
ON proj_equipment_role_summary (LOWER(name));
CREATE INDEX proj_equipment_role_summary_keyset_idx
ON proj_equipment_role_summary (created_at, role_id);
The Role summary folds RoleDefined and is the read-side anchor for the cross-aggregate satisfaction checks (Family.presents_as validation, Assembly.presents_as validation, Recipe's bind_plan_role role_kind path, Capability.suggested_roles existence check) and for future operator-facing list / get endpoints. The LOWER(name) unique index enforces case-insensitive name uniqueness, matching the deterministic role_stream_id(name) derivation so a define_role race on the same name collides at the event store before the projection writer. The four affordance / signal arrays default to empty; disjointness of required_affordances and optional_affordances is enforced at the decider, not the DB.
CREATE TABLE proj_equipment_fixture_summary (
fixture_id UUID PRIMARY KEY,
assembly_id UUID NOT NULL,
assembly_content_hash TEXT NOT NULL,
surface_id UUID NOT NULL,
binding_count INTEGER NOT NULL,
override_count INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_equipment_fixture_summary_keyset_idx
ON proj_equipment_fixture_summary (created_at, fixture_id);
CREATE INDEX proj_equipment_fixture_summary_content_hash_idx
ON proj_equipment_fixture_summary (assembly_content_hash);
CREATE INDEX proj_equipment_fixture_summary_surface_assembly_idx
ON proj_equipment_fixture_summary (surface_id, assembly_id);
The Fixture summary backs GET /fixtures plus filters by assembly_id, surface_id, and assembly_content_hash. Counts (binding_count, override_count) keep the summary narrow; the full slot_asset_bindings and parameter_overrides live in the FixtureRegistered event payload and are loaded on demand by get_fixture. The composite (surface_id, assembly_id) index supports Method.needed_assembly_ids satisfaction lookups scoped to a Trust Surface.
Cross-Module boundaries¶
| Module | Relationship | What's exchanged |
|---|---|---|
| Recipe | reads-from | Method.needed_family_ids references Family ids; Plan binding matches a Method's required Families against Asset.family_ids |
| Recipe | reads-from | Method.needed_assembly_ids references Assembly ids; Plan binding requires each id to be materialized as a Fixture whose assembly_id matches and whose bound Assets are among the Plan's bound Assets |
| Recipe | reads-from (via FamilyLookup + AssemblyLookup ports) |
A Method's RoleRequirement.role_kind references a Role.id; Recipe's bind_plan_role satisfaction path reads each candidate's presents_as set through FamilyLookup / AssemblyLookup and requires the Family's affordances to superset the Role's required_affordances |
| Recipe | reads-from (via RoleLookup port) |
Capability.suggested_roles and the add_family_presents_as / add_assembly_presents_as slices resolve a Role.id to its contract through RoleLookup; an unresolved id raises RoleNotFoundError (HTTP 404) |
| Recipe | reads-from | Plan.wiring references Asset ports by (asset_id, port_name) |
| Run | reads-from | Each Run's effective parameters are validated against the Method's parameters schema; Run pinned calibrations reference Asset ids transitively |
| Operation | reads-from | Procedure.target_asset_ids references the Assets the procedure acts on |
| Calibration | reads-from | Calibration.target_id references the Asset whose behaviour is being measured |
| Supply | reads-from | Physical infrastructure delivering a supply (a gas cabinet, a mass-flow controller) lives as Assets; the resource itself is a Supply |
| Trust | gated-by | Every write-side Equipment slice (Family / Model / Asset / Frame / Mount / Assembly / Fixture mutations) is gated by the Authorize port resolving a Policy for the (principal, command, conduit, surface) tuple |
| Trust | reads-from | Zones group Assets by trust-requirement homogeneity (orthogonal to the parent_id structural tree and the tier facet) |
| Trust | shared-id-with | Fixture.surface_id references a Trust Surface and scopes the Fixture's authorization context |
| Caution | targeted-by | Cautions with AssetTarget point at Asset.id values; with propagate_to_children: true the Caution surfaces on descendant Assets via projection walk of Asset.parent_id |
| Safety | shared-id-with | Clearance.facility_code references the owning Site Facility (a Federation aggregate, not an Asset); AssetBinding.asset_id references any Asset a Clearance gates |
| Access | shared-id-with | Every Equipment event envelope carries the actor id that authored the change |
| Federation | shared-content-hash-with | Assembly.content_hash and Fixture.assembly_content_hash are stable across facilities for federation dedup and cross-facility composition sharing (this holds because the Family ids the hash folds in are deterministic uuid5-over-name) |
| Federation | reads-from (via FacilityLookup port) |
Asset.facility_code (optional) binds an Asset to its owning Federation Facility via the cross-deployment convergent slug. The register_asset handler resolves the slug via FacilityLookup.lookup_by_code and rejects unknown codes with AssetFacilityNotFoundError (HTTP 404). Decommissioned-Facility binding is allowed. Set-once at registration; rebind path is decommission + re-register. |
The Asset hierarchy answers "where does this belong structurally"; Trust Zones answer "what security policy applies"; Frame / Mount answers "where is it physically installed". The three classifications are orthogonal: an Asset has all three, and zones, frames, and the structural parent tree all span Sites independently.
The Method.needed_assembly_ids reference is eventual-consistency: Recipe does not verify Assembly existence at define_method time. Mismatch surfaces at Plan binding. The binding rule is: for each id in Method.needed_assembly_ids, the Plan's bound Asset set must contain a Fixture whose assembly_id equals that id, and that Fixture's slot_asset_bindings must reference Assets that are themselves in the Plan's bound Asset set. Assembly.presents_as provides the Role contracts Recipe's Capability affordance-cover check binds against, so Method.needed_family_ids satisfaction and Method.needed_assembly_ids satisfaction compose without double-counting.
Two attachment paths exist for binding Assets to a Fixture. register_fixture writes the Fixture stream and persists the slot map there; it does NOT mutate the Asset streams. Each bound Asset's fixture_id back-reference is set separately through attach_asset_to_fixture on the Asset stream. The two-step shape keeps the Fixture's authorization Surface independent of the per-Asset write surfaces and lets the operator stage attachments rather than commit them atomically with the materialization.
Examples¶
The five examples below cover the canonical lifecycle of one beamline's installation flow: define the Family classes the equipment fits, register the Asset under a parent, install it into a provisioned Mount, compose it into an Assembly with its peers, and materialize that Assembly as a Fixture on a Trust Surface. For the REST/MCP equivalence, auth, and idempotency conventions these examples share, see Reading the examples on the Modules landing page.
Define a Family¶
POST /families
Content-Type: application/json
Idempotency-Key: 6f4a3b1c-8e2d-4f5a-9b8c-1d2e3f4a5b6c
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "RotaryStage",
"affordances": ["Move.Continuous", "Move.Step", "Signal.PositionFeedback"]
}
Returns 201 Created with the newly-assigned family_id. The Family starts in Defined; a subsequent update_family_settings_schema call attaches the JSON Schema that constrains what an Asset of this Family may carry in its settings dict.
Register an Asset under a parent¶
POST /assets
Content-Type: application/json
Idempotency-Key: 7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "Aerotech ABRS-200MP",
"tier": "Device",
"parent_id": "aaaa1111-2222-3333-4444-555555555555"
}
Returns 201 Created with the newly-assigned asset_id. Lifecycle starts at Commissioned; condition starts at Nominal; the Asset has no Families, ports, owners, alternate identifiers, or Fixture binding yet. A subsequent add_asset_family call attaches RotaryStage, after which update_asset_settings may carry the keys that Family's schema declares.
Install an Asset into a provisioned Mount¶
PUT /mounts/<mount-id>/installed-asset
Content-Type: application/json
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"asset_id": "<asset-id>"
}
Returns 200 OK. The Mount now points at the Asset, and the proj_equipment_asset_location row for the Asset is upserted to the Mount's id. The Mount must be Active and currently empty; the Asset must exist, not be Decommissioned, and not already be installed in any other active Mount. Calling install on the same Mount with a different Asset returns 409 Conflict with MountAlreadyOccupied until uninstall_asset clears the slot.
Define an Assembly composition blueprint¶
POST /assemblies
Content-Type: application/json
Idempotency-Key: c4f8e1a3-5b7d-9e0f-2c4a-6b8d0e1f3a5c
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "ScanChain",
"presents_as": ["ffff0000-0000-0000-0000-000000000001"],
"required_slots": [
{
"slot_name": "rotary_stage",
"required_family_ids": ["aaaa1111-1111-1111-1111-111111111111"],
"cardinality": "Exactly1"
},
{
"slot_name": "camera",
"required_family_ids": ["bbbb2222-2222-2222-2222-222222222222"],
"cardinality": "Exactly1"
},
{
"slot_name": "scintillator",
"required_family_ids": ["cccc3333-3333-3333-3333-333333333333"],
"cardinality": "Exactly1"
}
],
"required_wires": [
{
"source_slot": "rotary_stage",
"source_port": "trigger_out",
"target_slot": "camera",
"target_port": "trigger_in"
}
]
}
Returns 201 Created with the newly-assigned assembly_id. The Assembly starts in Defined; the decider computes the canonical content_hash from the structural content (slots, wires, schema) and pins it in the genesis event. Two Assemblies with identical structural intent across facilities share the same content_hash because their presents_as Role ids and slot required_family_ids are deterministic name-derived ids, which feeds the Federation dedup query.
mcp.call_tool(
"define_assembly",
{
"name": "ScanChain",
"presents_as": ["ffff0000-0000-0000-0000-000000000001"],
"required_slots": [
{"slot_name": "rotary_stage", "required_family_ids": ["aaaa1111-1111-1111-1111-111111111111"], "cardinality": "Exactly1"},
{"slot_name": "camera", "required_family_ids": ["bbbb2222-2222-2222-2222-222222222222"], "cardinality": "Exactly1"},
{"slot_name": "scintillator", "required_family_ids": ["cccc3333-3333-3333-3333-333333333333"], "cardinality": "Exactly1"},
],
"required_wires": [
{"source_slot": "rotary_stage", "source_port": "trigger_out",
"target_slot": "camera", "target_port": "trigger_in"},
],
},
)
Register a Fixture against an Assembly¶
POST /assemblies/<assembly-id>/fixtures
Content-Type: application/json
Idempotency-Key: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"slot_asset_bindings": [
{"slot_name": "rotary_stage", "asset_id": "11111111-2222-3333-4444-555555555555"},
{"slot_name": "camera", "asset_id": "22222222-3333-4444-5555-666666666666"},
{"slot_name": "scintillator", "asset_id": "33333333-4444-5555-6666-777777777777"}
],
"parameter_overrides": {}
}
Returns 201 Created with the newly-assigned fixture_id. The Fixture freezes the Assembly's current content_hash at registration time (so a later version_assembly does not retroactively change this materialization's identity), reads the caller's X-Surface-Id to scope the Fixture's authorization context, and validates every binding: the Asset must exist, must not be Decommissioned, and must include the slot's required Family in its family_ids. The Assembly must be in Defined or Versioned (not Deprecated). This call does NOT mutate the Asset streams; each bound Asset's fixture_id back-reference is set in a follow-up attach_asset_to_fixture call on each Asset.
mcp.call_tool(
"register_fixture",
{
"assembly_id": "<assembly-id>",
"slot_asset_bindings": [
{"slot_name": "rotary_stage", "asset_id": "11111111-2222-3333-4444-555555555555"},
{"slot_name": "camera", "asset_id": "22222222-3333-4444-5555-666666666666"},
{"slot_name": "scintillator", "asset_id": "33333333-4444-5555-6666-777777777777"},
],
"parameter_overrides": {},
},
)