Learning Platformindex / ADRs / ADR-008E2

ADR-008 — Learning-core integration seams

Status: Proposed (design-panel synthesis) · Date: 2026-07-22 · Track: E2 (learning core) Method: adversarial design panel — estate-scout + four thinker-canon lenses (build-vs-adapt · data · simplicity · composability). Dissent preserved below.

Follows the ADR-001 template. Brand-free: the learning core and every estate component are named by capability only.


1. Context

The learning core is settled and live: a mature, forked, Apache-2.0 LMS on our own Postgres, server-rendered, with a first-class evaluations subsystem (evaluation → question → option → attempt → answer), enrollments, courses→sections→resources, signed certificates, four native roles, and native argon2 auth (email-verify, self-serve reset). This ADR is not build-vs-buy — that decision is closed. It is about the seams: how the settled core plugs into the locked foundation without becoming a second source of truth for anything.

Five seams are in scope, each an integration decision the foundation spec gestures at but does not mechanize:

  1. Federation — federate the core's native auth into the identity spine, with the IdP sub as the join key (Pillar 1).
  2. Theme — brand the core fully via its theme layer, config-only, zero merge tax (Pillar 2).
  3. Content — migrate the preserved question banks into the core's native evaluation/question model (Pillar 3).
  4. Cohort — model a cohort as a time-limited course instance + a cohort board (Pillar 3).
  5. Emission — emit course/enrollment/certificate events into the permissioned index (Pillar 4).

The panel's cross-cutting finding: a server-rendered LMS has its own internal user_id that every enrollment, certificate, and progress row already FKs to. You cannot make sub the join key inside the core; you can only make it the join key at the core's boundary. Every seam below is a variant of one problem — how do you get durable, correctly-attributed, correctly-ordered state across that boundary without forking the schema (invariant #6, zero merge tax) and without inventing a second writer that silently diverges. The estate-scout confirms the seam is substantially pre-built: the native evaluation model is the exact import target, the banks are schema-stable content, and an idempotent bank→model importer already exists in the fork. The genuinely new work is thin and mostly subtractive.

2. Decision drivers

3. Options considered

Federation seam (the load-bearing one):

Emission seam (orthogonal sub-choice): outbox written in the same transaction as the domain write · vs · log-based CDC off the Postgres WAL · vs · polling a monotonic cursor.

4. The panel (rankings diverged; read the core argument, not the rank)

Lens Rank #1 Core argument
Build-vs-adapt (Fowler) B You do adopt-and-theme correctly for rendering, then risk undoing it for identity. Adopting native auth "as-is" leaves the core a second writer to identity — the ADR-001 "never two permission systems" lesson applies verbatim to auth. Broker/JIT keeps one write-authority; disable the native self-serve flows (config, else upstream) before first prod login. Theme is right, but zero merge tax only holds if you compile design tokens → core theme config as a build step.
Data (Kleppmann) C "Federates" is a distributed-systems word with teeth. The instant both stores can mint or mutate a user you have split-brain; email is the one field you must never join on (mutable, recyclable). Store an immutable sub↔︎local-id binding minted exactly once. Emission is CDC over mutable rows, not append-only — certs revoke, so it needs per-entity version + tombstone semantics + idempotent, per-entity-ordered index writes. Migration needs a count + answer-key-checksum reconciliation gate that fails closed.
Simplicity (Torvalds) E The core already does the hard boring things. E2 is two wires, not a build. Standing up a second auth store + a user migration before shipping a feature is ceremony ahead of need for 60 users. The core's existing stable UUID is the join key; the future IdP's job is to broker social/MFA/SSO onto that key, not reissue it. Emit via outbox-in-transaction (we own the fork). Refuse: no auth wrapper, no eval-model abstraction, no template fork, no first-class cohort entity.
Composability (Thompson) C Keep the core a black box behind the shell: reads one thing (sub via OIDC), writes one thing (an event stream), no back-channel. Federating two auth systems is more code than deleting one — delete the password store, hold zero secrets. The entanglement trap is the shared relational store: the moment cohort/board/enrollment join in SQL across two stores, the swap-the-component promise is gone forever.
Estate-scout The seam is mostly pre-built. The native evaluation → question → option → attempt → answer model is the import target; the banks are schema-stable (answerIndexis_correct, rationale/sourceexplanation); and an idempotent bank→model importer already exists in the fork. New work = harden it to idempotent-on-update, add the reconciliation gate, wire it to the deploy/seed lifecycle. Refuse only the temptation to treat the origin capture-API as reusable code — it isn't; its sole surviving contribution is the content.

5. The convergence (what all agree on, under the different ranks)

Despite ranking B/C/E differently on the mechanism, the lenses converge on one architecture:

  1. Exactly one write-authority for identity. The core must stop being a second writer: native self-serve signup, password auth, reset, and email-verify are turned off before the first production login. Every panelist reaches this — Fowler and Kleppmann name it, Thompson deletes the store outright, Torvalds keeps one store the whole way. Turning off working features is the counterintuitive move that holds the invariant.
  2. One stable, immutable, opaque join key — minted once, never reissued, never email. Whether the IdP mints a fresh sub that adopts the core's UUID (Fowler/Kleppmann) or the core's UUID is the sub (Torvalds), the invariant is identical: the binding is created exactly once and is as permanent as the certificates that FK to it. Email is refused by everyone.
  3. The core is an emitter, never a co-owner of a store. No other service reads the core's tables; the core reads no community table. Emission is a first-class event stream into the same permissioned index/outbox ADR-001 already locked — courses/enrollments/certs are just more event types.
  4. Emit via an outbox; carry retraction. Because we own the fork, an outbox is legitimate — and per invariant #6 it goes upstream, not into a private patch. Certificates revoke, so events carry a per-entity monotonic version + tombstone/retraction, and index writes are idempotent and per-entity-ordered.
  5. Question banks stay the content source of record; the core's model is a one-time idempotent import. Keyed on a stable per-question external id, gated by a fail-closed count + answer-key-checksum reconciliation, imported immutable-with-provenance. No live format-adapter between two schemas.
  6. Cohort is joined at read time, never by a cross-store FK. Course instance in the core, cohort board in the community store, joined only by sub + a cohort tag. No first-class cohort object, no shared relational store.
  7. The core's native four roles are display hints; the tier attribute is the sole permission authority (Kleppmann pushing Karpathy's I10 one layer down).

