Skip to content

Track: Interactive learning — the playground as a teaching substrate, then a graded course #994

Description

@accuser

The theme

Bynk's zero-install playground already does the hard thing an interactive course
needs: it compiles Bynk in the browser (bynk-wasm) and executes the result in a
sandboxed, separate-origin Worker (ADR 0140), with a CodeMirror editor and
on-type diagnostics. That machinery was built as a REPL. This track turns it into
a teaching substrate — hardened, tested, and reusable by more than one app —
and then builds an interactive course on top of it: sequenced lessons whose
exercises the toolchain grades, rather than prose the reader is trusted to try.

The course is explicitly not a product: no monetisation, no accounts, no
enrolment, no certificates. It is a second mode of learning alongside the Book,
for developers who learn by doing rather than by reading.

When the track retires: the playground's internals are a tested, shared package
with a deterministic execution contract; a graded-exercise course is live and
every exercise is compile-verified in CI; and the decision on whether
suite-based grading is worth its compiler changes has been taken on evidence.

  • Realises: design/bynk-design-notes.md's framing of Bynk as an explicitly
    educational language, and design/bynk-positioning.md's justification of the
    design partly by "novices, students and course design" (§§21, 29, 55, 60, 89).
    Gives the shipped playground (ADR 0140) a second consumer, which is what forces
    it to become a substrate rather than an app.
  • Track doc (added by the settling PR): design/tracks/interactive-learning.md
  • Relation to the documentation track ([#557](Track: documentation & web presence #557)):
    adjacent, not overlapping — and the seam needs stating. Track: documentation & web presence #557 owns the Book, By
    Example, the site shell, and the snippet-verification harness; it declares
    itself not a language-surface change. This track owns the playground's
    internals and the course surface, and includes work Track: documentation & web presence #557 excludes by
    construction (a new diagnostic, an emitter change to needs_compose). The
    course consumes Track: documentation & web presence #557's verification harness rather than building a second
    one; slice 4 below depends on it. site/src/components/Example.astro already
    implements the inline embed, so the site→playground seam exists and this track
    must not fork it.

Why a track (the ADR 0076 trigger)

All three hold.

  • Multi-increment — roughly a dozen slices across two phases: making the
    playground testable and shareable, changing the execution contract, then the
    course shell, the exercise corpus, and a conditional grading tier. The
    connective decisions (the entry contract, the corpus format, the origin model)
    constrain every later slice and cannot live in a delete-on-merge proposal.
  • Surface not yet settled — the exercise data format does not exist; where
    the grading boundary sits is genuinely open (see Q2); the entry-discovery
    contract needs designing rather than implementing; and whether the course
    eventually leaves this repo is undecided by intent (Q5).
  • Security/safety boundary — slice 0 moves the code that decides what
    executes
    in the sandbox (WORKER_SRC, linkGraph), and a second app surface
    means APP_ORIGIN stops being a single constant. Both touch the boundary
    ADR 0140 defines. Threat model below.

Open design questions

Q1 — The exercise corpus format. The highest-leverage artefact in the track,
because the corpus is the asset and the shell is replaceable: a discarded spike,
a rewritten shell, even a repo move all leave the corpus valid, but thirty
exercises against a bad schema is thirty rewrites. Needs: what an exercise
declares (starter source, solution, objective, hints, prerequisites, difficulty),
how it declares its grading tier and success criterion, and how the CI gate
reads it. Prior art to review: Exercism's per-exercise config, Rustlings'
exercises/ + info.toml, the Rust Book's listings/ extraction, and
playground/src/examples.ts (the closest in-repo precedent, and deliberately
thin — { id, title, source } only).

Q2 — Where the grading boundary sits. Three tiers with very different costs:
(1) assert on bynk_analyze diagnostic categories — "make this program produce
bynk.refine.literal_violates"
— needs no compiler change; (2) assert on the
sandbox's { logs, value } — also none; (3) a hidden suite grades the
learner's code, needing ~6 changes including a multi-file wasm entry. Tiers 1–2
plausibly cover two-thirds of a spine, and tier 1 is the most thesis-aligned
grading available. The settling question is whether tier 3 is in scope for this
track at all, or a follow-on justified by evidence from tiers 1–2.
Tier 1 also
has an under-exploited asset behind it: reference/diagnostics.md is ~74 KB of
stable category codes and currently backs zero exercises.

Q3 — The browser entry contract. Today sandbox.ts requires composeApp,
then takes the first surface function with fn.length === 0, so a program with
two zero-arg handlers resolves by object key enumeration order, and a program
with none returns an opaque noEntry flag. Acceptable for a REPL; a correctness
bug for a grader. Needs settling: whether the contract is exactly one
zero-arg on call per browser run; the diagnostic identities (candidates
bynk.target.no_browser_entry / bynk.target.ambiguous_browser_entry, beside
the three existing bynk.target.*); and whether needs_compose widens to fire
for a context holding a zero-arg on call — which is what makes the seven
site/src/snippets/*.bynk run rather than merely type-check, since all seven
are bare commons today and the five playground examples all carry a contrived
consumes bynk { Logger } to force a composition root. Compatibility check
required:
does widening needs_compose alter emission for any existing
program?

Closed already, recorded so it is not re-litigated. A dedicated service
kind — service … from playground with on run () — was considered and
rejected. All six ServiceProtocol variants name a real trigger (Http,
Cron, Queue, WebSocket, Events, Call); "playground" would name a
person clicking a button, i.e. a development affordance in architecture's
costume. ServiceProtocol::Call is already documented as "no from clause:
the service holds on call handlers only"
on call is the
non-triggered entry kind, so the gap is that the sandbox does not respect it,
not that the language lacks it. There is also a pedagogy cost specific to a
course: teaching from playground teaches a construct present in no real
program, which every learner then unlearns at the install boundary. If a
marker is ever wanted, an attribute (@entry) is the proportionate shape, not
a protocol variant.

Q4 — The sandbox origin model. A course is a third surface. Either it gets
its own sandbox origin (a fourth Pages project) or APP_ORIGIN becomes an
allow-list. The allow-list is cheaper and probably better, but it amends the
boundary ADR 0140 defines, and playground/README.md is emphatic that the two
origins must never collapse. Widening an allow-list is not collapsing — but the
distinction deserves an ADR, not a changed constant.

Q5 — Does the course stay in this repo? Intent is to build in-tree and keep
extraction possible. The tension to settle: the monorepo is what makes a language
change that breaks an exercise break the build in the same PR, and that is
the single highest-leverage anti-rot mitigation available. Extracted, it degrades
to a nightly against a published release — structurally always behind. The
README already documents this failure mode for
accuser/architecture-that-compiles: "a diagnostic rename here can invalidate a
listing there — that repository's nightly gate is what reports it."
Correct for
a manuscript pinning an edition; a downgrade for a course claiming to teach what
compiles today. Extraction would also need three pipelines that do not exist: a
published bynk-web-core, a published wasm artefact (src/vendor/ is gitignored
and built from in-tree crates), and a gate against a released toolchain. Settle
the extraction contract now (own directory, own package.json, own test
script, dependencies by workspace name, exercises as data behind a manifest,
nothing imported from site/) and leave the extraction trigger as a named
signal rather than a date.

Q6 — How the boundary half of the language gets taught. The browser can teach
values, types, refinement, capabilities and agents. It cannot teach the edge:
from http, cron, queue and websocket all compile for Platform::Browser
but are structurally unreachable, because the composed surface only ever carries
on call handlers. So a browser-only course stops before the learner has built a
service — which is what the language is for. Options: an install capstone; a
bynk learn CLI over bynkc test (the Rustlings/Ziglings pattern, no browser
constraints at all, reuses the native runner as-is); or the larger idea of
letting an in-process entry drive the generated router — synthesising a
request, essentially what as system suites already do under bynkc test
which would largely dissolve the break and deserves its own proposal on its own
merits.

Q7 — Page weight. Debug wasm is 17.7 MiB and the release size is
unmeasured — ADR 0139 says so directly ("the wasm payload is real and
unmeasured here — the REPL slice owns the size budget"
), and
deploy-playground.yml mentions a budget while enforcing no number. Tolerable for
a page visited once; a design input for a course where every lesson wants the
compiler. Needs a measured number and a decision on load strategy (once per
shell, lazy on first Run).

Candidate slice decomposition

Provisional until the doc settles. Phase A is worth shipping on its own merits —
every slice improves the playground for its existing users, so a decision not to
proceed to Phase B strands nothing.

Phase A — the playground as a substrate

  • Slice 0 — Testability. Move WORKER_SRC out of the template literal into a
    real typechecked module; give playground/ a test script (it has none — CI
    runs build:wasm then tsc --noEmit, so the comment-enforced "every curated
    example is runnable" invariant is unchecked); unit-test entry selection against
    hand-built fake surfaces. No wasm needed for those tests — the logic is
    plain JS over a composeApp() result. This slice is a prerequisite for slice 1:
    there is currently nowhere to write the test proving an ambiguous entry is
    rejected rather than guessed.
  • Slice 1 — The entry contract (Q3). Deterministic selection, real
    diagnostics, needs_compose widening. Carries a front-loaded ADR.
  • Slice 2 — Shared seams. Export linkGraph; make the wasm URLs configurable
    rather than location.href-relative (they break on any nested route, which a
    lessons app has); per-app build output (dist/ is rm'd at build start, so two
    apps collide); measure the release wasm and put a number in CI (Q7). Defer the
    full bynk-web-core package with an exports field until there are two real
    consumers — abstracting from one caller's shape is how the seams come out wrong.
  • Slice 3 — The origin model (Q4). ADR plus implementation.

Phase B — the course

  • Slice 4 — The corpus format (Q1) plus the CI gate reading it, plus the first
    handful of exercises expressed in it — the format is only validated by real
    content. Consumes Track: documentation & web presence #557's verification harness.
  • Slice 5 — The course shell and modules 1–5. Tier-1/2 grading only. Progress
    in localStorage: no accounts, no backend, no personal data. Reuse
    deeplink.ts unchanged so a learner can share an attempt.
  • Slice 6 — (conditional) Tier-3 grading (Q2). Multi-file wasm entry, test
    mode in the sandbox, RunReply widened to a suite/case/outcome shape reusing
    bynk-driver/src/test_json.rs's document rather than inventing one, and
    contracts: true on the in-memory path.
  • Slice 7 — (conditional) The boundary half (Q6).

Slice status

  • Slice 0 — Playground testability: WORKER_SRC as a module, test script, entry-selection unit tests
  • Slice 1 — The browser entry contract: deterministic selection, diagnostics, needs_compose
  • Slice 2 — Shared seams: linkGraph export, configurable wasm URLs, per-app build output, wasm size budget
  • Slice 3 — The sandbox origin model
  • Slice 4 — The exercise corpus format and its CI gate
  • Slice 5 — Course shell and modules 1–5, tier-1/2 grading
  • Slice 6 — (conditional) Tier-3 suite grading
  • Slice 7 — (conditional) The boundary half: install capstone or bynk learn

Front-loaded ADR candidates

Numbers taken at merge, not pre-allocated.

  1. The browser entry contract. Load-bearing and expensive to reverse: every
    exercise, every runnable Book snippet and every embedded example depends on
    which programs are runnable and how the entry is found. Changing it after a
    corpus exists invalidates content. Covers the exactly-one rule, the diagnostic
    identities, and the needs_compose widening.
  2. The sandbox origin model. Amends ADR 0140's safety boundary; security-
    relevant; hard to reverse once a second surface is deployed.
  3. The exercise corpus format. The corpus is the asset. Hard to reverse once
    N exercises exist, and it is the contract between the content, the CI gate and
    any future shell — including one in a different repository.

Explicitly not an ADR: a from playground service kind. Rejected during
scoping; reasoning recorded under Q3 so it is not re-opened by default.

Threat model

Assets. The app origin's storage and session on playground.bynk-lang.org
(and any future course origin); the learner's browser and anything else it holds.

Adversary. A malicious shared program. Deep links (#base64url(deflate-raw(…)))
and ?s=<id> share ids are attacker-controllable, so anyone can hand a victim a
URL that executes code in their browser. This exposure exists today and the track
must not widen it. A course adds a second vector of the same kind — a shared
exercise attempt.

Where verification happens. Four properties carry the boundary, and each is
load-bearing rather than defence-in-depth garnish: the sandbox runs on a
distinct origin in an <iframe sandbox="allow-scripts"> without
allow-same-origin, so it is an opaque origin with no access to app-origin
storage; both directions of postMessage validate (sandbox checks
e.origin === APP_ORIGIN; the app checks e.source === iframe.contentWindow);
execution is killed on a 3 s wall clock via worker.terminate(); and the
capability surface is withheld in the browser binding — Fetch and Secrets
throw by construction rather than being merely unbound.

What this track changes, and the invariants it must preserve. Slice 0 moves
WORKER_SRC and slice 2 moves linkGraph — i.e. the code deciding what
executes
— and slice 3 widens who may ask the sandbox to execute. Preserve:
distinct origin, no allow-same-origin, per-message origin validation on both
hops, and the wall clock. Widening APP_ORIGIN to an allow-list must be an
explicit finite list of first-party origins, never a wildcard or a suffix match.

One new constraint this track should adopt. Today the sandbox has no
affirmative network block — no CSP file exists under playground/, and dist/
ships no _headers. Outbound fetch is unreachable only because Bynk emits no
JavaScript escape hatch
, so everything running in the sandbox is compiler
output. A course is exactly the thing likely to break that assumption, by
wanting a hand-written JS test harness or scaffolding. So: everything executed
in the sandbox must remain compiler output; a graded exercise's assertions are
written in Bynk (suite/case/expect) or evaluated outside the sandbox from
{ logs, value } and the diagnostic set — never injected as hand-authored JS.

Adding a CSP to both origins is cheap belt-and-braces and worth doing in slice 0
regardless, so the property stops resting on a language accident.


Scoping notes, the reusability audit, the third-party build-vs-buy
comparison, and a verification checklist of claims that were read but not
executed are in the feasibility assessment this issue was cut from; it becomes
the basis of design/tracks/interactive-learning.md in the settling PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions