Import Pipeline¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 6 finding(s) fixed — the relation-import uuid is derive_overlay_relation_uuid(idem_key) with relation_import_idem_key(file_hash, base_id, rowIdx), shared by router and engine; the on-disk target is a single eidos:target (node IRI / ExternalRef{domain,ref} with no placeholder written / UnresolvedRef{eidos:ref}), toNodeId/unresolvedTargetRef are legacy-read only; no re-keying when an endpoint resolves; parse_excel/normalize_paths still exist (only the routes and excel* helpers were retired); the result status counts committed>0 OR enriched>0 (BL-IE-018 back-fill); the graph engine path is backend/domain/graph_crud_engine.py. Re-verified against main 4996d1ce.¶
flowchart LR
%% generated_from: import-pipeline
%% verified_at_commit: 4996d1ceb770fb4545edebc5fd140792bfb23830
%% description: Import Pipeline lifecycle
%% legend: aw=atomic-write, du=deterministic-uuid, owoc=overlay-wins-on-conflict, pbc=preview-before-commit
subgraph api
parse-excel["Parse Excel · pbc"]
end
subgraph domain
evaluate-commands["Evaluate Commands · du"]
commit-batch["Commit Batch · aw du pbc"]
cache-invalidation["Cache Invalidation · owoc"]
end
subgraph frontend
user-confirmation["User Confirmation · pbc"]
end
parse-excel --> evaluate-commands
evaluate-commands --> user-confirmation
user-confirmation --> commit-batch
commit-batch --> cache-invalidation
Retired routes (hærdning 3.4.1 / #588). The original bare-AddFromExcel HTTP routes
POST /mutations/import/excel/previewand.../commit— and their helpers_excel_parse_and_setup/_excel_evaluate_commands— were retired. (The pureparse_excel/normalize_pathsfunctions still exist inbackend/api/excel_importer.py, lines 45 and 624.) The live import surface is the product-node import,POST /mutations/import/excel/nodes/previewand.../nodes/commit. This chapter documents that live pipeline.
Overview — Excel as a Graph Import Format¶
The import pipeline converts a user-supplied .xlsx spreadsheet into a batch of new graph nodes persisted as overlay files. It is the primary mechanism for bulk-populating a branch of the EIDOS hierarchical graph without requiring the user to create nodes one at a time through the CRUD UI.
Each sheet row declares one node by its Reference path — a dot-separated string of segment labels (UCA10.QAB01.K02) that encodes the node's position in the tree — plus an optional Description, an optional product NodeType, and an optional Note. A dot-separated path implicitly declares its intermediate ancestors as well as the leaf, and the pipeline materialises any missing ancestors automatically.
Why Excel?¶
Excel is the lowest-friction format for the engineers and architects who own the source data. They already maintain hierarchical reference structures (subsystem breakdowns, functional trees) in spreadsheets. The format encodes hierarchy implicitly through dot notation: UCA10.QAB01.K02 means a leaf node K02 whose parent is QAB01, whose parent is UCA10. This matches the graph's own reference convention, so the import format and the graph's internal path representation are the same language.
A critical design decision is that the import is pure on the read side. The module backend/api/excel_importer.py performs no I/O and has no side effects — parse_product_node_rows turns bytes into row dicts. All writes are performed exclusively through the graph engine (backend/domain/graph_crud_engine.py) after evaluation returns its data structures, so the parse logic can be unit-tested without a running server.
Stage 1: File Upload and Parse¶
HTTP surface¶
The import reaches the server through two endpoints defined in backend/api/mutations_excel_router.py:
POST /mutations/import/excel/nodes/preview— handled bymutations_import_product_nodes_previewPOST /mutations/import/excel/nodes/commit— handled bymutations_import_product_nodes_commit
Both endpoints accept a multipart/form-data body containing:
| Field | Type | Description |
|---|---|---|
file |
UploadFile |
The .xlsx binary payload |
anchorNodeId |
str (Form) |
UUID of the graph node under which top-level paths will be placed |
anchorProductType |
str (Form, optional) |
Explicit product-type for the anchor; falls back to the anchor's stored type, then the default root type |
sheet |
str (Form, optional) |
Worksheet name to read; defaults to the active sheet |
fuseRoot |
bool (Form, default false) |
BL-IE-023 — collapse each row's first Reference segment onto the anchor |
Both endpoints are write-guarded. _require_mutations() blocks imports when the server is in read-only mode. The route's IamScope (via scope: IamGuard) enforces the node-scoped write gate — scope.require_write(anchorNodeId) blocks viewer-role users from importing (BL-IAM-008, hærdning 3.2). The commit endpoint additionally calls ctx.require_write() through a MutationContext, which enforces row-level write permissions.
parse_product_node_rows¶
def parse_product_node_rows(
file_bytes: bytes, sheet_name: str | None = None, *, require_node_type: bool = True,
) -> tuple[list[dict], str, list[dict]]
Returns (rows, file_hash, parse_errors) where each row is
{"path": str, "path_lower": str, "description": str, "node_type": str, "note": str}.
file_hashis the hex-encoded SHA-256 of the raw bytes, computed before the workbook is opened. Because it is taken over the raw bytes (not the parsed content), it is stable across re-uploads of the same file regardless of the active sheet, and it is available even if parsing subsequently fails. It becomes the basis for the idempotency key in later stages.- Column discovery is case-insensitive: the header row is lowercased before the
Reference/Description/NodeType/Noteindices are located. require_node_typeis passed asserver_mod._DOMAIN_USES_NODE_TYPES. Product domains require a canonicalNodeTypeon every row. Non-product domains (Location+, Signal;, Type%, Discipline&) passrequire_node_type=False: theNodeTypecolumn becomes optional, any value present is ignored, and every row'snode_typeis"". This lets a Locations export round-trip through the same single canonical parser (Contract 02.01: no forked location parser).
Any non-OOXML payload (wrong type, corrupt, or truncated archive) is wrapped inside the parser as a domain ValueError, which the endpoints convert to a 400 PARSE_ERROR — never an uncaught HTTP 500 (BL-IE-035, Contract 05 R5/R13/R31). No openpyxl/zipfile class name leaks into the response.
After parsing, the endpoint reads the effective graph and, if anchorNodeId is not present in it, returns a 400 ANCHOR_NOT_FOUND before any evaluation.
Stage 2: Anchor Fuse and Type Resolution¶
fuse_anchor_first_segment (opt-in template fuse)¶
def fuse_anchor_first_segment(rows: list[dict]) -> tuple[list[dict], list[str]]
When fuseRoot is set (BL-IE-023 — the default in the import dialog), each row's first Reference segment is collapsed onto the anchor: a template that models a reusable subtree can be dropped under any selected anchor. The rule is strip the first segment of every relative path and join the remainder onto the anchor; a single-segment relative path (the template root) maps onto the anchor itself and is dropped. A path whose first character is an ISO 81346 domain/aspect sigil (=, -, +, %) is absolute and is left untouched. The function also returns the distinct first segments so the preview can warn (MULTI_ROOT) when a sheet fuses more than one template root onto a single anchor.
Preview and commit apply the identical fuse so the committed tree is byte-identical to the previewed one (CCR 16).
Tree prefix and anchor product-type¶
The endpoint resolves tree_root_id = mutation_engine.find_subtree_root(anchorNodeId, eff) and derives a tree_prefix from the leading non-alphanumeric characters of the root label (the domain sigil), so imported labels stay consistent with the existing tree's convention.
For product domains the anchor's effective product-type is resolved in order: the explicit anchorProductType form value (mapped through _CANONICAL_NODE_TYPES), then the anchor's stored productType, then _PRODUCT_ROOT_DEFAULT_TYPE (Functional System). For non-product domains the resolved type is simply the anchor's stored type or blank.
Stage 3: Two-Pass Evaluation and UUID Derivation¶
After parsing, _product_nodes_evaluate_commands (in backend/api/mutations_excel_router.py) performs a speculative evaluation pass — it validates every row against the current effective graph without performing any writes. It deep-copies the effective graph into eff_local and, as each valid row passes, augments eff_local with the planned node so later rows in the same batch can see parents declared earlier in the same file.
def _product_nodes_evaluate_commands(
rows, file_hash, anchor_node_id, anchor_product_type,
tree_prefix, tree_root_id, username, eff, assignments, *, enforce_node_types=True,
) -> tuple[list[dict], dict, list[str] | None, dict]
- Pass 1 builds a file-level
path_lower → node_typemap (so out-of-order rows resolve their parents) and pre-seedspath_uuid_map/path_type_mapby walking the anchor's existing subtree (a BFS overchildren_of, stripping the tree prefix). - Pass 2 processes rows topologically — shallower paths first (
sortedby segment-dot count, then alphabetically) — so a child is always evaluated after its parent.
For a product domain with an untyped anchor, the evaluator computes anchor_type_choices: the set of product-types the anchor could take that is compatible with every top-level row (via _PRODUCT_VALID_PARENT_FOR), which the dialog offers the user.
UUID derivation¶
For each valid new node the UUID is computed deterministically:
node_uuid = mutation_engine.derive_entity_uuid(cmd, parent_context=parent_id)
The deterministic-uuid invariant (CCR 16) guarantees that the same (command, parent_context) always produces the same UUID. Preview and commit therefore produce identical UUIDs with no shared in-memory state, and re-running a commit after a partial failure is safe.
Per-row status and error codes¶
Each row becomes a command with status new, skipped, or invalid:
| Code | Status | Meaning |
|---|---|---|
PARENT_NOT_IN_TREE_OR_FILE |
invalid |
The parent path is neither in the existing tree nor declared earlier in the file |
NODE_TYPE_CASCADE_VIOLATION |
invalid |
Product domain only — node_type is not an allowed child of the parent's type (_PRODUCT_CHILD_TYPES) |
NODE_LABEL_DUPLICATE |
skipped |
A sibling with the same label stem already exists; its UUID is reused so the subtree below it can still be placed |
ALREADY_COMMITTED |
skipped |
An idempotency record from a prior import resolves to a node already in the graph |
NODE_LABEL_DUPLICATE uses stem-based detection: labels that differ only in a leading sigil character (CTP01 vs -CTP01) are treated as the same node, so re-importing a file that partially overlaps existing content places the rest of the subtree under the existing parent.
Stage 4: Idempotency¶
Idempotency key for node rows¶
The key for a node row is constructed inline in _product_nodes_evaluate_commands (and re-derived identically in the commit route):
idem_key = f"excel-nodes-{file_hash[:16]}-{anchor_node_id[:8]}-{path_lower}"
| Component | Source | Purpose |
|---|---|---|
file_hash[:16] |
SHA-256 of raw bytes, first 16 hex chars | Ties the key to the specific file content |
anchor_node_id[:8] |
First 8 chars of the anchor node's UUID | Distinguishes imports of the same file under different anchors |
path_lower |
Lowercased dot-separated path | Identifies the specific row within the file |
The anchor dimension is critical for template reuse: importing the same template file under node HG20 and later under HG21 produces distinct keys, so the second import is not skipped as already-committed.
On-disk record and orphan handling¶
When a node is committed, mutation_store.write_idempotency writes a JSON-LD record to {mutations_dir}/idempotency/{idem_key}.jsonld whose eidos:entityId field holds the created node's UUID. On re-import, the evaluator reads the record via mutation_store.get_idempotency_record(idem_key):
- If
eidos:entityIdresolves to a node present in the effective graph, the row isskipped(ALREADY_COMMITTED) and its UUID is reused so children resolve their parent. - If the record exists but the node is absent from the graph (an orphaned record — overlay file missing or UUID mismatch), the evaluator falls through to stem-based duplicate detection and re-creates the node.
Re-importing an unchanged file therefore writes no duplicate nodes and raises no errors: every row resolves to skipped, and the preview reports toCreate: 0.
Stage 5: Write¶
The commit endpoint mutations_import_product_nodes_commit runs the same parse-and-evaluate pipeline as preview, then iterates the command list and writes every status == "new" command. A skipped row whose existing node has an empty Description/Note is back-filled via update_node (fill-on-empty, BL-IE-018) and counted as enriched.
Row-by-row write loop¶
Each write goes through graph_crud_engine.create_node, which derives the deterministic UUID, writes the overlay node file atomically via write_atomic (fsync + rename — the atomic-write invariant), writes the idempotency record, and writes a snapshot record. The lock_fn=_get_subtree_lock serialises concurrent writes to the same subtree.
Writes are wrapped in an engine batch: begin_batch() before the loop and end_batch() after it coalesce every per-row cache invalidation into a single invalidation on batch close, and only if at least one node actually changed (the engine owns invalidation — the route makes no _invalidate_* call, hærdning 2.4). skipped and invalid rows do not abort the batch; their errors are accumulated and the loop continues, so a partially-valid file writes all valid rows and reports the rest.
Result codes¶
status |
Condition |
|---|---|
"complete" |
No errors, and at least one node committed or enriched |
"partial" |
Some nodes committed or enriched, some errors |
"failed" |
Zero nodes committed and zero enriched (all skipped or invalid) |
(backend/api/mutations_excel_router.py:749: _did = committed > 0 or enriched > 0.)
Committed nodes carry origin="excel-import" in their overlay file, so audit queries can distinguish bulk-import nodes from single-node CRUD.
Format Reference¶
Product-node import sheet¶
| Excel column | Required | Graph field | Notes |
|---|---|---|---|
Reference |
Yes | Node reference path | Dot-separated; case-preserved for display, lowercased for deduplication and key derivation |
Description |
No | node.properties.description |
Empty string if column absent or cell blank |
NodeType |
Product domains only | node.productType |
Canonical product-type (Functional System / Technical System / Component); ignored (blank) for non-product domains |
Note |
No | row note |
Always present on the row dict; empty when the column or cell is absent |
Column discovery is case-insensitive (the header row is lowercased before lookup), so REFERENCE, reference, and Reference are all valid.
Example row and resulting graph nodes¶
Given the Excel row UCA10.QAB01.K02 | Cooling capacity monitor | Component, imported under an anchor that is the parent of UCA10, three nodes are created parents-first — UCA10, UCA10.QAB01, then UCA10.QAB01.K02 (label K02, description Cooling capacity monitor). The idempotency key for the leaf is excel-nodes-{sha256[:16]}-{anchorId[:8]}-uca10.qab01.k02, and each overlay file is written atomically and becomes visible in the effective graph after the batch's single invalidate().
Relation import — EXTERNAL / UNRESOLVED rows (G4, #931)¶
The relation importer (mutations_io_router.mutations_import_relations_commit → graph_crud_engine.create_relation) handles three target shapes:
| Row status | Target channel | targetId passed to the engine |
On-disk eidos:target (WP4b #965) |
|---|---|---|---|
OK |
in-model (local graph node) | resolved node UUID | node IRI |
EXTERNAL |
cross-domain (another backend owns the target) | stable UUID v5 placeholder from derive_relation_uuid_for_unresolved_target(source, ext_ref, predicate) |
eidos:ExternalRef {domain, ref} — placeholder NOT written; the reader re-derives it |
UNRESOLVED |
deferred local (target node not yet in the graph) | empty string ("") |
eidos:UnresolvedRef {eidos:ref} |
For UNRESOLVED rows the target uuid stays empty so consumers that test to / targetId for truthiness see "no target yet" (CCR 05). The human-readable local ref the user typed in the Excel sheet is carried on disk as the eidos:UnresolvedRef node's eidos:ref (backend/domain/mutation_engine.py:738; the pre-WP4b eidos:toNodeId / eidos:unresolvedTargetRef keys are legacy-read only), and the preview/commit API carries it as targetPath / externalTargetRef on each row (tgtUuid is empty for UNRESOLVED).
The relation's OWN uuid is derive_overlay_relation_uuid(idem_key) (WP4 #953), with idem_key = relation_import_idem_key(file_hash, base_id, rowIdx) (backend/api/mutations_io_router.py:502/:555). The router passes the same idem_key to create_relation as its idempotency key, so engine and router agree on the on-disk id; the uuid no longer depends on source, ref or predicate. Distinct rows produce distinct relation files, re-imports of the same .xlsx under the same anchor land on the same file, and the planning pass's commit_existing_ext_rels set (built over loaded relations carrying either external_ref or unresolved_ref) treats a re-imported row as EXISTS and skips it.
Consequences:
- Idempotent re-import. Importing the same
.xlsxunder the same anchor produces identical relation UUIDs across runs. The commit path writes the same file on disk;skippedrows cover the second-and-subsequent runs. - No shadow overlays. The former random UUID placeholder could produce two different relation files for the same (source, cross-domain-ref, predicate) across runs; G4 collapses this to one.
- No re-keying on resolution. The relation UUID is idem-key derived (WP4), not endpoint derived, so when an
UNRESOLVEDtarget later resolves to a real node the relation keeps its UUID (backend/infra/key_derivation.py:89: moving an endpoint does not change the UUID).
Preview and commit both carry the row's resolution state verbatim: tgtUuid for OK rows, the stable derived placeholder for EXTERNAL rows, and an empty tgtUuid + the human-readable externalTargetRef / targetPath for UNRESOLVED rows.
Stage Reference¶
Parse Excel¶
Engine: excel-importer — Zone: api
POST /mutations/import/excel/nodes/preview — read uploaded .xlsx bytes; parse_product_node_rows: extract Reference + Description (+ optional NodeType) rows, compute file_hash (SHA-256); optional BL-IE-023 anchor fuse collapses each row's first Reference segment onto the anchor
Invariants enforced: preview-before-commit
Evaluate Commands¶
Engine: excel-importer — Zone: domain
_product_nodes_evaluate_commands: two-pass topological validation against the EffectiveGraph — assign status new / skipped (already exists) / invalid, cascade node-type checking + anchor-type-choice computation; return command list with file_hash — no writes at this stage
Invariants enforced: deterministic-uuid
User Confirmation¶
Engine: frontend-crud — Zone: frontend
display preview summary (toCreate / skipped / invalid counts) and command list; user confirms before commit is allowed
Invariants enforced: preview-before-commit
Commit Batch¶
Engine: mutation-store — Zone: domain
POST /mutations/import/excel/nodes/commit — re-derive same commands deterministically from file_hash (CCR 16); call graph_crud_engine.create_node for each "new" command; write overlay files atomically via write_atomic (fsync + rename)
Invariants enforced: atomic-write, deterministic-uuid, preview-before-commit
Cache Invalidation¶
Engine: eidos-loader — Zone: domain
invalidate EffectiveGraph _cache after batch commit
Invariants enforced: overlay-wins-on-conflict