Skip to content

Documentation 360 Engine

Layer: domain

Overview

Do not export a list of documents. Export the documentation context of the node.

Documentation 360° answers, for a selected node (optionally its whole subtree), which documents belong here, and why. It is derived entirely from explicit EIDOS graph relations governed by the rulebook — never from document text search — and it is a list, not a report: one PDF, no cover page, no table of contents, no summary chapter (spec §1, §7).

backend/domain/documentation_360_engine.py is the canonical, pure engine (Contract 02.01): no I/O, no globals, no side effects. It is fed by the async orchestrator backend/reports/documentation_360.py, rendered by backend/reports/documentation_360_pdf.py, and reachable only through POST /api/reports/documentation-360/pdf. A future XLSX export reuses the engine unchanged — the occurrence model and provenance vocabulary live here exactly once.


The Documentation Context of a Node

For each analysed node the context is the union of three direct channels (the same three the Documents panel already resolves) plus two rulebook-driven context kinds:

Relevance How found Provenance text (en)
direct hasDocument on the node itself (documents resolver, channel direct) Direct
inherited hasDocument on an ancestor with inherit_source (resolver channel inherited) Inherited from {ancestor ref}
from-type a document on the node's type via hasType (resolver channel from-type) Via type {type ref}
doc-to-doc one hop over hasRelatedDocument from the node's own direct-channel documents, per a documentContext rule of kind doc-to-doc References {source doc} / Referenced by {source doc}
via-node the direct-channel documents of another node reached over an associative relation, per a documentContext rule of kind via-relation Via {node ref} ({predicate}) (add , incoming when the relation runs into the node)

doc-to-doc direction, precisely (corrected 2026-08-31, review round 1 — the field/text split was itself wrong on the first pass). A doc-to-doc occurrence is about the target document reached from the node's own source document (src), one hasRelatedDocument hop away. Two separate things carry this direction, and they are not the same value:

  • Occurrence.direction names which edge of src was followed — "outgoing" for target ∈ src.references (src's own outgoing edge), "incoming" for target ∈ src.referenced_by (src's own incoming edge). This is filter plumbing: a persisted documentContext rule's DocToDoc.direction (outgoing/incoming/both) filters against exactly this pairing, so the field must keep this meaning — an earlier attempt at fixing the display text bug (below) swapped this pairing instead and silently broke the rule filter for any persisted direction: "outgoing"/"incoming" rule. Reverted.
  • provenance_text() swaps the wording, and ONLY the wording, on top of that field: direction="outgoing" (src points to the target) reads Referenced by {src} (the target, from its own point of view, was referenced by src); direction="incoming" (something points to src) reads References {src} (the target references src). The naive same-word reading of the field name is backwards from the correct English sentence, which is exactly why this bug existed in the first place — the fix lives entirely in the text-mapping if, not in the field's own meaning.

Occurrence.via_doc_ref always names src by its printable name (document code, then filename stem, then title — never a raw id); Occurrence.via_doc_id carries src's id, for dedup identity only.

Two boundedness rules keep this a 1-hop model, never a graph crawl:

  • doc-to-doc is exactly one hop, evaluated only from the node's own direct-channel documents — never chained from a via-node document, never recursive.
  • via-node is exactly one relation hop; the reached node's own inherited/from-type documents count as its direct documentation and are included, but that node's own doc-to-doc/via-node context is not followed further.

The three direct channels are untouched by this feature — they remain the read-only builtin descriptors (_BUILTIN_DOCUMENT_PROPAGATION) in RuleCatalog, category documentPropagation. Documentation 360° only adds the two rulebook-driven context kinds on top.


Occurrence Identity and Deduplication

An occurrence is (node, document, path). Occurrence.dedup_key() is

(doc_id, relevance, predicate, direction, via_doc_id, via_node_ref)

