Step 4 — Transclusion / Composition (Reference Nodes) — Implementation Spec
Hand-off-ready spec for Step 4 of the multi-chart plan (docs/admin/org-chart-multi-chart-design.md, issue #1549). Depends on Step 2 (#1558) — reuses scopePeople and the view model. This is the Institution-tier differentiator: distributed maintenance, one published artifact.
Goal
Let a node in a master org reference the root of another org (maintained separately) so that at render time both compose into one coherent, keyboard-navigable ARIA tree. Example: central comms owns the university-wide chart; each college maintains its own; the university chart “plugs in” each college under its VP without re-keying the data.
Non-negotiable guardrail (the moat)
Transclusion resolves into a single ARIA tree with one keyboard/focus model at render time. Never nest iframes-within-iframes; never stitch multiple independent ARIA trees. An accessibility product cannot ship a composed chart that breaks arrow-key nav or screen-reader announcement across the boundary. Every acceptance test enforces this.
Scope
In scope
- New
org_chart_referencestable (a reference node: where org B plugs into org A). POST/GET/DELETE /api/orgcharts/:id/references— manage reference nodes; validate existence, no cycles, ownership.- Render resolution: compose referenced org/view subtrees into the host tree via
scopePeople, with ID namespacing, cycle/ depth/ fan-out guards, and read-only transcluded data. - Graceful degradation when a target is deleted/unavailable (placeholder node, not a broken tree).
- Client: add a reference node under a person; visual + programmatic indication that a subtree is sourced from another chart.
Out of scope
- Cross-owner references — MVP restricts references to orgs owned by the same user. Cross-owner grants require the workspaces/permission model from Phase 3 (teams). Ship same-owner now; note the extension point.
- Editing transcluded data from the host (it is read-only here; edit the source org).
- Nested iframe embedding (explicitly rejected).
- Lazy-load-on-expand for huge subtrees is a follow-up (note it; not required for MVP).
Migration (additive, no backfill)
CREATE TABLE public.org_chart_references ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), host_org_chart_id UUID NOT NULL REFERENCES public.org_charts(id) ON DELETE CASCADE, host_parent_person_id UUID NOT NULL REFERENCES public.org_chart_people(id) ON DELETE CASCADE, -- where B attaches under A target_org_chart_id UUID NOT NULL REFERENCES public.org_charts(id) ON DELETE CASCADE, target_view_id UUID REFERENCES public.org_chart_views(id) ON DELETE SET NULL, -- plug in a view of B, or whole B user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(host_parent_person_id, target_org_chart_id));CREATE INDEX idx_refs_host ON public.org_chart_references(host_org_chart_id);CREATE INDEX idx_refs_target ON public.org_chart_references(target_org_chart_id);
ALTER TABLE public.org_chart_references ENABLE ROW LEVEL SECURITY;CREATE POLICY org_chart_references_owner ON public.org_chart_references FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);Note target_org_chart_id uses ON DELETE CASCADE — deleting the target org removes the reference rows; render then simply has no reference (see degradation for the softer in-flight case). Additive; ship with code + npm run rebuild.
API (workers/api/src/routes/org-chart/references.ts)
Mount /api/orgcharts/:id/references in both index.ts and index-aws.ts.
POST /api/orgcharts/:id/references—{ hostParentPersonId, targetOrgChartId, targetViewId? }. Validate: host person ∈:id; target org exists and is owned by the same user (MVP);targetOrgChartId !== :id; no cycle (walk the reference graph — reject if target transitively references the host); fan-out cap per host.GET /api/orgcharts/:id/references— list.DELETE /api/orgcharts/:id/references/:refId.
Render resolution (extend generate.ts / preview / export)
New workers/api/src/utils/compose-tree.ts:
composeTree(hostOrg, references, loadOrg) → { people, relationships } // one connected, namespaced set- Build the host tree (or host view via
scopePeopleif rendering a view). - For each reference on an in-scope host person: load the target org’s people/relationships, run
scopePeoplefortarget_view_id(or whole org), and re-root the target subtree underhost_parent_person_id. - Namespace IDs across orgs: prefix every id with its org id (e.g.
${orgId}:${personId}) so ids never collide when composed; remapmanager_id/relationships accordingly; set the target root’smanager_idto the namespaced host parent. - Cycle guard at render too (defense in depth): track visited org ids on the compose stack; refuse to expand an org already on the stack.
- Depth/fan-out caps: bound total composed node count and reference-nesting depth; if exceeded, stop expanding and mark the boundary node (log + surface, per the no-silent-caps rule).
- Output is one connected tree; transcluded nodes carry a
sourceOrgChartIdmarker.
Transcluded data is read-only in the host; person edits in the host UI are disabled for referenced nodes (edit the source org).
Degradation
If a target is unavailable at render (deleted mid-request, or over the cap), render a placeholder node (“Referenced chart unavailable”) in place of the subtree — never a broken/partial tree with no signal. Announce it to AT.
Client (apps/org-chart/src/app/)
- “Reference another chart here” action on a person (Institution-tier gated once Phase 3 lands; ungated in dev). Picker lists the user’s other orgs (+ optional view). Creates the reference and re-renders.
- Transcluded subtrees are visually and programmatically labeled as sourced from another chart (badge + accessible text like “Org: College of Engineering (linked)”) so viewers and screen readers understand the composition; referenced nodes are non-editable in the host.
- Placeholder rendering for unavailable targets.
Accessibility: the composed render is a single ARIA tree with one roving-tabindex/arrow-key model spanning the boundary (reuse the civic AccessibleTreeView); the “linked chart” label is real text, not color/icon only; focus order flows continuously across the seam. test:a11y axe fixtures cover a composed multi-org tree.
Tests
- Unit
compose-tree.test.ts: reference resolves into one connected tree; ID namespacing prevents collisions between orgs sharing person ids; target re-rooted under host parent; relationships remapped; whole-org vs view target. - Cycle: A→B→A rejected at create and at render; self-reference rejected.
- Caps: depth/fan-out limits enforced; boundary node marked + logged (not silently truncated).
- Degradation: deleted/unavailable target → placeholder node, tree still valid.
- Ownership: cross-owner reference rejected (MVP).
- Route
references.test.ts(route-test-coverage rule): CRUD + validations; composed generate/preview returns the merged tree; transcluded nodes flagged read-only. - A11y: axe on a composed 2-org tree; keyboard traversal crosses the seam continuously.
npm run typecheck(via npm) +test:cigreen; coverage not decreased.
Acceptance criteria
- A user attaches another of their orgs (or a view of it) under a person; the rendered chart shows both as one navigable tree.
- Person ids that collide across the two orgs do not clash after composition (namespacing).
- Cycles and self-references are rejected at creation and cannot be produced at render.
- Transcluded nodes are read-only in the host and labeled (visually + for AT) as linked from another chart.
- Deleting/unavailable target degrades to a clearly-marked placeholder, never a broken tree.
- Depth/fan-out caps prevent runaway composition and are logged when hit.
- The composed render passes axe as a single ARIA tree with continuous keyboard traversal; no nested iframes.
- Cross-owner references are refused (MVP); works on both Node and Lambda.
Runtime & deploy
Node + Lambda; register references routes in both entry points; additive migration ships with code → npm run rebuild on 10.1.1.4 after merge; Lambda/CF auto-deploy. New @org-chart/shared types → rebuild the shared package (Dockerfile workspace-deps rule). Smoke-test: reference org B under a node in org A, render, traverse the seam by keyboard, delete B → confirm placeholder.
Follow-ups (post-MVP)
- Cross-owner references via Phase 3 workspaces + an authorization grant from the target owner.
- Lazy-load-on-expand for very large transcluded subtrees (the drill-down technique from the design doc §3b).
- Reference a live external published chart (out of the single-tree guarantee — would require careful a11y design; not the iframe path).