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.directionnames which edge ofsrcwas followed —"outgoing"fortarget ∈ src.references(src's own outgoing edge),"incoming"fortarget ∈ src.referenced_by(src's own incoming edge). This is filter plumbing: a persisteddocumentContextrule'sDocToDoc.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 persisteddirection: "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) readsReferenced by {src}(the target, from its own point of view, was referenced by src);direction="incoming"(something points to src) readsReferences {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-mappingif, 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-docis exactly one hop, evaluated only from the node's own direct-channel documents — never chained from avia-nodedocument, never recursive.via-nodeis exactly one relation hop; the reached node's owninherited/from-typedocuments count as its direct documentation and are included, but that node's owndoc-to-doc/via-nodecontext 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 path — Direct 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:
- Relevance group, in the fixed order
direct,inherited,from-type,doc-to-doc,via-node(RELEVANCE_ORDER). - 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.
- 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"; direction — outgoing | 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 |
predicate — hasRelatedDocument (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):
- Plan.
plan_context()builds the DFS node set (subtree mode) or the single root, resolvesdocumentContextrules per node (rules_for_node), and computesvia-relationpivots anddoc-to-docapplicability — all local, in-memory, no document fetch yet. - 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-domainhasTyperesolution memoised for the life of the request via_RequestScopedServerso a subtree with many nodes pointing at the same type resolves it once, not once per node — plus one bounded cross-domain/node/{id}/documentsproxy round for pivots that live in another domain) is fetched once via_fetch_document_nodes. - Batch 2.
hasRelatedDocumentneighbours of the batch-1 documents are fetched once more — only for nodes where adoc-to-docrule actually applies. No further rounds. build_report()turns the plan plus the two batches' hydrated documents into the finalDocumentation360Report: 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_getraising 404 before any call is made) is also dropped, with adoc360_pivot_domain_unregisteredwarning 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/pdf — backend/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), oneRelated <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 withTotal(navy bold, right-aligned; the sum of every shown entry).Direct relevantandTotalalways show, even at - No
unique/rules:text — removed in v2.documentation_360_pdf._report_metrics(report)is the single function this line's numbers ANDrender()'s returneddisplay_metrics["occurrences"]both come from (review round 1, I7 — "one truth for totals": theX-Doc360-Occurrencesresponse header the frontend popover reads must equal what the PDF itself displays, never the engine's rawreport.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
Tablefills 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 foldedinherited/from-typeintoDIRECT RELEVANTand so silently erased their provenance and duplicated cards when the same document reached a node on two channels):DIRECT RELEVANT— channeldirectonly. 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, channelfrom-type, grouped byvia_node_ref— same navy band style as a via-node related band, with the domain word fixed toType. The type's name comes fromroutes/documents.py::classify_document_refs'sfrom_typeentries' newrefNamefield (the type node's owndescription, additive — no existing key changed), threaded ontoDocRef.via_name→Occurrence.via_node_namein the orchestrator's_to_refs(). - One
INHERITED — <ancestor name> (<ancestor ref>)band per ancestor, channelinherited, grouped byvia_node_ref— same navy band style. The ancestor's name comes from the orchestrator's already-loadednamesmap (the same sourceNodeSection.nameuses), 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, channelvia-node, grouped byvia_node_ref.<DOMAIN>is the pivot's domain word (Type,Location,Document, ... orProductfor 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-dococcurrences 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:
- 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 literaln/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 atfrontend/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. - The document code/name as an underlined external link to its source URL — plain text (never a dead link) when there is none.
- A uniform 4-column meta strip —
PAGES/CREATED/MODIFIED/AUTHOR, each a small grey micro-label over its value. An empty value renders the literaln/ain light grey — every card shows the same four columns, always. - 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.
- No per-row provenance/"Why" text on a card — the section band it sits under already carries the why.
- Headline = the document's
- Appendix A — collected once, at the back of the report (after every
node section), behind a steel-blue
APPENDIX A — REFERENCING DOCUMENTSband 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) thenREFERENCED 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; seetest_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, turningA&BintoA&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 destinationappendixA. - 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 combinesbuildBreadcrumbPath+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 inroutes/notifications.py's node-share links) — both now delegate to the one canonicaldomain.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_baseisserver_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),KeepTogetheron a node's heading and its first band, anden/dalocalisation via the_Spattern 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
documentContextcategory yields direct channels only andrules: nonein 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 owndomain_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_requesthelper JSON-decodes the upstream body, which would corrupt PDF bytes — this route calls_proxy_senddirectly instead and returns the raw bytes with the upstream'sContent-DispositionandX-Doc360-Nodes/-Occurrences/-Uniqueheaders 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).