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:
- Federation — federate the core's native auth into
the identity spine, with the IdP
sub as the join key
(Pillar 1).
- Theme — brand the core fully via its theme layer,
config-only, zero merge tax (Pillar 2).
- Content — migrate the preserved question banks into
the core's native evaluation/question model (Pillar 3).
- Cohort — model a cohort as a time-limited course
instance + a cohort board (Pillar 3).
- 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
- One member identity; the
sub binding is minted once,
immutable, never reissued, never inferred from a mutable field.
- The core is never a second writer to identity and never a co-owner
of a store — the whole zero-merge-tax / swappable-component promise
rides on that boundary.
- Certificates are immutable, signed, externally-verifiable artifacts
— a key that changes or an event that can't retract turns provenance
into a forgery.
- Small ops team: every standing sync job, replication slot, or
edge-auth service is a 3am liability. Gaps go upstream, not into a
private patch.
- Reversibility: theme, event mechanism, and question import must be
repoints; identity binding and store boundary must not be crossed at
all.
3. Options considered
Federation seam (the load-bearing one):
- A — Two writers kept live. Keep the core's native
argon2 + self-serve signup/reset/email-verify; link the two account
universes after the fact (worst case: by email).
- B — Broker / JIT-provision. The IdP is the sole
identity writer; on first SSO, JIT-provision a
sub-keyed
projection row in the core; disable native
signup/password/reset/email-verify. The core holds an identity
anchor, never an account. No hashes move.
- C — One-time hash migration → pure OIDC consumer.
Lift the portable argon2 hashes into the IdP once, delete the core's
credential store, cut the core to pure SSO forever.
- D — Dual credential stores, provisioning-synced.
Keep argon2 as the core's own login; federate to
sub via a
standing sync job. Two authorities, continuously reconciled.
- E — Adopt the core's stable user UUID as
sub; defer the standalone IdP. No reissue; the
eventual IdP adopts the existing key as its broker.
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)
| 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
(answerIndex→is_correct,
rationale/source→explanation);
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- The IdP is the sole identity writer. On first SSO,
JIT-provision a
sub-keyed projection row
inside the core. That row is an identity anchor, never
an account.
- Disable the core's native self-serve identity flows
— signup, password login, reset, email-verify — before the first
production login. Config where possible; a small upstream
contribution where not (this is the legitimate "gap goes
upstream").
- Adopt, don't migrate. The
sub binds to
the core's existing stable user id; we do
not lift argon2 hashes (Kleppmann's crown-jewel risk)
and we do not reissue the key (Torvalds'
irreversible-mistake). The sub↔︎local-id binding is
unique, immutable, minted exactly once at provisioning,
never inferred, never email.
- One validation contract from day one (Torvalds'
load-bearing point): community, realtime, and AI code against a
documented "who is this member + what tier" claim shape — never against
the core's internals — so the eventual full IdP is a repoint, not an
N-surface rewrite.
- Break-glass, gated. A local admin-only, audited
login door may exist for an IdP-outage during a live cert exam
(Thompson/Torvalds availability point) — but it is not a
member-facing self-serve flow and therefore not a second writer
in normal operation. Whether even that door is acceptable is preserved
as dissent.
6.2
The emission seam — outbox upstreamed, retraction-aware
- Emit course/enrollment/certificate events through an outbox
into the permissioned index, reusing ADR-001's at-least-once,
idempotent outbox seam. The AI home/tutor read the index, never the
core's store.
- The outbox hook is contributed upstream (invariant
#6) — transactional, ordered, clean domain events without a private fork
patch. If upstream won't take it in a reasonable window, fall
back to log-based CDC off the WAL, translating row-deltas to
domain events in a component we own, with the replication slot
monitored (an unconsumed slot silently fills the disk
and takes the core down). Polling is rejected: a cursor over
updated_at misses deletes and gives no revocation
signal.
- Every event carries a per-entity monotonic version +
tombstone/retraction semantics; index writes are idempotent and
per-entity-ordered — a duplicate "issued" must be a no-op, and
an out-of-order "revoked→issued" replay must never resurrect a dead
certificate.
6.3
The content seam — one-time idempotent import, harden what exists
- The question banks remain authoritative in their
neutral format — the content source of record. The core's question model
is a derived, one-time, idempotent import, never the
sole copy.
- Reuse and harden the existing bank→model importer
rather than rebuild: promote it from idempotent-on-insert to
idempotent-on-update, add a fail-closed count +
answer-key-checksum reconciliation gate (N in → N out,
answer-key hash preserved, stable per-question external key so re-runs
never duplicate), and wire it into the deploy/seed lifecycle. Import is
immutable-with-provenance.
- No live format-adapter between the bank schema and
the core model — that adapter is a permanent tax on two drifting
schemas.
6.4 The
cohort seam — instance + board, joined at read time
- A cohort is a time-limited course instance in the core + a
cohort board in the community store, joined only by
sub + a cohort tag, resolved at read time.
No first-class cross-store cohort object; no cross-store SQL join; no
shared relational store. The board itself is E4's object, not E2's.
- If the core models a course as one evergreen entity with no
session/round concept, then "a cohort is a course instance"
means copying the whole course per cohort (content that drifts). The
clean model is content-template + enrollment-group + date-window sharing
one body. If the core can't separate content from
cohort-instance, that separation is the first upstream PR — the
one genuine product gap, not a config flag (Fowler).
6.5 The theme
seam — token-compile, zero merge tax
- Brand fully through the theme layer as config (site
name, logo, colors as data) — no template fork.
- Compile the Pillar-2 design tokens → the core's theme config
as a build step (Fowler). Zero merge tax on identity/rendering
only holds with this compile step; without it the branded core drifts
off every other surface and you've traded merge tax for a visual-seam
tax.
7. Tradeoff matrix
| 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):
- I11 — Exactly one identity write-authority. The learning
core is never a second writer: native self-serve signup, password login,
reset, and email-verify are disabled before the first production login.
The core holds a
sub-keyed identity anchor, never an
account. (Fowler/Thompson — the single
most-costly-to-recover-from decision.)
- I12 — The
sub↔︎core-user binding is unique,
immutable, and minted exactly once at provisioning; never inferred
(never email), never reissued. Certificates and every FK depend on it
being permanent. (Kleppmann/Torvalds — the
poisoned-identity-graph failure is silent and unrecoverable.)
- I13 — The learning core is an emitter into the permissioned
index, never a co-owner of a store. No service reads the core's tables
and the core reads no community table; cross-surface joins happen only
by
sub + tag at read time. Course/enrollment/certificate
events carry a per-entity monotonic version and tombstone/retraction
semantics; index writes are idempotent and per-entity-ordered.
(Thompson/Kleppmann — the shared-store weld is the point of no
return.)
- I14 — The core's native four roles are display hints only;
the member tier attribute is the sole permission authority.
(extends I10 into the learning surface.)
- I15 — The question banks are the content source of record in
a neutral format; the core's question model is a derived, one-time,
idempotent import with provenance — never the sole copy.
New required evals (the seam is only as trustworthy as
these):
- P0 — Identity-binding integrity eval, fail-closed.
No member ever gets two identity rows; no
sub maps to two
core-user_ids (or vice versa); provisioning is idempotent; email is
never the join. Runs on every provisioning-path change. Before
the first production login.
- P0 — Native-self-serve-disabled assertion.
Automated deploy-gate check that signup/password/reset/email-verify
endpoints are unreachable in every environment; a
reachable endpoint fails the deploy. Before the first production
login.
- P1 — Certificate-revocation propagation eval. A
revoked certificate retracts in the index within bound; the AI
tutor/home never surface a revoked credential as valid. Exercises
tombstone + ordering + idempotency (issued-twice no-op, revoked→issued
replay must not resurrect).
- P1 — Question-bank migration reconciliation gate,
fail-closed. N in → N out; per-question answer-key checksum
preserved; stable-external-key idempotency (re-run = zero duplicates).
Gates the seed step. Silent answer-key corruption grades
certification wrong for months with no crash.
- P1 — Theme-token parity check. The compiled core
theme matches the design-token set, so the branded core cannot drift off
the shell.
- Extend ADR-001's P0 retrieval-permission tier-leak
eval to cover course/enrollment/certificate events now that
they land in the index.
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):
- Native evaluation → question → option → attempt → answer model → the
question-bank import target (nothing to design, only to map).
- The existing idempotent bank→model importer → hardened, gated, wired
to deploy — not rebuilt.
- ADR-001's outbox + permissioned index → the emission transport
(courses/certs are more event types).
- The identity spine (Pillar 1) → the
sub authority the
core JIT-provisions against.
- The design-token set (Pillar 2) → compiled into the core theme
config.
9. Preserved dissent
(do not resolve away)
- Adopt-the-UUID vs. stand-up-the-IdP-now (Torvalds vs.
Fowler/Kleppmann/Thompson): Torvalds holds that for 60 known
members, a broker + provisioning + disabled-flow patches is integration
machinery to solve a reconciliation problem we don't have yet — the
core's existing UUID should simply be
sub,
deferring the standalone IdP. The field answers that the cutover cost is
lowest now and compounds with every real member, and
that without one validation contract from day one, "just point SSO at
it" degrades into scattered session-scraping. The decision honors
Torvalds by adopting the existing key (no reissue, no hash
migration) while holding the single-writer invariant — but the
bet that the broker earns its complexity at this population size is real
and reversible.
- Delete the credential store vs. keep it disabled
(Thompson/Kleppmann-C vs. the decision): Thompson would delete
the argon2 store outright (hold zero secrets); Kleppmann's C would
migrate-then-delete. The decision keeps the store present-but-disabled
to sidestep the crown-jewel hash-migration risk — which leaves a
vestigial store a future maintainer could re-enable.
That latent footgun is the price of dodging the one-shot migration
hazard. If a full round-trip test of the exact argon2 parameters on a
production copy is committed to (old store read-only as a one-release
rollback), migrate-and-delete (C) becomes strictly cleaner and should be
reconsidered.
- Break-glass local login (availability vs. purity):
a gated, audited local door protects a live cert exam during an IdP
outage — but any local credential path is, in the strictest reading, a
second door. Preserved as an open call for E3's threat model to
settle.
- Outbox-upstreamed vs. CDC (Kleppmann's §3 tension):
you cannot own zero merge tax and get clean transactional
events for free. Upstreaming the outbox resolves it if upstream
accepts the hook in time; otherwise CDC off the WAL is the fallback, and
it brings a monitored replication slot as a standing ops liability. The
decision commits to the upstream path first but does not pretend the
fallback is free.
10.
Follow-up (beads to file in Phase B under the E2 epic)
- Lock invariants I11–I15 into the foundation spec
§7.
- 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.
- Establish the immutable
sub↔︎core-user
binding as a single-writer, minted-once mapping (never email,
never reissue) — priority 0.
- P0 identity-binding integrity eval +
native-self-serve-disabled deploy-gate assertion — before first
login.
- Define the one identity validation contract (member
+ tier claim shape) that community/realtime/AI code against — so the
full IdP is a repoint.
- 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.
- Certificate-revocation propagation eval (tombstone
+ ordering + idempotency) before certs emit into the index.
- Specify the course/enrollment/cert outbox seam as
an upstream contribution; document the CDC-off-WAL
fallback (with slot monitoring) if upstream declines.
- Compile design tokens → core theme config as a
build step; add the theme-token parity check.
- 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.