ADR-0023: The default profile foregrounds the loop — extensions are opt-in behind flags
Context
Loop stage: Converge — boundary governance; this gives the North Star's sprawl rule teeth in the running default, not just in the registry.
The North Star forbids architectural sprawl: the app is one loop (Observe → Remember → Reason → Act → Verify → Converge), and every surface must strengthen one stage or be an explicitly-optional extension beside it.
lib/surface-registry.js already draws that line — every public/*.html surface is classified CORE (names a loop stage) or EXTENSION (names a module + gating flag), and test/surface-boundary.test.js fails on any unclassified surface or on the extension:core ratio exceeding its cap.
But the running default did not honor the boundary:
- The default nav presented extensions as equals of the loop. The header hardcoded
a link to stock-trader.html (trading), and the footer to both stock-trader.html and create.html (creator), next to Chat/Explore — shown even when those extensions were disabled. The app read as ten products.
- Core surfaces did not self-declare their loop stage, so the pre-push sprawl
tripwire (which wants a loop-stage annotation per surface) was routinely bypassed with SKIP_SPRAWL_CHECK=1 — the boundary held on paper, not at the gate.
- Two surfaces had drifted in unclassified (the IBKR pages), leaving the contract
test red.
Only the cloud tenancy profile (ADR-0018) trimmed to the loop; the local/desktop default served — and foregrounded — everything.
Decision
The default profile foregrounds the loop. Concretely:
- Nav is tier-gated at one chokepoint.
feature-flags.js getNavConfig()marks an
EXTENSION nav entry hidden unless its gating flag is enabled (env var or admin toggle). CORE surfaces and always-on shell modules (account / meta) are never default-hidden. The existing client gate (auth-gate.js, consuming /api/nav-config) applies this to both the header <nav> and the site footer. Extensions are opt-in, not deleted: flip the flag and the link returns.
- Product-mainstay exception (
surface-registry.NAV_FOREGROUND). The owner may keep
a specific extension in the default nav as a deliberate product choice — currently the trader (stock-trader.html, served to everyone in guest read-only mode). It stays an EXTENSION in the boundary (it serves no loop stage) but is not default-hidden. The set is a one-line, reviewable edit, so "this extension is a mainstay" is explicit, not accidental.
- Every CORE surface self-declares its stage via
<meta name="loop-stage" content="…">,
sourced from the registry (one known drift: systems.html declares observe while the registry says Converge — tracked as a follow-up below) — so the sprawl tripwire passes clean, with no bypass, and future additions are gated rather than waved through.
- One gate, one source of truth.
sprawl-tripwire.mjsnow treats a surface already
classified in surface-registry.js as justified, rather than demanding a duplicate loop-stage annotation the registry already implies. The registry (with its contract test + budget cap) is authoritative; the tripwire catches only genuinely unclassified new top-level surfaces. Nested bundled mini-apps (e.g. games/2048/) are gated at their extension-cluster level, not as first-class surfaces.
- Redirect stubs are not surfaces. A moved surface is a server-side 302
(routes/pages.js REDIRECTS), never a stub .html in public/ that inflates the count.
- The contract test is the boundary's contract.
surface-boundary.test.js(run via
npm run test:boundary / test:sigma0) must be green — no silent sprawl, ratio within cap. Note: as of this writing no CI workflow runs it; only the sprawl-tripwire is re-run in CI (pr-gates.yml sprawl-tripwire job). Wiring the contract test into CI is a follow-up below, so the boundary becomes machine-enforced before merge as this ADR intends.
Consequences
- The default local app presents the loop: Chat · Settings · Work · Explore (+ account /
help shell), plus the trader as a foregrounded product mainstay. Creator, media, and game extensions appear only when enabled.
- Adding a surface still requires classifying it in
surface-registry.js(or the test
fails) and annotating its loop-stage (or the tripwire fails) — the boundary now bites in the running default, not just in review.
- No feature is removed; extensions remain one flag away. This is a posture change, not
a capability cut.
- Behaviour-preserving where a flag is already set (deployments that enable trading still
see the trader). The default simply stops advertising disabled extensions.
- Follow-ups:
- Wire
surface-boundary.test.jsinto CI (ci.ymlorpr-gates.yml) so the boundary is
- Wire
machine-enforced before merge, not just runnable locally.
- Reconcile
systems.html'sloop-stagemeta (observe) with its registry entry
(Converge), and extend the contract test to assert meta-tag ↔ registry consistency so the "one source of truth" claim stays true.
Alternatives considered
- Delete extension pages outright: rejected — a capability cut, not a posture change;
extensions are legitimate opt-in features.
- Leave the nav static and rely on the cloud profile only (ADR-0018): rejected — the
local default would keep advertising disabled extensions, so the boundary would hold only in the hosted tier.
- Hardcode per-page hiding: rejected — no single chokepoint;
getNavConfig()+
auth-gate.js give one reviewable gate for header and footer alike.
Evidence
| Claim | Evidence (file:line / commit / PR) | Confidence | Source |
|---|---|---|---|
| Decision implemented and merged | PR #2147 (commit 3ce7f3db, merged 2026-07-06) | High | repo |
| Product-mainstay exception exists and is a one-line edit | apps/lantern-garage/lib/surface-registry.js:119-130 (NAV_FOREGROUND) |
High | code |
| Nav tier-gated at one chokepoint | apps/lantern-garage/lib/feature-flags.js:192-219 (getNavConfig, default-hidden) |
High | code |
| Client applies gate to header and footer | apps/lantern-garage/public/js/auth-gate.js:49-58 (consumes /api/nav-config) |
High | code |
| Tripwire treats registry-classified surfaces as justified | scripts/sprawl-tripwire.mjs:33-39 |
High | code |
| Moved surfaces are 302s, not stub pages | apps/lantern-garage/routes/pages.js:56-84 (REDIRECTS) |
High | code |
| 19 CORE surfaces carry loop-stage meta (1 drift: systems.html) | public/*.html meta tags vs surface-registry.js:49 |
High | code |
| Contract test not yet in CI | grep of .github/workflows/ → no surface-boundary reference; pr-gates.yml:122-136 runs only the tripwire |
High | repo |