Skip to content

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:

  1. public is a preference, not a hard rule. A second schema is still our Postgres — RLS-capable and declarable in the erasure registry. So “must be in public” was really “must be in our Postgres.”
  2. 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.

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 external obligation), 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).

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.

  • 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.

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).

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.”

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.

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.

  • Vendored forum / SaaS / self-hosted headless service / Hazel — see Decision 1.
  • A bespoke immutable post_version table — 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.
  • 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 (scrub body + sever author), Report (sever reporter + scrub reason), Group Membership, Posting Restriction; a DB trigger writing change_audit_entry for post edits/deletes.
  • CC landDiscussion + a conformance fixture flipping discussion-topic to 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 customWhere offering/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).