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) viaEidosCore.module(...), and global-search reads theEidosIconsmodule. 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
windowwrites / 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/_iisCloseModalbecame theIISreverse module (iis-module.js);triggerRefArchDownloadbecameRefArch(report-ref-arch.js);buildTypeChipbecameTreeChipType(tree-chip-type.js); thewindow.eidos_icoSvgwrite was dropped (theEidosIconsmodule already carried the function since 4.5b-3);_docAttachErrorTextbecameDocAttach.errorText(detail-panel.js). 4.5c-2 removed 11: 8 connections-spider callbacks indetail-panel.js(__eidosAddToWorkbench,__eidosRemoveFromWorkbench,__eidosAddCreatedEdge,__eidosRemoveCreatedEdge,__eidosConnectMode,__eidosNextDrop,__ndpCy,_eidosDragRel) became a singleEidosCore.registerModule('SpiderConnections', {...})registered fromuseEffect([nodeId]);detail-engineering.js's 3 (confirmConnect,connectFromNeighbour,repointConnection) became local vars andrepointConnectionjoined the existingDetailEngineeringmodule registration. 4.5c-4 removed the remaining 8: theroot.X = apilines inrule-sentence.js,domain-routing.js,cross-sync.js,mutation-refresh.js,spider-compound-model.js,spider-layout-store.js, and thewindow.X = {...}writes inshortcuts.jsandglobal-search.js—registerModuleregistrations kept, all consumers already read viamodule('X'). 4.5c closes. Only theX = X || {}/X = X ?? {}self-init idiom (the scanner flags it) is excluded —root.EidosCore = root.EidosCore || {}inmutation-refresh.jsis 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 realwindow.EidosCore = shimplugin 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 throughEidosCore.registerAction+ the document-level delegated listener infrontend/eidos-action.js. The ratchet attests/test_frontend_inline_handlers_ratchet.pykeeps the count at 0 downward-only — a new inlineon*=attribute trips it immediately. The two remaining inline<script>blocks in index.html were also extracted intoeidos-cytoscape-init.js/eidos-theme.jsso'unsafe-inline'could drop from CSPscript-src. - C4 = 0 (exact, 4.5e #783) —
app.jsis 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).apimust be an object or function (a null/undefined/primitive throws — elsemodule(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 isRelationCatalog,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 viaEidosCore.module('X'). A guard pins that everymodule('X')read resolves to a registered module (a typo would otherwise be a silentundefined).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 asregisterModuleand the mutation engine); an unknownmodethrows (a typo'd'defered'never silently becomes immediate); the handle'sunregister()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 onctx(the finalrunReconcilersin the tick supplies the ctx); an uncaught reconciler error isconsole.error-ed, not swallowed.registerReconcileralready existed asmutation-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-callregisterReconciler(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)andlistReconcilers()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'sgraph-refreshuseEffect fires ahead of app.js's_registerViewRefresh, and the unifiedlistReconcilers()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.registerActionreturns an identity-guarded unregister handle (same pattern asregisterModule/registerReconciler). A document-level delegated listener ineidos-action.jsinterceptsclick/change/submit/inputevents and calls_dispatchActionwhen adata-actionattribute matches a registered name — this removes every inlineon*=handler from HTML and allows'unsafe-inline'to be dropped from CSPscript-src(C3 = 0 exact, 4.5d)._hasActionand_dispatchActionare 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)(kindsadd-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.Xexport. The 3const TREE_*become truly module-local; 4 are top-levelfunctiondeclarations 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
_catalogPendingcache off EidosCore (a boundary smell; closed by PR #753, which reconciled write-sites to a module-locallet _catalogPendinginside detail-panel's IIFE). The second half of #745 — the_KNOWN_SMELLSmechanism that lets future smell cleanups pass the snapshot test as expected greens instead of "removed member" reds — landed as a follow-up, with_catalogPendingseated 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 beloweidos-registry.jssoregisterModuleexists at their module top; the window global is kept until 4.5c); detail-panel reads them viaEidosCore.module('SpiderCompoundModel')/('SpiderLayoutStore')/('RelationCatalog').isCableNode; the nine export plugins readEidosCore.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) plusDetailEngineering(confirmConnect/connectFromNeighbour) andEidosIcons(eidos_icoSvg), and routed the last three plugin→plugin reads (detail-panel + global-search) throughmodule(). app.js consumes all seven viamodule('X')too (incl. the load-timeif (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/nodeCachegetters activated four paths that were dead — they readwindow.parentMap/window.nodeCache, which were never mirrored towindow, so they always sawundefined: the WarnSectionparentType/allowedHere+ R-PARENT-OF child list, the IEC-segmentdesc, and the spiderchainOf/ancestorOfKindgrouping. 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, andEidosCore.allTreeswere live-getter proxies that returned the same backing objects asEidosNodeStore._nodes,EidosTree._parentOf, andEidosTree._treesrespectively. 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) intests/test_frontend_node_tree_ratchet.pyfreezes the count ofEidosCore.{nodeCache,parentMap,allTrees}references at 0 in every frontend file, includingapp.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._eidosRefreshGraphandwindow._eidosRefreshDocuments(theConnectedSpiderandDocumentsZonereload pokes) becameEidosCore.registerReconciler('graph-refresh' | 'documents-refresh', fn, { mode: 'immediate' })— generic-immediate reconcilers registered from ReactuseEffect(the cleanup calls the handle'sunregister()so StrictMode double-mounts do not throw "already registered"). App.js'sgraphsanddocuments-listmutation reconcilers now callC.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. DirectswitchAdminTabreloads are unchanged — the deferred reconciler handles cross-session/cross-tab staleness only. C2 dropped 27 → 25 (only the twowindow._writes were counted in C2 — the six admin panels were app.js private functions, never window writes). - #748 guard fix, same slice.
_mountStaleBadgeused to guard onC.listReconcilers(), which unions mutation surfaces + generic reconciler names — so a generic name registered before its mutation surface mounts (detail-panel'sgraph-refreshat ReactuseEffecttime, ahead of app.js's_registerViewRefresh) false-positive'd the "already a mutation surface" check and silently skipped the badge registration. 4.5c-1 addedlistEngineReconcilers()(engine-only slice; the unifiedlistReconcilers()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 theSpiderConnectionsReact component'suseEffect([nodeId]), with the cleanup calling_scHandle.unregister()so StrictMode double-mount cannot throw "already registered" (mirrors the 4.5c-1graph-refreshpattern). The 3 detail-engineering helpers (confirmConnect,connectFromNeighbour,repointConnection) became localconsts;repointConnectionjoined the existingDetailEngineeringmodule registration. App.js's 4 consumer sites (window._eidosDragRel,window.__eidosConnectMode,window.__eidosRemoveCreatedEdge) now read viaEidosCore.module('SpiderConnections')?.<fn>?.(). C2 dropped 25 → 14. A new pytest bite pins that amodule('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/_closebecameEidosCore.registerModule('IIS', { openModal, closeModal })(dynamically-loaded plugin, registers at module top after the_closedefinition);index.html's Interfaces onclick now readsEidosCore.module('IIS')?.openModal?.().report-ref-arch.js'striggerRefArchDownloadbecameEidosCore.registerModule('RefArch', { triggerRefArchDownload })(the IIFE's anonymouswindow.X = async functionrefactored into a named localasync function+ registration); app.js'sdownloadRefArchreadsawait EidosCore.module('RefArch')?.triggerRefArchDownload?.().tree-chip-type.js'sbuildTypeChipbecameEidosCore.registerModule('TreeChipType', { buildTypeChip })(loads in the<head>before app.js — same early-guard pattern asshortcuts.js); app.js's typechip renderer readsEidosCore.module('TreeChipType')?.buildTypeChip?.(...) || ""(the empty-string fallback preserves the original guard).detail-panel.js'swindow.eidos_icoSvgwrite is dropped (theEidosIconsmodule already held the function since 4.5b-3 — the registration switched from{ eidos_icoSvg: window.eidos_icoSvg }to{ eidos_icoSvg }where the localconstname is bound at module top). And_docAttachErrorTextbecameEidosCore.registerModule('DocAttach', { errorText }); app.js's_aidDocErrorTextreadsEidosCore.module('DocAttach')?.errorText?.(err) ?? …fallback. C2 dropped 14 → 8. Four new pytest bites pin that each newmodule('X')read resolves to a registered provider (IIS/RefArch/TreeChipType/DocAttach), and the register-module round-trip vitest gainsTreeChipType(register-only — nowindow.TreeChipTypenamespace, 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 = apilines inrule-sentence.js,domain-routing.js,cross-sync.js,mutation-refresh.js,spider-compound-model.js,spider-layout-store.jsdeleted;window.Shortcutsandwindow.GlobalSearchinshortcuts.jsandglobal-search.jsreplaced withconst apilocals.registerModuleregistrations kept; all consumers already read viamodule('X'). C2 dropped 8 → 0 (exact) — the ratchet now catches a new plugin window write immediately (same shape as C1).EidosMutationRefreshhad no consumer viamodule()— its write was simply dropped. The vitestregister-module-roundtrip.test.jsidentity assertions (expect(api).toBe(sandbox.X)) andsameAsWindow: trueflags 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.