6. Decision — "One writer to identity · adopt don't reissue · emit don't share · import don't adapt"

A synthesis that takes the winning move from each lens: broker the identity (Fowler), make the binding immutable (Kleppmann), adopt the existing key rather than migrate hashes (Torvalds), keep the core a black box behind the shell (Thompson), and reuse the importer that already exists (estate-scout).

6.1 The federation seam — broker / JIT-provision, sole writer, adopt-don't-reissue

6.2 The emission seam — outbox upstreamed, retraction-aware

6.3 The content seam — one-time idempotent import, harden what exists

6.4 The cohort seam — instance + board, joined at read time

6.5 The theme seam — token-compile, zero merge tax

7. Tradeoff matrix

Dimension A two-writers B broker/JIT C hash-migrate D dual-synced E UUID-is-sub Decision
One identity write-authority ❌ two authorities ⚠️ if native auth off B mechanism
No hash-migration risk on crown-jewel column ❌ one-shot risk adopt, don't migrate
Join key never reissued / immutable ⚠️ minted once
Zero standing sync job (small team)
No second auth store to babysit now
Core holds no store another service reads ⚠️ emit, don't share
Reversibility of theme / events / import ✅ (repoints)
Availability if IdP down (cert exam) ◑ break-glass ◑ (gated door)

8. Consequences

New locked invariants (promote into the foundation spec §7, continuing I9/I10):

New required evals (the seam is only as trustworthy as these):

E2's first task is a decommission + a binding, not a build (Fowler's most-costly decision): disable the core's native self-serve identity flows and stand up the sub-keyed broker/JIT provisioning — before the first real login. After that, harden the existing importer to idempotent-on-update behind the reconciliation gate. Both are subtractive/repoints, not greenfield.

Estate seams (what we actually reuse):

9. Preserved dissent (do not resolve away)

10. Follow-up (beads to file in Phase B under the E2 epic)

  1. Lock invariants I11–I15 into the foundation spec §7.
  2. Disable native self-serve identity flows + stand up sub-keyed broker/JIT provisioning — priority 0, before first production login. Config where possible; upstream contribution where not.
  3. Establish the immutable sub↔︎core-user binding as a single-writer, minted-once mapping (never email, never reissue) — priority 0.
  4. P0 identity-binding integrity eval + native-self-serve-disabled deploy-gate assertion — before first login.
  5. Define the one identity validation contract (member + tier claim shape) that community/realtime/AI code against — so the full IdP is a repoint.
  6. Harden the existing bank→model importer to idempotent-on-update behind a fail-closed count + answer-key-checksum reconciliation gate; wire to deploy/seed; import immutable-with-provenance.
  7. Certificate-revocation propagation eval (tombstone + ordering + idempotency) before certs emit into the index.
  8. Specify the course/enrollment/cert outbox seam as an upstream contribution; document the CDC-off-WAL fallback (with slot monitoring) if upstream declines.
  9. Compile design tokens → core theme config as a build step; add the theme-token parity check.
  10. Upstream investigation: can the core separate course content-template from cohort-instance (date-window + enrollment-group)? If not, this is the first real product-gap PR — file as an upstream bead, not a config toggle.

Panel transcript basis: estate-scout + Fowler/Kleppmann/Torvalds/Thompson lenses, 2026-07-22. Follows the ADR-001 template; the identity binding and store boundary are the irreversible decisions — theme, event mechanism, and question import are repoints.