Object Class Graph Mutations
Restructure the single-parent ObjectClass tree in api-v1 and fan out to the four handlers that recompute everything derived from tree position.
Overview
This flow covers restructuring the single-parent ObjectClass tree and the derived-state recomputation that follows, because they are one causal chain rather than two stories. A graph write on its own leaves hierarchy fields, classification inheritance, Revit categories, and rendered parameter templates stale; the mutation is only complete once ObjectClassParentGraphChanged has fanned out to its four handlers.
It replaces the retired object-category-graph-mutations flow. That flow described ObjectCategoryWorkflowUseCases, CategoryGraphService, AirtableObjectCategoryGraphAdapter, and an ObjectCategoryHierarchyChanged event — none of which exist as concrete implementations in apps/api-v1/src today. The graph itself moved to BIM Ontology as ObjectClass.
Operations that dispatch the event
Only createRoot, addChild, and runGraphMutation reach dispatchParentGraphChanged, and runGraphMutation backs delete, move, merge, and copy. So exactly six operations restructure the tree. Routes below are relative to the /api/v1/bim-ontology/object-classes mount.
| Operation | Route | Notes |
|---|---|---|
| createObjectClass | POST /object-classes | Creates a draft tree root. Requires Idempotency-Key. Rejects any parent key with a message pointing at add-child. Returns 201 with a Location header |
| addObjectClassChild | POST /object-classes/actions/add-child | Creates a draft child. Requires Idempotency-Key |
| deleteObjectClasses | POST /object-classes/actions/delete | The only bulk action with a cap — sourceObjectClassIds is max(500) |
| moveObjectClasses | POST /object-classes/actions/move | Re-parents subtrees. Unbounded id list |
| mergeObjectClasses | POST /object-classes/actions/merge | keepData of source or target is required; omitting it is a 400. Unbounded id list |
| copyObjectClasses | POST /object-classes/actions/copy | Clones subtrees under a target. Unbounded id list |
Partial success is a real outcome. When the graph write persists but a handler throws, the workflow returns a warning result and the controller answers 207, not 200 — the tree changed, but some derived state is stale. This applies to all six operations.
Operations that do NOT dispatch the event
This distinction matters, because these look like they would touch the graph and do not:
| Operation | Why no event |
|---|---|
| approveObjectClass, archiveObjectClass | Lifecycle transitions, not parent edges. ObjectClassLifecycleUseCases.ts contains no dispatch at all — the lifecycle use cases persist directly |
| setObjectClassRevitCategory | Writes an authored classification link on one class. Requires Idempotency-Key, but changes no parent edge |
| listObjectClasses | Read-only. ETag / If-None-Match on ontologyVersion |
| syncAllObjectClasses, syncObjectClassHierarchy, syncObjectClassRenderedParamTemplates, syncObjectClassClassificationInheritance | Recompute endpoints. They call the same ports and use cases the handlers call, invoked directly rather than through the event, so publishing an event would be circular. Whole-tree recompute stays here rather than on the event path (ADR-0047) |
Two nuances in the sync endpoints:
syncObjectClassClassificationInheritancedoes slightly more than the classification handler: after the refill and demote pass it also runsPersistDerivedRevitCategorySetsUseCase. Demote only runs when a seed key is supplied, so a seedless whole-tree recompute skips it.syncAllObjectClasseschains hierarchy (whole tree), rendered templates, and classification (whole tree) — it does not run the Revit reconcile use case that the graph event’s first handler runs.
Event Path
ObjectClassParentGraphChanged— payload isaffectedObjectClassIds(de-duplicateddomain_idseeds) pluschangedAt
Four handlers are registered against it in bim-ontology.composition.ts, in this order:
| Handler | Work | Scoping |
|---|---|---|
ObjectClassParentGraphChangedRevitCategoryHandler | ReconcileObjectClassRevitCategoriesUseCase, then PersistDerivedRevitCategorySetsUseCase | One call with all ids, includeDescendants: true |
ObjectClassParentGraphChangedHierarchyHandler | syncHierarchy | Batched — all affected ids in a single call. The adapter reads the table once and unions each seed’s descendant ids into one write scope, so untouched rows are never rewritten |
ObjectClassParentGraphChangedClassificationHandler | syncClassificationInheritance, then DemoteObjectClassesThatLostClassificationUseCase per required kind | Loops per id |
RenderedParamTemplatesSyncHandler | SyncObjectClassRenderedParamTemplatesUseCase with recomputeSubtree | Loops per id; a move with includeChildren: false yields disjoint subtrees, so it must not stop after the first success |
The demote use case is an optional constructor parameter on the classification handler, but composition always passes it, so demotion is unconditional in production.
Every handler is idempotent on the sorted id list plus changedAt, skips seeds whose row is gone (deleted seeds stay in the payload on purpose), and records failures through ForRecordingBimOntologyHandlerFailure before rethrowing.
Why a graph mutation must trigger classification recomputation
Under ADR-0050 the classification fill is materialized, not resolved at read time:
- Stewards author
*_directvalues only. Make-unique is never authored — it is inferred on the edited node when its direct value differs from the value at the nearest make-unique ancestor. Clearing the value unsets make-unique and the node inherits again. - The fill walks up to the nearest make-unique ancestor and then down until it reaches the next one.
- The effective field mirrors the materialized
*_direct, so no read-time ancestry walk happens and*_inheritedis always cleared. - The four kinds — Uniformat, Masterformat, ObjectClass code, Revit category — fill independently. More than one direct link for a kind is recorded as a
MULTIPLE_DIRECT_CLASSIFICATIONSviolation rather than resolved by guessing.
Re-parenting a node changes which ancestor is its nearest make-unique source, which changes the materialized value for that node and every descendant down to the next make-unique boundary. That is precisely why the graph event has to drive classification and Revit-category recomputation: nothing would notice otherwise, and an approved class could silently keep an effective classification it no longer inherits.
Graph invariants and limits
- Single-parent tree, no primary-parent device (ADR-0046). A missing parent field is a true root. A parent link pointing at a record with no
domain_idis integrity damage, not a root — bothloadSnapshotandlistParentGraphthrowObjectClassParentIdentityMissingErrorand fail closed. - Public identifiers only (ADR-0048).
sourceObjectClassIds,targetObjectClassId, andparentObjectClassIdsaredomain_idvalues; a handle is also accepted, and Airtablerec…ids are accepted during the transition but logged as deprecated. - Bulk caps are asymmetric. Delete caps
sourceObjectClassIdsat 500; move, merge, and copy are unbounded today. - Live shape as of 2026-08-16: 776
object_classesrows, 60 true roots, 0 dangling parent links.
Airtable entry point
- AT-A1 —
object_class_actions.jsdrives theui_object_class_actionstable. It handles Add Child, Move, Copy, Merge, and Delete, plus bulk Approve and Archive (which are lifecycle calls and dispatch no graph event). Setting a Revit category is no longer an AT-A1 action; stewards editrevit_categories_directon the class instead. - AT-A2 —
object_class_approve.jsandobject_class_archive.jssit on theobject_classesrecord detail, one automation per action, for single-class lifecycle changes.
Both patterns POST to api-v1 and never write domain state directly (ADR-0047).
Code References
apps/api-v1/src/modules/bim-ontology/core/application/use-cases/ObjectClassWorkflowUseCases.tsapps/api-v1/src/modules/bim-ontology/core/domain/object-class/ObjectClassGraphService.tsapps/api-v1/src/modules/bim-ontology/core/domain/object-class/ClassificationInheritance.tsapps/api-v1/src/modules/bim-ontology/core/domain/object-class/ObjectClass.events.tsapps/api-v1/src/modules/bim-ontology/core/application/use-cases/ObjectClassLifecycleUseCases.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.routes.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.controller.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.schemas.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedRevitCategoryHandler.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedHierarchyHandler.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedClassificationHandler.tsapps/api-v1/src/modules/bim-ontology/adapters/inbound/events/RenderedParamTemplatesSyncHandler.tsapps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassGraphMutationAdapter.tsapps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassGraphReadAdapter.tsapps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassHierarchyAdapter.tsapps/api-v1/src/modules/bim-ontology/composition/bim-ontology.composition.ts— the fourObjectClassParentGraphChangedregistrationsapps/airtable-front-end/current/bim-ontology/object_class_actions.js
ADRs
| ADR | Bearing on this flow |
|---|---|
| Platform ADR-0046 | The hierarchy is a single-parent tree; no primary-parent device |
| Platform ADR-0047 | Handlers recompute seeded subtrees; whole-tree recompute stays on explicit sync endpoints, and Airtable scripts call the API rather than writing domain state |
| Platform ADR-0048 | domain_id at the public boundary, never a rec… id |
| Platform ADR-0050 | Inherit-by-default classification fill with inferred make-unique, and the lifecycle that demotion feeds |