Skip to content

Documents Engine

Verified against the code · 9e119ef0950aa1f8c8f87a640132addf0109872c · 2026-10-20

956 D1b-3 (2026-10-20): 7 finding(s) fixed — ImportedRepo fields are fingerprint/source/format_version/mode/root_id/nodes/relations/containers (no documents), preview keys relation status on the (from, to, predicate) triple (WP4 #953), _compute_risks returns orphaned/danglingAttachments/possibleRename/newNodes, FME 1.3.0-1.6.0 are accepted, commit takes filename=None, the risks call is at documents_import.py:326 (def :226), routes/documents.py blocklist citations re-pinned (:2155, :263-306, :1856, :760, :774, :871). Re-verified against main 9e119ef0. #1077 (2026-10-22): DocuGraph import boundary validation documented — canonical uuid / node_iri ids (INVALID_NODE_ID, INVALID_PARENT_ID, INVALID_RELATION_ENDPOINT), @id == node_iri(id) (NODE_IRI_MISMATCH), validate_against_graph (UNRESOLVED_RELATION_SOURCE, UNRESOLVED_RELATION_TARGET) in preview, commit and the commit endpoint pre-loop, and dangling external-ref relations written as eidos:ExternalRef / eidos:UnresolvedRef; checked against documents_import.py and routes/documents.py on archon/issue-1077 (base main c7aa9768). #1112 WP9 S4b (2026-10-23): FME 1.3.0-2.0.0 accepted, a 2.0.0 file (identity values as IRIs) reads to the same bare uuids as the same model in 1.7.0 through infra.iri.canonical_node_uuid, UNSUPPORTED_VERSION names the refused version, and a legacy urn node or relation id is refused LEGACY_ID_REFUSED; checked against documents_import.py on archon/issue-1112 at 86bac62b.

Layer: domain

Overview — What the Documents Module Is

The DOCUMENTS module makes a document a first-class object instead of a URL-valued property. A document is a node in the first-class, visible-by-default documents domain, attached to items via hasDocument relations, and resolved for display through rulebook-driven channels (#508: the documentPropagation category) with a locked class-priority merge and a strict no-leak rule. The two behaviour-preserving seed rules ("From type", "Inherited from") plus any user-authored rule (e.g. "documents follow isLocatedIn across domains") are edited from the Rule Book — no channel is implemented in code any more.

A plain URL string cannot carry classification, description, sharing (one document referenced by many items), propagation (documents on a %Type shown on instances), or mass-assignment. All of these require identity — hence a node.

Repo content (whole trees of DocumentContainer + Document nodes) enters the domain via DocuGraph FME import (see §DocuGraph FME Import below). The legacy DocumentLibrary + StorageBackend + generations upload machinery was retired in PR #431 — the new spec (docs/superpowers/specs/2026-08-18-documents-domain-docugraph-import-design.md) is authoritative.

Code layout (single-owner, GCF 02.01):

Concern Owner
Pure domain logic: identity, normalization, node shapes, merge/dedup, URL resolution, classification catalog backend/domain/documents_engine.py
DocuGraph FME import engine: parse → preview → commit backend/domain/documents_import.py
Moderation / blocklist backend/domain/documents_blocklist.py
Orchestration: all /documents*, /node/{id}/documents, /admin/documents* endpoints backend/routes/documents.py (router factory; server.py contact = one include_router line)
Classification catalog data backend/document_classification_catalog.json (admin-extensible, atomic writes)
Frontend user view DocumentsZone (read/list) in frontend/plugins/detail-panel.js; attach runs through the generic Add Information → Document dialog (openAddInformationDialog in frontend/app.js) — the retired bespoke AttachDocumentFlow modal is kept only as a design reference
Frontend admin view: Data Exchange import panel frontend/app.js

The documents Domain — First-Class, Visible by Default

document is a normal, first-class secondary domain like any other: its own container running the same server binary with EIDOS_DOMAIN=document, internalPort: 8097, prefix @, structural predicate contains (registered in config/domains.yaml, the domain SSOT).

Two predicates are in play and must not be confused:

  • contains — the domain's own structural (Hierarchy) predicate, used within the documents tree (container → document), exactly like every other domain's structural predicate.
  • hasDocument — the cross-domain attachment edge from an item in any domain to a document node in the documents domain.

Visibility is controlled by the two-layer Domain Visibility system: operators set hidden: true in config/domains.yaml (or configs/<env>/deploy.json) to hide the Documents tab for every user on the deployment (Layer 1); individual users can hide it just for themselves via Admin → Domain visibility (Layer 2, stored in localStorage.eidos_tree_prefs.hiddenDomains). Backend access is never gated by visibility — per-node IAM (read/write gates) governs actual access regardless of whether the Documents tab is visible, and cross-domain hasDocument relations keep resolving in both directions when the tab is hidden. See the Visible Not Accessible invariant for the formal guarantee.

Node classes inside the domain: Document (leaf, carries the source), DocumentContainer (holds imported repo trees; also the single "External links" container for user-attached external documents).


Data Model — Two Source Types

Every document node carries source_type plus source-specific engineering entries. Identity is deterministic uuid5 — external documents via derive_document_uuid("external", normalized_url); imported repo nodes keep DocuGraph's own uuid5 verbatim (never reminted, so the same FME export always maps to the same node).

Node envelope — @id / rdfs:label (WP1 #950)

The node-shape builders (build_document_node, build_external_container_node, and the DocuGraph import path in documents_import) emit the WP1 JSON-LD envelope that mutation_engine.build_overlay_node emits for graph nodes:

Field Source
@id infra.iri.node_iri(doc_uuid) → https://id.ontoteq.com/{doc_uuid} (flat IRI space, shared with relations and audit events; @type distinguishes eidos:OverlayNode from eidos:OverlayRelation / eidos:AuditEvent). eidos:id is no longer emitted — the pre-WP1 duplicate was removed.
@context infra.jsonld_context.overlay_context() — the single owner of the overlay @context mapping (eidos / rdfs / skos); _envelope in documents_engine and the per-node JSON-LD base in documents_import both pick it up via node_jsonld_base.
rdfs:label the resolved display name (title or filename). eidos:label is no longer emitted — readers (documents_engine.read_label-style accessors on routes) read rdfs:label first and fall back to eidos:label only on pre-WP1 legacy input.
eidos:createdAuditRef infra.iri.audit_iri(audit_event_id) when non-empty (= https://id.ontoteq.com/{audit_event_id}); overlay files written before WP1 that carry the legacy urn:eidos:audit:{audit_event_id} form continue to load unchanged via the from_iri fallback, with a debug-level from_iri_legacy_form log.

Readers (route-side payload builders, loader) key on @id via infra.iri.id_from_envelope, which falls back to eidos:id only for pre-WP1 legacy files. The DocuGraph import's parse-and-validate stage accepts both envelope shapes on input (the exporter is a third party), but every write path emits the new shape — rewrite of existing pre-WP1 on-disk files is deferred to #946, and WP1 is forward-only (no eidos:id re-emission, see docs/specs/identity-facade.md §4a and the governance exception .archon/exceptions/06-TEST-ROLLBACK-wp1-forward-only.yaml).

Source type Origin URL resolution Engineering entries
external A URL reference the user adds through the generic "Add Information → Document" dialog — either attach an existing document, or paste a new hyperlink (POST /documents with type=external, external-only since PR #459) stored URL as-is (a path-less URL like https://www.dr.dk/ is labelled by its host, www.dr.dk) url
repo (DocumentContainer / Document under one) DocuGraph FME import (see §DocuGraph FME Import below) read-model fallback: docLink → webURI → legacy url docLink, webURI (reserved), source_type=repo, plus import_fingerprint / import_source on the container

Imported Document nodes render with readOnly: true in the read-model payload (documents_engine.document_node_to_payload), so the detail panel disables edit affordances — DocuGraph is the sole producer for repo content. Since the fresh rebuild (PR #459) external nodes are read-only references too: document-content editing was removed (PATCH /documents/{id} deleted), along with the entire legacy DocumentLibrary route/upload surface (GET /documents/libraries, the library source branch of POST /documents, and the DocumentLibrary node class). A document is created once — a repo import, or an external URL reference — and thereafter only attached / detached, never edited in place. hasRelatedDocument (doc-to-doc, 1-hop) is a registered relation-catalog predicate surfaced read-only in the subpanel.

Shared metadata entries: classification (default unspecified, catalog-validated ids), description, filename (derived by derive_filename), fileType (derived by derive_file_type, fallback LINK). Title defaults to filename without extension. derive_file_type maps a known extension to an uppercase token via _KNOWN_FILE_TYPES — PDF, the Office family (doc/docx, xls/xlsx/xlsm, csv, pptx), CAD (dwg/dxf/step/ifc), images, txt, zip — and returns LINK for anything unrecognised (a bare hyperlink with no file extension).


Attaching a Document (UX)

Since the fresh rebuild, attaching is done through the one generic relation dialog every other relation uses — there is no separate document modal. The user opens Add Information, picks Document, and lands in the standard (source node) —hasDocument→ (target) picker:

  • Attach an existing document — pick a target document node in the tree. The commit routes to POST /documents with a documentId (the cross-domain attach path; a generic CreateRelation to a document node is not used, since that fails for cross-domain document targets).
  • Add a hyperlink — paste a URL into the in-dialog "+ Add hyperlink" field. POST /documents with an external source creates the reference node and the hasDocument relation in one step. A path-less URL falls back to its host for the label.

The attach request is sent to the source node's own domain backend (resolved from the node's domain, not the panel's current base), so a document attaches correctly to items in any domain (product, location, signal, type, discipline). hasDocument is only valid from those model domains — you do not attach a document to another documents node.


File-Type Colour Families (frontend — one classification)

Both surfaces that show a file-type badge — the attach-dialog chips (.adl-type-chip--*) and the document-list / panel / header-pin pills (.ndp-doc-fmt--*) — colour by family, never by raw extension. The extension / fileType-token → family mapping is a single canonical registry (GCF 02.01 §3.6): _fileTypeFamily() in frontend/plugins/detail-panel.js. _fileTypeChipClass (chips) and _docFmtPillClass (pills) both derive from it, so the classification lives in exactly one place.

Families: pdf; docx (doc/docx/rtf/odt/txt/md); xlsx (xls/xlsx/xlsm/csv/ods); dwg (dwg/dxf/step/stp/ifc); png (png/jpg/jpeg/gif/webp/svg); and other (everything else — neutral base).

The family tones are defined once as --ft-* CSS custom properties in frontend/index.html (--ft-pdf, --ft-docx, --ft-xlsx, --ft-dwg); both class families reference those variables, so a tone is never duplicated. There are no per-extension colour rules — the former .ndp-doc-fmt.<ext> store was a parallel implementation that drifted from the chip classes (e.g. xlsm coloured in one surface but grey in the other) and was removed (#464/#465).

Adding a new file type is therefore at most two edits: the backend token (_KNOWN_FILE_TYPES in derive_file_type, so the badge text is right) and the frontend family map (_fileTypeFamily, so the colour is right). No CSS change is needed unless a genuinely new family/tone is introduced.


DocuGraph FME Import

backend/domain/documents_import.py is the sole write path for repo content. It is a three-stage boundary — parse_and_validate → preview → commit — matching the Excel importer's shape (docs/manual/dev/import-pipeline.md).

Function Purpose
parse_and_validate(raw) -> ImportedRepo Parse a single DocuGraph EIDOS-FME view-mode .jsonld payload and enforce shape (root count, allowed classes, container source_type=repo). Any format version in _SUPPORTED_FORMAT_VERSIONS (1.3.0, 1.4.0, 1.5.0, 1.6.0, 1.7.0, 2.0.0, 2.1.0) is accepted; in 2.0.0 (#1112 WP9 S4b) every identity value is its IRI, and it reads to the same bare uuids as the same model in 1.x. Raises ImportValidationError(code, message) — Contract 05 structured error — on any shape drift, or UNSUPPORTED_VERSION, naming the version, for a version outside that set (an unknown major such as 3.0.0 is never read on a guess). Every node id, node parent, and relation from/to must be a canonical lowercase uuid or exactly node_iri(<uuid>) (infra.iri.canonical_node_uuid); the returned ImportedRepo carries the bare uuid. Anything else (../x, uppercase hex, a non-uuid, a foreign IRI) is refused with INVALID_NODE_ID, INVALID_PARENT_ID or INVALID_RELATION_ENDPOINT before any id reaches a filename. A legacy urn:eidos: node or relation id there, or in a relation's @id, id or source, is refused with LEGACY_ID_REFUSED, as the overlay readers refuse it since #1102 S8b. A node @id, when present, must equal node_iri(id), else NODE_IRI_MISMATCH (#1077).
validate_against_graph(imported, eff) Refuse with UNRESOLVED_RELATION_SOURCE any relation whose from is neither a node in the file nor a node in eff["nodes"], and with UNRESOLVED_RELATION_TARGET any relation whose to is neither a node in the file nor a node in eff["nodes"] and carries no external ref (#1077). preview() and commit() both call it first, and the commit endpoint calls it for every file before the write lock, so preview reports every commit refusal and a refused file blocks the whole batch.
preview(imported, eff) -> dict Diff the parsed repo against the effective graph. Node status is keyed on the DocuGraph uuid5 id vs eff["nodes"]; relation status is keyed against eff["relations"] with no endpoint-derived uuid (WP4 #953). An in-model target (a node in the file or the live graph) is matched on the (from, to, predicate) triple. An external or unresolved target is matched on source, predicate and its domain/ref (_imported_rel_key / _existing_rel_key, documents_import.py:278, :293); DocuGraph's synthetic to is ignored, because it is never written. Preview and commit use the same key (#1077). An existing relation stored as an external or unresolved target is not upgraded when its target node later appears: the re-import adds an in-model relation beside it (#1077 scope; upgrading placeholders is WP8 P4's write-path mechanism). Read-only. Returns {fingerprint, summary:{added,updated,unchanged}, nodes:[...], relations:[...], risks:{...}} — risks is produced by _compute_risks(imported, eff) (called at documents_import.py:434, defined at :333) and reports the re-import's impact on the current repo's documents: orphaned (current repo docs absent from the import), danglingAttachments (count of orphaned docs carrying a user hasDocument attachment), possibleRename (orphaned docs whose filename matches a brand-new incoming node) and newNodes (count of incoming nodes not in the effective graph).
commit(imported, eff, username, filename=None) -> dict Write overlay nodes + relations via mutation_store.write_atomic. When filename is given, container labels are overridden with repo_label_from_filename(filename) and an import_filename engineering entry is added (documents_import.py:490). Fingerprint-idempotent: if every container already carries import_fingerprint == imported.fingerprint, returns {unchanged: True, skipped: N} without writing. Re-import of a previously-tombstoned repo raises the nodes from the dead (overlay-wins). A relation's eidos:target is node_iri(to) only when to is a node in the file or the live graph. A DocuGraph dangling citation (synthetic to plus an external ref) is written as an explicit eidos:ExternalRef (external_ref_jsonld(domain, ref)), or as an eidos:UnresolvedRef when the relation names no external domain. It is never written as node_iri(<synthetic id>) (#1077).
ImportedRepo (dataclass) Parsed payload: fingerprint, source, format_version, mode, root_id, nodes, relations, containers (documents_import.py:81; there is no separate documents field).
ImportValidationError ValueError subclass carrying stable .code + .message; endpoint boundary maps into the mixed-outcome per-file response shape.

Endpoints (backend/routes/documents.py, all admin-gated, all forwarded to the documents instance when this backend is not it):

Method + Path Purpose
POST /admin/documents/import/preview Batch-preview one or many .jsonld payloads. Always returns 200 with a per-file mixed-outcome table ({name, summary, nodes, relations} or {name, error:{code, message}}).
POST /admin/documents/import/commit Commit one or many pre-previewed payloads. Blocks the whole batch with 400 IMPORT_CONFLICTS on any validation failure; writes overlay nodes+relations via commit(), invalidates the cache ONCE after the batch (Contract 13 bounded cost), audits ImportDocumentRepo per file.
GET /admin/documents/imported-repos List every imported DocumentContainer + source_type=repo with its descendant Document count, source, fingerprint.
DELETE /admin/documents/import/{repo_id} Cascade-tombstone the container and every descendant Document + their relations via delete_node(cascade=True). Idempotent — repeat delete returns {removed: false}, not 404.
POST /admin/documents/purge DEV-only clean slate (guarded by confirm=='PURGE'): snapshot the domain, then cascade-tombstone every direct child of the domain root; per-child failures are collected into failed:[{id,code,message}] in the response.

Root binding. commit() re-binds any node whose parent is the exported root to the live eff["domain_root_uid"] — the exported root is never written; the live #Documents root is authoritative.

Cascade semantics. merge_overlay (backend/domain/graph_merge.py) filters both base and overlay relations whose endpoint is in node_tombstones (Contract 06 §3.11 R37-R39). This keeps the composite view free of dangling edges after a cascade delete.


Document Resolution — Where It Runs, and Rule-Derived Channels

GET /node/{node_id}/documents returns the union of every enabled documentPropagation rule's channel plus the intrinsic direct attachment, deduplicated with a locked class-priority order (D2). The channels are DATA, not code:

  1. Direct (intrinsic, never a rule) — the item's effective outgoing hasDocument relations targeting the documents domain.
  2. Hierarchy-mechanism rules (default seed: "Inherited from") — an ancestor hasDocument relation that satisfies the rule's condition (the seed's condition is inherit_source: true, matching the pre-#508 locked semantics) contributes a source labelled with the rule's name and the ancestor.
  3. Relation-mechanism rules (default seed: "From type"; user-authorable, e.g. "From location") — the item's outgoing via relation into targetDomain (resolved through _resolve_cross_domain_target) reaches a target node whose own hasDocument external relations contribute sources labelled with the rule's name and the target.
  4. Related — when the viewed node itself carries hasRelatedDocument relations (a Document node's References / Referenced-by), its 1-hop neighbours, both directions, surface as a top-level related key: {"references": […], "referencedBy": […], "count": <int>}, projected via de.related_doc_ref — the identical projection each direct document's own per-row inline expander already uses (_attach_related, #445/#444). Omitted entirely when there are no neighbours; this channel is local-only (the viewed node's own relations), so it never appears on the cross-domain secondary-item path below.

de.derive_document_channels(rules) turns the enabled documentPropagation envelopes into ordered ChannelSpecs (class order: hierarchy first, then relation by rank asc, then rule id). merge_document_sources(direct, channels) dedupes by document id with direct > hierarchy > relation priority (D2 class order). primarySource is the strongest channel and sources preserves all provenance labels — each non-direct source carries the rule's label/ruleId plus an order the frontend groups by. Channel 4 (Related) is independent of that merge — it augments the response, it does not compete with a document's provenance.

By design, not a bug: channel 4 only ever appears on a Document node's own panel. Channel 4 is sourced from the viewed node's own hasRelatedDocument relations — it has nothing to do with which domain the viewed node lives in, only with whether the viewed node itself has doc-to-doc neighbours, which is only ever true for a Document node. A Products/Type/Location item's Documents panel therefore never shows a "Related documents" section, while a Document node's own panel does — this is a real, visible structural difference between the Documents domain's own subpanel and every other domain's, and is intentional (see _related_node threading above), not something the union-read fix or any other panel-parity work should try to equalise.

Rule registry mapping (#508). The document channels are stored in the rulebook as ordinary documentPropagation rows (seeded once, backup-first, by rulebook_schema.seed_document_propagation_file at startup — key-presence idempotent, D4). The canonical RuleCatalog enumerates them with provenance == "rulebook" (was builtin); the editor lets an operator create, disable/enable, or delete any of them, and the resolver picks up the change on the next request via the RULEBOOK proxy's reload-on-read.

Where the resolver runs — primary vs. secondary vs. document domain

The canonical rule: assemble where the relations and the documents domain are both visible.

  • Primary-domain items (e.g. product) and document-domain nodes (viewing a doc) resolve locally: get_node_documents (backend/routes/documents.py) reads the item's own effective outgoing relations via _resolve_effective_outgoing (canonical accessor), classifies them into the three channels (classify_document_refs), and — since this instance holds the viewed node itself — also wires channel 4 from the node's own hasRelatedDocument relations.
  • Non-document secondary-domain items (location, type, signal, discipline, …) resolve on the primary, not on the secondary instance. A secondary-domain item's hasDocument/hasType relations can live in either of two stores: the primary's overlay (external_source_domain == <domain>, the exact convention _apply_secondary_overlay uses to enrich the detail panel) when the relation was written through the primary, or the secondary instance's own local store, because the existing attach route (POST /api/proxy/{server}/documents) forwards the write verbatim to the secondary instance itself, which persists it there. Reading only the primary's overlay (as the read path did before this fix) found zero relations for anything attached through that route, so the Documents panel showed nothing for a Location/Type/etc. item even when a real hasDocument attachment existed (bug #1).

GET /api/proxy/{server}/node/{id}/documents (backend/api/proxy_router.py, proxy_node_documents) now validates server against the registered secondary domains first (an unknown/invalid server 404s "Unknown domain", the same shape every other proxy route gives — it no longer silently returns an empty documents list), then branches: for server == "document" it still forwards verbatim (a doc node's own channels are local to the documents instance, per above). For any other secondary, it does not forward — the primary assembles the response itself: - collect_cross_domain_document_refs(server_mod, server, node_id, auth) rebuilds the same effective-relations shape from the primary's overlay (same external_source_domain == server scoping and ancestor-set construction _apply_secondary_overlay already implements for inherited pills), hands them to the same classify_document_refs used by the local path (Contract 02.01, single owner) — and then unions in the secondary node's own hasDocument relations, read directly off its raw node fetch (GET /node/{id} on the secondary, identity-forwarded) via _secondary_own_hasDocument_refs. The two lists are deduped by document id with the primary-overlay entry winning on conflict, so a relation visible in both stores is never shown twice. A 403/404 from that raw-node fetch (no access, or the node genuinely has no local record) falls back to overlay-only results, never an error; any other non-200 is a domain-level infra failure and raises (Contract 05), matching _fetch_document_nodes's own policy. - _assemble_documents_response(...) then does the identical fetch (_fetch_document_nodes, identity-forwarded) + merge (merge_document_sources) + payload (document_node_to_payload) + per-doc related-attach (_attach_related) as the local path. The cross-domain call passes no related_node, so channel 4 never appears here — it is only wired for the node the resolving instance actually holds.

Net effect: a Location or Type node's Documents panel is populated from the primary — reading BOTH the store the write path historically used and the store the read path originally expected — through the same merge/priority machinery as a primary-domain item, with no duplicated channel logic between the local and cross-domain paths. This union-read is a compatibility fix, not the end state: the write and read paths still disagree about which store is authoritative, and collapsing to one store (with a migration for existing secondary-store data) is tracked separately (BL-DM-020).

Error handling (Contract 05) — infra failure vs. per-document no-leak

_fetch_document_nodes fetches each resolved document id from the documents domain with the caller's real identity forwarded (on_behalf=auth.user_id), so the no-leak filter always evaluates the actual principal, never a service identity:

  • A per-document 404/403 (missing or restricted) is dropped silently — the id is simply omitted from the result, so no filename/path/url/title of a withheld document ever appears (Contract 09, unchanged).
  • Any other non-200 from the documents domain (5xx, or a transport/timeout failure surfaced by the proxy layer) raises (ExternalServiceError) instead of being swallowed. Previously any non-200 was treated as "not found," so an unreachable or broken documents domain silently read as an empty document list. Now a genuine infra failure surfaces as an error to the caller, while a normal missing/restricted document still degrades gracefully to "not shown."

URL Normalization and Scheme Guard

normalize_external_url strips whitespace, lowercases scheme+host, drops #fragment, keeps ?query, and preserves path case. The scheme guard accepts only http://, https://, file:// — anything else (javascript:, data:, vbscript:, scheme-relative //…, relative paths) raises ValueError so it can never become a document node or a stored, clickable URL. This is the stored-XSS guard.

normalize_relative_path rejects any traversal (../, ..\\, embedded /../).


Moderation / Blocklist

backend/domain/documents_blocklist.py implements the admin blocklist. is_blocked(url, blocklist) (documents_blocklist.py:67) is a hostname-only check — it extracts the host from the URL and tests membership against the configured blocklist. There is no document-id axis.

The blocklist is enforced at create-time, not read-time: routes/documents.py:2155 calls dbl.is_blocked(url, await _load_blocklist_effective(server_mod)) inside the attach handler and refuses to persist a document whose URL host is blocked. Neither of the read paths invokes is_blocked: GET /node/{id}/documents (routes/documents.py:263-306) calls _collect_document_refs then _assemble_documents_response (routes/documents.py:1856); the Documents admin browse (admin_browse_documents, routes/documents.py:760) fetches with _fetch_all_document_nodes (called at routes/documents.py:774). The admin external-links view (:871) reads is_blocked(url, blocklist) only to stamp the row with a blocked: true display flag — the row still appears.

If a document is attached before its host is blocklisted, it continues to appear in the read-time responses. Un-blocking has no effect on previously attached documents (they were never blocked in the response path).


Documentation 360°

Documentation 360° (issue #490) is a separate, downstream consumer of this module's three direct document channels — it does not add a fourth channel here, and it is not owned by this engine. For a selected node (optionally its whole subtree) it produces an A4 PDF document list explaining, per node, which documents belong there and why.

The three direct channels above (direct, from-type, inherited) are its starting point for every analysed node. On top of them, a new v2 rulebook category, documentContext, adds two further, rulebook-governed context kinds: doc-to-doc (one hop over a document's own hasRelatedDocument neighbours) and via-node (the direct-channel documents of another node reached over an associative relation). Both are opt-in, rulebook-driven, and fully editable/removable — there is no hardcoded fallback.

See documentation-360-engine.explanation.md for the occurrence model, the documentContext category, deduplication, ordering, the IAM invariants, and the API; see the Rulebook Engine page for the documentContext schema entry.


Route Layer (orchestration, not owned by this engine)

backend/routes/documents.py owns orchestration for the /documents*, /node/{id}/documents and /admin/documents* endpoints. It composes the engine's pure functions (documents_engine, documents_import, documents_blocklist) with IAM gating, cross-domain proxying, and the internal POST /documents/nodes/upsert handler (service token + X-Eidos-Username; refuses to run unless EIDOS_DOMAIN=document). The engine layer is route-agnostic and holds no HTTP or FastAPI dependencies — Contract 02.01 layer discipline.

The /node/{id}/documents resolver itself is split into small, independently reusable pieces so the local and cross-domain placements above share one implementation: classify_document_refs (pure channel classification over an already-resolved effective-outgoing-relations list; see documentation-360-engine.explanation.md for the opt-in track_consumption=True 3-tuple variant used by Doc 360° to track per-(node, predicate) pivot consumption — all other callers use the default 2-tuple return unchanged), collect_cross_domain_document_refs (the primary-side overlay sourcing that feeds it for a secondary item), _fetch_document_nodes (identity-forwarded, no-leak, infra-failure-raising document fetch), and _assemble_documents_response (fetch + merge + payload + related-attach, shared verbatim by get_node_documents and proxy_router.proxy_node_documents). backend/api/proxy_router.py owns the cross-domain entry point (proxy_node_documents) that decides, per server, whether to forward to the documents instance or assemble on the primary.