Two occurrences collapse into one only when every field of that key matches. Deduplication is per node — the same document under two different nodes is never merged — and per pathDirect and Referenced by DOC-004 on the same node both survive, because they carry different relevance/via_doc_id values. This is intentional (spec D9): the same document may legitimately appear under many nodes, and under one node via several distinct discovery paths.

via_doc_id vs via_doc_ref. For a doc-to-doc occurrence, via_doc_ref holds the source document's printable name (for display in the provenance text — never a raw id) while via_doc_id holds its id (for dedup identity only, never rendered). The two are deliberately separate fields: two distinct source documents that happen to share a printable name (e.g. two revisions both named "System Description") must not collapse into one occurrence just because their display text matches — dedup identity stays id-based even though the display text is name-based.


Ordering (Deterministic)

Nodes are visited root first, then children in the tree's own DFS order — plan_context builds this with an explicit stack rather than relying on dict/set iteration order, so the report is reproducible across runs.

Within a node, occurrences are sorted by:

  1. Relevance group, in the fixed order direct, inherited, from-type, doc-to-doc, via-node (RELEVANCE_ORDER).
  2. The document's printable name (see below), case-insensitively — not its internal id, so the list sorts the way a reader expects (by document code / title), regardless of the underlying UUID.
  3. Provenance text (so multiple occurrences of the same document sort deterministically by why they appear).

Rulebook Category documentContext

documentContext is a v2 rulebook category with the same envelope as every other category (id, enabled, effect, name, appliesTo, provenance, body):

{
  "id": "dc-<derived>",
  "enabled": true,
  "effect": "apply",
  "name": "Associative relations",
  "appliesTo": { "kind": "global" },
  "provenance": { "source": "default" },
  "body": { "kind": "via-relation", "predicate": "*association", "direction": "both" }
}

The id shown above is illustrative only — ids are content-derived (_derive_rule_id(category, {kind, predicate, direction}), a DC-<sha1[:10]> hash), not a fixed literal string; the id stays stable across edits to non-identifying fields (name, description) but changes if kind, predicate, or direction change.

The two body kinds

body.kind Body fields Meaning
via-relation predicate — a relation-catalog predicate id, or the wildcard "*association"; directionoutgoing | incoming | both Any node reached from the query node over this relation contributes its own direct-channel documents as via-node context.
doc-to-doc predicatehasRelatedDocument (the only value accepted in v1); direction — as above From the query node's own direct-channel documents, follow document-to-document relations one hop, in the given direction(s).

rulebook_store.py validates the body shape at write time: kind must be one of the two above, predicate is required, direction must be one of outgoing/incoming/both, and a doc-to-doc rule's predicate is locked to hasRelatedDocument.

Defaults, stop rules, and appliesTo

