flow

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.

FlowStatus: Implemented in api-v1

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.

OperationRouteNotes
createObjectClassPOST /object-classesCreates a draft tree root. Requires Idempotency-Key. Rejects any parent key with a message pointing at add-child. Returns 201 with a Location header
addObjectClassChildPOST /object-classes/actions/add-childCreates a draft child. Requires Idempotency-Key
deleteObjectClassesPOST /object-classes/actions/deleteThe only bulk action with a cap — sourceObjectClassIds is max(500)
moveObjectClassesPOST /object-classes/actions/moveRe-parents subtrees. Unbounded id list
mergeObjectClassesPOST /object-classes/actions/mergekeepData of source or target is required; omitting it is a 400. Unbounded id list
copyObjectClassesPOST /object-classes/actions/copyClones 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:

OperationWhy no event
approveObjectClass, archiveObjectClassLifecycle transitions, not parent edges. ObjectClassLifecycleUseCases.ts contains no dispatch at all — the lifecycle use cases persist directly
setObjectClassRevitCategoryWrites an authored classification link on one class. Requires Idempotency-Key, but changes no parent edge
listObjectClassesRead-only. ETag / If-None-Match on ontologyVersion
syncAllObjectClasses, syncObjectClassHierarchy, syncObjectClassRenderedParamTemplates, syncObjectClassClassificationInheritanceRecompute 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:

  • syncObjectClassClassificationInheritance does slightly more than the classification handler: after the refill and demote pass it also runs PersistDerivedRevitCategorySetsUseCase. Demote only runs when a seed key is supplied, so a seedless whole-tree recompute skips it.
  • syncAllObjectClasses chains 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 is affectedObjectClassIds (de-duplicated domain_id seeds) plus changedAt

Four handlers are registered against it in bim-ontology.composition.ts, in this order:

HandlerWorkScoping
ObjectClassParentGraphChangedRevitCategoryHandlerReconcileObjectClassRevitCategoriesUseCase, then PersistDerivedRevitCategorySetsUseCaseOne call with all ids, includeDescendants: true
ObjectClassParentGraphChangedHierarchyHandlersyncHierarchyBatched — 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
ObjectClassParentGraphChangedClassificationHandlersyncClassificationInheritance, then DemoteObjectClassesThatLostClassificationUseCase per required kindLoops per id
RenderedParamTemplatesSyncHandlerSyncObjectClassRenderedParamTemplatesUseCase with recomputeSubtreeLoops 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 *_direct values 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 *_inherited is 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_CLASSIFICATIONS violation 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_id is integrity damage, not a root — both loadSnapshot and listParentGraph throw ObjectClassParentIdentityMissingError and fail closed.
  • Public identifiers only (ADR-0048). sourceObjectClassIds, targetObjectClassId, and parentObjectClassIds are domain_id values; a handle is also accepted, and Airtable rec… ids are accepted during the transition but logged as deprecated.
  • Bulk caps are asymmetric. Delete caps sourceObjectClassIds at 500; move, merge, and copy are unbounded today.
  • Live shape as of 2026-08-16: 776 object_classes rows, 60 true roots, 0 dangling parent links.

Airtable entry point

  • AT-A1object_class_actions.js drives the ui_object_class_actions table. 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 edit revit_categories_direct on the class instead.
  • AT-A2object_class_approve.js and object_class_archive.js sit on the object_classes record 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.ts
  • apps/api-v1/src/modules/bim-ontology/core/domain/object-class/ObjectClassGraphService.ts
  • apps/api-v1/src/modules/bim-ontology/core/domain/object-class/ClassificationInheritance.ts
  • apps/api-v1/src/modules/bim-ontology/core/domain/object-class/ObjectClass.events.ts
  • apps/api-v1/src/modules/bim-ontology/core/application/use-cases/ObjectClassLifecycleUseCases.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.routes.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.controller.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/rest/app/object-classes/objectClasses.schemas.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedRevitCategoryHandler.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedHierarchyHandler.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/events/ObjectClassParentGraphChangedClassificationHandler.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/inbound/events/RenderedParamTemplatesSyncHandler.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassGraphMutationAdapter.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassGraphReadAdapter.ts
  • apps/api-v1/src/modules/bim-ontology/adapters/outbound/persistence/airtable/AirtableObjectClassHierarchyAdapter.ts
  • apps/api-v1/src/modules/bim-ontology/composition/bim-ontology.composition.ts — the four ObjectClassParentGraphChanged registrations
  • apps/airtable-front-end/current/bim-ontology/object_class_actions.js

ADRs

ADRBearing on this flow
Platform ADR-0046The hierarchy is a single-parent tree; no primary-parent device
Platform ADR-0047Handlers recompute seeded subtrees; whole-tree recompute stays on explicit sync endpoints, and Airtable scripts call the API rather than writing domain state
Platform ADR-0048domain_id at the public boundary, never a rec… id
Platform ADR-0050Inherit-by-default classification fill with inferred make-unique, and the lifecycle that demotion feeds