Planning corpus: this is the real design-discussion doc for epic admin-adapter-visibility-settings, synced verbatim from this repo’s own internal planning process.
Design Discussion — Epic 25: admin-adapter-visibility-settings
0. Prelude
Source: the 2026-09-08 hand-off brief’s second goal, final item: “a settings page showing which concrete adapter is active for each swappable subsystem (persistence, CMS, payments) — visibility into ‘how this instance is actually running,’ which is the whole ‘adapters and options for how it runs’ pitch made concrete in the UI itself.” This is the last epic in the entire hand-off brief’s scope — epics 20-24 are all merged to master.
1. Goal
An /admin page showing, plainly, which concrete adapter this running instance chose for
persistence, CMS, and payments (plus one bonus row, see §4), and whether each is actually
configured.
2. Research findings (grounding)
apps/reference-storefront/lib/services.tsis the sole composition root — its own header comment states it’s “THE ONLY MODULE IN THIS REPO that imports concrete adapter implementations.” Today: persistence is a hardcodedcreateSqliteAdapter(":memory:")(services.ts:136, no env branching at all); CMS is a hardcodedcreateInMemoryCmsAdapter()(services.ts:241-244, no env branching); payments always constructs a real Stripe adapter (createLazyStripeAdapter,services.ts:109-120,145-149), but its key is env-driven —process.env.STRIPE_SECRET_KEY ?? ""— so “configured vs. not” is a real, observable runtime fact for this one subsystem specifically.- No adapter in this repo self-identifies.
CatalogPersistenceAdapter,CmsPersistenceAdapter, andPaymentAdapterare all bare structural interfaces (products/ skus/attributes; pages/marketingMeta; createPaymentSession/confirmPayment/handleWebhookEvent) with zero name/label/kind field anywhere, confirmed across every concrete implementation (adapter-sqlite, adapter-postgres, adapter-shopify, the in-memory CMS default, adapter-sanity, the Stripe adapter). This is deliberate, not an oversight — the docs (docs/subsystems/00-core-schema.md, epic 18’s own design doc) frame adapter-agnosticism as the whole point; an adapter that knew its own name would be a small crack in that. - The one counterexample: plugins.
packages/plugins’sPlugininterface requires aname: stringfield, and the existing/admin/pluginspage trivially rendersplugins.list().map(p => p.name)— genuine dynamic introspection, because plugins are designed to self-identify. Persistence/CMS/payment adapters are designed the opposite way on purpose. This page cannot copy that mechanism. - Conclusion: this must be a small, explicit, manually-maintained descriptor built at the
app-composition layer, not a new capability any package-level interface needs to grow.
The only place that knows which concrete factory was called is
services.tsitself (or a sibling module reading the same environment). - No
.env.exampleexists in this repo. The only documented env-var-per-adapter convention lives inpackages/create-store/src/scaffold.ts(for scaffolding new, separate deployments) — this app’s ownservices.tsdoesn’t readDATABASE_URL/SANITY_PROJECT_ID/etc. at all today. The settings page reports what’s actually wired in this instance, not a hypothetical menu of every adapter that could theoretically exist.
3. The design question, resolved: an app-layer descriptor module, no new subsystem/package
Decision: no new packages/* subsystem. Unlike epics 20-24, this isn’t a reusable
cross-deployment domain capability — it’s metadata about this one app’s own composition
choices, which by the architecture’s own rule only ever lives in services.ts. Inventing a
package for it would be a package with exactly one real consumer and no adapter-swappability
of its own (a “settings registry adapter contract” would be self-parodying — the thing being
described has no self-description to abstract over).
What gets built instead: apps/reference-storefront/lib/adapter-info.ts, exporting
getAdapterInfo(): AdapterInfo[] where
AdapterInfo = { subsystem: string; adapter: string; detail: string; status: "active" | "unconfigured" }. Each entry is written by hand, next to the actual create*Adapter(...)
call it describes, so the descriptor can never silently drift from reality the way a
fully-decoupled config file might — a reviewer changing which adapter services.ts wires in
sees the descriptor update in the same diff, the same discipline every prior epic in this wave
used for its own decoupling-verification comments.
Required three rows (per the backlog’s explicit scope): persistence, CMS, payments.
One bonus row: analytics — already a real, env-driven two-branch swap
(POSTHOG_API_KEY truthy → PostHog, else a no-op adapter, services.ts:131-133) that costs
nothing extra to report and materially strengthens the “how this instance is actually running”
pitch the brief asks for. Documented here as a deliberate, small scope extension, not creep —
it reuses data this page needs to read anyway (env var presence) and required no new code path
to compute.
4. /admin/settings page
Plain, read-only Server Component, matching /admin/plugins’s minimal shape exactly (the
closest existing precedent for “list what’s currently active,” even though the underlying
mechanism differs) — a <table> of subsystem / adapter / detail / status, no CRUD, no forms.
5. Scope
Small. Two stories: the descriptor module, and the page + admin-nav link + closeout. This epic doesn’t warrant the four-story shape every domain-model epic in this wave used — a smaller, honestly-scoped epic is the correct call here, not padding for consistency’s own sake.
6. Version bump
patch — a new read-only introspection page and one new small app-layer module; no new
package, no change to any existing subsystem’s public contract.