Two rules are seeded once, only when the documentContext key is entirely absent from the rulebook (rulebook_schema.seed_document_context_file, invoked at startup; a rulebook that already has the key — even an empty list — is left untouched, because that is the customer's own decision):

Default rule Name Kind Predicate Direction appliesTo provenance.source
Associative relations "Associative relations" via-relation *association both global default
Related documents "Related documents" doc-to-doc hasRelatedDocument both global default

provenance.source == "default" marks these as editable defaults, not builtins — a customer may disable, rescope, or delete either one through the generic admin CRUD (/admin/rulebook/rules); nothing falls back to a hardcoded rule if they are removed.

appliesTo (global / subtree / type), effect: "stop", and most-specific-wins resolve for documentContext exactly as they do for every other movement category, through the same RulebookEngine machinery — see the Rulebook Engine page. A stop rule scoped to a subtree suppresses a broader via-relation/doc-to-doc rule there without supplying a replacement; _via_rule_for picks the specific-predicate rule over the *association wildcard when both exist for a node.

documentContext is registered in rulebook_store._MOVEMENT_CATEGORIES and in rulebook_schema, so the existing generic admin CRUD, validate, preview, and for-node endpoints all work for it with no new endpoints; RuleCatalog registers documentContext rules exactly like propertyInheritance or relationInheritance rows.

No category ⇒ rules: none. A rulebook with no documentContext key (or an empty one) yields an empty context-rule list: plan_context finds no via-relation/doc-to-doc rule for any node, so the report contains the three direct channels only, and the PDF's count line reads rules: none. There is no hidden fallback to hardcoded context rules.

Out of scope (v1): multi-hop traversal, predicate negation, a dedicated documentContext tab in the Rule Book editor (the rules are visible in the All-Rules overview and editable via the admin API like any other rule).


Data Flow — Two Fetch Batches

detail-panel IdentityToolbar ── plugin export-documentation-360.js
        │  POST /api/reports/documentation-360/pdf
        ▼
api_reports_router.py  (orchestration only: auth → IAM → limit → 2 fetch batches → engine → render → audit)
        │
        ├── routes/documents.py helpers: _collect_document_refs, _related_neighbour_ids, _fetch_document_nodes
        ├── domain/rulebook_engine.py: documentContext rules resolved per node
        ├── domain/relation_catalog.py: is_hierarchy / association predicates
        ├── domain/documentation_360_engine.py   ← canonical engine (pure, no I/O)
        └── reports/documentation_360_pdf.py     ← reportlab renderer

documentation_360.collect() is the orchestrator. It always does exactly two document-fetch batches, regardless of node count (Contract 13 R14 — no N+1):

  1. Plan. plan_context() builds the DFS node set (subtree mode) or the single root, resolves documentContext rules per node (rules_for_node), and computes via-relation pivots and doc-to-doc applicability — all local, in-memory, no document fetch yet.
  2. Batch 1. Every distinct document id referenced by any plan node or pivot (per-node direct-channel refs — collected concurrently in chunks of _FETCH_CONCURRENCY, with cross-domain hasType resolution memoised for the life of the request via _RequestScopedServer so a subtree with many nodes pointing at the same type resolves it once, not once per node — plus one bounded cross-domain /node/{id}/documents proxy round for pivots that live in another domain) is fetched once via _fetch_document_nodes.
  3. Batch 2. hasRelatedDocument neighbours of the batch-1 documents are fetched once more — only for nodes where a doc-to-doc rule actually applies. No further rounds.
  4. build_report() turns the plan plus the two batches' hydrated documents into the final Documentation360Report: per-node dedup, deterministic order, and stats.

Cross-domain pivots

A via-relation pivot that resolves to a node in another domain is fetched through one bounded proxy round (_direct_refs_cross_domain, batched by _FETCH_CONCURRENCY), mirroring _fetch_document_nodes's exact no-leak error policy:

  • A reachable secondary returning 403/404 for that one pivot drops it silently (no-leak) — the report continues.
  • A pivot domain not registered with this instance's proxy allowlist (_proxy_get raising 404 before any call is made) is also dropped, with a doc360_pivot_domain_unregistered warning logged — one unresolvable pivot must not abort the whole report, and must not be confused with the endpoint's own root-not-found 404.
  • Any other non-200 status is a genuine domain-level infra failure and raises ExternalServiceError → the endpoint returns 502 — a broken secondary must surface as an error (Contract 05), never as a silently empty pivot.

IAM Invariant

A node or document the caller may not read appears nowhere in the PDF — not as a section, not as a via-node pivot, not as a doc-to-doc neighbour. _fetch_document_nodes already drops 403/404 without leaking; related nodes are filtered through _visible() before they are ever used as pivots. Authorization filtering happens before the report model exists (Contract 08 R16).

Hidden node prunes its subtree. plan_context's DFS walk checks visible(node) before pushing that node's children onto the traversal stack — a hidden node's descendants are never visited at all, even when a descendant would itself be visible. This is deliberate, not a bug: printing a visible descendant's path (e.g. HVAC › <hidden ancestor> › -WDB01) would leak the hidden ancestor's reference into the PDF. The same pruning-not-substitution rule applies to via-relation pivots — an invisible pivot target is simply excluded, never replaced by a placeholder.

Inherited-channel provenance may name an ancestor above the reader's grant. The inherited relevance's provenance text is Inherited from {ancestor ref} — this mirrors the Documents panel's own resolver, which has always shown the inheriting ancestor's reference for this channel. It is current, intended behaviour, not a new leak introduced by this engine: it is the SAME exposure the Documents panel already has today. It is not, however, guaranteed to be on the reader's own visible path — _resolve_effective_outgoing walks the whole parent chain unfiltered (it has no IAM predicate to filter against), so under a subtree-scoped grant Inherited from {ancestor ref} can name a node above what that grant makes visible. Tracked as a resolver-level follow-up (backlog/items/iam/BL-IAM-012.md), not fixed by this engine — fixing it means changing the shared resolver, which the Documents panel also depends on.

IAM rules load once per request, not per node. _get_iam_rules() is called exactly once in documentation_360.collect(), and the resulting rules list is closed over by the _visible() predicate used for every node and pivot lookup in that request (up to DOC360_MAX_NODES, 2 000, lookups). Re-opening the access-rules store per lookup — which is what a naive per-call _has_iam_read() would do — is fine for a single lookup but not for thousands in one report; this is a documented performance decision, not an accident.


API

POST /api/reports/documentation-360/pdfbackend/api/api_reports_router.py, Depends(deps.current_user) (the canonical CurrentUser role, api/deps.py). Additive (minor-version) change.

POST /api/proxy/{server}/api/reports/documentation-360/pdf — the same request/response contract, forwarded to a secondary domain's own instance of the endpoint above (BL-IE-039, backend/api/proxy_router.py). See "Secondary-Domain Nodes" above.

Request body (extra="forbid"):

{ "root_node_id": "<uuid>", "include_subtree": false, "lang": "en" }
Code When Body / headers
200 success application/pdf, Content-Disposition: attachment; filename="documentation-360-<ref>.pdf", X-Doc360-Nodes, X-Doc360-Occurrences, X-Doc360-Unique
401 not authenticated project error envelope
404 root unknown or not IAM-readable (identical body — no IDOR signal) {"code": "node_not_found"}
422 validation failure — missing/blank id, unknown lang, unknown fields field-level detail
422 subtree over DOC360_MAX_NODES {"code": "subtree_too_large", "limit": 2000, "actual": N, "hint": "choose a deeper node"}
502 document domain unavailable (cross-domain pivot fetch failure) ExternalServiceError envelope
500 the renderer raised (e.g. reportlab's outline error on a depth gap) — any exception from render() becomes a structured 500, never a bare traceback {"code": "render_failed", "message": "PDF rendering failed."}

Performance budget (documented in the route/module docstrings, Contract 13 R1): p99 < 3 s for a single node; subtree ≈ 30 ms per visible node on DEV (13 s at 461 nodes), i.e. up to ~60 s at the DOC360_MAX_NODES limit (2 000 nodes) — synchronous by design (D4); optimisation tracked in BL-IE-040. Memory is the report model plus the PDF buffer, linear in occurrences (≈ 2 KB per occurrence). Every subtree request occupies one worker for its duration (DEV runs 4 workers). No JSON variant ships in v1 — it can be added additively later.

Every attempt is audited (Contract 09 R33, data export): Documentation360Export — principal, root id, subtree flag, node and occurrence counts, outcome (success, or failure with its error code).


PDF — v2 Layout (Bands, Cards, Appendix A)

backend/reports/documentation_360_pdf.py::render() builds an A4-portrait, single-page-template PDF using the ref_arch_pdf.py flowable pattern. The v2 layout (2026-08-31, owner-approved mockup; corrected in review round 1) replaced the earlier flat Document/Description/Pages table with sectioned colour bands over document cards, and moved doc-to-doc cross-references into a single trailing appendix. Description-first, everywhere: wherever a node or document is named, the human description/name precedes the machine reference/code, never the reverse.

render() returns (pdf_bytes: bytes, display_metrics: dict) — the caller (api_reports_router.py) sets the X-Doc360-Nodes/-Occurrences/-Unique response headers from display_metrics, not from report.stats (review round 1, I7 — see the metrics-line bullet below).

  • No cover, no table of contents, no summary chapter. The list starts on line one.
  • Title block (once, top of page 1): "Documentation 360°" alone, large navy bold, with a navy bottom rule; right-aligned small grey <project name> · Generated <YYYY-MM-DD HH:MM UTC> (the real generation timestamp).
  • Metrics line: one entry per section kind with its report-level count, in this fixed order — Direct relevant, Related Type (from-type, omitted when 0), Inherited (omitted when 0), one Related <Domain> entry per distinct via-node pivot domain seen anywhere in the report (omitted entries never appear — a domain with no occurrences is simply absent, not shown as 0), Referencing (the Appendix A row count, present only when Appendix A exists) — ending with Total (navy bold, right-aligned; the sum of every shown entry). Direct relevant and Total always show, even at
  • No unique/rules: text — removed in v2. documentation_360_pdf._report_metrics(report) is the single function this line's numbers AND render()'s returned display_metrics["occurrences"] both come from (review round 1, I7 — "one truth for totals": the X-Doc360-Occurrences response header the frontend popover reads must equal what the PDF itself displays, never the engine's raw report.stats.occurrences, which partitions occurrences differently before Appendix A dedup).
  • Per analysed node (subtree mode: repeated per node, tree order, indented by depth via Indenter, exactly as before):
  • Heading — the node's description first, bold navy 12pt, with its ref in grey 10pt parentheses; on its own line below, the node's canonical slug (see below) as a small clickable link to its EIDOS deep link. A node with no description falls back to its ref alone as the bold headline.
  • Section bands — full-width, navy-filled bars (square corners — the mockup's rounded corners are a print-safe simplification the renderer's own implementation notes explicitly sanction; reportlab Table fills are square) with uppercase white text and a right-aligned document count. Each occurrence channel gets its own band kind (review round 1, C2, owner ruling — corrects the first pass, which folded inherited/from-type into DIRECT RELEVANT and so silently erased their provenance and duplicated cards when the same document reached a node on two channels):
    • DIRECT RELEVANT — channel direct only. Carries a light-blue "Appendix A ↓" internal quick-link on its right side whenever the report has an appendix (report-level, not per-card).
    • One RELATED TYPE — <type name> (<type ref>) band per type, channel from-type, grouped by via_node_ref — same navy band style as a via-node related band, with the domain word fixed to Type. The type's name comes from routes/documents.py::classify_document_refs's from_type entries' new refName field (the type node's own description, additive — no existing key changed), threaded onto DocRef.via_nameOccurrence.via_node_name in the orchestrator's _to_refs().
    • One INHERITED — <ancestor name> (<ancestor ref>) band per ancestor, channel inherited, grouped by via_node_ref — same navy band style. The ancestor's name comes from the orchestrator's already-loaded names map (the same source NodeSection.name uses), looked up by the ancestor's node id (refId) in _to_refs() — no extra fetch. A cross-domain via-node pivot has no such synchronous source and keeps an empty name (the band then shows just the ref).
    • One RELATED <DOMAIN> — <pivot name> (<pivot ref>) band per pivot node, channel via-node, grouped by via_node_ref. <DOMAIN> is the pivot's domain word (Type, Location, Document, ... or Product for a same-domain/local pivot — via_node_domain == "") via a small lookup table with a camelCase-preserving fallback for an unrecognised domain key.
    • A band with zero documents is omitted entirely (unchanged from v1); a node section whose occurrences are entirely doc-to-doc (nothing lands in any of the four band kinds above) renders no bands at all — just its heading plus one small grey line, "All documents for this node are reference documents — see Appendix A ↓", the tail an internal link to Appendix A (review round 1, I3).
    • doc-to-doc occurrences are never rendered as their own band/card here — a doc-to-doc target is not itself "direct relevant" to the node; it belongs only in Appendix A (see below). A document that has doc-to-doc relations still gets an in-card "References in Appendix A ↓" link (item 4 below) on EVERY band it appears under.
  • Document cards (one per document, under its band) — light ice-blue fill, thin border, ~8pt gap between cards:
    1. Headline = the document's description, falling back to its printable name (document_code → filename stem → title) — never a blank headline, never a raw id, never the literal n/a. A small file-type chip sits right-aligned: filled rect (square corners, same print-safe simplification as the bands), white uppercase text, coloured by family (pdf/docx/xlsx/dwg, else grey) — the SAME classification + colour SSOT the frontend document surfaces use (frontend/plugins/detail-panel.js:3198 _fileTypeFamily, tones at frontend/index.html:2602 --ft-*); the renderer runs server-side with no access to CSS/JS, so the mapping is duplicated here as literal presentation constants (documentation_360_pdf.CHIP_COLORS), documented in the module docstring so the two copies don't silently drift.
    2. The document code/name as an underlined external link to its source URL — plain text (never a dead link) when there is none.
    3. A uniform 4-column meta stripPAGES / CREATED / MODIFIED / AUTHOR, each a small grey micro-label over its value. An empty value renders the literal n/a in light grey — every card shows the same four columns, always.
    4. If the document is itself an Appendix A source (it has doc-to-doc relations), a small "References in Appendix A ↓" internal link jumps straight to its appendix entry.
    5. No per-row provenance/"Why" text on a card — the section band it sits under already carries the why.
  • Appendix A — collected once, at the back of the report (after every node section), behind a steel-blue APPENDIX A — REFERENCING DOCUMENTS band with the total row count on the right:
  • One card per source document that has doc-to-doc relations, deduped across nodes by source doc id (a source under two nodes gets one appendix entry, not two). Header row: the source's description (bold) left, its code as a link right.
  • Two sub-groups, in order, each omitted when empty: REFERENCES (docs the source points to) then REFERENCED BY (docs that point at the source) — see the direction note above; these two words are the ONLY provenance-vocabulary literals the renderer is allowed to contain (they are layout group labels here, not per-occurrence provenance — the engine still owns that; see test_no_context_logic_in_router_or_orchestrator).
  • Each record is two lines (description bold, filename as an underlined external link below) with a thin top hairline. The filename/name is handed to _link() (which escapes internally) exactly once — an earlier pass double-escaped it first, turning A&B into A&amp;amp;B (review round 1, I4).
  • Every appendix card carries a named PDF destination apxA-<doc_id> that the per-card "References in Appendix A ↓" links (above) jump to; the band itself carries destination appendixA.
  • Deep link + slug (backend, reports/documentation_360.py):
  • build_slug(node_id, parent_of, labels) — the canonical dotted path: the absolute tree root's full label (sigil kept, e.g. -FEM) then every descendant's label with its leading sigil stripped, dot-joined, ending at the node itself (e.g. -FEM.X01.K22.HG01.UCAxx). Pure, cycle-safe; mirrors the frontend's _buildIecPath (frontend/plugins/detail-panel.js:976, which combines buildBreadcrumbPath + stripNodePrefix, frontend/app.js:6201-6213) exactly, so a slug printed in the PDF matches what the app itself shows.
  • build_node_url(public_base, node_id) — the EIDOS deep link. Review round 1, I6: this formula was duplicated (here, and inline in routes/notifications.py's node-share links) — both now delegate to the one canonical domain.deeplink.build_node_deeplink(public_base, node_id) (<public_base>/#<urlencode(public_base + "/node/" + node_id)>, Contract 02.01 §3.1 single canonical owner). public_base is server_mod.BASE_URL.
  • Footer/page header, every page (unchanged mechanism): the header line now reads Documentation 360° · <root description> (<root ref>) · <scope> (description-first); the footer is unchanged (EIDOS · <customer> / page n).
  • Kept: clickable link annotations, a PDF outline/bookmark per node section nested by depth (the reader's side panel is the navigation aid), page numbers, Indenter-based nesting (a frame-level offset around plain, splittable flowables — never a single non-splitting wrapper table, so a large section can still paginate), KeepTogether on a node's heading and its first band, and en/da localisation via the _S pattern shared with the other report renderers.

Limits, Errors, Audit

  • DOC360_MAX_NODES (default 2 000, overridable via env var) is enforced after IAM filtering — it counts only nodes the caller can actually see. Over the limit is a structured 422; there is no truncated PDF.
  • There is no separate cap on occurrence count; the memory model (~2 KB per occurrence) is documented in the renderer's module docstring.
  • A down/slow document domain surfaces as a 502 (ExternalServiceError), logged at WARNING — never a silently empty or partial PDF.
  • An individual unreadable document (403/404) is silently absent — the existing no-leak behaviour, unchanged.
  • A missing documentContext category yields direct channels only and rules: none in the count line — never a hidden fallback ruleset.
  • All 4xx/5xx responses use the project's structured error envelope (Contract 05): no stack traces, no internal paths.

Secondary-Domain Nodes (BL-IE-039)

Documentation 360° works for any domain's node, not just the primary (Products) domain. Every EIDOS instance runs the same server, so the report endpoint exists on each secondary backend too — the only thing that was missing was a proxy route on the primary. That gap is closed by POST /api/proxy/{server}/api/reports/documentation-360/pdf (backend/api/proxy_router.py), which mirrors the read-forwarding pattern used by the other /api/proxy/{server}/* GET routes:

  • Gated by _gate_proxy_domain (a domain-READ IAM rule) — no write gate, this is a read-only report.
  • Forwards the JSON body verbatim and the caller's identity (X-Eidos-Username) alongside the service token, so the secondary applies that user's own IAM read-filter and records the audit entry under its own domain_key — not the anonymous service identity.
  • Uses the EXPORT-class proxy timeout (PROXY_TIMEOUT_EXPORT, default 120 s) — subtree reports can take well over the default 15 s.
  • Binary passthrough. The generic _proxy_request helper JSON-decodes the upstream body, which would corrupt PDF bytes — this route calls _proxy_send directly instead and returns the raw bytes with the upstream's Content-Disposition and X-Doc360-Nodes/-Occurrences/ -Unique headers forwarded unchanged. A non-200 upstream response is still the engine's structured JSON error envelope (404/422/502) and passes through with its status code and shape intact.

The frontend plugin (export-documentation-360.js) no longer hides the toolbar button on a secondary-domain node — the old visible: ({ domainBase }) => !domainBase || domainBase === core.baseUrl gate is gone, matching the primary-only limitation the System Context PDF still has today (not fixed by this change). When the selected node's domainBase is a secondary domain, the plugin resolves the domain key the same way apiFetch() does (window.baseToDomain, frontend/app.js) and posts to /api/proxy/{domain}/api/reports/documentation-360/pdf; a primary-domain node still posts directly to /api/reports/documentation-360/pdf.

Documentation 360°'s own cross-domain logic — via-relation pivots reached from the root (see "Cross-domain pivots" above) — is unaffected: that machinery already handled a pivot living in another domain than the root. This proxy route only concerns a root that itself lives on a secondary domain.

Out of scope for v1 (spec §12): an XLSX/CSV export (a separate feature that reuses this same engine), a JSON endpoint, multi-hop or recursive traversal beyond the one-hop rules above, a dedicated Rule Book editor tab for documentContext, an entry in the global Export modal, asynchronous generation/progress reporting, and a language picker in the popover (the export follows the app's current language).