Skip to content

EidosCore Boundary (the frontend black-box contract)

Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20

956 D1b-1 (2026-10-20): 2 finding(s) fixed — EidosCore snapshot is 65 members (connectivityPredicates #918 + resolveNodeBase); 16 registered modules including SpiderEdges. Re-verified against main 421c63ba.

Layer: frontend

EidosCore exists (a namespace on window), but for a long time it was not the boundary: the plugins reached around it. Before wave 4, app.js was not wrapped in an IIFE, so all ~643 of its top-level names were reachable by bare name from any other <script>; the plugins made ~160 undeclared cross-file reads of those names; and they pushed ~27 callbacks back through window. This engine turns EidosCore into the real contract — plugins consume only the declared surface, app.js leaks no globals — and freezes it so it stays that way. All four counters (C1/C2/C3/C4) are now exact 0 after 4.5e (#783) wrapped app.js in an outer IIFE; the boundary is fully closed — trin 4.5 done. The full design is documented in this file (docs/manual/dev/eidos-core-boundary.md).

The measurement (an AST inventory, not a grep)

scripts/babel-build/global_boundary_scan.js (@babel/parser + @babel/traverse, the shared parse_js.js front-end) walks every deployed script — index.html's <script src> ∪ every plugins/*.js ∪ index.html's inline <script> — scanning the transpiled detail-panel, never the JSX source. tools/global_boundary_inventory.py cross-references reads↔providers, assigns a direction, and classifies every cross-boundary name (contract / internal / plugin-callback / third-party-lib / html-handler / dead) — an unclassified name fails the guard. Comments, strings, CSS attribute selectors ([onclick=…]) and //-commented emitted markup never inflate a count.

The four frozen counters (downward-only)

tests/test_global_boundary_inventory.py freezes exact baselines, 4.0/4.6 ratchet style — above the baseline is a regression; below it means a slice removed a coupling and must lower the baseline in the same PR:

  • C1 = 0 (exact) — undeclared cross-file reads by plugins (was 160; 4.5b-1 → 41; 4.5b-2 → 6; 4.5b-3 → 0). The last plugin→plugin reads are gone: detail-panel reads DomainRouting + DetailEngineering (confirmConnect/connectFromNeighbour) via EidosCore.module(...), and global-search reads the EidosIcons module. Zero is the trin's done-criterion — "detail-panel forbruger kun deklarerede interfaces"; any new off-contract plugin read raises it above 0 and fails the guard.
  • C2 = 0 (exact, 4.5c-4 #776) — plugin window writes / reverse callbacks (was 8 after 4.5c-3, 14 at 4.5c-2, 25 at 4.5c-1, 27 at 4.5b). 4.5c-3 removed 6 — _iisOpenModal/_iisCloseModal became the IIS reverse module (iis-module.js); triggerRefArchDownload became RefArch (report-ref-arch.js); buildTypeChip became TreeChipType (tree-chip-type.js); the window.eidos_icoSvg write was dropped (the EidosIcons module already carried the function since 4.5b-3); _docAttachErrorText became DocAttach.errorText (detail-panel.js). 4.5c-2 removed 11: 8 connections-spider callbacks in detail-panel.js (__eidosAddToWorkbench, __eidosRemoveFromWorkbench, __eidosAddCreatedEdge, __eidosRemoveCreatedEdge, __eidosConnectMode, __eidosNextDrop, __ndpCy, _eidosDragRel) became a single EidosCore.registerModule('SpiderConnections', {...}) registered from useEffect([nodeId]); detail-engineering.js's 3 (confirmConnect, connectFromNeighbour, repointConnection) became local vars and repointConnection joined the existing DetailEngineering module registration. 4.5c-4 removed the remaining 8: the root.X = api lines in rule-sentence.js, domain-routing.js, cross-sync.js, mutation-refresh.js, spider-compound-model.js, spider-layout-store.js, and the window.X = {...} writes in shortcuts.js and global-search.js — registerModule registrations kept, all consumers already read via module('X'). 4.5c closes. Only the X = X || {} / X = X ?? {} self-init idiom (the scanner flags it) is excluded — root.EidosCore = root.EidosCore || {} in mutation-refresh.js is a defensive namespace guard, not a callback. Zero is exact — any new plugin window write raises C2 above 0 and fails immediately, mirroring the C1 ratchet. A real window.EidosCore = shim plugin write is NOT self-init and stays counted.
  • C3 = 0 (exact, 4.5d #779) — inline on*="…" bare-callee sites: all 101 index.html attributes and 4 app.js template-literal sites were routed through EidosCore.registerAction + the document-level delegated listener in frontend/eidos-action.js. The ratchet at tests/test_frontend_inline_handlers_ratchet.py keeps the count at 0 downward-only — a new inline on*= attribute trips it immediately. The two remaining inline <script> blocks in index.html were also extracted into eidos-cytoscape-init.js / eidos-theme.js so 'unsafe-inline' could drop from CSP script-src.
  • C4 = 0 (exact, 4.5e #783) — app.js is now IIFE-wrapped; all 643 formerly-top-level declarations are module-local. The done-criterion "app.js leaks no globals" is met. The boundary is fully closed — trin 4.5 done. A new top-level declaration outside the outer IIFE raises the count above 0 and trips the ratchet immediately (same shape as C1/C2/C3).

Plus: the EidosCore export-list snapshot (a committed 65-member sorted array — #918 (#700) added connectivityPredicates and RDF WP6 W4 #955 added resolveNodeBase after 5.2f's 63; was 66 before 5.2f #906 removed allTrees/nodeCache/parentMap; 4.5b-1 promoted 21 app.js names + openDialog and dropped _catalogPending; 4.5c-1 added listEngineReconcilers; 4.5d added registerAction, _dispatchAction, _hasAction;

749 taught the scanner to expand static string-literal-array forEach publish patterns → added

dispatchMutation, reconcileDirty, getOptimisticCtx (mutation-refresh.js's publish loop, previously invisible to the scanner) and dropped isDirty from the publish list (zero cross-file callers); adding/removing a member fails until the array is edited in the same PR, so a surface change is visible by construction) with a companion _KNOWN_SMELLS frozenset (#745) that separates boundary smells (private caches / provisional state that leaked onto EidosCore as implementation detail rather than declared API) from the declared surface: the snapshot test excludes smells from both the measured and snapshot sides — so cleaning a smell off EidosCore reads as an expected improvement, not a surface-change red — and a companion test_known_smells_are_absent_from_eidoscore_surface asserts each smell stays off the measured surface as a permanent regression guard (first entry: _catalogPending, already moved module-local in 4.5b-1), the two-path ratchet (a downward-only count of off-contract reads of names that ARE on the surface — 4.5a froze the set of 10, 4.5b-1 turned it into a count and lowered it to 21; 4.5b-2 drove it to 0 and the guard is now exact-0 with an empty name-set — the export family + the sweep (apiFetch, baseToDomain, loadNodeDetail, navigateTo) + the last one, resolveTargetDomainForPredicate (app.js's getter now reads EidosCore.module('RelationCatalog')), are all routed), the inventory's modules facet (the names registered via registerModule — RelationCatalog, SpiderCompoundModel, SpiderLayoutStore (b-2) + RuleSentence, DomainRouting, Shortcuts, GlobalSearch, EidosCrossSync, DetailEngineering, EidosIcons (b-3) + SpiderConnections (4.5c-2 reverse module for the connections-spider workbench/createdEdges callbacks + isConnectMode/getCy/getDragRel getters) + IIS (4.5c-3, openModal/closeModal), RefArch (4.5c-3, triggerRefArchDownload), TreeChipType (4.5c-3, buildTypeChip), DocAttach (4.5c-3, errorText) + SpiderEdges (BL-TEST-006 #172, registered by frontend/plugins/spider-edges.js:21), 16 modules — with a companion guard that every EidosCore.module('X') read resolves to a registered module, so a typo'd module('Spiderlayoutstore') that ?. guards would silence fails a test), and 0 unclassified.

The reverse surface — a typed registry on EidosCore

Owner decision §8-2 chose a typed registry over an event bus. It is delivered in three files (the module/registry split in 4.5b-3 #763 by the load-order blocker; the action layer added in 4.5d #779): frontend/eidos-modules.js owns the plugin-namespace half (registerModule / module) and loads first — before the §8-5 providers that register at their module top and before app.js; frontend/eidos-registry.js (loaded after mutation-refresh.js) keeps the reconciler half (registerReconciler, unregisterReconciler, listReconcilers, runReconcilers), which must stay after the mutation engine because it wraps it; frontend/eidos-action.js (loaded in _preEidosCore before app.js) owns the delegated event-dispatch layer (registerAction / _dispatchAction / _hasAction) — exactly like eidos-modules.js carries registerModule/module, the _actions Map lives in the IIFE's closure so a double-load is idempotent and actions registered before app.js survive the reassignment. See EidosAction — delegated event dispatcher. app.js carries registerModule/module through its _preEidosCore merge so its window.EidosCore = { … } literal does not drop them — the _modules Map lives in eidos-modules.js's closure, so a namespace registered before app.js runs survives the reassignment.

  • registerModule(name, api) / module(name) — the plugin-namespace slot (§8-5). api must be an object or function (a null/undefined/primitive throws — else module(name) couldn't tell "registered with undefined" from "not registered"); a duplicate name throws; the handle un-registers, identity-guarded so a stale handle can't tear down a re-registered name. The registered set is RelationCatalog, SpiderCompoundModel, SpiderLayoutStore (4.5b-2) + RuleSentence, DomainRouting, Shortcuts, GlobalSearch, EidosCrossSync, DetailEngineering, EidosIcons (4.5b-3) + SpiderConnections (4.5c-2 — the connections-spider workbench/createdEdges callbacks and isConnectMode/getCy/getDragRel getters) + IIS, RefArch, TreeChipType, DocAttach (4.5c-3) + SpiderEdges (BL-TEST-006 #172), 16 modules — consumed via EidosCore.module('X'). A guard pins that every module('X') read resolves to a registered module (a typo would otherwise be a silent undefined).
  • registerReconciler(name, fn, { mode: 'immediate' | 'deferred' }) / runReconcilers(ctx) — the reverse-callback surface. Immediate reconcilers run synchronously; deferred ones coalesce into a single microtask (the 6 admin reload loops became deferred reconcilers in 4.5c-1 — admin-users/admin-iam/admin-snapshots/admin-export-info/admin-domain-visibility/admin-propcatalog — via _mountStaleBadge). Contract: a duplicate name throws (unregister first to re-register — same as registerModule and the mutation engine); an unknown mode throws (a typo'd 'defered' never silently becomes immediate); the handle's unregister() also drops a queued-but-not-yet-run deferred token, so a reconciler torn down in the same tick does not fire; the coalesced deferred flush is last-wins on ctx (the final runReconcilers in the tick supplies the ctx); an uncaught reconciler error is console.error-ed, not swallowed. registerReconciler already existed as mutation-refresh.js's (surfaceId, descriptor, fn) mutation form; the registry wraps and shape-dispatches it — a call whose 2nd arg is a function is the new generic form (which requires an opts object, so a mis-call registerReconciler(surfaceId, fn) fails loudly rather than becoming a silent immediate reconciler), anything else delegates to the mutation engine unchanged. A generic name and a mutation surfaceId may not collide — registering either when the other already owns the name throws, so the two registries never disagree. unregisterReconciler(name) and listReconcilers() are wrapped too, so a generic name unregisters / is listed alongside the mutation surfaces (and unregistering a generic name never tears down a mutation surface). listEngineReconcilers() (added in 4.5c-1, #748) returns the ENGINE-only slice — mutation surfaces without the generic names — for _mountStaleBadge's "is this surfaceId already a mutation surface?" guard, which must not false-positive on a generic reconciler name registered before the mutation surface mounts (detail-panel's graph-refresh useEffect fires ahead of app.js's _registerViewRefresh, and the unified listReconcilers() would else block the admin-panel registrations). One name, both behaviours, nothing clobbered.
  • registerAction(name, fn) / _dispatchAction(name, ev, el) / _hasAction(name) (4.5d #779) — the delegated event-dispatch layer. registerAction returns an identity-guarded unregister handle (same pattern as registerModule/registerReconciler). A document-level delegated listener in eidos-action.js intercepts click/change/submit/input events and calls _dispatchAction when a data-action attribute matches a registered name — this removes every inline on*= handler from HTML and allows 'unsafe-inline' to be dropped from CSP script-src (C3 = 0 exact, 4.5d). _hasAction and _dispatchAction are private surfaces exposed for tests only.

The registry sets new own properties on EidosCore — it never Object.assigns over it, so the live getters stay live (the 4.6a HIGH-1 lesson; tests/test_bl_fe_080_app_js_patches.py stays green). No consumer is migrated in 4.5a — the guard and the registry land first; 4.5b–4.5e do the movement.

Moving the plugins onto the surface — family by family (4.5b)

4.5b migrates the consumers, one plugin family per slice, detail-panel first (the largest). Each slice changes the declared surface once, then rewrites that family's off-contract reads to EidosCore.X:

  • Promotions (§8-1): navigation (navigateTo, navigateToUri, navigateToRef, navigateRelTarget, resolveBaseForURI, …), identity/label (stripNodePrefix, baseToDomain, sysClassName, locateNodeInTree, loadNodeDetail, computeWarnState, …) and read-only tables (PRODUCT_CHILD_TYPES, PRODUCT_TYPE_COLORS, GLOBAL_SEARCH_V2) become EidosCore members — functions/tables as stable refs, mutable state as live getters. The four promoted privates drop their underscore on the surface (§8-4: panelDomainBase, isDevHost, tier, eidosDragNode). No new top-level names, so C4 stays 643.
  • One dialog entry: the 8 node/relation/information dialog openers + the share-link copy collapse to EidosCore.openDialog(kind, ctx) (kinds add-node/update-node/delete-node/delete-relation/ notify-team/add-information/edit-information/share-link; an unknown kind throws). Plugins open a dialog through the one declared entry instead of a bare cross-file call.
  • Demotions: exported-but-unread names drop their dead window.X export. The 3 const TREE_* become truly module-local; 4 are top-level function declarations still reachable by bare name — real privacy waits for the IIFE (4.5e), so "demote" here means the ambient window export is gone.
  • 4.5b-1 (#751) landed the surface change + migrated detail-panel (135 → 19 off-contract reads; the residual 19 are the Spider/DomainRouting/connect names owned by 4.5b-2/4.5b-3). It also moved detail-panel's private _catalogPending cache off EidosCore (a boundary smell; closed by PR #753, which reconciled write-sites to a module-local let _catalogPending inside detail-panel's IIFE). The second half of #745 — the _KNOWN_SMELLS mechanism that lets future smell cleanups pass the snapshot test as expected greens instead of "removed member" reds — landed as a follow-up, with _catalogPending seated as the first historical entry / permanent regression guard.
  • 4.5b-2 (#761) migrated the spider family and the export family — and the cheap two-path sweep those families exposed — onto the declared surface. The two spider models and relation-catalog now EidosCore.registerModule(...) (their <script>s moved below eidos-registry.js so registerModule exists at their module top; the window global is kept until 4.5c); detail-panel reads them via EidosCore.module('SpiderCompoundModel') / ('SpiderLayoutStore') / ('RelationCatalog').isCableNode; the nine export plugins read EidosCore.EidosExportRegistry; and the sweep routed the remaining two-path names. C1 41 → 6, two-path 21 → 0.
  • 4.5b-3 (#763) closes 4.5b: it split the module registry into eidos-modules.js (loads first) so the §8-5 providers can register at their module top, registered the remaining namespaces (RuleSentence, DomainRouting, Shortcuts, GlobalSearch, EidosCrossSync) plus DetailEngineering (confirmConnect/connectFromNeighbour) and EidosIcons (eidos_icoSvg), and routed the last three plugin→plugin reads (detail-panel + global-search) through module(). app.js consumes all seven via module('X') too (incl. the load-time if (window.Shortcuts) gate, since Shortcuts registers before app.js). C1 6 → 0 (exact) — detail-panel now reads nothing off-contract.

Documented Contract-15 exception (4.5b-1). Migrating detail-panel's reads onto the LIVE EidosCore.parentMap/nodeCache getters activated four paths that were dead — they read window.parentMap/window.nodeCache, which were never mirrored to window, so they always saw undefined: the WarnSection parentType/allowedHere + R-PARENT-OF child list, the IEC-segment desc, and the spider chainOf/ancestorOfKind grouping. This is intended UI that had been broken; b-1 is where it surfaced. The owner accepted the activation as this slice's one documented behaviour change; the O(model) child scan was replaced with a memoised parent→children index.

5.2f (#906) — thin proxy removal. EidosCore.nodeCache, EidosCore.parentMap, and EidosCore.allTrees were live-getter proxies that returned the same backing objects as EidosNodeStore._nodes, EidosTree._parentOf, and EidosTree._trees respectively. After all cross-file call sites reached 0 (B3/B2 ratchets in 5.2c–5.2e), the three getters were removed in 5.2f and the EidosCore boundary snapshot shrunk from 66 → 63 members. Plugins must now use the public store APIs: EidosNodeStore.get(uid) / EidosTree.parentOf(uid) / EidosTree.allTrees(). A new whole-codebase ratchet (B4) in tests/test_frontend_node_tree_ratchet.py freezes the count of EidosCore.{nodeCache,parentMap,allTrees} references at 0 in every frontend file, including app.js.

Moving the reverse callbacks onto the reverse registry (4.5c)

4.5c drives C2 → 0 by migrating the plugin window._ callbacks onto the reverse registry — one family at a time, exactly like 4.5b's forward-direction migration:

  • 4.5c-1 (#766) — first chunk: two of detail-panel's callbacks moved to the typed reverse registry. window._eidosRefreshGraph and window._eidosRefreshDocuments (the ConnectedSpider and DocumentsZone reload pokes) became EidosCore.registerReconciler('graph-refresh' | 'documents-refresh', fn, { mode: 'immediate' }) — generic-immediate reconcilers registered from React useEffect (the cleanup calls the handle's unregister() so StrictMode double-mounts do not throw "already registered"). App.js's graphs and documents-list mutation reconcilers now call C.runReconcilers(ctx) instead of the window callback; that runs every generic reconciler in one pass and coalesces deferred ones into a single microtask. Six admin panels — admin-users, admin-iam, admin-snapshots, admin-export-info, admin-domain-visibility, admin-propcatalog — became DEFERRED mutation reconcilers via _mountStaleBadge: when a related mutation reaches the server while the tab is open, the reconciler is marked dirty and can be reconciled without a full tab reload. Direct switchAdminTab reloads are unchanged — the deferred reconciler handles cross-session/cross-tab staleness only. C2 dropped 27 → 25 (only the two window._ writes were counted in C2 — the six admin panels were app.js private functions, never window writes).
  • #748 guard fix, same slice. _mountStaleBadge used to guard on C.listReconcilers(), which unions mutation surfaces + generic reconciler names — so a generic name registered before its mutation surface mounts (detail-panel's graph-refresh at React useEffect time, ahead of app.js's _registerViewRefresh) false-positive'd the "already a mutation surface" check and silently skipped the badge registration. 4.5c-1 added listEngineReconcilers() (engine-only slice; the unified listReconcilers() stays for cleanup and cross-namespace collision checks) and pointed the guard at it. A vitest bite pins that a generic name registered ahead of a mutation surface no longer blocks the guard.
  • 4.5c-2 (#769) — connections-spider chunk: the 11 remaining connections-spider callbacks moved onto the reverse registry as EidosCore.registerModule('SpiderConnections', {...}). The 8 detail-panel callbacks (__eidosAddToWorkbench, __eidosRemoveFromWorkbench, __eidosAddCreatedEdge, __eidosRemoveCreatedEdge, __eidosConnectMode, __eidosNextDrop, __ndpCy, _eidosDragRel) are registered from the SpiderConnections React component's useEffect([nodeId]), with the cleanup calling _scHandle.unregister() so StrictMode double-mount cannot throw "already registered" (mirrors the 4.5c-1 graph-refresh pattern). The 3 detail-engineering helpers (confirmConnect, connectFromNeighbour, repointConnection) became local consts; repointConnection joined the existing DetailEngineering module registration. App.js's 4 consumer sites (window._eidosDragRel, window.__eidosConnectMode, window.__eidosRemoveCreatedEdge) now read via EidosCore.module('SpiderConnections')?.<fn>?.(). C2 dropped 25 → 14. A new pytest bite pins that a module('SpiderConnections') read with no registered provider is caught by the guard — the same silent-typo failure class the b-2 SpiderLayoutstore bite catches.
  • 4.5c-3 (#774) — misc callbacks chunk: six window writes migrated across four providers. iis-module.js's _open/_close became EidosCore.registerModule('IIS', { openModal, closeModal }) (dynamically-loaded plugin, registers at module top after the _close definition); index.html's Interfaces onclick now reads EidosCore.module('IIS')?.openModal?.(). report-ref-arch.js's triggerRefArchDownload became EidosCore.registerModule('RefArch', { triggerRefArchDownload }) (the IIFE's anonymous window.X = async function refactored into a named local async function + registration); app.js's downloadRefArch reads await EidosCore.module('RefArch')?.triggerRefArchDownload?.(). tree-chip-type.js's buildTypeChip became EidosCore.registerModule('TreeChipType', { buildTypeChip }) (loads in the <head> before app.js — same early-guard pattern as shortcuts.js); app.js's typechip renderer reads EidosCore.module('TreeChipType')?.buildTypeChip?.(...) || "" (the empty-string fallback preserves the original guard). detail-panel.js's window.eidos_icoSvg write is dropped (the EidosIcons module already held the function since 4.5b-3 — the registration switched from { eidos_icoSvg: window.eidos_icoSvg } to { eidos_icoSvg } where the local const name is bound at module top). And _docAttachErrorText became EidosCore.registerModule('DocAttach', { errorText }); app.js's _aidDocErrorText reads EidosCore.module('DocAttach')?.errorText?.(err) ?? …fallback. C2 dropped 14 → 8. Four new pytest bites pin that each new module('X') read resolves to a registered provider (IIS/RefArch/TreeChipType/DocAttach), and the register-module round-trip vitest gains TreeChipType (register-only — no window.TreeChipType namespace, so the identity-check branch is skipped). A Playwright DEV smoke spec (wave45c3-misc-callbacks.spec.ts) exercises three paths on a live stack: IIS modal open/close via the module, RefArch module registered with a function, TreeChipType registered at page load with a chip-HTML round-trip.
  • 4.5c-4 (#776) — closes 4.5c: the 8 §8-5 namespace window/UMD-root writes removed. The root.X = api lines in rule-sentence.js, domain-routing.js, cross-sync.js, mutation-refresh.js, spider-compound-model.js, spider-layout-store.js deleted; window.Shortcuts and window.GlobalSearch in shortcuts.js and global-search.js replaced with const api locals. registerModule registrations kept; all consumers already read via module('X'). C2 dropped 8 → 0 (exact) — the ratchet now catches a new plugin window write immediately (same shape as C1). EidosMutationRefresh had no consumer via module() — its write was simply dropped. The vitest register-module-roundtrip.test.js identity assertions (expect(api).toBe(sandbox.X)) and sameAsWindow: true flags updated accordingly.

Closing the last surface — the app.js IIFE (4.5e)

4.5e (#783) — the wave-4 exit-gate. app.js is wrapped in an outer (function () { … })(); IIFE. Every top-level function/let/const/class declaration becomes module-local; C4 drops from 643 to 0 exact. Two lines are added to app.js (the opening bracket after the file-comment block and the closing })(); at EOF), plus one explicit cross-frame bridge assignment: window.graphNodeClick = graphNodeClick; — the SVG connections-spider iframe's <script> uses window.parent.graphNodeClick as a click-fallback, so the bridge keeps that path working after the wrap. The window.EidosCore = { … } contract assignment is unchanged (it is the boundary itself); the nested (async () => { … })(); boot IIFE is now doubly-nested and still executes; document.currentScript is valid inside the outer IIFE (synchronous script evaluation preserves it). "use strict" is NOT added — app.js uses non-strict idioms.

With this slice all four counters are exact 0 — C1 0, C2 0, C3 0, C4 0 — and the "app.js leaks no globals" done-criterion is met. Any new top-level declaration outside the outer IIFE now trips the ratchet immediately, so the boundary stays closed by construction. Trin 4.5 is done.

See also the UI Primitives (EidosModal) engine — the modal primitive is one member of this boundary, and its single-path ratchet is the same pattern at one overlay's scale.