Discussion is a native capability on the sync rail — not a vendored forum
Status: accepted (2026-06-17) — design-only this session; build is sliced in
docs/design/discussion-capability-plan.md and not yet started.
Builds on ADR-0001 (QTI is the canonical assessment model), ADR-0012 (OneRoster is a projection;
the gradebook is neutral, one gradebook_entry per (assessment_definition, person)), ADR-0014
(erasure is anonymization), ADR-0019/ADR-0027 (server-anchored, human-scored Assessments; “an
assignment is not a new domain”), ADR-0022 (Common Cartridge decompose-and-land; the long tail),
and ADR-0026 (offline-capable learner surfaces). Pre-launch, no legacy
([[feedback_no_legacy_pre_launch]]); pgxsinkit is the committed data layer
([[project_pgxsinkit_full_commitment]]); upstream bugs are fixed upstream, never worked around
in-app.
The trigger (and the premise it pressure-tests)
Section titled “The trigger (and the premise it pressure-tests)”ADR-0022 quarantines a CC discussion-topic with reason; the companion analysis
(docs/design/cc-long-tail-landing-analysis.md §3) sized “un-quarantining” it as build a
Discussion domain (L) but flagged that forums are a mature, crowded category and that the
first question is vendor vs build. This ADR records that grilling.
Two corrections narrowed the constraints during the session, and matter:
publicis a preference, not a hard rule. A second schema is still our Postgres — RLS-capable and declarable in the erasure registry. So “must be inpublic” was really “must be in our Postgres.”- Offline is conditional, not blanket. A bounded cohort should be offline-capable; an open-ended MOOC (10⁴+ posts) should not. So “must be on the sync rail” was overzealous as a universal.
The real boundary the non-negotiables defend is therefore “in our Postgres, under our RLS and our erasure registry” — which every external-store vendor option forfeits.
The decision
Section titled “The decision”1. Build native — not a vendored forum
Section titled “1. Build native — not a vendored forum”Each vendor shape was walked against erasure + authz/roster + offline:
- Full platform (Discourse / NodeBB / Flarum / Misago, SSO + embed): posts in a foreign DB
→ drops out of the erasure registry (becomes a trust-their-API
externalobligation), forfeits the sync rail, re-implements authz/roster at a seam, can’t speak neutral vocabulary or per-role layouts. This is the LTI-consumer shape ADR-0022 already sized XL and deferred. Rejected. - Embeddable/headless comments SaaS: third-party custody of person-PII (Art. 28 processor, cross-border), no offering-membership model, no offline. Rejected.
- Self-hosted headless service: better custody, identical architectural seam (data outside our Postgres ⇒ no Electric sync, no RLS row-filter, cross-system erasure). Rejected for an offline-first LMS.
- Threaded-comment library: only the renderer is third-party; the data stays in our Postgres under RLS + erasure. So it is not a distinct architecture — it collapses into “build native, optionally borrow a renderer.”
- Hazel (HazelChat, suggested during the session): a near-stack-twin (Bun/Drizzle/Postgres/ ElectricSQL/OXC) but (a) chat, not threaded discussion, (b) diverges at every integration seam (Clerk auth, TanStack DB, Effect-TS, React-Aria, Cloudflare R2, docker-compose), and (c) FSL-1.1 licensed (not OSS; commercial chat is plausibly a “Competing Use”). Useful as a read-only reference that Electric-synced messaging works on a stack like ours; not adoptable.
Decision: build the Discussion natively — data in our Postgres, under RLS and the erasure registry. Any third-party thread renderer is an optional in-app convenience, never the home of data or access logic.
2. A new general Group primitive (Discussion is its first consumer)
Section titled “2. A new general Group primitive (Discussion is its first consumer)”The schema has nothing between Offering (everyone) and Membership (one person) — a genuine modelling gap (the only existing “Group” is the xAPI LRS actor type, a spec boundary). Rather than a discussion-only grouping, introduce a general, two-level primitive:
- Group Set — a named partitioning of (some of) an Offering’s Members for a purpose; a Person belongs to at most one Group per Set.
- Group — one cell of a Set; the unit that scopes group-private collaboration.
A consumer targets a Set, which is what makes “my group for this” unambiguous and lets Groups be reused (a Discussion now, a group task later). v1 formation is manual teacher assignment (self-select/random deferred).
3. The Discussion model
Section titled “3. The Discussion model”A Discussion is an asynchronous, threaded conversation anchored to an activity Learning
Unit: a prompt (title + sanitized HTML body) plus a tree of Posts. A Discussion declares a
visibility scope: offering | group (the latter targets a Group Set). A Post is
person-authored, threaded by an optional parent Post — mutable application state, not an
append-only event — and low-conflict (one author per post; auditAndLwwColumnsUs()
last-write-wins resolves the rare same-author-two-devices case).
4. Delivery: sync-rail-first (cohort); MOOC online path deferred
Section titled “4. Delivery: sync-rail-first (cohort); MOOC online path deferred”v1 rides the pgxsinkit/PGlite + Electric rail as the first readwrite + offering/group-membership
sync table (today that membership filter, buildOfferingMembershipRowFilterWhere, serves only
readonly content). This combination is unproven on the rail and is a deliberate early stress
test of pgxsinkit — if it bends it is hardened upstream, never worked around. A scale guard
refuses/flags a discussion that would blow a sane sync budget; that guard is the trigger for
deferred seam A (the MOOC-scale online, server-paginated delivery — a delivery swap on the
same domain model, not a new domain).
5. Edit / delete: one soft-state mechanism, three triggers
Section titled “5. Edit / delete: one soft-state mechanism, three triggers”A Post carries a status; what’s shown derives from it. Author edit overwrites the body
(edited_at). Author delete hard-deletes a leaf (no replies; mirrors the Learning Unit
“hard-delete only with no downstream history” rule) and otherwise tombstones (status
deleted_by_author, body cleared) to keep threads coherent. Moderator hide sets
hidden_by_moderator (body retained server-side for appeal, hidden from learners). Erasure of
the author is the same tombstone: Post is scrub (body) + sever (author_person_id) — never
cascade (that orphans replies); matches feedback_entry.
6. Safeguarding: edit/delete history is mandatory, and must be a DB trigger
Section titled “6. Safeguarding: edit/delete history is mandatory, and must be a DB trigger”On a platform minors use, edit-to-conceal and delete-to-conceal make a “what did it say before”
trail obligatory. It rides the existing scrub-class change_audit_entry (erasure-compliant by
construction), is moderation-read only (teacher of the offering + org-admin in v1; widen on
feedback), is not synced to learner devices, and is written by a Postgres trigger on the
post table — because with sync, mutations arrive through the Electric apply path, so an app-layer
audit write would be silently bypassed. Routine author edit/delete clears the surface; only an
erasure request clears the audit — different operations, different lawful bases — which resolves
“deletable in principle” against safeguarding retention.
7. Moderation & safety (v1 floor)
Section titled “7. Moderation & safety (v1 floor)”- Report — a learner flags a Post (optional reason). It is an offline-capable, owner-scoped sync table: composed offline, synced up, reporter-owned, never synced to peers or moderator devices (anonymity falls out of the ownership filter); moderators read server-side online. Erasure: sever reporter + scrub reason.
- Hide / resolve — the broad teaching set (teacher / co-teacher / assistant) hides a Post and resolves the report.
- Thread-lock — moderator locks a Discussion or a sub-thread (root Post); enforced by a subquery in the RLS insert policy.
- Posting Restriction — offering-scoped ban (indefinite) or timed mute; enforced by the same insert policy; the restriction syncs to the restricted Person owner-scoped so their client disables the compose box gracefully even offline (rather than write-then-bounce).
- Online write-rejections (locked/banned) must surface to the learner, not silently drop the draft.
8. Realtime, unread, notifications
Section titled “8. Realtime, unread, notifications”Realtime is free via the sync rail (no websocket layer). v1 unread = a local per-(person, Discussion) last-seen “new posts” badge (owner-scoped). All subscription/notification machinery
is deferred to the cross-cutting ADR-0030 (Notifications and messaging system).
9. Grading: ungraded v1; grade only via an associated Assessment
Section titled “9. Grading: ungraded v1; grade only via an associated Assessment”A Discussion is never gradable itself — that would re-break the ADR-0012 invariant ADR-0027
protected. If participation must be graded, it is a separate human-scored Assessment (existing
path); a Canvas graded discussion’s grade already lands via the CC assignment → Assessment path
(ADR-0027), while its prompt lands as a Discussion. A “grade this discussion” convenience
association (Discussion ↔ human-scored Assessment) is v2 (see Deferred).
10. AuthZ / RLS
Section titled “10. AuthZ / RLS”Read = any member of the offering (or the Group for group scope); insert = only as myself and only into an offering/group I belong to; update/soft-delete = author; hide/lock/restrict = teaching set; audit-read = teacher + org-admin. Group scope introduces a role-aware row filter asymmetry — a learner sees only their own Group’s posts, a teacher/admin sees across all Groups — so the filter becomes “rows in a Group I’m in OR rows in an Offering I teach/admin.”
11. Per-role layouts
Section titled “11. Per-role layouts”A role-agnostic thread renderer lives in packages/ui, parameterized by capability flags
(canPost, canModerate, canReadAudit…) — no role branching inside it. learner-web composes the
participate layout; teacher-portal the moderate layout (cross-group, hide/lock/restrict,
resolve, audit); admin-console exposes org-admin as the escalation reader only in v1.
12. CC import hook
Section titled “12. CC import hook”A thin landDiscussion (mirroring landWebLink/landAssignment/landDocument) parses the
imsdt <topic> (title + sanitized HTML prompt), creates one offering-scoped Discussion on
the resource’s activity Unit, and reports imported.discussions[]. Group-scoping is native,
never seeded from CC. This un-quarantines discussion-topic; the ADR-0022 totality/conformance
lane flips it from quarantined to imported.
Considered and rejected
Section titled “Considered and rejected”- Vendored forum / SaaS / self-hosted headless service / Hazel — see Decision 1.
- A bespoke immutable
post_versiontable — fights “deletable in principle” by creating a second PII store with its own erasure class;change_audit_entry(scrub-class) is the erasure-compliant home. Rejected. - Discussion as a new gradable concept — re-breaks ADR-0012; ADR-0027 already proved grading has exactly one home. Rejected.
- Online-only reporting — fails the deliberately-offline scenario (content synced down, but the safety response can’t be filed): a safeguarding gap exactly when it matters most. Rejected in favour of offline-capable owner-scoped reports.
- A discussion-specific “group” — would force the ADR-0027 one-off anti-pattern; Groups are a general primitive. Rejected.
Deferred / v2 (named seams, each with its trigger)
Section titled “Deferred / v2 (named seams, each with its trigger)”- Seam A — MOOC-scale forum: an online, server-paginated, searchable delivery on the same Discussion model. Trigger: the scale guard, or a product MOOC line.
- Seam B — real-time Chat: a separate synchronous, channel-based surface (name reserved; not built). Hazel is the thing to revisit here, post-Apache or licensed.
- “Grade this discussion” — Discussion ↔ human-scored Assessment convenience association.
- Self-select / random group formation (v1 is manual only).
- Cross-device drafts — a synced owner-scoped draft row (v1 drafts are client-local only).
- Present-back workflow — group-private posts publishing to the whole offering.
- Richer safeguarding — automated keyword/abuse flagging, external mandatory-reporting.
- Moderation depth — per-group/per-discussion restriction granularity, appeals workflow.
- Notifications / per-post read state — the whole subscription/notification block → ADR-0030.
Consequences
Section titled “Consequences”- New Group domain (
group_set,group,group_membership), general and reusable. - New Discussion + Post tables; Report + Posting Restriction tables; first readwrite + offering/group-membership sync table (pgxsinkit hardening).
- New RLS insert/update/moderate policies, including lock/restriction subqueries and the role-aware group-asymmetry filter.
- New erasure-registry entries: Post (
scrubbody + sever author), Report (sever reporter + scrub reason), Group Membership, Posting Restriction; a DB trigger writingchange_audit_entryfor post edits/deletes. - CC
landDiscussion+ a conformance fixture flippingdiscussion-topicto imported. - CONTEXT.md gains Discussion, Post, Report, Posting Restriction, Group, Group Set, Group Membership; ADR-0030 spun out for notifications.
- Executed incrementally, a rebased branch per slice ([[feedback_rebase_never_merge]]).
Verify upstream before/while building (pgxsinkit)
Section titled “Verify upstream before/while building (pgxsinkit)”- A readwrite sync table with a
customWhereoffering/group-membership filter (cross-person fan-out on the write path). - A trigger on a synced table writing to a non-synced audit table.
- Subqueries in RLS insert policies (lock + restriction state).
- Graceful offline write-rejection UX (locked/banned/post-vanished bounce).