Project Delivery Graph
Top-level bounded context for the project graph. The Project Definition Graph is the first model: nodes, relationships, import runs, and the rematerialized summary view. Process Graph and Resource Graph are named and not modeled yet.
Overview
ADR-0069 places this context at apps/api-v1/src/modules/project-delivery-graph. Project Delivery Graph is the module. Project Definition Graph is the designed-project model inside it.
There is no project-model module and no project-object-model folder. ADR-0069 retired “Project Object Model” as the name of what is being built. Cross-cutting POM language on the platform page still describes the idea of a rendered project graph; this domain owns the implemented definition-graph lifecycle.
The module is served by api-v1. There is no nested System. Revit Connector owns the desktop seam (add-in + panel) that produces the export artifact. This domain owns the model the artifact lands in, and the HTTP ingest on api-v1.
This domain sends four internal in-process events. Three live on the Node aggregate after steward assign/clear/change-class commands; TypeTakeoffPolicySet is emitted after a takeoff-policy write. They are not cross-context integration events — there is no outbox. GraphProjectionOnAssignmentChangedHandler rematerializes the summary view; BomRematerializeOnAssignmentChangedHandler rematerializes the Design BOM (summary first). Do not treat them like ProductVariantCatalogChanged.
Import and rematerialize paths do not add catalog-style integration events. Do not invent SourceImportRegistered or similar.
Implementation Status
Status: Implemented in api-v1
Hybrid persistence (ADR-0068 / ADR-0072):
- Graph nodes, relationships, import runs, and
pdg_summary_rowlive in Postgres - Airtable Design-base tables are a materialized summary view, not the graph store
- Design
revit_import_runsis an Airtable log projection of the same run (status wordprocessingfor Postgresin_progress)
Aggregates and supporting types
| Concept | Persistence today | Badge |
|---|---|---|
| ImportRun | Postgres pdg_import_run; Airtable revit_import_runs log | Implemented in api-v1 |
Node | Postgres graph nodes (kinds: space, level, phase, assembly_type, part_type, assembly_instance, part_instance). Optional ProductAssignment value object and object_class_domain_id on type nodes. | Implemented in api-v1 |
Relationship | Postgres relationships; not part of the Node aggregate | Implemented in api-v1 |
| Graph summary view | Postgres pdg_summary_row; Airtable *_instance_summary (and type/space tables when written) | Implemented in api-v1 |
| Design BOM | Postgres pdg_bom_contribution ledger + pdg_bom_line (imported by type, or by type and space; authored lines inserted). Construction project_bom_lines is the line projection (ADR-0131) | Implemented in api-v1 |
| Takeoff policy | Postgres pdg_type_takeoff_policy (value object on a type node; defaults when absent) | Implemented in api-v1 |
ArtifactReference | Value object on ImportRun (object store key, gzip, SHA-256 of uncompressed JSONL) | Implemented in api-v1 |
Node and Relationship exist in code. They do not have catalog entity pages yet — do not invent extra entity files to fill the tree.
Project is not an aggregate here. HTTP {id} is the party-directory project domain_id (for example project-c1). Party-directory projects is the system of record for existence and the plugin picker. Design projects_sync is a read-only mirror used to fill Airtable project links once domain_id matches.
Boundary with Revit Connector
The add-in writes a contract-v1.1 JSONL artifact (phase on LOCATED_IN, sequence on phase nodes, phase on rooms, required baselinePhase in the manifest) and uploads it through object storage (ADR-0071; local PUT-is-submit and dev-revit-imports in ADR-0103). An artifact that omits baselinePhase is refused at intake, not guessed (ADR-0098). {id} and manifest.projectId must be a known party-directory domain_id; the document title is never a project id.
This domain registers that object, processes it into the definition graph, and rematerializes the summary. Folding the graph into revit-connector would make the connector responsible for the model.
Not modeled yet
Do not invent catalog pages for:
- Process Graph
- Resource Graph
- Construction BOM / work recipes (ADR-0078 — not this Design BOM)
- Type composition (ADR-0075);
expandstays reserved and rejected - A
Projectaggregate owned by this domain
Internal domain events
| Event | When | Consumer |
|---|---|---|
| ProductVariantAssignedToType | Assign command persisted | GraphProjectionOnAssignmentChangedHandler (summary) and BomRematerializeOnAssignmentChangedHandler (BOM) |
| ProductAssignmentClearedFromType | Clear command persisted | same |
| TypeObjectClassChanged | Change-class command persisted | same |
| TypeTakeoffPolicySet | Takeoff-policy write persisted | BomRematerializeOnAssignmentChangedHandler only |
Payloads are primitive IDs (and policy scalars on TypeTakeoffPolicySet) only. Dispatch is in-process, best-effort, with handler idempotency — not an outbox.
Flows
- Ingest Revit Export — upload URL → PUT (submit) → process (or cron) → rematerialize → poll. Register is fallback only. Delete-graph / same-run retry: ADR-0103.
HTTP surface
Canonical mount: /api/v1/projects/{id}/…. Authenticate with X-API-Key. Do not copy request/response schemas here; use generated OpenAPI.
Revit imports (ingest + log)
| Method | Path | OpenAPI operationId | Behavior |
|---|---|---|---|
POST | /revit-imports/upload-url | requestRevitImportUploadUrl | Short-lived URL. Production signed URL keeps bytes out of the API |
PUT | /revit-imports/local-upload | putLocalRevitImportArtifact | Local-dev stand-in: PUT is submit; live composition writes dev-revit-imports |
POST | /revit-imports | registerRevitImport | Fallback when storage PUT did not queue. Idempotent on key (skips deleted) |
GET | /revit-imports | listRevitImports | Import-run log, newest first. API labels in_progress as processing |
GET | /revit-imports/{runId} | getRevitImport | One run. 404 if missing or another project |
POST | /revit-imports/{runId}/process | processRevitImport | Process now. Ready rematerializes summary first. Cron remains in production |
POST | /revit-imports/{runId}/actions/delete-graph | deleteRevitImportGraph | Wipe imported_from_source nodes; mark run deleted; keep gzip |
POST | /revit-imports/{runId}/actions/retry | retryRevitImport | Same-run requeue from failed or deleted |
Definition-graph summary
| Method | Path | OpenAPI operationId | Behavior |
|---|---|---|---|
GET | /definition-graph/summary | getProjectDefinitionGraphSummary | Nested JSON from persisted pdg_summary_row. Does not rematerialize |
POST | /definition-graph/summary/rematerialize | rematerializeProjectDefinitionGraphSummary | Persist membership, upsert Airtable, return the nested summary. Does not accept phaseNodeId. |
Graph reads
Tagged Project Delivery Graph / Graph Reads. No dedicated flow page.
Query ?phaseNodeId= means alive in that phase, placed where the occurrence stands in that phase — not “created in that phase”.
| Method | Path | OpenAPI operationId |
|---|---|---|
GET | /nodes/{nodeId} | getProjectDefinitionGraphNode |
GET | /nodes/{nodeId}/relationships | getProjectDefinitionGraphNodeRelationships |
GET | /graph/subtree/{nodeId} | getProjectDefinitionGraphSubtree |
GET | /graph/ancestors/{nodeId} | getProjectDefinitionGraphAncestors |
GET | /graph/types/{nodeId}/instances | getProjectDefinitionGraphTypeInstances |
GET | /graph/counts | getProjectDefinitionGraphCounts |
Type actions (product assignment + object class)
OpenAPI: apps/api-v1/src/modules/project-delivery-graph/adapters/inbound/rest/graph/graphTypeActions.openapi.ts
| Method | Path | OpenAPI operationId | Behavior |
|---|---|---|---|
POST | /definition-graph/types/actions/assign-product-variant | assignProductVariantToType | Assign ProjectProductVariant to resolved type. Requires Idempotency-Key. Emits ProductVariantAssignedToType. |
POST | /definition-graph/types/actions/clear-product-assignment | clearProductAssignmentFromType | Clear assignment on resolved type. Emits ProductAssignmentClearedFromType. |
POST | /definition-graph/types/actions/change-object-class | changeTypeObjectClass | Change object class when assignment compatible (409 otherwise). Emits TypeObjectClassChanged. |
POST | /definition-graph/types/actions/set-takeoff-policy | setTypeTakeoffPolicy | Set takeoff rule, unit, waste factor, and procurement mode on the resolved type. Reserved members are rejected. Emits TypeTakeoffPolicySet. |
Auth: X-API-Key + project-scoped design:update. Body userId is audit-only. AT-A1 script: pdg_graph_actions.js (see MUTATION_ROUTING.md). Airtable action dropdown still needs steward option Set Takeoff Policy (tealBright); REST works without it.
Design BOM
OpenAPI: apps/api-v1/src/modules/project-delivery-graph/adapters/inbound/rest/graph/graphBom.openapi.ts
| Method | Path | OpenAPI operationId | Behavior |
|---|---|---|---|
GET | /definition-graph/bom | getProjectDefinitionGraphBom | Read persisted lines + contributions + resolution report, including origin, spaces, prices, and product code. Does not rematerialize. Default omits orphaned lines. |
POST | /definition-graph/bom/lines | addProjectDefinitionGraphBomLines | Insert authored lines and one unbound product code each, then project them. Requires Idempotency-Key. Taken requestedOrdinal is 409. |
POST | /definition-graph/bom/lines/{domainId}/actions/delete | deleteAuthoredBomLine | Delete an authored line. Refuses an imported line. Requires Idempotency-Key. |
POST | /definition-graph/bom/lines/{domainId}/actions/assign-code | assignProductCodeToAuthoredBomLine | Rename or issue the line’s unbound code. Refuses an imported line and a code bound to a type. Requires Idempotency-Key. |
POST | /definition-graph/bom/lines/{domainId}/actions/reconcile-takeoff | reconcileAuthoredBomLineTakeoff | Body { derivation: "authored-takeoff" } only. Authored lines adopt Construction quantity and unit. Requires Idempotency-Key. |
POST | /definition-graph/bom/rematerialize | rematerializeProjectDefinitionGraphBom | Rematerialize summary first, then imported lines. Query grain is collapsed (default) or by_space. Requires Idempotency-Key. Does not accept phaseNodeId. |
No ordered/received fields, no purchase-unit conversion, no quantity field on the takeoff reconcile. Hand-added lines are not orphaned by rematerialize. Product-code rename and retire (POST /api/v1/design/product-codes/{productCodeId}/actions/rename and .../retire) update every BOM line that stores that code.
Primary Code References
- Module root:
apps/api-v1/src/modules/project-delivery-graph - Composition:
apps/api-v1/src/modules/project-delivery-graph/composition/project-delivery-graph.composition.ts - Import-run aggregate:
apps/api-v1/src/modules/project-delivery-graph/core/domain/import-run/ImportRun.ts - OpenAPI:
…/revit-imports/revitImports.openapi.ts,…/graph/graphSummary.openapi.ts,…/graph/graphReads.openapi.ts,…/graph/graphTypeActions.openapi.ts,…/graph/graphBom.openapi.ts - Contract notes:
docs/api-v1/project-delivery-graph/REVIT_EXPORT_CONTRACT_V1.md
Documentation Links
- api-v1 service:
/docs/services/api-v1 - Ubiquitous language:
/docs/domains/project-delivery-graph/language - Revit Connector:
/docs/systems/revit-connector - Backend platform:
/docs/systems/backend-platform - ADRs:
docs/adr/README.md
Open Questions
- Process Graph and Resource Graph, when modeled, belong in this bounded context. The Design BOM is modeled (ledger + imported and authored lines + Construction projection, ADR-0131). The Construction BOM (recipes) is not.
- api-v2 migration. The module lives in api-v1 only. No page here carries
Being migrated from api-v1 to api-v2.