diff --git a/.github/scripts/provisioning/__tests__/github-app-auth.test.js b/.github/scripts/provisioning/__tests__/github-app-auth.test.js
new file mode 100644
index 00000000..831a7423
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/github-app-auth.test.js
@@ -0,0 +1,83 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+
+const { createAppJwt, mintInstallationToken, base64url } = require('../github-app-auth');
+
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
+});
+
+function decodeSegment(seg) {
+ return JSON.parse(Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
+}
+
+test('createAppJwt produces a verifiable RS256 token with correct claims', () => {
+ const now = 1_700_000_000_000;
+ const jwt = createAppJwt({ appId: '12345', privateKey, now });
+ const [headerB64, payloadB64, sigB64] = jwt.split('.');
+ const header = decodeSegment(headerB64);
+ const payload = decodeSegment(payloadB64);
+
+ assert.equal(header.alg, 'RS256');
+ assert.equal(payload.iss, '12345');
+ assert.equal(payload.iat, Math.floor(now / 1000) - 60);
+
+ const verifier = crypto.createVerify('RSA-SHA256');
+ verifier.update(`${headerB64}.${payloadB64}`);
+ verifier.end();
+ const signature = Buffer.from(sigB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+ assert.ok(verifier.verify(publicKey, signature));
+});
+
+test('createAppJwt rejects lifetimes over 600 seconds', () => {
+ assert.throws(() => createAppJwt({ appId: '1', privateKey, expiresInSeconds: 601 }), /600/);
+});
+
+test('createAppJwt requires appId and privateKey', () => {
+ assert.throws(() => createAppJwt({ privateKey }), /appId/);
+ assert.throws(() => createAppJwt({ appId: '1' }), /privateKey/);
+});
+
+test('base64url has no padding or url-unsafe chars', () => {
+ const out = base64url('hello world??>>');
+ assert.equal(/[+/=]/.test(out), false);
+});
+
+test('mintInstallationToken posts to the right endpoint and returns the token', async () => {
+ let captured;
+ const fakeFetch = async (url, opts) => {
+ captured = { url, opts };
+ return {
+ status: 201,
+ async json() {
+ return { token: 'ghs_faketoken', expires_at: '2026-01-01T01:00:00Z' };
+ }
+ };
+ };
+ const result = await mintInstallationToken({
+ appId: '12345',
+ privateKey,
+ installationId: '99',
+ fetchImpl: fakeFetch
+ });
+ assert.equal(result.token, 'ghs_faketoken');
+ assert.match(captured.url, /\/app\/installations\/99\/access_tokens$/);
+ assert.equal(captured.opts.method, 'POST');
+ assert.match(captured.opts.headers.Authorization, /^Bearer /);
+});
+
+test('mintInstallationToken throws a clear error on failure', async () => {
+ const fakeFetch = async () => ({
+ status: 403,
+ async text() {
+ return 'forbidden';
+ }
+ });
+ await assert.rejects(
+ () => mintInstallationToken({ appId: '1', privateKey, installationId: '2', fetchImpl: fakeFetch }),
+ /HTTP 403.*forbidden/
+ );
+});
diff --git a/.github/scripts/provisioning/__tests__/github-client.test.js b/.github/scripts/provisioning/__tests__/github-client.test.js
new file mode 100644
index 00000000..1fc2d691
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/github-client.test.js
@@ -0,0 +1,117 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { createFetchClient, fromOctokit, REQUIRED_WORKFLOWS } = require('../github-client');
+
+function fakeFetchFactory(routes) {
+ return async (url, opts) => {
+ const method = (opts && opts.method) || 'GET';
+ const key = `${method} ${url.replace('https://api.github.com', '')}`;
+ const handler = routes[key];
+ if (!handler) throw new Error(`Unexpected request: ${key}`);
+ return handler(opts);
+ };
+}
+
+test('REQUIRED_WORKFLOWS lists the core automation files', () => {
+ assert.ok(REQUIRED_WORKFLOWS.includes('pr-validation-bot.yml'));
+ assert.ok(REQUIRED_WORKFLOWS.includes('student-progression.yml'));
+});
+
+test('createFetchClient requires a token', () => {
+ assert.throws(() => createFetchClient({}), /token/);
+});
+
+test('repoExists returns true on 200 and false on 404', async () => {
+ const client = createFetchClient({
+ token: 't',
+ fetchImpl: fakeFetchFactory({
+ 'GET /repos/o/yes': () => ({ status: 200 }),
+ 'GET /repos/o/no': () => ({ status: 404 })
+ })
+ });
+ assert.equal(await client.repoExists({ owner: 'o', repo: 'yes' }), true);
+ assert.equal(await client.repoExists({ owner: 'o', repo: 'no' }), false);
+});
+
+test('createFromTemplate posts to generate endpoint', async () => {
+ let body;
+ const client = createFetchClient({
+ token: 't',
+ fetchImpl: fakeFetchFactory({
+ 'POST /repos/o/tmpl/generate': (opts) => {
+ body = JSON.parse(opts.body);
+ return { status: 201, async json() { return { full_name: 'o/new' }; } };
+ }
+ })
+ });
+ const repo = await client.createFromTemplate({
+ templateOwner: 'o',
+ templateRepo: 'tmpl',
+ owner: 'o',
+ repo: 'new'
+ });
+ assert.equal(repo.full_name, 'o/new');
+ assert.equal(body.private, true);
+ assert.equal(body.name, 'new');
+});
+
+test('ensureCollaborator treats 201 and 204 as success', async () => {
+ const client = createFetchClient({
+ token: 't',
+ fetchImpl: fakeFetchFactory({
+ 'PUT /repos/o/r/collaborators/alice': () => ({ status: 201 }),
+ 'PUT /repos/o/r/collaborators/bob': () => ({ status: 204 })
+ })
+ });
+ assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'alice' })).status, 'invited');
+ assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'bob' })).status, 'already-collaborator');
+});
+
+test('listWorkflowPaths returns file names and [] on 404', async () => {
+ const client = createFetchClient({
+ token: 't',
+ fetchImpl: fakeFetchFactory({
+ 'GET /repos/o/has/contents/.github/workflows': () => ({
+ status: 200,
+ async json() {
+ return [
+ { type: 'file', name: 'a.yml' },
+ { type: 'dir', name: 'sub' }
+ ];
+ }
+ }),
+ 'GET /repos/o/none/contents/.github/workflows': () => ({ status: 404 })
+ })
+ });
+ assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'has' }), ['a.yml']);
+ assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'none' }), []);
+});
+
+test('fromOctokit adapts repoExists 404 to false', async () => {
+ const octokit = {
+ rest: {
+ repos: {
+ get: async () => {
+ const err = new Error('nf');
+ err.status = 404;
+ throw err;
+ }
+ }
+ }
+ };
+ const client = fromOctokit(octokit);
+ assert.equal(await client.repoExists({ owner: 'o', repo: 'r' }), false);
+});
+
+test('fromOctokit listWorkflowPaths filters files and handles 404', async () => {
+ const octokit = {
+ rest: {
+ repos: {
+ getContent: async () => ({ data: [{ type: 'file', name: 'x.yml' }, { type: 'dir', name: 'd' }] })
+ }
+ }
+ };
+ const client = fromOctokit(octokit);
+ assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'r' }), ['x.yml']);
+});
diff --git a/.github/scripts/provisioning/__tests__/progress.test.js b/.github/scripts/provisioning/__tests__/progress.test.js
new file mode 100644
index 00000000..84d6cf11
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/progress.test.js
@@ -0,0 +1,86 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ deriveStatus,
+ isDay2Eligible,
+ completedChallenges,
+ closedIssueRefs,
+ challengeNumberFromTitle,
+ hasTextSignal
+} = require('../progress');
+
+test('awaiting-ack when no signals present', () => {
+ const { status } = deriveStatus({ comments: [], labels: [], issues: [] });
+ assert.equal(status, 'awaiting-ack');
+});
+
+test('ack text signal moves to active-day1', () => {
+ const { status, evidence } = deriveStatus({ comments: [{ body: 'ack' }] });
+ assert.equal(status, 'active-day1');
+ assert.equal(evidence.acked, true);
+});
+
+test('ack matches as a standalone token only', () => {
+ assert.equal(hasTextSignal([{ body: 'I acknowledge the rules' }], 'ack'), false);
+ assert.equal(hasTextSignal([{ body: 'ack!' }], 'ack'), true);
+ assert.equal(hasTextSignal([{ body: 'Ready: ack' }], 'ack'), true);
+});
+
+test('day1-complete signal moves to day1-complete', () => {
+ const { status } = deriveStatus({ comments: [{ body: 'day1-complete' }] });
+ assert.equal(status, 'day1-complete');
+});
+
+test('day2-only learners are day1-complete by default', () => {
+ const { status } = deriveStatus({ path: 'day2-only', comments: [] });
+ assert.equal(status, 'day1-complete');
+});
+
+test('day2-released label takes precedence', () => {
+ const { status } = deriveStatus({
+ comments: [{ body: 'day1-complete' }],
+ labels: ['day2-released']
+ });
+ assert.equal(status, 'day2-released');
+});
+
+test('needs-info label overrides everything', () => {
+ const { status } = deriveStatus({
+ comments: [{ body: 'day1-complete' }],
+ labels: [{ name: 'needs-info' }, 'day2-released']
+ });
+ assert.equal(status, 'needs-info');
+});
+
+test('completed challenges from closed issues, deduped and sorted', () => {
+ const issues = [
+ { title: 'Challenge 2: file an issue', state: 'closed' },
+ { title: 'Challenge 1: find your way', state: 'closed' },
+ { title: 'Challenge 1: find your way', state: 'closed' },
+ { title: 'Challenge 3: branch out', state: 'open' }
+ ];
+ assert.deepEqual(completedChallenges(issues), [1, 2]);
+});
+
+test('a closed challenge makes a silent learner active-day1', () => {
+ const { status } = deriveStatus({
+ issues: [{ title: 'Challenge 1', state: 'closed' }]
+ });
+ assert.equal(status, 'active-day1');
+});
+
+test('closedIssueRefs parses closing keywords', () => {
+ assert.deepEqual(closedIssueRefs('This closes #12 and fixes #34, resolves #12'), [12, 34]);
+});
+
+test('challengeNumberFromTitle extracts the number', () => {
+ assert.equal(challengeNumberFromTitle('Challenge 10: capstone'), 10);
+ assert.equal(challengeNumberFromTitle('No number here'), null);
+});
+
+test('isDay2Eligible true only at day1-complete', () => {
+ assert.equal(isDay2Eligible({ comments: [{ body: 'day1-complete' }] }), true);
+ assert.equal(isDay2Eligible({ comments: [{ body: 'ack' }] }), false);
+ assert.equal(isDay2Eligible({ labels: ['day2-released'] }), false);
+});
diff --git a/.github/scripts/provisioning/__tests__/provision-cli.test.js b/.github/scripts/provisioning/__tests__/provision-cli.test.js
new file mode 100644
index 00000000..8b856ad9
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/provision-cli.test.js
@@ -0,0 +1,56 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+const { parseArgs, readPrivateKey, resolveToken, usage } = require('../provision-cli');
+
+test('parseArgs reads roster, log, dry-run, help', () => {
+ const a = parseArgs(['--roster', 'r.json', '--log', 'l.json', '--dry-run']);
+ assert.equal(a.roster, 'r.json');
+ assert.equal(a.log, 'l.json');
+ assert.equal(a.dryRun, true);
+ assert.equal(parseArgs(['-h']).help, true);
+});
+
+test('usage mentions both modes', () => {
+ assert.match(usage(), /github-app/);
+ assert.match(usage(), /actions-bot/);
+});
+
+test('readPrivateKey reads @file references', () => {
+ const tmp = path.join(os.tmpdir(), `key-${Date.now()}.pem`);
+ fs.writeFileSync(tmp, 'PEMDATA');
+ try {
+ assert.equal(readPrivateKey(`@${tmp}`), 'PEMDATA');
+ } finally {
+ fs.unlinkSync(tmp);
+ }
+});
+
+test('readPrivateKey converts literal escaped newlines', () => {
+ assert.equal(readPrivateKey('line1\\nline2'), 'line1\nline2');
+});
+
+test('resolveToken returns PAT in actions-bot mode', async () => {
+ const token = await resolveToken(
+ { PROVISIONING_MODE: 'actions-bot', PROVISIONING_TOKEN: 'pat123' },
+ 'https://api.github.com'
+ );
+ assert.equal(token, 'pat123');
+});
+
+test('resolveToken actions-bot mode requires a token', async () => {
+ await assert.rejects(
+ () => resolveToken({ PROVISIONING_MODE: 'actions-bot' }, 'https://api.github.com'),
+ /PROVISIONING_TOKEN/
+ );
+});
+
+test('resolveToken rejects unknown mode', async () => {
+ await assert.rejects(
+ () => resolveToken({ PROVISIONING_MODE: 'banana' }, 'https://api.github.com'),
+ /Unknown PROVISIONING_MODE/
+ );
+});
diff --git a/.github/scripts/provisioning/__tests__/provision-core.test.js b/.github/scripts/provisioning/__tests__/provision-core.test.js
new file mode 100644
index 00000000..ed78981e
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/provision-core.test.js
@@ -0,0 +1,171 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { provisionRoster, defaultRepoName, missingWorkflows, isSecondaryRateLimit } = require('../provision-core');
+const { upsertLearner, emptyRoster } = require('../roster');
+
+const REQUIRED = ['pr-validation-bot.yml', 'student-progression.yml', 'content-validation.yml'];
+
+function makeClient(overrides = {}) {
+ const state = {
+ repos: new Map(), // repoName -> { workflows: [...] }
+ calls: { create: 0, collaborator: 0, list: 0 }
+ };
+ const client = {
+ async repoExists({ repo }) {
+ return state.repos.has(repo);
+ },
+ async createFromTemplate({ repo }) {
+ state.calls.create += 1;
+ state.repos.set(repo, { workflows: [...REQUIRED] });
+ return { full_name: repo };
+ },
+ async ensureCollaborator() {
+ state.calls.collaborator += 1;
+ return { status: 'invited' };
+ },
+ async listWorkflowPaths({ repo }) {
+ state.calls.list += 1;
+ const r = state.repos.get(repo);
+ return r ? r.workflows : [];
+ },
+ async getDefaultBranchSha() {
+ return 'abc123';
+ },
+ ...overrides
+ };
+ return { client, state };
+}
+
+const noSleep = async () => {};
+const config = {
+ studentOwner: 'Community-Access',
+ templateOwner: 'Community-Access',
+ templateRepo: 'learning-room-template',
+ requiredWorkflows: REQUIRED,
+ delayMs: 0
+};
+
+function rosterWith(...learners) {
+ let r = emptyRoster();
+ for (const l of learners) r = upsertLearner(r, l);
+ return r;
+}
+
+test('defaultRepoName is deterministic and sanitized', () => {
+ assert.equal(defaultRepoName('Octo.Cat', 'Spring 2026'), 'learning-room-spring-2026-octo-cat');
+});
+
+test('missingWorkflows is case-insensitive', () => {
+ assert.deepEqual(missingWorkflows(['A.yml', 'B.yml'], ['a.yml']), ['B.yml']);
+});
+
+test('provisions a new learner end to end', async () => {
+ const { client, state } = makeClient();
+ const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' });
+ const { roster: out, log, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });
+
+ assert.equal(out.learners[0].provision_state, 'provisioned');
+ assert.equal(out.learners[0].learning_room_repo, 'Community-Access/learning-room-c1-alice');
+ assert.equal(state.calls.create, 1);
+ assert.equal(state.calls.collaborator, 1);
+ assert.equal(summary.created, 1);
+ assert.equal(log[0].result, 'created');
+ assert.equal(log[0].template_sha, 'abc123');
+});
+
+test('idempotent: existing complete repo is already-exists, no create', async () => {
+ const { client, state } = makeClient();
+ state.repos.set('learning-room-c1-alice', { workflows: [...REQUIRED] });
+ const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' });
+ const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });
+
+ assert.equal(state.calls.create, 0);
+ assert.equal(out.learners[0].provision_state, 'provisioned');
+ assert.equal(summary.already_exists, 1);
+});
+
+test('heals an incomplete repo when seedContent is available', async () => {
+ let seeded = false;
+ const { client, state } = makeClient({
+ async seedContent({ repo }) {
+ seeded = true;
+ state.repos.get(repo).workflows = [...REQUIRED];
+ }
+ });
+ state.repos.set('learning-room-c1-bob', { workflows: ['pr-validation-bot.yml'] });
+ const roster = rosterWith({ github_handle: 'bob', cohort_id: 'c1' });
+ const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });
+
+ assert.ok(seeded);
+ assert.equal(out.learners[0].provision_state, 'healed');
+ assert.equal(summary.healed, 1);
+});
+
+test('fails loudly when repo exists but incomplete and cannot heal', async () => {
+ const { client, state } = makeClient();
+ state.repos.set('learning-room-c1-carol', { workflows: ['pr-validation-bot.yml'] });
+ const roster = rosterWith({ github_handle: 'carol', cohort_id: 'c1' });
+ const { roster: out, log, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });
+
+ assert.equal(out.learners[0].provision_state, 'failed');
+ assert.equal(summary.error, 1);
+ assert.match(log[0].error_detail, /missing required workflows/);
+});
+
+test('retries on secondary rate limit then succeeds', async () => {
+ let attempts = 0;
+ const { client } = makeClient({
+ async repoExists() {
+ attempts += 1;
+ if (attempts === 1) {
+ const err = new Error('HTTP 403 secondary rate limit');
+ err.status = 403;
+ throw err;
+ }
+ return false;
+ }
+ });
+ const roster = rosterWith({ github_handle: 'dave', cohort_id: 'c1' });
+ const sleeps = [];
+ const { roster: out } = await provisionRoster({
+ roster,
+ client,
+ config,
+ sleep: async (ms) => sleeps.push(ms)
+ });
+
+ assert.equal(out.learners[0].provision_state, 'provisioned');
+ assert.ok(sleeps.includes(1000));
+});
+
+test('a failure does not stop the batch; others still provision', async () => {
+ let firstChecked = false;
+ const { client, state } = makeClient({
+ async repoExists({ repo }) {
+ if (repo.endsWith('-eve') && !firstChecked) {
+ firstChecked = true;
+ throw new Error('boom');
+ }
+ return state.repos.has(repo);
+ }
+ });
+ const roster = rosterWith(
+ { github_handle: 'eve', cohort_id: 'c1' },
+ { github_handle: 'frank', cohort_id: 'c1' }
+ );
+ const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });
+
+ const eve = out.learners.find((l) => l.github_handle === 'eve');
+ const frank = out.learners.find((l) => l.github_handle === 'frank');
+ assert.equal(eve.provision_state, 'failed');
+ assert.equal(frank.provision_state, 'provisioned');
+ assert.equal(summary.error, 1);
+ assert.equal(summary.created, 1);
+});
+
+test('isSecondaryRateLimit detects status and message forms', () => {
+ assert.ok(isSecondaryRateLimit({ status: 429 }));
+ assert.ok(isSecondaryRateLimit({ message: 'oops HTTP 403 here' }));
+ assert.equal(isSecondaryRateLimit({ message: 'HTTP 404' }), false);
+});
diff --git a/.github/scripts/provisioning/__tests__/roster.test.js b/.github/scripts/provisioning/__tests__/roster.test.js
new file mode 100644
index 00000000..31867dfd
--- /dev/null
+++ b/.github/scripts/provisioning/__tests__/roster.test.js
@@ -0,0 +1,109 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ emptyRoster,
+ parseRoster,
+ upsertLearner,
+ serializeRoster,
+ findLearner,
+ entriesToProvision,
+ isValidHandle,
+ normalizeHandle,
+ learnerKey,
+ publicView,
+ validateLearner
+} = require('../roster');
+
+test('parseRoster returns empty roster for empty input', () => {
+ const r = parseRoster('');
+ assert.equal(r.version, 1);
+ assert.deepEqual(r.learners, []);
+});
+
+test('parseRoster validates and rejects bad version', () => {
+ assert.throws(() => parseRoster(JSON.stringify({ version: 2, learners: [] })), /version/);
+});
+
+test('parseRoster rejects duplicate learner keys', () => {
+ const data = {
+ version: 1,
+ learners: [
+ { github_handle: 'alice', cohort_id: 'c1', path: 'day1-day2', provision_state: 'pending', status: 'awaiting-ack' },
+ { github_handle: 'Alice', cohort_id: 'c1', path: 'day1-day2', provision_state: 'pending', status: 'awaiting-ack' }
+ ]
+ };
+ assert.throws(() => parseRoster(JSON.stringify(data)), /Duplicate/);
+});
+
+test('isValidHandle accepts valid and rejects invalid handles', () => {
+ assert.ok(isValidHandle('octo-cat'));
+ assert.ok(isValidHandle('@octocat'));
+ assert.equal(isValidHandle('-bad'), false);
+ assert.equal(isValidHandle('bad-'), false);
+ assert.equal(isValidHandle('two--hyphens'), false);
+ assert.equal(isValidHandle(''), false);
+});
+
+test('normalizeHandle strips @ and lowercases', () => {
+ assert.equal(normalizeHandle('@OctoCat'), 'octocat');
+});
+
+test('learnerKey is case-insensitive on handle', () => {
+ assert.equal(learnerKey('Alice', 'c1'), learnerKey('alice', 'c1'));
+});
+
+test('upsertLearner inserts then updates without mutating input', () => {
+ const r0 = emptyRoster();
+ const r1 = upsertLearner(r0, { github_handle: 'alice', cohort_id: 'c1' });
+ assert.equal(r0.learners.length, 0);
+ assert.equal(r1.learners.length, 1);
+ assert.equal(r1.learners[0].provision_state, 'pending');
+
+ const r2 = upsertLearner(r1, { github_handle: 'alice', cohort_id: 'c1', provision_state: 'provisioned' });
+ assert.equal(r2.learners.length, 1);
+ assert.equal(r2.learners[0].provision_state, 'provisioned');
+});
+
+test('upsertLearner rejects invalid handle', () => {
+ assert.throws(() => upsertLearner(emptyRoster(), { github_handle: '-bad', cohort_id: 'c1' }), /handle/);
+});
+
+test('findLearner locates by key', () => {
+ const r = upsertLearner(emptyRoster(), { github_handle: 'Bob', cohort_id: 'c2' });
+ assert.ok(findLearner(r, 'bob', 'c2'));
+ assert.equal(findLearner(r, 'bob', 'cX'), null);
+});
+
+test('entriesToProvision returns pending and failed only', () => {
+ let r = emptyRoster();
+ r = upsertLearner(r, { github_handle: 'a', cohort_id: 'c', provision_state: 'pending' });
+ r = upsertLearner(r, { github_handle: 'b', cohort_id: 'c', provision_state: 'failed' });
+ r = upsertLearner(r, { github_handle: 'c', cohort_id: 'c', provision_state: 'provisioned' });
+ const targets = entriesToProvision(r);
+ assert.equal(targets.length, 2);
+});
+
+test('serializeRoster sorts learners and stamps generated_at', () => {
+ let r = emptyRoster();
+ r = upsertLearner(r, { github_handle: 'zed', cohort_id: 'c1' });
+ r = upsertLearner(r, { github_handle: 'amy', cohort_id: 'c1' });
+ const out = serializeRoster(r, { now: new Date('2026-01-01T00:00:00Z') });
+ const parsed = JSON.parse(out);
+ assert.equal(parsed.learners[0].github_handle, 'amy');
+ assert.equal(parsed.generated_at, '2026-01-01T00:00:00.000Z');
+ assert.ok(out.endsWith('\n'));
+});
+
+test('publicView omits notes and extra fields', () => {
+ let r = emptyRoster();
+ r = upsertLearner(r, { github_handle: 'a', cohort_id: 'c', notes: 'secret note' });
+ const view = publicView(r);
+ assert.equal(view.learners[0].notes, undefined);
+ assert.equal(view.learners[0].github_handle, 'a');
+});
+
+test('validateLearner reports each problem', () => {
+ const problems = validateLearner({ github_handle: '-x', cohort_id: '', path: 'nope', provision_state: 'nope', status: 'nope' });
+ assert.ok(problems.length >= 4);
+});
diff --git a/.github/scripts/provisioning/github-app-auth.js b/.github/scripts/provisioning/github-app-auth.js
new file mode 100644
index 00000000..3f501cc5
--- /dev/null
+++ b/.github/scripts/provisioning/github-app-auth.js
@@ -0,0 +1,97 @@
+/**
+ * GitHub App authentication helpers, implemented with Node's built-in `crypto`
+ * so the provisioning subsystem needs no extra npm dependencies. See SPEC.md
+ * section 7.2a.
+ *
+ * A GitHub App is the production identity for provisioning because it is not tied
+ * to a human account, mints short-lived installation tokens, and uses fine-grained
+ * least-privilege permissions. This module turns an App ID plus a PEM private key
+ * into a short-lived installation token, fetched fresh on every provisioning run
+ * and never persisted.
+ */
+
+'use strict';
+
+const crypto = require('node:crypto');
+
+function base64url(input) {
+ return Buffer.from(input)
+ .toString('base64')
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+
+/**
+ * Create a signed RS256 JWT for a GitHub App. Valid for `expiresInSeconds`
+ * (max 600 per GitHub) with a 60s backdated `iat` to tolerate clock drift.
+ */
+function createAppJwt({ appId, privateKey, now = Date.now(), expiresInSeconds = 540 }) {
+ if (!appId) throw new Error('createAppJwt requires appId');
+ if (!privateKey) throw new Error('createAppJwt requires privateKey (PEM)');
+ if (expiresInSeconds > 600) {
+ throw new Error('GitHub App JWT lifetime must not exceed 600 seconds');
+ }
+ const iat = Math.floor(now / 1000) - 60;
+ const header = { alg: 'RS256', typ: 'JWT' };
+ const payload = { iat, exp: iat + expiresInSeconds + 60, iss: String(appId) };
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(
+ JSON.stringify(payload)
+ )}`;
+ const signer = crypto.createSign('RSA-SHA256');
+ signer.update(signingInput);
+ signer.end();
+ const signature = signer
+ .sign(privateKey)
+ .toString('base64')
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+ return `${signingInput}.${signature}`;
+}
+
+/**
+ * Exchange an App JWT for a short-lived installation access token.
+ * Uses global fetch (Node 18+). Returns { token, expires_at }.
+ */
+async function mintInstallationToken({
+ appId,
+ privateKey,
+ installationId,
+ apiBaseUrl = 'https://api.github.com',
+ fetchImpl = fetch,
+ now = Date.now()
+}) {
+ if (!installationId) throw new Error('mintInstallationToken requires installationId');
+ const jwt = createAppJwt({ appId, privateKey, now });
+ const res = await fetchImpl(
+ `${apiBaseUrl}/app/installations/${installationId}/access_tokens`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'glow-provisioning'
+ }
+ }
+ );
+ if (res.status !== 201) {
+ const detail = await safeText(res);
+ throw new Error(
+ `Failed to mint installation token (HTTP ${res.status}): ${detail}`
+ );
+ }
+ const body = await res.json();
+ return { token: body.token, expires_at: body.expires_at };
+}
+
+async function safeText(res) {
+ try {
+ return await res.text();
+ } catch (err) {
+ return ``;
+ }
+}
+
+module.exports = { createAppJwt, mintInstallationToken, base64url };
diff --git a/.github/scripts/provisioning/github-client.js b/.github/scripts/provisioning/github-client.js
new file mode 100644
index 00000000..d25896dd
--- /dev/null
+++ b/.github/scripts/provisioning/github-client.js
@@ -0,0 +1,179 @@
+/**
+ * Minimal GitHub REST client for the provisioning subsystem.
+ *
+ * Implements exactly the operations the idempotent provisioning algorithm needs
+ * (SPEC.md section 7.2b) and nothing more, so the surface stays auditable. Two
+ * constructors are provided:
+ *
+ * - createFetchClient(...) uses global fetch + a bearer token (App installation
+ * token or a fine-grained PAT). This is what the CLI uses.
+ * - fromOctokit(octokit) wraps the `github` client that actions/github-script
+ * injects, so the workflow path needs no extra dependency.
+ *
+ * Every method is idempotent-friendly: existence checks return booleans rather
+ * than throwing, so the algorithm can branch on state instead of on exceptions.
+ */
+
+'use strict';
+
+const REQUIRED_WORKFLOWS = Object.freeze([
+ 'pr-validation-bot.yml',
+ 'student-progression.yml',
+ 'content-validation.yml'
+]);
+
+function createFetchClient({
+ token,
+ apiBaseUrl = 'https://api.github.com',
+ fetchImpl = fetch,
+ userAgent = 'glow-provisioning'
+}) {
+ if (!token) throw new Error('createFetchClient requires a token');
+
+ async function request(method, path, body) {
+ const res = await fetchImpl(`${apiBaseUrl}${path}`, {
+ method,
+ headers: {
+ Authorization: `token ${token}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': userAgent,
+ ...(body ? { 'Content-Type': 'application/json' } : {})
+ },
+ body: body ? JSON.stringify(body) : undefined
+ });
+ return res;
+ }
+
+ return {
+ async repoExists({ owner, repo }) {
+ const res = await request('GET', `/repos/${owner}/${repo}`);
+ if (res.status === 200) return true;
+ if (res.status === 404) return false;
+ throw new Error(`repoExists failed (HTTP ${res.status}) for ${owner}/${repo}`);
+ },
+
+ async createFromTemplate({ templateOwner, templateRepo, owner, repo, isPrivate = true }) {
+ const res = await request(
+ 'POST',
+ `/repos/${templateOwner}/${templateRepo}/generate`,
+ {
+ owner,
+ name: repo,
+ private: isPrivate,
+ include_all_branches: false
+ }
+ );
+ if (res.status === 201) return res.json();
+ const detail = await res.text();
+ throw new Error(
+ `createFromTemplate failed (HTTP ${res.status}) for ${owner}/${repo}: ${detail}`
+ );
+ },
+
+ async ensureCollaborator({ owner, repo, username, permission = 'push' }) {
+ const res = await request(
+ 'PUT',
+ `/repos/${owner}/${repo}/collaborators/${username}`,
+ { permission }
+ );
+ // 201 = invitation created, 204 = already a collaborator. Both are success.
+ if (res.status === 201 || res.status === 204) {
+ return { status: res.status === 201 ? 'invited' : 'already-collaborator' };
+ }
+ const detail = await res.text();
+ throw new Error(
+ `ensureCollaborator failed (HTTP ${res.status}) for ${username} on ${owner}/${repo}: ${detail}`
+ );
+ },
+
+ async listWorkflowPaths({ owner, repo }) {
+ const res = await request(
+ 'GET',
+ `/repos/${owner}/${repo}/contents/.github/workflows`
+ );
+ if (res.status === 404) return [];
+ if (res.status !== 200) {
+ throw new Error(`listWorkflowPaths failed (HTTP ${res.status}) for ${owner}/${repo}`);
+ }
+ const entries = await res.json();
+ return entries.filter((e) => e.type === 'file').map((e) => e.name);
+ },
+
+ async getDefaultBranchSha({ owner, repo }) {
+ const repoRes = await request('GET', `/repos/${owner}/${repo}`);
+ if (repoRes.status !== 200) {
+ throw new Error(`getDefaultBranchSha failed (HTTP ${repoRes.status}) for ${owner}/${repo}`);
+ }
+ const meta = await repoRes.json();
+ const branch = meta.default_branch || 'main';
+ const refRes = await request(
+ 'GET',
+ `/repos/${owner}/${repo}/git/ref/heads/${branch}`
+ );
+ if (refRes.status !== 200) return null;
+ const ref = await refRes.json();
+ return ref.object ? ref.object.sha : null;
+ }
+ };
+}
+
+/**
+ * Adapt the Octokit instance that actions/github-script injects as `github` to
+ * the client interface above. Lets the workflow reuse the algorithm verbatim.
+ */
+function fromOctokit(octokit) {
+ return {
+ async repoExists({ owner, repo }) {
+ try {
+ await octokit.rest.repos.get({ owner, repo });
+ return true;
+ } catch (err) {
+ if (err.status === 404) return false;
+ throw err;
+ }
+ },
+ async createFromTemplate({ templateOwner, templateRepo, owner, repo, isPrivate = true }) {
+ const res = await octokit.rest.repos.createUsingTemplate({
+ template_owner: templateOwner,
+ template_repo: templateRepo,
+ owner,
+ name: repo,
+ private: isPrivate,
+ include_all_branches: false
+ });
+ return res.data;
+ },
+ async ensureCollaborator({ owner, repo, username, permission = 'push' }) {
+ const res = await octokit.rest.repos.addCollaborator({ owner, repo, username, permission });
+ return { status: res.status === 201 ? 'invited' : 'already-collaborator' };
+ },
+ async listWorkflowPaths({ owner, repo }) {
+ try {
+ const res = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: '.github/workflows'
+ });
+ const entries = Array.isArray(res.data) ? res.data : [];
+ return entries.filter((e) => e.type === 'file').map((e) => e.name);
+ } catch (err) {
+ if (err.status === 404) return [];
+ throw err;
+ }
+ },
+ async getDefaultBranchSha({ owner, repo }) {
+ const meta = await octokit.rest.repos.get({ owner, repo });
+ const branch = meta.data.default_branch || 'main';
+ try {
+ const ref = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
+ return ref.data.object.sha;
+ } catch (err) {
+ if (err.status === 404) return null;
+ throw err;
+ }
+ }
+ };
+}
+
+module.exports = { createFetchClient, fromOctokit, REQUIRED_WORKFLOWS };
diff --git a/.github/scripts/provisioning/progress.js b/.github/scripts/provisioning/progress.js
new file mode 100644
index 00000000..a2d15f06
--- /dev/null
+++ b/.github/scripts/provisioning/progress.js
@@ -0,0 +1,141 @@
+/**
+ * Owned progress of record for the GLOW workshop.
+ *
+ * Progress is never authored by a vendor. It is *derived* from deterministic
+ * signals the project controls: challenge issue state, PR closing keywords,
+ * labels, and the plain-text signals `ack` and `day1-complete`. See SPEC.md
+ * section 6.2. Re-running this over the same signals yields the same status,
+ * so the roster status column is always reconstructable.
+ *
+ * Pure and synchronous: the caller supplies an already-collected snapshot of
+ * signals (typically gathered by the workflow via the GitHub API) and gets back
+ * a derived status plus the evidence behind it.
+ */
+
+'use strict';
+
+const CHALLENGE_TITLE_RE = /challenge\s+(\d+)/i;
+const CLOSES_RE = /\b(?:closes|fixes|resolves)\s+#(\d+)/gi;
+
+function hasTextSignal(comments, signal) {
+ const needle = String(signal).toLowerCase();
+ return (comments || []).some((c) => {
+ const body = String((c && c.body) || '').toLowerCase();
+ // Match the signal as a standalone token so "acknowledge" never fires "ack".
+ return new RegExp(`(^|[^a-z0-9-])${escapeRegExp(needle)}([^a-z0-9-]|$)`).test(body);
+ });
+}
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function hasLabel(labels, name) {
+ const target = String(name).toLowerCase();
+ return (labels || []).some((l) => {
+ const value = typeof l === 'string' ? l : (l && l.name);
+ return String(value || '').toLowerCase() === target;
+ });
+}
+
+function challengeNumberFromTitle(title) {
+ const match = String(title || '').match(CHALLENGE_TITLE_RE);
+ return match ? Number(match[1]) : null;
+}
+
+/**
+ * Count distinct completed (closed) challenge issues from an issues snapshot.
+ * snapshot.issues: array of { title, state, labels }.
+ */
+function completedChallenges(issues) {
+ const done = new Set();
+ for (const issue of issues || []) {
+ if (String(issue.state).toLowerCase() !== 'closed') continue;
+ const n = challengeNumberFromTitle(issue.title);
+ if (n !== null) done.add(n);
+ }
+ return [...done].sort((a, b) => a - b);
+}
+
+/**
+ * Extract issue numbers a PR body closes via closing keywords.
+ */
+function closedIssueRefs(prBody) {
+ const refs = new Set();
+ let match;
+ const re = new RegExp(CLOSES_RE);
+ while ((match = re.exec(String(prBody || ''))) !== null) {
+ refs.add(Number(match[1]));
+ }
+ return [...refs];
+}
+
+/**
+ * Derive a learner's status from a signals snapshot.
+ *
+ * snapshot = {
+ * path: 'day1-day2' | 'day2-only',
+ * comments: [{ body }], // issue/PR comments in the learner repo
+ * labels: [{ name } | string], // labels on the enrollment/tracking issue
+ * issues: [{ title, state, labels }]
+ * }
+ *
+ * Returns { status, evidence: { acked, day1Complete, day2Released, completedChallenges } }.
+ * The precedence intentionally walks the journey forward, latest milestone wins.
+ */
+function deriveStatus(snapshot) {
+ const path = snapshot.path || 'day1-day2';
+ const comments = snapshot.comments || [];
+ const labels = snapshot.labels || [];
+ const issues = snapshot.issues || [];
+
+ const acked = hasTextSignal(comments, 'ack') || hasLabel(labels, 'acked');
+ const day1Complete =
+ hasTextSignal(comments, 'day1-complete') || hasLabel(labels, 'day1-complete');
+ const day2Released = hasLabel(labels, 'day2-released');
+ const needsInfo = hasLabel(labels, 'needs-info');
+ const done = completedChallenges(issues);
+
+ let status;
+ if (needsInfo) {
+ status = 'needs-info';
+ } else if (day2Released) {
+ status = 'day2-released';
+ } else if (day1Complete || path === 'day2-only') {
+ status = 'day1-complete';
+ } else if (acked || done.length > 0) {
+ status = 'active-day1';
+ } else {
+ status = 'awaiting-ack';
+ }
+
+ return {
+ status,
+ evidence: {
+ acked,
+ day1Complete,
+ day2Released,
+ needsInfo,
+ completedChallenges: done
+ }
+ };
+}
+
+/**
+ * Whether a learner is eligible for the Day 2 release. Eligible when Day 1 is
+ * complete (or they are a day2-only learner) and Day 2 has not been released yet.
+ */
+function isDay2Eligible(snapshot) {
+ const { status } = deriveStatus(snapshot);
+ return status === 'day1-complete';
+}
+
+module.exports = {
+ deriveStatus,
+ isDay2Eligible,
+ completedChallenges,
+ closedIssueRefs,
+ challengeNumberFromTitle,
+ hasTextSignal,
+ hasLabel
+};
diff --git a/.github/scripts/provisioning/provision-cli.js b/.github/scripts/provisioning/provision-cli.js
new file mode 100644
index 00000000..e66f875d
--- /dev/null
+++ b/.github/scripts/provisioning/provision-cli.js
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+/**
+ * Provisioning CLI: the standalone runner for the owned, GitHub-native
+ * provisioning subsystem. Reads the roster of record, provisions every
+ * pending/failed learner idempotently, writes the roster back, and appends to
+ * the provisioning log. See SPEC.md sections 7 and 16, and admin/OWNED_PROVISIONING.md.
+ *
+ * Modes (PROVISIONING_MODE):
+ * github-app (production) mints a short-lived installation token from
+ * PROVISIONING_APP_ID + PROVISIONING_APP_PRIVATE_KEY
+ * + PROVISIONING_APP_INSTALLATION_ID.
+ * actions-bot (Phase 1 spike only) uses PROVISIONING_TOKEN, a least-privilege
+ * fine-grained PAT.
+ *
+ * Usage:
+ * node provision-cli.js --roster path/to/roster.json [--log path/to/log.json] [--dry-run]
+ *
+ * No third-party dependencies: uses Node built-ins and global fetch.
+ */
+
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+const { parseRoster, serializeRoster } = require('./roster');
+const { provisionRoster } = require('./provision-core');
+const { createFetchClient, REQUIRED_WORKFLOWS } = require('./github-client');
+const { mintInstallationToken } = require('./github-app-auth');
+
+function parseArgs(argv) {
+ const args = { dryRun: false };
+ for (let i = 0; i < argv.length; i += 1) {
+ const a = argv[i];
+ if (a === '--roster') args.roster = argv[++i];
+ else if (a === '--log') args.log = argv[++i];
+ else if (a === '--dry-run') args.dryRun = true;
+ else if (a === '--help' || a === '-h') args.help = true;
+ }
+ return args;
+}
+
+function usage() {
+ return [
+ 'Usage: node provision-cli.js --roster [--log ] [--dry-run]',
+ '',
+ 'Environment:',
+ ' PROVISIONING_MODE github-app | actions-bot',
+ ' LEARNING_ROOM_TEMPLATE_REPO owner/name of the template (required)',
+ ' PROVISIONING_STUDENT_OWNER org/user that owns student repos (required)',
+ ' GITHUB_API_URL default https://api.github.com',
+ ' github-app mode:',
+ ' PROVISIONING_APP_ID',
+ ' PROVISIONING_APP_PRIVATE_KEY (PEM, or @path to a PEM file)',
+ ' PROVISIONING_APP_INSTALLATION_ID',
+ ' actions-bot mode:',
+ ' PROVISIONING_TOKEN'
+ ].join('\n');
+}
+
+function readPrivateKey(value) {
+ if (!value) return value;
+ if (value.startsWith('@')) {
+ return fs.readFileSync(value.slice(1), 'utf8');
+ }
+ // Support keys passed with literal \n escapes (common in CI secrets).
+ return value.includes('\\n') && !value.includes('\n')
+ ? value.replace(/\\n/g, '\n')
+ : value;
+}
+
+async function resolveToken(env, apiBaseUrl) {
+ const mode = env.PROVISIONING_MODE || 'github-app';
+ if (mode === 'actions-bot') {
+ if (!env.PROVISIONING_TOKEN) throw new Error('actions-bot mode requires PROVISIONING_TOKEN');
+ return env.PROVISIONING_TOKEN;
+ }
+ if (mode === 'github-app') {
+ const { token } = await mintInstallationToken({
+ appId: env.PROVISIONING_APP_ID,
+ privateKey: readPrivateKey(env.PROVISIONING_APP_PRIVATE_KEY),
+ installationId: env.PROVISIONING_APP_INSTALLATION_ID,
+ apiBaseUrl
+ });
+ return token;
+ }
+ throw new Error(`Unknown PROVISIONING_MODE: ${mode}`);
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help || !args.roster) {
+ process.stdout.write(`${usage()}\n`);
+ process.exit(args.help ? 0 : 2);
+ }
+
+ const env = process.env;
+ const apiBaseUrl = env.GITHUB_API_URL || 'https://api.github.com';
+ const templateRepoFull = env.LEARNING_ROOM_TEMPLATE_REPO;
+ const studentOwner = env.PROVISIONING_STUDENT_OWNER;
+ if (!templateRepoFull) throw new Error('LEARNING_ROOM_TEMPLATE_REPO is required');
+ if (!studentOwner) throw new Error('PROVISIONING_STUDENT_OWNER is required');
+ const [templateOwner, templateRepo] = templateRepoFull.split('/');
+ if (!templateOwner || !templateRepo) {
+ throw new Error('LEARNING_ROOM_TEMPLATE_REPO must be owner/name');
+ }
+
+ const rosterJson = fs.readFileSync(args.roster, 'utf8');
+ const roster = parseRoster(rosterJson);
+
+ if (args.dryRun) {
+ const { entriesToProvision } = require('./roster');
+ const targets = entriesToProvision(roster);
+ process.stdout.write(
+ `Dry run: ${targets.length} learner(s) would be provisioned:\n` +
+ targets.map((t) => ` - ${t.github_handle} (${t.cohort_id})`).join('\n') +
+ '\n'
+ );
+ return;
+ }
+
+ const token = await resolveToken(env, apiBaseUrl);
+ const client = createFetchClient({ token, apiBaseUrl });
+
+ const { roster: updated, log, summary } = await provisionRoster({
+ roster,
+ client,
+ config: {
+ studentOwner,
+ templateOwner,
+ templateRepo,
+ requiredWorkflows: REQUIRED_WORKFLOWS
+ },
+ logger: (msg) => process.stdout.write(`${msg}\n`)
+ });
+
+ fs.writeFileSync(args.roster, serializeRoster(updated));
+
+ if (args.log) {
+ appendLog(args.log, log);
+ }
+
+ process.stdout.write(`\nProvisioning summary: ${JSON.stringify(summary)}\n`);
+ if (summary.error > 0) {
+ process.exitCode = 1;
+ }
+}
+
+function appendLog(logPath, entries) {
+ let existing = [];
+ if (fs.existsSync(logPath)) {
+ const raw = fs.readFileSync(logPath, 'utf8').trim();
+ if (raw) existing = JSON.parse(raw);
+ } else {
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
+ }
+ const merged = existing.concat(entries);
+ fs.writeFileSync(logPath, `${JSON.stringify(merged, null, 2)}\n`);
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ process.stderr.write(`Provisioning failed: ${err.message}\n`);
+ process.exit(1);
+ });
+}
+
+module.exports = { parseArgs, resolveToken, readPrivateKey, usage };
diff --git a/.github/scripts/provisioning/provision-core.js b/.github/scripts/provisioning/provision-core.js
new file mode 100644
index 00000000..0fbf7070
--- /dev/null
+++ b/.github/scripts/provisioning/provision-core.js
@@ -0,0 +1,253 @@
+/**
+ * Idempotent provisioning core for the GLOW workshop: the GitHub-native
+ * replacement for GitHub Classroom. Implements the serial, backoff, idempotent
+ * algorithm in SPEC.md section 7.2b.
+ *
+ * This module is pure orchestration. It takes an injected `client` (see
+ * github-client.js) so it can run against the real GitHub API from the workflow
+ * or the CLI, and against a fake client in unit tests. It never reads secrets,
+ * never touches the network directly, and never persists state itself: it
+ * returns an updated roster plus a provisioning log for the caller to store.
+ */
+
+'use strict';
+
+const { entriesToProvision, learnerKey } = require('./roster');
+const { REQUIRED_WORKFLOWS } = require('./github-client');
+
+function defaultRepoName(handle, cohortId) {
+ const safeHandle = String(handle).toLowerCase().replace(/[^a-z0-9-]/g, '-');
+ const safeCohort = String(cohortId).toLowerCase().replace(/[^a-z0-9-]/g, '-');
+ return `learning-room-${safeCohort}-${safeHandle}`;
+}
+
+function isSecondaryRateLimit(err) {
+ if (!err) return false;
+ if (err.status === 403 || err.status === 429) return true;
+ return /HTTP (403|429)/.test(String(err.message || ''));
+}
+
+const sleepReal = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Provision (or heal) every pending/failed learner in the roster.
+ *
+ * config = {
+ * studentOwner, // org/user that owns student repos
+ * templateOwner,
+ * templateRepo,
+ * requiredWorkflows, // defaults to REQUIRED_WORKFLOWS
+ * repoNameFor, // (handle, cohortId) => name; defaults to defaultRepoName
+ * delayMs, // delay between entries (default 1500)
+ * maxRetries, // backoff attempts on secondary rate limit (default 5)
+ * collaboratorPermission // default 'push'
+ * }
+ *
+ * Returns { roster (updated), log: [entries], summary }.
+ */
+async function provisionRoster({
+ roster,
+ client,
+ config = {},
+ sleep = sleepReal,
+ now = () => new Date(),
+ logger = () => {}
+}) {
+ const studentOwner = required(config, 'studentOwner');
+ const templateOwner = required(config, 'templateOwner');
+ const templateRepo = required(config, 'templateRepo');
+ const requiredWorkflows = config.requiredWorkflows || REQUIRED_WORKFLOWS;
+ const repoNameFor = config.repoNameFor || defaultRepoName;
+ const delayMs = config.delayMs == null ? 1500 : config.delayMs;
+ const maxRetries = config.maxRetries == null ? 5 : config.maxRetries;
+ const permission = config.collaboratorPermission || 'push';
+
+ const log = [];
+ const targets = entriesToProvision(roster);
+
+ for (let i = 0; i < targets.length; i += 1) {
+ const learner = targets[i];
+ const handle = learner.github_handle;
+ const cohortId = learner.cohort_id;
+ const repoName = repoNameFor(handle, cohortId);
+ const repo = `${studentOwner}/${repoName}`;
+ const attemptAt = now().toISOString();
+
+ let result;
+ try {
+ result = await withRateLimitRetry(
+ () =>
+ provisionOne({
+ client,
+ handle,
+ studentOwner,
+ templateOwner,
+ templateRepo,
+ repoName,
+ requiredWorkflows,
+ permission
+ }),
+ { maxRetries, sleep, logger }
+ );
+ } catch (err) {
+ learner.provision_state = 'failed';
+ learner.learning_room_repo = repo;
+ const entry = {
+ github_handle: handle,
+ cohort_id: cohortId,
+ attempt_at: attemptAt,
+ result: 'error',
+ repo,
+ template_sha: null,
+ error_detail: String(err && err.message ? err.message : err)
+ };
+ log.push(entry);
+ logger(`FAILED ${learnerKey(handle, cohortId)}: ${entry.error_detail}`);
+ if (i < targets.length - 1) await sleep(delayMs);
+ continue;
+ }
+
+ learner.provision_state = result.healed ? 'healed' : 'provisioned';
+ learner.learning_room_repo = repo;
+ log.push({
+ github_handle: handle,
+ cohort_id: cohortId,
+ attempt_at: attemptAt,
+ result: result.result,
+ repo,
+ template_sha: result.templateSha,
+ error_detail: null
+ });
+ logger(`OK ${learnerKey(handle, cohortId)}: ${result.result} -> ${repo}`);
+
+ if (i < targets.length - 1) await sleep(delayMs);
+ }
+
+ const summary = summarize(log);
+ return { roster, log, summary };
+}
+
+async function provisionOne({
+ client,
+ handle,
+ studentOwner,
+ templateOwner,
+ templateRepo,
+ repoName,
+ requiredWorkflows,
+ permission
+}) {
+ const exists = await client.repoExists({ owner: studentOwner, repo: repoName });
+
+ let result = 'created';
+ let healed = false;
+
+ if (exists) {
+ const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName });
+ const missing = missingWorkflows(requiredWorkflows, present);
+ if (missing.length === 0) {
+ result = 'already-exists';
+ } else if (typeof client.seedContent === 'function') {
+ await client.seedContent({
+ owner: studentOwner,
+ repo: repoName,
+ templateOwner,
+ templateRepo,
+ missing
+ });
+ healed = true;
+ result = 'healed';
+ } else {
+ throw new Error(
+ `Repo ${studentOwner}/${repoName} exists but is missing required workflows: ${missing.join(', ')}`
+ );
+ }
+ } else {
+ await client.createFromTemplate({
+ templateOwner,
+ templateRepo,
+ owner: studentOwner,
+ repo: repoName,
+ isPrivate: true
+ });
+ result = 'created';
+ }
+
+ await client.ensureCollaborator({
+ owner: studentOwner,
+ repo: repoName,
+ username: handle,
+ permission
+ });
+
+ // Final verification gate: required workflows must be present.
+ const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName });
+ const stillMissing = missingWorkflows(requiredWorkflows, present);
+ if (stillMissing.length > 0) {
+ throw new Error(
+ `Verification failed for ${studentOwner}/${repoName}: missing ${stillMissing.join(', ')}`
+ );
+ }
+
+ let templateSha = null;
+ if (typeof client.getDefaultBranchSha === 'function') {
+ templateSha = await client.getDefaultBranchSha({ owner: studentOwner, repo: repoName });
+ }
+
+ return { result, healed, templateSha };
+}
+
+async function withRateLimitRetry(fn, { maxRetries, sleep, logger }) {
+ let attempt = 0;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ try {
+ return await fn();
+ } catch (err) {
+ if (isSecondaryRateLimit(err) && attempt < maxRetries) {
+ const backoff = Math.min(60000, 1000 * 2 ** attempt);
+ attempt += 1;
+ logger(`Secondary rate limit hit; backing off ${backoff}ms (attempt ${attempt})`);
+ await sleep(backoff);
+ continue;
+ }
+ throw err;
+ }
+ }
+}
+
+function missingWorkflows(required, present) {
+ const have = new Set((present || []).map((p) => String(p).toLowerCase()));
+ return required.filter((w) => !have.has(String(w).toLowerCase()));
+}
+
+function summarize(log) {
+ const summary = {
+ total: log.length,
+ created: 0,
+ already_exists: 0,
+ healed: 0,
+ error: 0
+ };
+ for (const entry of log) {
+ if (entry.result === 'created' || entry.result === 'seeded') summary.created += 1;
+ else if (entry.result === 'already-exists') summary.already_exists += 1;
+ else if (entry.result === 'healed') summary.healed += 1;
+ else if (entry.result === 'error') summary.error += 1;
+ }
+ return summary;
+}
+
+function required(config, key) {
+ if (!config[key]) throw new Error(`provisionRoster config requires "${key}"`);
+ return config[key];
+}
+
+module.exports = {
+ provisionRoster,
+ provisionOne,
+ defaultRepoName,
+ isSecondaryRateLimit,
+ missingWorkflows,
+ summarize
+};
diff --git a/.github/scripts/provisioning/roster.js b/.github/scripts/provisioning/roster.js
new file mode 100644
index 00000000..a39e4aa5
--- /dev/null
+++ b/.github/scripts/provisioning/roster.js
@@ -0,0 +1,226 @@
+/**
+ * Owned roster of record for the GIT Going with GitHub (GLOW) workshop.
+ *
+ * The roster maps a learner GitHub handle to a cohort, path, provisioning state,
+ * and progression status. It is the canonical, vendor-independent source of truth
+ * required by the decoupling contract in golden.md and SPEC.md section 6.1.
+ *
+ * Everything here is pure and synchronous so it is fully unit-testable and can be
+ * driven from a workflow, the CLI, or the Flask companion without a network call.
+ * Persistence (where the JSON physically lives) is the caller's concern.
+ */
+
+'use strict';
+
+const PROVISION_STATES = Object.freeze(['pending', 'provisioned', 'failed', 'healed']);
+const STATUSES = Object.freeze([
+ 'awaiting-ack',
+ 'active-day1',
+ 'day1-complete',
+ 'day2-released',
+ 'needs-info'
+]);
+const PATHS = Object.freeze(['day1-day2', 'day2-only']);
+
+// GitHub usernames: 1-39 chars, alphanumeric or single hyphens, no leading/trailing hyphen.
+const HANDLE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
+
+function normalizeHandle(handle) {
+ return String(handle || '').trim().replace(/^@+/, '').toLowerCase();
+}
+
+function isValidHandle(handle) {
+ return HANDLE_RE.test(String(handle || '').replace(/^@+/, ''));
+}
+
+function emptyRoster() {
+ return { version: 1, cohorts: [], learners: [] };
+}
+
+/**
+ * Parse a roster from a JSON string, tolerating an empty/missing input by
+ * returning a fresh empty roster. Throws on malformed JSON or wrong shape.
+ */
+function parseRoster(json) {
+ if (json === undefined || json === null || String(json).trim() === '') {
+ return emptyRoster();
+ }
+ const data = typeof json === 'string' ? JSON.parse(json) : json;
+ return validateRoster(data);
+}
+
+function validateRoster(data) {
+ const errors = [];
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
+ throw new Error('Roster must be an object');
+ }
+ if (data.version !== 1) {
+ errors.push(`Unsupported roster version: ${data.version}`);
+ }
+ if (!Array.isArray(data.learners)) {
+ errors.push('Roster.learners must be an array');
+ }
+ if (data.cohorts !== undefined && !Array.isArray(data.cohorts)) {
+ errors.push('Roster.cohorts must be an array');
+ }
+ if (errors.length) {
+ throw new Error(`Invalid roster: ${errors.join('; ')}`);
+ }
+ const seen = new Set();
+ for (const learner of data.learners) {
+ const problems = validateLearner(learner);
+ if (problems.length) {
+ throw new Error(`Invalid learner ${learner && learner.github_handle}: ${problems.join('; ')}`);
+ }
+ const key = learnerKey(learner.github_handle, learner.cohort_id);
+ if (seen.has(key)) {
+ throw new Error(`Duplicate learner for key ${key}`);
+ }
+ seen.add(key);
+ }
+ return {
+ version: 1,
+ generated_at: data.generated_at,
+ cohorts: data.cohorts || [],
+ learners: data.learners
+ };
+}
+
+function validateLearner(learner) {
+ const problems = [];
+ if (!learner || typeof learner !== 'object') {
+ return ['learner must be an object'];
+ }
+ if (!isValidHandle(learner.github_handle)) {
+ problems.push('invalid github_handle');
+ }
+ if (!learner.cohort_id) {
+ problems.push('missing cohort_id');
+ }
+ if (!PATHS.includes(learner.path)) {
+ problems.push(`invalid path: ${learner.path}`);
+ }
+ if (!PROVISION_STATES.includes(learner.provision_state)) {
+ problems.push(`invalid provision_state: ${learner.provision_state}`);
+ }
+ if (!STATUSES.includes(learner.status)) {
+ problems.push(`invalid status: ${learner.status}`);
+ }
+ return problems;
+}
+
+function learnerKey(handle, cohortId) {
+ return `${normalizeHandle(handle)}@@${String(cohortId || '').trim()}`;
+}
+
+function findLearner(roster, handle, cohortId) {
+ const key = learnerKey(handle, cohortId);
+ return roster.learners.find((l) => learnerKey(l.github_handle, l.cohort_id) === key) || null;
+}
+
+/**
+ * Insert or update a learner, keyed on (github_handle, cohort_id). Returns a new
+ * roster object (does not mutate the input) so callers can diff/compare safely.
+ */
+function upsertLearner(roster, entry) {
+ const problems = validateLearner({
+ github_handle: entry.github_handle,
+ cohort_id: entry.cohort_id,
+ path: entry.path || 'day1-day2',
+ provision_state: entry.provision_state || 'pending',
+ status: entry.status || 'awaiting-ack'
+ });
+ if (problems.length) {
+ throw new Error(`Cannot upsert learner: ${problems.join('; ')}`);
+ }
+ const next = cloneRoster(roster);
+ const key = learnerKey(entry.github_handle, entry.cohort_id);
+ const existing = next.learners.find(
+ (l) => learnerKey(l.github_handle, l.cohort_id) === key
+ );
+ if (existing) {
+ Object.assign(existing, entry);
+ return next;
+ }
+ next.learners.push({
+ github_handle: entry.github_handle,
+ cohort_id: entry.cohort_id,
+ path: entry.path || 'day1-day2',
+ learning_room_repo: entry.learning_room_repo || null,
+ provision_state: entry.provision_state || 'pending',
+ status: entry.status || 'awaiting-ack',
+ registered_at: entry.registered_at || null,
+ last_signal_at: entry.last_signal_at || null,
+ notes: entry.notes || ''
+ });
+ return next;
+}
+
+function cloneRoster(roster) {
+ return {
+ version: 1,
+ generated_at: roster.generated_at,
+ cohorts: (roster.cohorts || []).map((c) => ({ ...c })),
+ learners: (roster.learners || []).map((l) => ({ ...l }))
+ };
+}
+
+/**
+ * Serialize a roster to canonical, stable JSON: learners sorted by key so diffs
+ * are minimal and reviewable in the admin repository.
+ */
+function serializeRoster(roster, { now = new Date() } = {}) {
+ const next = cloneRoster(roster);
+ next.generated_at = now.toISOString();
+ next.learners.sort((a, b) =>
+ learnerKey(a.github_handle, a.cohort_id).localeCompare(
+ learnerKey(b.github_handle, b.cohort_id)
+ )
+ );
+ return `${JSON.stringify(next, null, 2)}\n`;
+}
+
+/**
+ * Return the entries the provisioning loop should act on: pending or failed.
+ * Mirrors the algorithm guard in SPEC.md section 7.2b.
+ */
+function entriesToProvision(roster) {
+ return roster.learners.filter(
+ (l) => l.provision_state === 'pending' || l.provision_state === 'failed'
+ );
+}
+
+/**
+ * Public, redacted view of the roster suitable for any non-private surface.
+ * Drops facilitator notes and never leaks anything beyond handle/cohort/status.
+ */
+function publicView(roster) {
+ return {
+ version: 1,
+ learners: roster.learners.map((l) => ({
+ github_handle: l.github_handle,
+ cohort_id: l.cohort_id,
+ path: l.path,
+ provision_state: l.provision_state,
+ status: l.status
+ }))
+ };
+}
+
+module.exports = {
+ PROVISION_STATES,
+ STATUSES,
+ PATHS,
+ emptyRoster,
+ parseRoster,
+ validateRoster,
+ validateLearner,
+ isValidHandle,
+ normalizeHandle,
+ learnerKey,
+ findLearner,
+ upsertLearner,
+ serializeRoster,
+ entriesToProvision,
+ publicView
+};
diff --git a/.github/workflows/automation-tests.yml b/.github/workflows/automation-tests.yml
index 382f3f60..ef58848d 100644
--- a/.github/workflows/automation-tests.yml
+++ b/.github/workflows/automation-tests.yml
@@ -46,3 +46,13 @@ jobs:
- name: Run automation test suite
run: npm run test:automation
+
+ - name: Run provisioning test suite
+ run: npm run test:provisioning
+
+ - name: Install companion dependencies
+ run: pip install -r companion/requirements.txt
+
+ - name: Run companion test suite
+ working-directory: companion
+ run: python -m unittest discover -s tests
diff --git a/.github/workflows/provision-learning-rooms.yml b/.github/workflows/provision-learning-rooms.yml
new file mode 100644
index 00000000..ed3eb47c
--- /dev/null
+++ b/.github/workflows/provision-learning-rooms.yml
@@ -0,0 +1,93 @@
+name: Provision Learning Rooms (owned, GitHub-native)
+
+# Owned, GitHub-native replacement for GitHub Classroom provisioning.
+# Implements SPEC.md section 7. The roster of record lives in a private admin
+# repository; this workflow reads it, idempotently provisions every pending or
+# failed learner, and commits the updated roster plus the provisioning log back.
+#
+# Nothing here is destructive: re-running heals partial failures and never
+# corrupts an existing student repository (idempotent on github_handle+cohort).
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: List who would be provisioned without making changes
+ type: boolean
+ default: false
+ schedule:
+ # Trickle-provision newly registered learners every 30 minutes. Provisioning
+ # on registration (a trickle) avoids the burst that would trip rate limits.
+ - cron: '*/30 * * * *'
+
+# Least privilege: this workflow needs no write scope on THIS repository. All
+# writes happen against the private admin repo and student repos via a scoped
+# token or GitHub App installation token configured in secrets.
+permissions:
+ contents: read
+
+concurrency:
+ group: provision-learning-rooms
+ cancel-in-progress: false
+
+jobs:
+ provision:
+ name: Provision learning rooms
+ runs-on: ubuntu-latest
+ environment: provisioning
+ steps:
+ - name: Check out workshop repo (provisioning scripts)
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Check out admin roster repo
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ vars.ADMIN_ROSTER_REPO }}
+ token: ${{ secrets.PRIVATE_STUDENT_DATA_TOKEN }}
+ path: _roster
+
+ - name: Provision (dry run)
+ if: ${{ inputs.dry_run }}
+ run: |
+ node .github/scripts/provisioning/provision-cli.js \
+ --roster _roster/roster.json --dry-run
+ env:
+ LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }}
+ PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }}
+
+ - name: Provision learners
+ if: ${{ !inputs.dry_run }}
+ run: |
+ node .github/scripts/provisioning/provision-cli.js \
+ --roster _roster/roster.json \
+ --log _roster/provisioning-log.json
+ env:
+ PROVISIONING_MODE: ${{ vars.PROVISIONING_MODE }}
+ LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }}
+ PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ # github-app mode (production):
+ PROVISIONING_APP_ID: ${{ secrets.PROVISIONING_APP_ID }}
+ PROVISIONING_APP_PRIVATE_KEY: ${{ secrets.PROVISIONING_APP_PRIVATE_KEY }}
+ PROVISIONING_APP_INSTALLATION_ID: ${{ secrets.PROVISIONING_APP_INSTALLATION_ID }}
+ # actions-bot mode (Phase 1 spike only):
+ PROVISIONING_TOKEN: ${{ secrets.PROVISIONING_TOKEN }}
+
+ - name: Commit updated roster and provisioning log
+ if: ${{ !inputs.dry_run }}
+ working-directory: _roster
+ run: |
+ if [ -n "$(git status --porcelain)" ]; then
+ git config user.name "glow-provisioning[bot]"
+ git config user.email "provisioning@users.noreply.github.com"
+ git add roster.json provisioning-log.json
+ git commit -m "chore(provisioning): update roster and provisioning log"
+ git push
+ else
+ echo "No roster changes to commit."
+ fi
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 00000000..68ed9786
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,521 @@
+# SPEC.md
+
+Complete project specification for GIT Going with GitHub (GLOW). This is the technical source of truth: what the system is, what every component does, how data and state flow, the interfaces between parts, and the requirements every piece must meet. It implements the vision in [golden.md](golden.md). Where this spec and reality disagree, fix one of them and note it here.
+
+## Table of contents
+
+- [1. Overview](#1-overview)
+- [2. Goals and non-goals](#2-goals-and-non-goals)
+- [3. Personas and primary journeys](#3-personas-and-primary-journeys)
+- [4. System architecture](#4-system-architecture)
+- [5. Component catalog](#5-component-catalog)
+- [6. Data model and state of record](#6-data-model-and-state-of-record)
+- [7. Provisioning subsystem (the Classroom replacement)](#7-provisioning-subsystem-the-classroom-replacement)
+- [8. Automation contracts](#8-automation-contracts)
+- [9. Curriculum subsystem](#9-curriculum-subsystem)
+- [10. Content pipeline: docs, HTML, EPUB, audio](#10-content-pipeline-docs-html-epub-audio)
+- [11. Optional Flask companion](#11-optional-flask-companion)
+- [12. Accessibility requirements](#12-accessibility-requirements)
+- [13. Security requirements](#13-security-requirements)
+- [14. Reliability, observability, and recovery](#14-reliability-observability-and-recovery)
+- [15. Testing and quality strategy](#15-testing-and-quality-strategy)
+- [16. Configuration surface](#16-configuration-surface)
+- [17. Repository layout](#17-repository-layout)
+- [18. Migration plan: from Classroom to owned provisioning](#18-migration-plan-from-classroom-to-owned-provisioning)
+- [19. Acceptance criteria](#19-acceptance-criteria)
+- [20. Open questions](#20-open-questions)
+
+## 1. Overview
+
+GLOW is a two-day, accessibility-first workshop that teaches blind and low vision technologists to navigate and contribute to open source on GitHub using a screen reader and keyboard alone. It is delivered as:
+
+- A **curriculum** of 22 chapters plus appendices in [docs/](docs/), published to HTML, EPUB, and an audio podcast series.
+- A **Learning Room**: a per-student private repository, created from a template, that drives 16 core challenges plus 5 bonus challenges through GitHub-native automation (a PR validation bot named Gandalf, a Student Progression Bot, and a suite of autograders).
+- A **registration and cohort system** that intakes learners, provisions their Learning Room, releases Day 2 content, and gives facilitators a live status dashboard.
+
+The current production system depends on GitHub Classroom for provisioning. This spec defines both the present system and the target system in which Classroom is replaced by owned, GitHub-native provisioning, per [golden.md](golden.md).
+
+## 2. Goals and non-goals
+
+### Goals
+
+- A learner completes the full arc from first GitHub navigation to a real, review-ready open source contribution.
+- Every learner-facing surface is fully operable with NVDA, JAWS, and VoiceOver, by keyboard alone.
+- Provisioning, roster, and progress are owned and reconstructable, with no single vendor as a point of failure.
+- Facilitators can open a cohort, run it, and recover from any failure using documented runbooks.
+- The system degrades gracefully: every automated step has a manual fallback.
+
+### Non-goals
+
+- Email delivery is not a dependency. All flows complete using the GitHub web notification inbox.
+- The system does not require learners to join an organization, hold a paid plan, or change Actions settings.
+- The system does not aim to replace VS Code, Copilot, or GitHub itself; it teaches their accessible use.
+- Real-time chat, grading-for-credit, and LMS integration are out of scope.
+
+## 3. Personas and primary journeys
+
+### Personas
+
+- **Learner.** New-to-GitHub, uses assistive technology, may not code. Needs belonging, clarity, and a forgiving path.
+- **Facilitator.** Runs a cohort, seeds challenges, monitors progress, recovers stuck learners.
+- **Maintainer.** Owns the curriculum, automation, content pipeline, and this spec.
+
+### Primary learner journey
+
+1. Register through an accessible front door (Flask companion, GitHub Pages form, or issue form fallback).
+2. Receive a provisioned private Learning Room repository.
+3. Acknowledge readiness (`ack`), complete Day 1 challenges, signal `day1-complete`.
+4. Receive Day 2 release, complete Day 2 challenges and the capstone.
+5. Open or prepare a real upstream contribution; continue asynchronously with support hub access.
+
+### Primary facilitator journey
+
+1. Sync and validate the Learning Room template.
+2. Open a cohort; provisioning creates student repositories.
+3. Seed Challenge 1 (and Challenge 10 for Day 2) per student.
+4. Monitor the dashboard; intervene on watchdog alerts.
+5. Run teardown after the cohort.
+
+## 4. System architecture
+
+The architecture separates a dependable GitHub-native core (the critical learner path) from an optional companion at the edges. The companion can vanish without breaking any learner.
+
+```text
+ ACCESSIBLE FRONT DOOR
+ (Flask companion OR GitHub Pages form OR issue form fallback)
+ |
+ v
+ REGISTRATION + ROSTER (owned)
+ private admin repo roster record <----+ companion mirror (optional)
+ |
+ v
+ PROVISIONING SUBSYSTEM (GitHub-native)
+ idempotent action: template ----> per-student private Learning Room repo
+ |
+ v
+ THE LEARNING ROOM (per student)
+ Gandalf (PR bot) | Student Progression Bot | Autograders | Challenge issues
+ |
+ v
+ PROGRESSION + DAY 2 RELEASE (deterministic text signals)
+ |
+ v
+ FACILITATOR DASHBOARD (admin issues + optional companion view)
+```
+
+Architectural rules:
+
+- The critical path (front door fallback, provisioning, Learning Room, progression, release) runs entirely on GitHub.
+- The companion only ever renders owned state more nicely; it never holds state the learner depends on.
+- Each arrow is a documented contract (Section 8) with a manual fallback.
+
+## 5. Component catalog
+
+This table is the index of moving parts. Each component has an owner, a trigger, and a fallback.
+
+| Component | Location | Trigger | Responsibility | Manual fallback |
+|---|---|---|---|---|
+| Registration workflow | [.github/workflows/registration.yml](.github/workflows/registration.yml) | Registration issue opened | Validate, redact, label, store intake, post links | Facilitator labels and replies by hand |
+| Day 2 release | [.github/workflows/day2-release.yml](.github/workflows/day2-release.yml) | Schedule, dispatch | Post Day 2 link when `day1-complete` signal present | Facilitator posts Day 2 link |
+| Dashboard sync | [.github/workflows/instructor-dashboard-sync.yml](.github/workflows/instructor-dashboard-sync.yml) | Schedule, events | Upsert per-student status issue in admin repo | Facilitator reads enrollment issues |
+| Student grouping | [.github/workflows/student-grouping.yml](.github/workflows/student-grouping.yml) | Schedule, dispatch | Cohort grouping support | Manual roster grouping |
+| Skills progression | [.github/workflows/skills-progression.yml](.github/workflows/skills-progression.yml) | Issue events | Track skills progression | Manual tracking |
+| Learning Room validation | [.github/workflows/learning-room-validation.yml](.github/workflows/learning-room-validation.yml) | PR, dispatch | Validate template integrity | Manual template review |
+| Authoritative sources validation | [.github/workflows/validate-authoritative-sources.yml](.github/workflows/validate-authoritative-sources.yml) | PR | Enforce source maps in content | Run validator script locally |
+| Gandalf (PR bot) | [learning-room/.github/workflows/pr-validation-bot.yml](learning-room/.github/workflows/pr-validation-bot.yml) | PR events, comments | Welcome, validate PR, answer help | Facilitator comments |
+| Student Progression Bot | [learning-room/.github/workflows/student-progression.yml](learning-room/.github/workflows/student-progression.yml) | Issue closed, dispatch | Create next challenge issue | Dispatch with `start_challenge` |
+| Autograders (8) | [learning-room/.github/workflows/autograder-*.yml](learning-room/.github/workflows/) | Issue, PR, push, schedule | Verify objective challenge evidence | Facilitator verifies manually |
+| Content validation | [learning-room/.github/workflows/content-validation.yml](learning-room/.github/workflows/content-validation.yml) | PR | Validate student content edits | Manual review |
+| Provisioning action (target) | new, GitHub App or Actions bot | Roster entry created | Create and seed Learning Room repo idempotently | Classroom invite during transition |
+| Content pipeline | [.github/workflows/](.github/workflows/) build-* and deploy-* | Push, dispatch | Build HTML, EPUB, diagrams, Pages | Run build scripts locally |
+| Flask companion (optional) | new service | HTTP | Accessible front door and dashboard | GitHub Pages form and admin issues |
+
+### The three Learning Room automation systems
+
+- **Gandalf (PR Validation Bot).** Welcomes first-time contributors, validates PR structure, responds to help requests, posts real-time feedback on every push. Idempotent comment updates keyed by a marker string so it never spams a thread.
+- **Student Progression Bot.** On close of a `Challenge N` issue, creates the next challenge issue; on `workflow_dispatch` with `start_challenge`, seeds a specific challenge. Concurrency-guarded per issue. See [challenge-progression.js](learning-room/.github/scripts/challenge-progression.js).
+- **Autograders.** Eight workflows verifying objective evidence: issue filed, branch commit, PR link, merge conflicts resolved, local commit, template validity, capstone, and a watchdog. Each posts a single, kind, idempotent pass or fail comment and a separate error notice on workflow failure so the learner is never blamed for infrastructure faults.
+
+## 6. Data model and state of record
+
+The decoupling contract from [golden.md](golden.md) requires three owned, reconstructable sources of truth. This section defines their schemas.
+
+### 6.1 Roster of record
+
+Canonical store: one record per learner in the private admin repository (optionally mirrored by the companion). Logical schema:
+
+| Field | Type | Notes |
+|---|---|---|
+| `github_handle` | string | Primary identity key |
+| `cohort_id` | string | Cohort the learner belongs to |
+| `path` | enum | `day1-day2` or `day2-only` |
+| `learning_room_repo` | string | Owner/name of provisioned repo, null until provisioned |
+| `provision_state` | enum | `pending`, `provisioned`, `failed`, `healed` |
+| `status` | enum | `awaiting-ack`, `active-day1`, `day1-complete`, `day2-released`, `needs-info` |
+| `registered_at` | timestamp | Intake time |
+| `last_signal_at` | timestamp | Last deterministic signal observed |
+| `notes` | string | Facilitator notes, redacted in any public surface |
+
+Reconstruction rule: the roster can be rebuilt from registration issues plus provisioning results without any third party.
+
+### 6.2 Progress of record
+
+Derived, never authored by a vendor. Progress is computed from signals the project controls:
+
+- Challenge issue state (open or closed) and titles (`Challenge N`).
+- PR state and closing-keyword links (`Closes #N`).
+- Labels (`enrolled`, `day1-complete`, `day2-eligible`, `day2-released`).
+- Deterministic text signals in comments: `ack`, `day1-complete`.
+
+Reconstruction rule: re-derivable by replaying issue and PR events in the student repository.
+
+### 6.3 Provisioning of record
+
+A log of provisioning attempts and outcomes per learner, sufficient to prove a repository is correctly seeded and to safely re-run. Fields: `github_handle`, `attempt_at`, `result` (`created`, `already-exists`, `seeded`, `healed`, `error`), `repo`, `template_sha`, `error_detail`.
+
+Reconstruction rule: running the idempotent provisioning action against the roster reproduces a healthy state for every learner.
+
+## 7. Provisioning subsystem (the Classroom replacement)
+
+This is the only piece that fundamentally changes when Classroom departs. Everything downstream is unchanged because the resulting student repository is byte-shaped identically to a Classroom-created one.
+
+### 7.1 Responsibilities
+
+1. Given a roster entry, create a private repository from `Community-Access/learning-room-template`.
+2. Grant the learner access.
+3. Confirm all required automation files are present (the workflow set listed in the go-live gate).
+4. Record the outcome in the provisioning log.
+5. Be idempotent: re-running heals partial failures and never corrupts an existing repository.
+
+### 7.2 Implementation options and decision
+
+- **Option A, GitHub App (decided for production).** A GitHub App with least-privilege permissions (repository administration to create, contents to seed, metadata). Scoped, auditable, installable, and not tied to any human account.
+- **Option B, Actions bot (Phase 1 spike only).** A scheduled or dispatched workflow in the admin repo using a least-privilege fine-grained token with repo-creation scope. Faster to stand up; acceptable only as a throwaway validation of seeding logic, then discarded.
+
+Both must implement the same internal contract so the method is swappable via `PROVISIONING_MODE`.
+
+**Decision: build the GitHub App for production; use the PAT path only as an optional Phase 1 spike.** The deciding factor is not rate limits or convenience, it is the first principle in [golden.md](golden.md): never let a single point of failure hold a cohort hostage. A PAT is bound to a facilitator's account, so that person leaving, resetting credentials, or changing 2FA can break provisioning. The App's properties (not tied to a human, short-lived installation tokens, fine-grained least-privilege) are the literal embodiment of the golden vision, and the extra one-time setup is small.
+
+The pros and cons that drove the decision:
+
+| Factor | GitHub App | Actions bot (PAT) |
+|---|---|---|
+| Identity | Purpose-built, survives staff change | Bound to a human account, single point of failure |
+| Token lifetime | Short-lived installation token (~1 hour), minted on demand | Long-lived (months); larger leak blast radius |
+| Permission granularity | Fine-grained (Administration, Contents, Metadata only) | Account-level even when fine-grained |
+| Rate limit ceiling | Scales with org (floor 5,000/hour, scaling up) | Flat 5,000/hour |
+| Audit trail | Clean App identity per action | Everything looks like the token owner |
+| Setup cost | ~30 to 60 minutes one-time (App, App ID, PEM secret, token mint step) | Fastest; afternoon prototype |
+| Key custody | Holds a signing key; must be in Secrets and rotatable | One token to store and rotate |
+
+Because the downstream system cannot tell which mode created a repository, spiking with a PAT first and graduating to the App loses nothing.
+
+### 7.2a GitHub App permission set
+
+Grant only these. Anything beyond this list is over-privileged and fails the security review in Section 13.
+
+| Permission | Level | Why |
+|---|---|---|
+| Repository administration | Write | Create the per-student private repository from the template |
+| Contents | Write | Seed and heal repository content (workflows, issue templates) |
+| Metadata | Read | Mandatory baseline for any App |
+| Issues | Write | Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot) |
+
+App configuration rules:
+
+- Install the App only on the `Community-Access` organization, scoped to the template and student repositories.
+- Store `PROVISIONING_APP_ID` and `PROVISIONING_APP_PRIVATE_KEY` in GitHub Secrets; never in code or public repos.
+- Mint a short-lived installation token at the start of each provisioning run; never persist it.
+- Document a private-key rotation procedure and rotate on any suspected exposure.
+
+### 7.2b Provisioning algorithm (idempotent, serial)
+
+Provisioning runs serially with a small delay and exponential backoff, not parallel fan-out, to stay clear of GitHub secondary (abuse) rate limits on content-creating requests. The algorithm is idempotent on `(github_handle, cohort_id)`, so a re-run resumes a half-finished batch instead of duplicating or corrupting work.
+
+```text
+for each roster entry where provision_state in (pending, failed):
+ key = (github_handle, cohort_id)
+ expected_repo = name_for(key)
+
+ 1. If expected_repo already exists:
+ - Verify required workflow files and template SHA.
+ - If complete: mark provisioned (or healed), log already-exists, continue.
+ - If incomplete: re-seed missing files, mark healed, log healed.
+ Else:
+ - Create private repo from template at the pinned template SHA.
+ - Log created.
+
+ 2. Grant the learner access (idempotent; skip if already granted).
+
+ 3. Seed required content if not already present
+ (workflows, issue templates). Log seeded.
+
+ 4. Verify required workflow set is present (go-live gate list).
+ - On success: provision_state = provisioned (or healed).
+ - On failure: provision_state = failed, write error_detail,
+ surface to watchdog. Do NOT leave a half-seeded repo silently.
+
+ 5. Wait a short delay (1 to a few seconds) before the next entry.
+ On HTTP 403/429 (secondary limit), back off exponentially and retry
+ the same entry; never skip it.
+```
+
+Invariants: at-most-one repository per key; pinned template SHA; learner has access; required workflows present; every outcome written to the provisioning log; any failure visible to a human before a learner notices.
+
+### 7.3 Provisioning contract
+
+- Input: roster entry (`github_handle`, `cohort_id`, `path`).
+- Output: `learning_room_repo`, `provision_state`, provisioning log entry.
+- Idempotency key: `(github_handle, cohort_id)`.
+- Guarantees: at-most-one repository per key; correct template SHA; learner has access; required workflows present.
+- Failure behavior: on any error, set `provision_state = failed`, write `error_detail`, and surface to the watchdog. Never leave a half-seeded repo silently.
+
+### 7.4 Rate and scale considerations
+
+For any realistic size of this workshop, documented hourly rate limits are a non-issue. The real risk is GitHub secondary (abuse) rate limits, which trigger on bursts of content-creating requests fired in parallel. The serial-with-delay-and-backoff algorithm in Section 7.2b avoids them, and idempotency means hitting a limit just means run it again, it heals.
+
+**Design target: 1 to 100 learners.** That is what a high-touch, accessibility-first, facilitator-led workshop actually is. A cohort large enough to hit real rate limits would also be too large to run with the recover-every-stuck-learner quality bar that defines this project. The constraint that bites first is facilitator attention, not API quota.
+
+This table is the planning guidance by cohort size.
+
+| Cohort size | Reality | What to do |
+|---|---|---|
+| 1 to 30 (likely case) | Trivial; serial creation finishes in minutes | Serial loop, 1 to 2 second delay, backoff on error |
+| 30 to 100 | Comfortable within an hour | Same pattern; rely on idempotent re-run to heal mid-batch failures |
+| 100 to 300 | Approaching where bursting would trip secondary limits | Throttle to a steady rate, chunk the batch, resume via idempotency; the App ceiling helps |
+| 300+ | Plan deliberately | Stagger provisioning over time windows, pre-flight seat and quota checks, monitor for 403s |
+
+Two design choices make cohort size a permanent non-worry, and both are already specified:
+
+- **Idempotent provisioning keyed on `(github_handle, cohort_id)`** (Section 7.2b). A re-run resumes a half-finished batch rather than duplicating or corrupting it.
+- **Provision on registration, not big-bang.** Creating repositories as learners register (a trickle) rather than all at once on go-live morning (a burst) means you essentially never approach a burst limit regardless of total size.
+
+Operational rules:
+
+- Pre-flight check org seat and repository quotas before a cohort opens.
+- Template sync and smoke validation (`Prepare-LearningRoomTemplate.ps1`, `Test-LearningRoomTemplate.ps1`) run before any provisioning.
+
+## 8. Automation contracts
+
+Each arrow in the architecture is a stable contract. Contracts are intentionally simple text and label signals so facilitators can test and recover them by hand.
+
+| Contract | Producer | Consumer | Signal | Idempotent | Manual fallback |
+|---|---|---|---|---|---|
+| Enroll | Registration workflow | Roster | `enrolled` label, roster record | Yes | Apply label, add record |
+| Acknowledge | Learner | Roster, dashboard | comment `ack` | Yes | Facilitator marks acked |
+| Day 1 complete | Learner | Day 2 release | comment `day1-complete` or label | Yes | Facilitator posts Day 2 link |
+| Day 2 release | Release workflow | Learner | comment with Day 2 link, `day2-released` label | Yes | Facilitator posts link |
+| Next challenge | Progression bot | Learner | new `Challenge N+1` issue | Yes (per issue) | Dispatch with `start_challenge` |
+| Autograde | Autograder | Learner | single pass/fail comment keyed by marker | Yes | Facilitator verifies and comments |
+| Dashboard | Dashboard sync | Facilitator | upserted status issue | Yes | Read enrollment issues |
+| Provision | Provisioning action | Roster, learner | repo created and seeded | Yes (per key) | Classroom invite during transition |
+
+Contract invariants:
+
+- Every bot comment is updated in place via a marker string, never duplicated.
+- Every workflow that can fail posts a separate, blameless error notice on failure.
+- Every signal is plain text or a label so it is human-testable and human-recoverable.
+
+## 9. Curriculum subsystem
+
+- 22 chapters ([docs/00-pre-workshop-setup.md](docs/00-pre-workshop-setup.md) through [docs/22-what-comes-next.md](docs/22-what-comes-next.md)) plus appendices A through Z and AA through AC.
+- 16 core challenges and 5 bonus challenges, indexed in [docs/CHALLENGES.md](docs/CHALLENGES.md), each with instructions, evidence requirements, and a reference solution in [docs/solutions/](docs/solutions/).
+- Each chapter is authored screen-reader-first: every step keyboard-accessible, every concept explained without sight, an "If You Get Stuck" section, and a Section-Level Source Map enforced by the authoritative-sources validator.
+- The arc is one journey: browser, then github.dev, then desktop VS Code with Accessibility Agents. Every Day 1 skill maps to a Day 2 agent command.
+
+Curriculum requirements:
+
+- Source maps required on in-scope content; podcasts and non-content paths excluded (per repo memory and the validator).
+- Every reference URL must resolve; broken links fail the content gate.
+- Tools-change resilience: chapters teach exploration so learners survive UI drift.
+
+## 10. Content pipeline: docs, HTML, EPUB, audio
+
+The pipeline turns Markdown into every delivery format.
+
+| Stage | Input | Output | Workflow or script |
+|---|---|---|---|
+| HTML build | `docs/*.md` | `html/` | build per [BUILD.md](BUILD.md) |
+| EPUB build | `docs/*.md`, `epub/epub.css` | EPUB | [build-epub.yml](.github/workflows/build-epub.yml) |
+| Diagrams | diagram sources | SVGs | [generate-diagram-svgs.yml](.github/workflows/generate-diagram-svgs.yml) |
+| Pages deploy | HTML | published site | [deploy-pages.yml](.github/workflows/deploy-pages.yml) |
+| Wiki sync | docs | wiki | [sync-docs-to-wiki.yml](.github/workflows/sync-docs-to-wiki.yml) |
+| Audio series | transcripts | MP3, RSS | batch scripts, RSS feed builder |
+
+Audio policy: minimize MP3 regeneration. Prefer transcript-only and site-only updates unless a podcast release is explicitly in scope. The catalog currently covers 54 companion episodes plus planned Challenge Coach episodes.
+
+Pipeline requirements:
+
+- HTML must build from current Markdown before any cohort opens.
+- The RSS feed validates before publish.
+- All generated HTML preserves heading structure, descriptive links, and table semantics.
+
+## 11. Optional Flask companion
+
+The companion is strictly optional and lives at the edges. It must add delight without becoming a dependency.
+
+### 11.1 Scope
+
+- Accessible registration landing page (writes to the owned roster).
+- Facilitator cohort dashboard (reads owned roster and progress).
+- Bulk facilitator operations (trigger provisioning, re-seed, recover) as thin wrappers over the owned provisioning action.
+
+### 11.2 Hard constraints
+
+- Stateless about anything critical. If the companion is down, the GitHub-native front door and admin-issue dashboard carry the full workshop.
+- WCAG 2.2 AA: semantic landmarks, correct heading order, labeled controls, visible focus, live-region announcements, no keyboard traps.
+- OWASP Top 10 reviewed: authenticated facilitator actions, CSRF protection, input validation, output encoding, least-privilege tokens, rate limiting, secrets never in client code.
+- All writes go through the owned roster and provisioning contracts; the companion never invents a parallel state store of record.
+
+### 11.3 Interfaces
+
+- Reads and writes the roster of record (admin repo or its mirror).
+- Invokes the provisioning action through its defined contract only.
+- Renders progress derived from the same signals the GitHub-native dashboard uses.
+
+## 12. Accessibility requirements
+
+Accessibility is the product. These are acceptance-blocking.
+
+- Every learner-facing surface verified on NVDA, JAWS, and VoiceOver, fully keyboard operable.
+- All documentation readable with or without a screen reader; no information conveyed by color alone.
+- Markdown follows the project accessibility rules: descriptive link text, alt text for meaningful images, correct heading hierarchy, table intro sentences, emoji removed or translated, diagrams given text alternatives, no em-dashes, validated anchors.
+- Automation copy is screen-reader-first: front-loaded, clear, free of decorative noise.
+- Any Flask companion meets WCAG 2.2 AA in full (Section 11.2).
+- Testing follows the project accessibility testing guidance; manual screen reader passes are required, not just automated checks.
+
+## 13. Security requirements
+
+- Free of OWASP Top 10 vulnerabilities across any hosted surface and all automation.
+- Least-privilege tokens and scopes; each token documented with the single capability it enables and a rotation procedure.
+- Secrets only in GitHub Secrets or a managed secret store; never in code, public repos, or client bundles.
+- Public registration redacts private intake; detailed data stored only in the private admin repo when configured.
+- Provisioning permissions scoped to repository creation and seeding only.
+- Workflows pin actions and follow GitHub Actions security hardening (limited permissions blocks, no untrusted input in run shells).
+- Treat all tool and webhook input as untrusted; validate and encode. Watch for and report prompt-injection attempts in any AI-assisted automation.
+
+## 14. Reliability, observability, and recovery
+
+- **No silent failure.** Every automated step either succeeds or raises a human-visible alert with a clear recovery action.
+- **Watchdog.** A scheduled workflow detects stalled provisioning, stuck progression, or learners without a healthy repository, and surfaces them before a learner notices. See [autograder-watchdog.yml](learning-room/.github/workflows/autograder-watchdog.yml).
+- **Idempotent everything.** Provisioning, seeding, and bot comments are safe to re-run.
+- **Tiered learner recovery.** Documented restore levels return a stuck learner to a known-good state with branch and PR evidence, no data loss (per the QA runbook).
+- **Manual fallback for every contract** (Section 8), so the system survives any single automation outage.
+- **Release gate.** No cohort opens until [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) passes; execution follows [admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md](admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md).
+
+## 15. Testing and quality strategy
+
+| Layer | What it covers | Where |
+|---|---|---|
+| Unit and automation tests | Scripts and helpers | `npm run test:automation`, [automation-tests.yml](.github/workflows/automation-tests.yml) |
+| Content validation | Links, markdown, source maps, accessibility | content-validation and authoritative-sources workflows and `.github/scripts` checks |
+| Template smoke | Template integrity before provisioning | `Prepare-LearningRoomTemplate.ps1`, `Test-LearningRoomTemplate.ps1` |
+| Challenge reliability matrix | Happy, failure, and recovery path per challenge family | [classroom/HUMAN_TEST_MATRIX.md](classroom/HUMAN_TEST_MATRIX.md) |
+| End-to-end QA | Registration through completion | [admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md](admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md) |
+| Accessibility passes | NVDA, JAWS, VoiceOver, keyboard | Project accessibility testing guidance |
+| Release gate | All of the above consolidated | [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) |
+
+Quality rules:
+
+- Every challenge has happy-path, failure-path, and recovery-path evidence before go-live.
+- Provisioning is tested with at least one re-run to prove idempotency.
+- Any companion ships only after an accessibility pass and an OWASP review.
+
+## 16. Configuration surface
+
+Variables:
+
+- `CLASSROOM_DAY1_ASSIGNMENT_URL`, `CLASSROOM_DAY2_ASSIGNMENT_URL` (transitional; retired after Classroom removal).
+- `PRIVATE_STUDENT_DATA_REPO`.
+- `ENABLE_PUBLIC_CLASSROOM_INTAKE_EXPORT`, `ENABLE_PUBLIC_REGISTRATION_EXPORT`.
+- New for owned provisioning: `LEARNING_ROOM_TEMPLATE_REPO`, `ADMIN_ROSTER_REPO`, `PROVISIONING_MODE` (`github-app` or `actions-bot`).
+
+Secrets:
+
+- `PRIVATE_STUDENT_DATA_TOKEN`, `INSTRUCTOR_DASHBOARD_TOKEN`.
+- New for owned provisioning: `PROVISIONING_APP_ID` and `PROVISIONING_APP_PRIVATE_KEY` (App mode) or `PROVISIONING_TOKEN` (bot mode), least-privilege and rotatable.
+
+## 17. Repository layout
+
+| Path | Purpose |
+|---|---|
+| [docs/](docs/) | Curriculum chapters, appendices, challenges, solutions |
+| [learning-room/](learning-room/) | Source of the per-student template: workflows, scripts, issue templates |
+| [.github/workflows/](.github/workflows/) | Cohort-level automation and content pipeline |
+| [classroom/](classroom/) | Deployment guide, assignments, test matrix, grading guide, teardown |
+| [admin/](admin/) | Facilitator operations, QA runbooks, architecture, podcasts |
+| [scripts/](scripts/) | Generation and classroom preparation scripts |
+| [html/](html/), [epub/](epub/), [podcasts/](podcasts/) | Generated delivery formats |
+| [golden.md](golden.md) | Vision and quality north star |
+| [SPEC.md](SPEC.md) | This specification |
+| [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) | Release gate |
+
+## 18. Migration plan: from Classroom to owned provisioning
+
+Aligned to the phased roadmap in [golden.md](golden.md).
+
+1. **Phase 0, harden the present.** Pass the full go-live gate on the current Classroom flow.
+2. **Phase 1, prove decoupling.** Implement the owned roster, owned progress derivation, and the idempotent provisioning action. Validate end to end with test accounts. Classroom remains primary.
+3. **Phase 2, production provisioning.** Run a real test cohort on GitHub-native provisioning with Classroom kept as parallel fallback. Prove idempotency with a re-run.
+4. **Phase 3, accessible front door.** Add the optional companion (or Pages form) under the full accessibility and security bar.
+5. **Phase 4, retire Classroom.** After a clean full cohort, remove the hard Classroom dependency, retire `CLASSROOM_*` variables, and mark owned provisioning canonical.
+6. **Phase 5, continuous delight.** Iterate on copy, recovery speed, audio companions, and accessibility.
+
+No phase ships until the prior phase is golden. No phase weakens accessibility, reliability, or learner trust.
+
+## 19. Acceptance criteria
+
+The system is acceptable for a cohort when:
+
+- The full learner journey completes end to end with provisioning that does not require GitHub Classroom (or with both paths proven during transition).
+- Provisioning is idempotent; a re-run heals partial failures and never corrupts a repo.
+- Roster, progress, and provisioning are owned and reconstructable without a third party.
+- Every learner-facing surface passes NVDA, JAWS, and VoiceOver review and is fully keyboard operable.
+- Any companion passes WCAG 2.2 AA and an OWASP Top 10 review with least-privilege, rotatable secrets.
+- Every automation contract has a tested manual fallback.
+- The watchdog surfaces stalled provisioning or progression before a learner notices.
+- Zero broken learner-facing links or dead assignment URLs.
+- Automation copy is warm, screen-reader-first, and frames failure as the next step.
+- The go-live release gate passes.
+
+## 20. Open questions
+
+- GitHub App versus Actions bot for production provisioning: **Resolved.** Build a GitHub App for production; PAT path is a Phase 1 spike only (Section 7.2).
+- Maximum cohort size for rate-limit and quota planning: **Resolved.** Design for 1 to 100 learners with serial, idempotent, provision-on-registration behavior (Section 7.4).
+- Where does the companion (if built) get hosted, and what is its uptime expectation given it must be non-critical?
+- Should the owned roster mirror live in the companion database, or stay solely in admin-repo records for a single source of truth?
+- Which Challenge Coach audio episodes are in scope for the next release, given the minimize-regeneration policy?
+
+---
+
+This spec is a living contract. When provisioning moves off Classroom, when the companion ships, or when a challenge changes, update the relevant section and the acceptance criteria together. Measure every change against [golden.md](golden.md): more dependable, more accessible, more flexible, more error free, more delightful.
+
+## Implementation status
+
+The owned, GitHub-native core of the Hybrid plan is implemented and tested:
+
+- Owned roster (section 6.1): [roster.js](.github/scripts/provisioning/roster.js), [roster.schema.json](roster.schema.json), example in [admin/examples/roster.example.json](admin/examples/roster.example.json).
+- Owned progress derivation (section 6.2): [progress.js](.github/scripts/provisioning/progress.js).
+- Idempotent provisioning subsystem (section 7): [provision-core.js](.github/scripts/provisioning/provision-core.js), [github-app-auth.js](.github/scripts/provisioning/github-app-auth.js), [github-client.js](.github/scripts/provisioning/github-client.js), [provision-cli.js](.github/scripts/provisioning/provision-cli.js), and the [provisioning workflow](.github/workflows/provision-learning-rooms.yml).
+- Optional accessible Flask companion (section 11): [companion/](companion/).
+- Operator guide: [admin/OWNED_PROVISIONING.md](admin/OWNED_PROVISIONING.md).
+- Tests: `npm run test:provisioning` (Node) and `companion/tests` (Python).
+
+## Authoritative Sources
+
+Use these official references when you need the current source of truth for facts in this document.
+
+- [GitHub Docs, home](https://docs.github.com/en)
+- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template)
+- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
+- [OWASP Top Ten](https://owasp.org/www-project-top-ten/)
+
+### Section-Level Source Map
+
+Use this map to verify facts for each major section in this file.
+
+- **Overview, Goals and non-goals, Personas, System architecture, Component catalog, Curriculum subsystem, Content pipeline, Repository layout:** [GitHub Docs, home](https://docs.github.com/en)
+- **Data model, Provisioning subsystem, Automation contracts, Configuration surface, Migration plan, Acceptance criteria, Implementation status:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template)
+- **Optional Flask companion, Accessibility requirements:** [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
+- **Security requirements, Reliability and recovery, Testing and quality strategy:** [OWASP Top Ten](https://owasp.org/www-project-top-ten/), [GitHub Docs, home](https://docs.github.com/en)
diff --git a/admin/COHORT_PROVISIONING.md b/admin/COHORT_PROVISIONING.md
index 73044219..5350f70f 100644
--- a/admin/COHORT_PROVISIONING.md
+++ b/admin/COHORT_PROVISIONING.md
@@ -6,6 +6,8 @@ The previous "multi-player sandbox" provisioning model (shared repo + batch issu
See the [Workshop Deployment Guide](../classroom/README.md) for all setup instructions.
+> **Looking ahead, beyond Classroom.** The owned, GitHub-native replacement for Classroom provisioning is specified in [SPEC.md](../SPEC.md) and operated per [OWNED_PROVISIONING.md](OWNED_PROVISIONING.md). That subsystem removes the single-vendor dependency by owning the roster, progress, and provisioning of record.
+
## Authoritative Sources
Use these official references when you need the current source of truth for facts in this chapter.
diff --git a/admin/HYBRID_DEPLOYMENT_GUIDE.md b/admin/HYBRID_DEPLOYMENT_GUIDE.md
new file mode 100644
index 00000000..18d9665a
--- /dev/null
+++ b/admin/HYBRID_DEPLOYMENT_GUIDE.md
@@ -0,0 +1,302 @@
+# Hybrid Deployment Guide
+
+This is the step-by-step guide to deploying the Hybrid architecture from
+[golden.md](../golden.md) and [SPEC.md](../SPEC.md): the owned, GitHub-native
+provisioning core that replaces GitHub Classroom, plus the optional accessible Flask
+companion. For the conceptual model and day-to-day operations, see
+[OWNED_PROVISIONING.md](OWNED_PROVISIONING.md). For the legacy Classroom-based flow,
+see [../classroom/README.md](../classroom/README.md). To review every piece of the
+Hybrid deliverable from one place, start at [HYBRID_REVIEW_INDEX.md](HYBRID_REVIEW_INDEX.md).
+
+Follow the phases in order. Each phase ends with a verification step. Do not advance
+until the current phase verifies, exactly as the phased roadmap in golden.md requires.
+
+## Table of contents
+
+- [What you are deploying](#what-you-are-deploying)
+- [Prerequisites](#prerequisites)
+- [Phase 1: Prepare the admin roster repository](#phase-1-prepare-the-admin-roster-repository)
+- [Phase 2: Create the provisioning GitHub App](#phase-2-create-the-provisioning-github-app)
+- [Phase 3: Configure variables and secrets](#phase-3-configure-variables-and-secrets)
+- [Phase 4: Deploy and smoke-test provisioning](#phase-4-deploy-and-smoke-test-provisioning)
+- [Phase 5: Deploy the optional companion](#phase-5-deploy-the-optional-companion)
+- [Phase 6: Go-live checklist](#phase-6-go-live-checklist)
+- [Operating a cohort](#operating-a-cohort)
+- [Rollback and fallback](#rollback-and-fallback)
+- [Reference: configuration surface](#reference-configuration-surface)
+- [Authoritative Sources](#authoritative-sources)
+
+## What you are deploying
+
+| Piece | Where it runs | Required? |
+|---|---|---|
+| Provisioning scripts | This repository, `.github/scripts/provisioning/` | Yes |
+| Provisioning workflow | GitHub Actions in this repository | Yes |
+| Roster of record (`roster.json`) | A private admin repository | Yes |
+| Provisioning identity | A GitHub App in the `Community-Access` org | Yes (production) |
+| Learning Room template | `Community-Access/learning-room-template` | Yes (already exists) |
+| Flask companion | A small host you control (or none) | No, optional |
+
+The critical path is the first five rows. The companion is a convenience at the edges
+and can be skipped entirely; the issue-form front door and admin-issue dashboard carry
+the workshop without it.
+
+## Prerequisites
+
+- Owner or admin access to the `Community-Access` GitHub organization.
+- The Learning Room template repository exists and passes template validation
+ (`scripts/classroom/Test-LearningRoomTemplate.ps1`).
+- A private admin repository you control, to hold the roster and provisioning log.
+- Node.js 20 or newer for local runs and tests.
+- Python 3.12 or newer if you deploy the companion.
+- The `gh` CLI authenticated, for the convenience commands below.
+
+Confirm your toolchain:
+
+```bash
+node --version # v20+
+python --version # 3.12+
+gh auth status
+```
+
+## Phase 1: Prepare the admin roster repository
+
+The roster of record lives in a private repository so intake data is never public.
+
+1. Create (or choose) a private repository, for example `Community-Access/glow-admin`.
+2. Add a starter roster at its root. Copy the shape from
+ [examples/roster.example.json](examples/roster.example.json), or start empty:
+
+ ```json
+ { "version": 1, "cohorts": [], "learners": [] }
+ ```
+
+3. Commit it as `roster.json`. The provisioning run will also create and maintain
+ `provisioning-log.json` next to it.
+
+Verification: the file validates locally.
+
+```bash
+node -e "require('./.github/scripts/provisioning/roster').parseRoster(require('fs').readFileSync('roster.json','utf8')); console.log('roster ok')"
+```
+
+## Phase 2: Create the provisioning GitHub App
+
+A GitHub App is the production identity because it is not tied to a human account,
+mints short-lived tokens, and uses fine-grained least-privilege permissions
+(SPEC.md section 7.2). Create it once.
+
+1. In the organization, go to Settings, Developer settings, GitHub Apps, New GitHub App.
+2. Name it (for example `GLOW Provisioning`). Set a homepage URL (the repo is fine).
+3. Disable the webhook (this App is called by Actions, not by webhooks).
+4. Grant only these repository permissions, nothing more:
+ - Administration: Read and write (create student repositories).
+ - Contents: Read and write (seed and heal repository content).
+ - Metadata: Read-only (mandatory baseline).
+ - Issues: Read and write (optional; only if the App seeds the first issue).
+5. Create the App, then note the numeric App ID.
+6. Generate a private key; a `.pem` file downloads. Keep it secret.
+7. Install the App on the `Community-Access` organization, scoped to the template
+ repository and the student repositories (or all repositories if simpler for now).
+8. Open the installation and note the numeric Installation ID (it is in the
+ installation settings URL).
+
+Verification: you now hold three values: App ID, Installation ID, and the PEM key.
+
+## Phase 3: Configure variables and secrets
+
+Set these on this repository (Settings, Secrets and variables, Actions). Using an
+Environment named `provisioning` is recommended so you can add required reviewers.
+
+Repository or environment variables:
+
+| Variable | Value |
+|---|---|
+| `PROVISIONING_MODE` | `github-app` |
+| `LEARNING_ROOM_TEMPLATE_REPO` | `Community-Access/learning-room-template` |
+| `PROVISIONING_STUDENT_OWNER` | `Community-Access` |
+| `ADMIN_ROSTER_REPO` | `Community-Access/glow-admin` (your admin repo) |
+
+Secrets:
+
+| Secret | Value |
+|---|---|
+| `PROVISIONING_APP_ID` | The App ID from Phase 2 |
+| `PROVISIONING_APP_INSTALLATION_ID` | The Installation ID from Phase 2 |
+| `PROVISIONING_APP_PRIVATE_KEY` | The full contents of the `.pem` file |
+| `PRIVATE_STUDENT_DATA_TOKEN` | A token that can check out and push to the admin repo |
+
+Using `gh` for the App secrets (run from this repository):
+
+```bash
+gh secret set PROVISIONING_APP_ID --body "123456"
+gh secret set PROVISIONING_APP_INSTALLATION_ID --body "98765432"
+gh secret set PROVISIONING_APP_PRIVATE_KEY < glow-provisioning.private-key.pem
+```
+
+Security rules: the PEM lives only in Secrets, never in code or a public repo. Mint
+tokens on demand and never persist them (the workflow already does this). Document and
+rehearse key rotation; rotate on any suspected exposure.
+
+## Phase 4: Deploy and smoke-test provisioning
+
+The provisioning workflow ships in this repository at
+[../.github/workflows/provision-learning-rooms.yml](../.github/workflows/provision-learning-rooms.yml).
+Merging this branch deploys it. It runs on a 30-minute schedule and on demand.
+
+Smoke test with a single test account before any real cohort:
+
+1. Add one test learner to the admin `roster.json` (a throwaway GitHub account you
+ control), `provision_state` set to `pending`.
+2. In the Actions tab, run **Provision Learning Rooms** with `dry_run` checked.
+ Confirm it lists exactly your test learner.
+3. Run it again with `dry_run` unchecked. Confirm:
+ - A private student repository is created from the template.
+ - The test account receives a repository invitation.
+ - The roster entry flips to `provisioned` and `provisioning-log.json` records
+ `created`.
+4. Prove idempotency: run it a second time. The log should record `already-exists`
+ and make no changes.
+
+You can also run it locally against a checkout of the admin repo:
+
+```bash
+LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \
+PROVISIONING_STUDENT_OWNER=Community-Access \
+PROVISIONING_MODE=github-app \
+PROVISIONING_APP_ID=123456 \
+PROVISIONING_APP_INSTALLATION_ID=98765432 \
+PROVISIONING_APP_PRIVATE_KEY="@glow-provisioning.private-key.pem" \
+node .github/scripts/provisioning/provision-cli.js --roster roster.json --log provisioning-log.json
+```
+
+Verification: a healthy student repository exists, the invite arrived, the roster and
+log updated, and a re-run heals rather than duplicates.
+
+## Phase 5: Deploy the optional companion
+
+Skip this phase entirely if you are not running the companion. The workshop is fully
+functional without it.
+
+The companion lives in [../companion/](../companion/). See its
+[README](../companion/README.md) for full detail. Production deployment outline:
+
+1. Provision a small host with Python 3.12+ and TLS (a reverse proxy terminating
+ HTTPS in front of the app).
+2. Install dependencies and a WSGI server:
+
+ ```bash
+ cd companion
+ python -m venv .venv
+ .venv/bin/pip install -r requirements.txt gunicorn
+ ```
+
+3. Provide configuration via the environment (never in code):
+
+ ```bash
+ export COMPANION_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')"
+ export COMPANION_FACILITATOR_TOKEN="a-strong-random-token"
+ export COMPANION_ROSTER_PATH="/srv/glow/roster.json"
+ export COMPANION_SECURE_COOKIES=1
+ ```
+
+4. Run behind gunicorn (bind to localhost; let the proxy handle TLS and the public port):
+
+ ```bash
+ .venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
+ ```
+
+5. Wire the roster: the companion writes registrations to `COMPANION_ROSTER_PATH`. A
+ separate scheduled job (or a small commit hook) must sync that file to the admin
+ repository so provisioning picks it up. The companion deliberately does not push to
+ GitHub itself; it only owns the local roster file through the shared contract.
+
+Verification:
+
+- `GET /healthz` returns `{"status":"ok"}`.
+- The registration page loads, and a screen reader pass (NVDA, JAWS, or VoiceOver)
+ confirms landmarks, labeled fields, the error summary, and live-region messages.
+- Facilitator sign-in with the token reaches the dashboard; a bad token is rejected.
+- Response headers include `Content-Security-Policy` and `X-Frame-Options: DENY`.
+- A registration appears in `roster.json` with `provision_state` `pending`.
+
+## Phase 6: Go-live checklist
+
+Do not open a cohort until every item holds. This complements the release gate in
+[../GO-LIVE-QA-GUIDE.md](../GO-LIVE-QA-GUIDE.md).
+
+- [ ] Template validation passes; the template has all required workflows.
+- [ ] Admin `roster.json` exists and validates.
+- [ ] The GitHub App is installed with only the four permissions listed.
+- [ ] All variables and secrets are set on the provisioning environment.
+- [ ] A single-account smoke test provisioned cleanly and a re-run reported
+ `already-exists` (idempotency proven).
+- [ ] Org seat and repository quotas are sufficient for the cohort size.
+- [ ] If used, the companion passes its accessibility and security verification.
+- [ ] The manual fallback (issue-form registration, Classroom invite during
+ transition) is documented and ready.
+
+## Operating a cohort
+
+- **Provision on registration, not big-bang.** Let the 30-minute schedule trickle new
+ learners in. This keeps you far below any rate limit regardless of cohort size.
+- **Watch the roster and log.** `failed` entries and `error` log lines are your signal
+ to intervene; re-running provisioning heals pending and failed entries.
+- **Recover a stuck learner.** Fix the root cause (seat, name clash, permission), then
+ re-run provisioning. Only pending and failed entries are retried; healthy learners
+ are untouched.
+- **Day 2 and progression** continue to run on the existing deterministic text signals
+ (`ack`, `day1-complete`); the owned provisioning swap does not change them.
+
+## Rollback and fallback
+
+Because the owned core is additive and the downstream system cannot tell which method
+created a repository, rollback is low-risk.
+
+- **Disable owned provisioning.** Turn off the schedule on the provisioning workflow
+ (or set its environment to require manual approval) and fall back to the Classroom
+ invite during the transition window. No learner data is lost; the roster is still the
+ source of truth.
+- **Companion outage.** None of the critical path depends on it. Point learners at the
+ issue-form front door and read the admin-issue dashboard until it returns.
+- **Bad App credential.** Re-issue the App private key, update
+ `PROVISIONING_APP_PRIVATE_KEY`, and re-run. Idempotency means the re-run heals.
+
+## Reference: configuration surface
+
+See SPEC.md section 16 for the authoritative list. Summary:
+
+| Name | Type | Purpose |
+|---|---|---|
+| `PROVISIONING_MODE` | variable | `github-app` (production) or `actions-bot` (spike) |
+| `LEARNING_ROOM_TEMPLATE_REPO` | variable | `owner/name` of the template |
+| `PROVISIONING_STUDENT_OWNER` | variable | Org or user that owns student repos |
+| `ADMIN_ROSTER_REPO` | variable | `owner/name` of the private admin repo |
+| `PROVISIONING_APP_ID` | secret | GitHub App ID (App mode) |
+| `PROVISIONING_APP_INSTALLATION_ID` | secret | Installation ID (App mode) |
+| `PROVISIONING_APP_PRIVATE_KEY` | secret | PEM private key (App mode) |
+| `PROVISIONING_TOKEN` | secret | Fine-grained PAT (actions-bot spike only) |
+| `PRIVATE_STUDENT_DATA_TOKEN` | secret | Checkout and push to the admin repo |
+| `COMPANION_SECRET_KEY` | env | Flask session signing key (companion) |
+| `COMPANION_FACILITATOR_TOKEN` | env | Facilitator sign-in token (companion) |
+| `COMPANION_ROSTER_PATH` | env | Path to `roster.json` (companion) |
+
+## Authoritative Sources
+
+Use these official references when you need the current source of truth for facts in
+this document.
+
+- [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app)
+- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)
+- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template)
+- [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
+
+### Section-Level Source Map
+
+Use this map to verify facts for each major section in this file.
+
+- **What you are deploying, Prerequisites, Phase 1, Operating a cohort:** [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template), [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
+- **Phase 2: Create the provisioning GitHub App:** [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app), [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- **Phase 3: Configure variables and secrets, Reference: configuration surface:** [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)
+- **Phase 4, Phase 5, Phase 6, Rollback and fallback:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)
diff --git a/admin/HYBRID_REVIEW_INDEX.md b/admin/HYBRID_REVIEW_INDEX.md
new file mode 100644
index 00000000..f2356185
--- /dev/null
+++ b/admin/HYBRID_REVIEW_INDEX.md
@@ -0,0 +1,117 @@
+# Hybrid Plan: Review Index
+
+This is the single entry point for reviewing everything built for the **Hybrid plan**
+(Option C): a dependable, GitHub-native provisioning core that replaces GitHub Classroom,
+plus an optional, fully accessible Flask companion at the edges. It is described in
+[golden.md](../golden.md) (vision) and specified in [SPEC.md](../SPEC.md) (technical spec).
+
+Most functional files stay where the repository expects them (workflows under
+`.github/workflows/`, Node scripts under `.github/scripts/`) so the system keeps working.
+This page points you to all of them, grouped by concern, in a suggested reading order.
+
+## Suggested review order
+
+1. **Start with the vision and spec** so the rest has context.
+2. **Read the deployment guide** to see how it is operated end to end.
+3. **Skim the provisioning core** (the critical path).
+4. **Skim the optional companion** (the edges).
+5. **Confirm the tests and CI wiring.**
+
+---
+
+## 1. Planning and specification (repo root)
+
+| File | What it is |
+| --- | --- |
+| [golden.md](../golden.md) | Vision document for the Hybrid plan. |
+| [SPEC.md](../SPEC.md) | Technical specification, including an Implementation status section. |
+| [roster.schema.json](../roster.schema.json) | JSON Schema for the owned roster of record. |
+
+## 2. Documentation (`admin/`)
+
+| File | What it is |
+| --- | --- |
+| [HYBRID_DEPLOYMENT_GUIDE.md](HYBRID_DEPLOYMENT_GUIDE.md) | Phased, verification-driven deployment guide (start here to operate it). |
+| [OWNED_PROVISIONING.md](OWNED_PROVISIONING.md) | Operator guide: data model, running provisioning, failure recovery. |
+| [COHORT_PROVISIONING.md](COHORT_PROVISIONING.md) | Existing cohort doc, now pointing at the owned subsystem. |
+| [examples/roster.example.json](examples/roster.example.json) | Example owned roster you can copy. |
+
+## 3. Provisioning core (`.github/scripts/provisioning/`) — the critical path
+
+These stay in place because the workflow and CLI load them from here.
+
+| File | Responsibility |
+| --- | --- |
+| `.github/scripts/provisioning/roster.js` | Owned roster contract: parse, validate, upsert, serialize, redact. Keyed on `(github_handle, cohort_id)`. |
+| `.github/scripts/provisioning/progress.js` | Derives learner progress from owned signals. |
+| `.github/scripts/provisioning/github-app-auth.js` | RS256 GitHub App JWT + installation-token minting (Node `crypto`, no deps). |
+| `.github/scripts/provisioning/github-client.js` | Minimal REST client; `fetch` adapter (CLI) + `fromOctokit` adapter (workflows). |
+| `.github/scripts/provisioning/provision-core.js` | Idempotent, serial, backoff-aware provisioning algorithm. |
+| `.github/scripts/provisioning/provision-cli.js` | Standalone CLI runner. |
+| `.github/scripts/provisioning/__tests__/` | 6 test files, 55 tests, run with `node --test`. |
+
+## 4. Optional accessible companion (`companion/`) — the edges
+
+Fully self-contained Flask app. It writes a local `roster.json`; a separate sync job
+commits it to the admin repo. It deliberately does not push to GitHub itself.
+
+| Path | What it is |
+| --- | --- |
+| [companion/README.md](../companion/README.md) | Companion overview, run, and security/accessibility notes. |
+| `companion/app.py` | Flask application factory and routes. |
+| `companion/roster_store.py` | Python mirror of the owned roster contract. |
+| `companion/templates/` | WCAG 2.2 AA templates (base, register, success, login, dashboard, error). |
+| `companion/static/styles.css` | Accessible styling. |
+| `companion/requirements.txt` | Python dependencies (Flask). |
+| `companion/tests/` | 2 test files, 25 tests (`python -m unittest`). |
+
+## 5. CI wiring and commands
+
+| File | What changed |
+| --- | --- |
+| `.github/workflows/provision-learning-rooms.yml` | New provisioning workflow: scheduled + manual `dry_run`; `github-app` and `actions-bot` modes. |
+| `.github/workflows/automation-tests.yml` | Added provisioning + companion test steps. |
+| [package.json](../package.json) | Added `test:provisioning` script. |
+
+Run the suites locally:
+
+```bash
+# Existing automation tests
+npm run test:automation
+
+# Provisioning subsystem (Node, no install needed)
+npm run test:provisioning
+
+# Flask companion (Python)
+cd companion
+python -m venv .venv && .venv/bin/pip install -r requirements.txt
+.venv/bin/python -m unittest discover -s tests
+```
+
+## Commits on this branch
+
+The work landed in two commits on `feature/hybrid-owned-provisioning`:
+
+- `d6dd05ddc` — provisioning core, companion, tests, docs, schema, CI wiring.
+- `89bc204bd` — Hybrid deployment guide + cross-link.
+
+This index adds one more documentation commit. Nothing was moved out of its
+conventional location, so the workflows and CLI continue to resolve their imports.
+
+## Authoritative Sources
+
+Use these official references when you need the current source of truth for facts in
+this document.
+
+- [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows/about-workflows)
+- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
+
+### Section-Level Source Map
+
+Use this map to verify facts for each major section in this file.
+
+- **Planning and specification, Provisioning core:** [golden.md](../golden.md), [SPEC.md](../SPEC.md)
+- **CI wiring and commands:** [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows/about-workflows)
+- **Provisioning core, Documentation:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- **Optional accessible companion:** [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
diff --git a/admin/OWNED_PROVISIONING.md b/admin/OWNED_PROVISIONING.md
new file mode 100644
index 00000000..73f304b9
--- /dev/null
+++ b/admin/OWNED_PROVISIONING.md
@@ -0,0 +1,210 @@
+# Owned, GitHub-native Provisioning
+
+This is the operator guide for the GitHub-native provisioning subsystem: the owned
+replacement for GitHub Classroom described in [golden.md](../golden.md) and specified
+in [SPEC.md](../SPEC.md) sections 6 and 7. It explains the data model, how to run
+provisioning, how to recover from failure, and how the optional Flask companion fits
+in at the edges. For first-time setup and deployment, follow the
+[Hybrid Deployment Guide](HYBRID_DEPLOYMENT_GUIDE.md) first. To review the whole
+deliverable from one place, see [HYBRID_REVIEW_INDEX.md](HYBRID_REVIEW_INDEX.md).
+
+The whole point of this subsystem is captured in one promise: a vendor sunset is a
+non-event. Provisioning, roster, and progress are owned and reconstructable, so no
+future change to GitHub Classroom can strand a cohort.
+
+## Table of contents
+
+- [Why this exists](#why-this-exists)
+- [The three owned sources of truth](#the-three-owned-sources-of-truth)
+- [How provisioning works](#how-provisioning-works)
+- [One-time setup](#one-time-setup)
+- [Running a cohort](#running-a-cohort)
+- [Idempotency and self-healing](#idempotency-and-self-healing)
+- [Failure modes and recovery](#failure-modes-and-recovery)
+- [The optional Flask companion](#the-optional-flask-companion)
+- [Security and least privilege](#security-and-least-privilege)
+- [Local development and testing](#local-development-and-testing)
+- [Authoritative Sources](#authoritative-sources)
+
+## Why this exists
+
+GitHub Classroom does only two things for this workshop: it copies a template
+repository into a per-student private repository, and it maps a GitHub identity to a
+roster entry. Everything else already lives in infrastructure the project controls.
+This subsystem replaces those two things with code we own, so the critical learner
+path no longer depends on a single vendor feature.
+
+## The three owned sources of truth
+
+The decoupling contract requires three owned, reconstructable records. See SPEC.md
+section 6 for the full schemas.
+
+1. **Roster of record.** One canonical JSON document mapping each learner handle to
+ cohort, path, provisioning state, and progression status. Lives in the private
+ admin repository as `roster.json`. Schema and validation live in
+ [roster.js](../.github/scripts/provisioning/roster.js) and
+ [roster.schema.json](../roster.schema.json). An example is in
+ [examples/roster.example.json](examples/roster.example.json).
+2. **Progress of record.** Never authored by a vendor. Derived from deterministic
+ signals the project controls (challenge issue state, PR closing keywords, labels,
+ and the plain-text signals `ack` and `day1-complete`) by
+ [progress.js](../.github/scripts/provisioning/progress.js).
+3. **Provisioning of record.** An append-only log of provisioning attempts and
+ outcomes (`provisioning-log.json`), sufficient to prove a repository is correctly
+ seeded and to safely re-run.
+
+Reconstruction rule: running the idempotent provisioning action against the roster
+reproduces a healthy state for every learner, with no third party involved.
+
+## How provisioning works
+
+The provisioning subsystem is plain Node with no third-party dependencies (it uses
+built-in `crypto` and global `fetch`). The pieces are:
+
+| File | Role |
+|---|---|
+| [roster.js](../.github/scripts/provisioning/roster.js) | Owned roster: parse, validate, upsert, serialize, redact |
+| [progress.js](../.github/scripts/provisioning/progress.js) | Derive learner status from deterministic signals |
+| [github-app-auth.js](../.github/scripts/provisioning/github-app-auth.js) | Sign an App JWT and mint a short-lived installation token |
+| [github-client.js](../.github/scripts/provisioning/github-client.js) | Minimal GitHub REST client (fetch or Octokit) |
+| [provision-core.js](../.github/scripts/provisioning/provision-core.js) | The idempotent, serial, backoff provisioning algorithm |
+| [provision-cli.js](../.github/scripts/provisioning/provision-cli.js) | Standalone runner used by the workflow |
+| [provision-learning-rooms.yml](../.github/workflows/provision-learning-rooms.yml) | Scheduled and manual workflow wrapper |
+
+The algorithm (SPEC.md section 7.2b) runs serially with a short delay and exponential
+backoff, not a parallel fan-out, to stay clear of GitHub secondary rate limits. For
+each pending or failed learner it: checks whether the repository exists; creates it
+from the template if not; ensures the learner is a collaborator; verifies the required
+workflow set is present; and records the outcome. Every step is safe to repeat.
+
+## One-time setup
+
+Provisioning supports two modes via the `PROVISIONING_MODE` variable.
+
+### Production: GitHub App (`github-app`)
+
+A GitHub App is the production identity because it is not tied to a human account,
+mints short-lived tokens, and uses fine-grained least-privilege permissions.
+
+1. Create a GitHub App in the `Community-Access` organization with only these
+ permissions: Repository administration (write), Contents (write), Metadata (read),
+ and optionally Issues (write). Nothing more.
+2. Install the App on the organization, scoped to the template and student repositories.
+3. Generate a private key (PEM). Store these as repository or environment secrets:
+ - `PROVISIONING_APP_ID`
+ - `PROVISIONING_APP_PRIVATE_KEY` (the PEM contents)
+ - `PROVISIONING_APP_INSTALLATION_ID`
+4. Set repository variables:
+ - `PROVISIONING_MODE` = `github-app`
+ - `LEARNING_ROOM_TEMPLATE_REPO` = `Community-Access/learning-room-template`
+ - `PROVISIONING_STUDENT_OWNER` = `Community-Access`
+ - `ADMIN_ROSTER_REPO` = the private admin repository holding `roster.json`
+5. Provide `PRIVATE_STUDENT_DATA_TOKEN`, a token that can check out and push to the
+ admin roster repository.
+
+### Phase 1 spike only: Actions bot (`actions-bot`)
+
+For an early validation spike you may use a least-privilege fine-grained PAT in
+`PROVISIONING_TOKEN` with `PROVISIONING_MODE` = `actions-bot`. Because the downstream
+system cannot tell which mode created a repository, spiking with a PAT and graduating
+to the App loses nothing. Do not use the PAT path for a real cohort: a PAT is bound to
+a person and is a single point of failure.
+
+## Running a cohort
+
+Provisioning is designed to run as a trickle, on registration, rather than as a
+big-bang on go-live morning. The scheduled workflow picks up newly registered learners
+every 30 minutes; you can also run it on demand.
+
+- **Dry run (no changes).** In the Actions tab, run *Provision Learning Rooms* with
+ the `dry_run` input checked. It lists who would be provisioned.
+- **Provision.** Run the same workflow with `dry_run` unchecked. It provisions every
+ pending or failed learner, commits the updated `roster.json` and
+ `provisioning-log.json` back to the admin repository, and prints a summary.
+- **Local run.** From a checkout of the admin repo:
+
+ ```bash
+ LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \
+ PROVISIONING_STUDENT_OWNER=Community-Access \
+ PROVISIONING_MODE=actions-bot PROVISIONING_TOKEN=*** \
+ node .github/scripts/provisioning/provision-cli.js \
+ --roster roster.json --log provisioning-log.json
+ ```
+
+ Add `--dry-run` to preview without making changes.
+
+## Idempotency and self-healing
+
+The provisioning action is idempotent on `(github_handle, cohort_id)`:
+
+- Running it twice is safe. An existing, complete repository is recorded as
+ `already-exists` and left untouched.
+- A re-run resumes a half-finished batch instead of duplicating work.
+- If a repository exists but is missing required workflows, the run heals it (when a
+ content-seeding capability is available) or fails loudly so the watchdog and the
+ facilitator see it before a learner does.
+
+Always prove idempotency before go-live by running provisioning twice and confirming
+the second run reports `already-exists` for healthy learners and makes no changes.
+
+## Failure modes and recovery
+
+| Symptom | What it means | Recovery |
+|---|---|---|
+| One learner is `failed` in the roster | Repo creation or verification failed for that entry | Fix the cause (seat, permission, name clash), then re-run provisioning. Only pending/failed entries are retried. |
+| `provisioning-log.json` shows `error` with a rate-limit detail | Secondary rate limit during a burst | Re-run. The algorithm already backs off; idempotency means a re-run heals. |
+| Repo exists but missing workflows | Partial seed | Re-run; the verify gate re-checks and heals or flags. |
+| App token mint fails | Bad App ID, key, or installation ID | Re-check the three App secrets; rotate the private key if exposure is suspected. |
+
+No learner should ever be the first to discover a failure. The watchdog and the
+facilitator find it first.
+
+## The optional Flask companion
+
+The companion in [../companion/](../companion/) is strictly optional and lives at the
+edges. It renders the owned roster more nicely (an accessible registration front door
+and a facilitator dashboard) but never holds state the learner depends on. If it is
+down, the GitHub-native issue-form front door and the admin-issue dashboard carry the
+entire workshop. See [../companion/README.md](../companion/README.md).
+
+## Security and least privilege
+
+- Grant only the four App permissions listed above. Anything more fails the security
+ review in SPEC.md section 13.
+- Store the App ID, installation ID, and private key only in GitHub Secrets. Never in
+ code, never in a public repository.
+- Mint the installation token at the start of each run and never persist it.
+- Document and rehearse private-key rotation; rotate on any suspected exposure.
+- The companion authenticates facilitator actions, protects forms with per-session
+ CSRF tokens, validates and encodes all input, and sets strict security headers.
+
+## Local development and testing
+
+```bash
+# Provisioning subsystem (Node, no install needed)
+npm run test:provisioning
+
+# Flask companion (Python)
+cd companion
+python -m venv .venv && .venv/bin/pip install -r requirements.txt
+.venv/bin/python -m unittest discover -s tests
+```
+
+## Authoritative Sources
+
+Use these official references when you need the current source of truth for facts in
+this document.
+
+- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
+- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template)
+- [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
+- [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app)
+
+### Section-Level Source Map
+
+Use this map to verify facts for each major section in this file.
+
+- **Why this exists, The three owned sources of truth:** [golden.md](../golden.md), [SPEC.md](../SPEC.md)
+- **How provisioning works, Idempotency and self-healing:** [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template), [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
+- **One-time setup, Security and least privilege:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app)
+- **Running a cohort, Failure modes and recovery, The optional Flask companion, Local development and testing:** [SPEC.md](../SPEC.md), [golden.md](../golden.md)
diff --git a/admin/examples/roster.example.json b/admin/examples/roster.example.json
new file mode 100644
index 00000000..e9e3e63c
--- /dev/null
+++ b/admin/examples/roster.example.json
@@ -0,0 +1,35 @@
+{
+ "version": 1,
+ "generated_at": "2026-01-15T09:00:00.000Z",
+ "cohorts": [
+ {
+ "cohort_id": "spring-2026",
+ "label": "Spring 2026 cohort",
+ "opens_at": "2026-03-02T15:00:00.000Z"
+ }
+ ],
+ "learners": [
+ {
+ "github_handle": "ada-lovelace",
+ "cohort_id": "spring-2026",
+ "path": "day1-day2",
+ "learning_room_repo": "Community-Access/learning-room-spring-2026-ada-lovelace",
+ "provision_state": "provisioned",
+ "status": "active-day1",
+ "registered_at": "2026-01-14T18:22:00.000Z",
+ "last_signal_at": "2026-03-02T16:05:00.000Z",
+ "notes": ""
+ },
+ {
+ "github_handle": "grace-hopper",
+ "cohort_id": "spring-2026",
+ "path": "day2-only",
+ "learning_room_repo": null,
+ "provision_state": "pending",
+ "status": "awaiting-ack",
+ "registered_at": "2026-01-15T08:40:00.000Z",
+ "last_signal_at": null,
+ "notes": ""
+ }
+ ]
+}
diff --git a/companion/.gitignore b/companion/.gitignore
new file mode 100644
index 00000000..8bda93fd
--- /dev/null
+++ b/companion/.gitignore
@@ -0,0 +1,8 @@
+# Local instance data should never be committed.
+.venv/
+venv/
+instance/
+*.local.json
+__pycache__/
+*.pyc
+.pytest_cache/
diff --git a/companion/README.md b/companion/README.md
new file mode 100644
index 00000000..0f4eb09b
--- /dev/null
+++ b/companion/README.md
@@ -0,0 +1,108 @@
+# GLOW Companion (optional, accessible front door)
+
+The companion is the optional Flask service that sits at the edges of the Hybrid
+architecture in [golden.md](../golden.md) (Option C) and [SPEC.md](../SPEC.md)
+section 11. It makes a good thing nicer; it never makes a working thing necessary.
+
+If this service is ever down, learners can still register and complete the entire
+workshop on GitHub through the issue-form front door, and facilitators can still read
+the admin-issue dashboard. The companion is non-critical by design.
+
+## What it provides
+
+- An accessible registration landing page that writes to the owned roster.
+- A facilitator cohort dashboard that reads the owned roster and derived progress.
+- A redacted `roster.json` endpoint for facilitator tooling.
+
+## What it deliberately does not do
+
+- It does not invent a parallel store of record. The only state it touches is the
+ owned roster JSON, through the same contract the provisioning subsystem uses.
+- It does not provision repositories directly. Provisioning is the GitHub-native
+ subsystem's job (see [../admin/OWNED_PROVISIONING.md](../admin/OWNED_PROVISIONING.md)).
+
+## Accessibility (WCAG 2.2 AA)
+
+Every template is authored screen-reader-first and keyboard-operable:
+
+- Semantic landmarks (`header`, `nav`, `main`, `footer`) and a visible skip link.
+- Correct heading order and labeled form controls with hint and error associations
+ via `aria-describedby` and `aria-invalid`.
+- An error summary with in-page links to each invalid field.
+- Live-region announcements for flash messages (`role="status"`, `aria-live`).
+- Visible focus styles and no keyboard traps.
+- No information conveyed by color alone; no emoji.
+- A data table with a `caption`, `scope`-d headers, and an intro sentence.
+
+Run a manual screen reader pass (NVDA, JAWS, VoiceOver) before any cohort, per the
+project accessibility testing guidance.
+
+## Security (OWASP-aware)
+
+- Output is auto-escaped by Jinja2.
+- Per-session CSRF tokens protect every state-changing POST.
+- Facilitator actions require sign-in; the token is compared in constant time.
+- Input is validated (GitHub handle regex, cohort, path enum) before any write.
+- Best-effort per-IP rate limiting on registration and sign-in.
+- Strict security headers: a restrictive `Content-Security-Policy` (no inline
+ scripts), `X-Content-Type-Options`, `X-Frame-Options`, and `Referrer-Policy`.
+- Secrets come only from the environment; cookies are `HttpOnly`, `SameSite=Lax`,
+ and `Secure` by default.
+
+## Configuration
+
+All configuration is environment-driven:
+
+| Variable | Purpose | Default |
+|---|---|---|
+| `COMPANION_SECRET_KEY` | Flask session signing key | random per process (set this in production) |
+| `COMPANION_ROSTER_PATH` | Path to the owned `roster.json` | `roster.json` |
+| `COMPANION_FACILITATOR_TOKEN` | Facilitator sign-in token | empty (dashboard locked until set) |
+| `COMPANION_SECURE_COOKIES` | Set `0` only for local HTTP testing | `1` |
+| `COMPANION_RATE_LIMIT_MAX` | Requests per window per IP | `10` |
+| `COMPANION_RATE_LIMIT_WINDOW` | Rate-limit window in seconds | `300` |
+| `PORT` | Port for the built-in dev server | `5000` |
+
+## Run locally
+
+```bash
+cd companion
+python -m venv .venv
+.venv/bin/pip install -r requirements.txt # Windows: .venv\Scripts\pip install -r requirements.txt
+
+export COMPANION_SECRET_KEY="dev-only-not-a-real-secret"
+export COMPANION_FACILITATOR_TOKEN="dev-token"
+export COMPANION_SECURE_COOKIES=0
+export COMPANION_ROSTER_PATH="../admin/examples/roster.example.json"
+
+.venv/bin/python app.py
+# Visit http://127.0.0.1:5000/
+```
+
+For production, serve `app:app` behind a WSGI server (for example gunicorn) and a
+TLS-terminating reverse proxy, set a strong `COMPANION_SECRET_KEY`, and point
+`COMPANION_ROSTER_PATH` at a working copy of the admin roster that a separate job
+commits back to the admin repository.
+
+## Test
+
+```bash
+cd companion
+.venv/bin/python -m unittest discover -s tests
+```
+
+The suite covers routing, CSRF enforcement, input validation, facilitator auth,
+rate limiting, roster redaction, and the accessible page shell.
+
+## Routes
+
+| Method | Path | Auth | Purpose |
+|---|---|---|---|
+| GET | `/` | none | Redirects to `/register` |
+| GET, POST | `/register` | none | Accessible registration form; writes the roster |
+| GET | `/register/success` | none | Confirmation and next steps |
+| GET, POST | `/login` | none | Facilitator sign-in |
+| POST | `/logout` | session | Sign out |
+| GET | `/dashboard` | facilitator | Cohort dashboard |
+| GET | `/roster.json` | facilitator | Redacted roster JSON |
+| GET | `/healthz` | none | Liveness probe |
diff --git a/companion/app.py b/companion/app.py
new file mode 100644
index 00000000..693358f8
--- /dev/null
+++ b/companion/app.py
@@ -0,0 +1,295 @@
+"""Optional accessible Flask companion for the GLOW workshop.
+
+This service lives strictly at the edges (golden.md Option C, SPEC.md section 11).
+It makes a good thing nicer; it never makes a working thing necessary. If it is
+down, the GitHub-native issue-form front door and admin-issue dashboard carry the
+entire workshop.
+
+What it provides:
+ - An accessible registration landing page that writes to the owned roster.
+ - A facilitator cohort dashboard that reads the owned roster and progress.
+
+Hard constraints honored here (SPEC.md sections 11.2, 12, 13):
+ - WCAG 2.2 AA templates: landmarks, heading order, labeled controls, visible
+ focus, aria-live announcements, an error summary, no keyboard traps.
+ - OWASP-aware: Jinja2 autoescaping, per-session CSRF tokens, facilitator
+ authentication with constant-time comparison, input validation, rate
+ limiting, security headers, secrets only from the environment.
+ - Stateless about anything critical: the only store of record it touches is the
+ owned roster JSON, through the same contract the provisioning subsystem uses.
+"""
+
+from __future__ import annotations
+
+import hmac
+import os
+import secrets
+import time
+from collections import defaultdict, deque
+from datetime import datetime, timezone
+from functools import wraps
+from typing import Deque, Dict
+
+from flask import (
+ Flask,
+ abort,
+ flash,
+ get_flashed_messages,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+
+from roster_store import Roster, RosterError, is_valid_handle, PATHS
+
+# In-memory, best-effort rate limiter. Good enough for a low-traffic facilitator
+# front door; a multi-process deployment should front this with a shared store.
+_RATE_BUCKETS: Dict[str, Deque[float]] = defaultdict(deque)
+
+
+def create_app(config: Dict | None = None) -> Flask:
+ app = Flask(__name__)
+ app.config.update(
+ SECRET_KEY=os.environ.get("COMPANION_SECRET_KEY") or secrets.token_hex(32),
+ ROSTER_PATH=os.environ.get("COMPANION_ROSTER_PATH", "roster.json"),
+ FACILITATOR_TOKEN=os.environ.get("COMPANION_FACILITATOR_TOKEN", ""),
+ SESSION_COOKIE_HTTPONLY=True,
+ SESSION_COOKIE_SAMESITE="Lax",
+ SESSION_COOKIE_SECURE=os.environ.get("COMPANION_SECURE_COOKIES", "1") == "1",
+ RATE_LIMIT_MAX=int(os.environ.get("COMPANION_RATE_LIMIT_MAX", "10")),
+ RATE_LIMIT_WINDOW=int(os.environ.get("COMPANION_RATE_LIMIT_WINDOW", "300")),
+ )
+ if config:
+ app.config.update(config)
+
+ register_security(app)
+ register_routes(app)
+ return app
+
+
+# ---------------------------------------------------------------------------
+# Security plumbing
+# ---------------------------------------------------------------------------
+
+def register_security(app: Flask) -> None:
+ @app.after_request
+ def set_security_headers(response):
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ # No inline scripts are used anywhere in the templates.
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'self'; img-src 'self'; style-src 'self'; "
+ "script-src 'self'; base-uri 'none'; form-action 'self'; "
+ "frame-ancestors 'none'"
+ )
+ return response
+
+ @app.context_processor
+ def inject_csrf():
+ return {"csrf_token": _ensure_csrf_token()}
+
+
+def _ensure_csrf_token() -> str:
+ token = session.get("_csrf_token")
+ if not token:
+ token = secrets.token_urlsafe(32)
+ session["_csrf_token"] = token
+ return token
+
+
+def _check_csrf() -> None:
+ expected = session.get("_csrf_token", "")
+ provided = request.form.get("csrf_token", "")
+ if not expected or not hmac.compare_digest(expected, provided):
+ abort(400, description="Invalid or missing CSRF token.")
+
+
+def _rate_limited(app: Flask, key: str) -> bool:
+ window = app.config["RATE_LIMIT_WINDOW"]
+ limit = app.config["RATE_LIMIT_MAX"]
+ now = time.time()
+ bucket = _RATE_BUCKETS[key]
+ while bucket and now - bucket[0] > window:
+ bucket.popleft()
+ if len(bucket) >= limit:
+ return True
+ bucket.append(now)
+ return False
+
+
+def login_required(view):
+ @wraps(view)
+ def wrapped(*args, **kwargs):
+ if not session.get("facilitator"):
+ return redirect(url_for("login", next=request.path))
+ return view(*args, **kwargs)
+
+ return wrapped
+
+
+# ---------------------------------------------------------------------------
+# Roster persistence (the only store of record the companion touches)
+# ---------------------------------------------------------------------------
+
+def load_roster(app: Flask) -> Roster:
+ path = app.config["ROSTER_PATH"]
+ if not os.path.exists(path):
+ return Roster.empty()
+ with open(path, "r", encoding="utf-8") as handle:
+ return Roster.parse(handle.read())
+
+
+def save_roster(app: Flask, roster: Roster) -> None:
+ path = app.config["ROSTER_PATH"]
+ directory = os.path.dirname(os.path.abspath(path))
+ os.makedirs(directory, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(roster.to_json())
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+def register_routes(app: Flask) -> None:
+ @app.route("/")
+ def index():
+ return redirect(url_for("register"))
+
+ @app.route("/healthz")
+ def healthz():
+ return {"status": "ok"}, 200
+
+ @app.route("/register", methods=["GET", "POST"])
+ def register():
+ errors = []
+ form = {"github_handle": "", "cohort_id": "", "path": "day1-day2"}
+ if request.method == "POST":
+ _check_csrf()
+ if _rate_limited(app, f"register:{request.remote_addr}"):
+ abort(429, description="Too many registrations from this address. Please wait and try again.")
+ form["github_handle"] = (request.form.get("github_handle") or "").strip()
+ form["cohort_id"] = (request.form.get("cohort_id") or "").strip()
+ form["path"] = (request.form.get("path") or "day1-day2").strip()
+
+ if not is_valid_handle(form["github_handle"]):
+ errors.append(("github_handle", "Enter a valid GitHub username (letters, numbers, single hyphens)."))
+ if not form["cohort_id"]:
+ errors.append(("cohort_id", "Choose the cohort you are registering for."))
+ if form["path"] not in PATHS:
+ errors.append(("path", "Choose a valid workshop path."))
+
+ if not errors:
+ roster = load_roster(app)
+ try:
+ roster.upsert(
+ {
+ "github_handle": form["github_handle"].lstrip("@"),
+ "cohort_id": form["cohort_id"],
+ "path": form["path"],
+ "provision_state": "pending",
+ "status": "awaiting-ack",
+ "registered_at": datetime.now(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z"),
+ }
+ )
+ save_roster(app, roster)
+ except RosterError as exc:
+ errors.append(("github_handle", str(exc)))
+ else:
+ flash("Registration received. Your Learning Room will be provisioned shortly.", "success")
+ return redirect(url_for("register_success", handle=form["github_handle"].lstrip("@")))
+
+ return render_template("register.html", errors=errors, form=form, paths=PATHS)
+
+ @app.route("/register/success")
+ def register_success():
+ handle = request.args.get("handle", "")
+ if not is_valid_handle(handle):
+ handle = ""
+ return render_template("register_success.html", handle=handle)
+
+ @app.route("/login", methods=["GET", "POST"])
+ def login():
+ error = None
+ if request.method == "POST":
+ _check_csrf()
+ if _rate_limited(app, f"login:{request.remote_addr}"):
+ abort(429, description="Too many sign-in attempts. Please wait and try again.")
+ token = request.form.get("token", "")
+ expected = app.config["FACILITATOR_TOKEN"]
+ if expected and hmac.compare_digest(token, expected):
+ session["facilitator"] = True
+ target = request.args.get("next") or url_for("dashboard")
+ # Only allow local redirects.
+ if not target.startswith("/"):
+ target = url_for("dashboard")
+ return redirect(target)
+ error = "That facilitator access token was not recognized."
+ return render_template("login.html", error=error)
+
+ @app.route("/logout", methods=["POST"])
+ def logout():
+ _check_csrf()
+ session.pop("facilitator", None)
+ flash("You have signed out.", "success")
+ return redirect(url_for("login"))
+
+ @app.route("/dashboard")
+ @login_required
+ def dashboard():
+ roster = load_roster(app)
+ cohort_filter = request.args.get("cohort", "").strip()
+ learners = roster.learners
+ if cohort_filter:
+ learners = [l for l in learners if l["cohort_id"] == cohort_filter]
+ learners = sorted(learners, key=lambda l: (l["cohort_id"], l["github_handle"].lower()))
+ cohorts = sorted({l["cohort_id"] for l in roster.learners})
+ summary = _summarize(roster.learners)
+ return render_template(
+ "dashboard.html",
+ learners=learners,
+ cohorts=cohorts,
+ cohort_filter=cohort_filter,
+ summary=summary,
+ )
+
+ @app.route("/roster.json")
+ @login_required
+ def roster_json():
+ roster = load_roster(app)
+ return roster.public_view(), 200
+
+ @app.errorhandler(400)
+ def bad_request(err):
+ return render_template("error.html", code=400, message=err.description), 400
+
+ @app.errorhandler(404)
+ def not_found(err):
+ return render_template("error.html", code=404, message="Page not found."), 404
+
+ @app.errorhandler(429)
+ def too_many(err):
+ return render_template("error.html", code=429, message=err.description), 429
+
+
+def _summarize(learners):
+ summary = {"total": len(learners)}
+ for learner in learners:
+ key = f"provision_{learner['provision_state']}"
+ summary[key] = summary.get(key, 0) + 1
+ status_key = f"status_{learner['status']}"
+ summary[status_key] = summary.get(status_key, 0) + 1
+ return summary
+
+
+# Module-level app for `flask run` / gunicorn `companion.app:app` usage.
+app = create_app()
+
+
+if __name__ == "__main__":
+ app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "5000")))
diff --git a/companion/requirements.txt b/companion/requirements.txt
new file mode 100644
index 00000000..840d434b
--- /dev/null
+++ b/companion/requirements.txt
@@ -0,0 +1 @@
+Flask>=3.0,<4.0
diff --git a/companion/roster_store.py b/companion/roster_store.py
new file mode 100644
index 00000000..06beee7d
--- /dev/null
+++ b/companion/roster_store.py
@@ -0,0 +1,184 @@
+"""Owned roster store for the optional Flask companion.
+
+This is a faithful Python port of the roster contract in
+.github/scripts/provisioning/roster.js (SPEC.md section 6.1). The companion
+never invents a parallel state store of record: it reads and writes the same
+roster JSON shape that the GitHub-native provisioning subsystem owns, so if the
+companion disappears the roster is still valid and complete.
+
+Pure, synchronous, dependency-free (standard library only) so it is trivially
+unit-testable and safe to run without a network call.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+
+PROVISION_STATES = ("pending", "provisioned", "failed", "healed")
+STATUSES = (
+ "awaiting-ack",
+ "active-day1",
+ "day1-complete",
+ "day2-released",
+ "needs-info",
+)
+PATHS = ("day1-day2", "day2-only")
+
+# GitHub usernames: 1-39 chars, alphanumeric or single hyphens, no leading or
+# trailing hyphen. Mirrors the regex in roster.js.
+_HANDLE_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$")
+
+
+class RosterError(ValueError):
+ """Raised when roster data or an input fails validation."""
+
+
+def normalize_handle(handle: str) -> str:
+ return (handle or "").strip().lstrip("@").lower()
+
+
+def is_valid_handle(handle: str) -> bool:
+ return bool(_HANDLE_RE.match((handle or "").lstrip("@")))
+
+
+def learner_key(handle: str, cohort_id: str) -> str:
+ return f"{normalize_handle(handle)}@@{(cohort_id or '').strip()}"
+
+
+@dataclass
+class Roster:
+ version: int = 1
+ cohorts: List[Dict[str, Any]] = field(default_factory=list)
+ learners: List[Dict[str, Any]] = field(default_factory=list)
+ generated_at: Optional[str] = None
+
+ @staticmethod
+ def empty() -> "Roster":
+ return Roster()
+
+ @staticmethod
+ def parse(text: Optional[str]) -> "Roster":
+ if text is None or str(text).strip() == "":
+ return Roster.empty()
+ data = json.loads(text)
+ return Roster.from_dict(data)
+
+ @staticmethod
+ def from_dict(data: Dict[str, Any]) -> "Roster":
+ if not isinstance(data, dict):
+ raise RosterError("Roster must be an object")
+ if data.get("version") != 1:
+ raise RosterError(f"Unsupported roster version: {data.get('version')}")
+ learners = data.get("learners")
+ if not isinstance(learners, list):
+ raise RosterError("Roster.learners must be a list")
+ seen = set()
+ for learner in learners:
+ problems = validate_learner(learner)
+ if problems:
+ raise RosterError(
+ f"Invalid learner {learner.get('github_handle')}: {'; '.join(problems)}"
+ )
+ key = learner_key(learner["github_handle"], learner["cohort_id"])
+ if key in seen:
+ raise RosterError(f"Duplicate learner for key {key}")
+ seen.add(key)
+ return Roster(
+ version=1,
+ cohorts=data.get("cohorts", []) or [],
+ learners=learners,
+ generated_at=data.get("generated_at"),
+ )
+
+ def find(self, handle: str, cohort_id: str) -> Optional[Dict[str, Any]]:
+ key = learner_key(handle, cohort_id)
+ for learner in self.learners:
+ if learner_key(learner["github_handle"], learner["cohort_id"]) == key:
+ return learner
+ return None
+
+ def upsert(self, entry: Dict[str, Any]) -> Dict[str, Any]:
+ """Insert or update a learner keyed on (github_handle, cohort_id).
+
+ Returns the stored learner record. Raises RosterError on invalid input.
+ """
+ candidate = {
+ "github_handle": (entry.get("github_handle") or "").lstrip("@"),
+ "cohort_id": entry.get("cohort_id"),
+ "path": entry.get("path") or "day1-day2",
+ "provision_state": entry.get("provision_state") or "pending",
+ "status": entry.get("status") or "awaiting-ack",
+ }
+ problems = validate_learner(candidate)
+ if problems:
+ raise RosterError("; ".join(problems))
+ existing = self.find(candidate["github_handle"], candidate["cohort_id"])
+ if existing is not None:
+ existing.update({k: v for k, v in entry.items() if v is not None})
+ return existing
+ record = {
+ "github_handle": candidate["github_handle"],
+ "cohort_id": candidate["cohort_id"],
+ "path": candidate["path"],
+ "learning_room_repo": entry.get("learning_room_repo"),
+ "provision_state": candidate["provision_state"],
+ "status": candidate["status"],
+ "registered_at": entry.get("registered_at"),
+ "last_signal_at": entry.get("last_signal_at"),
+ "notes": entry.get("notes", ""),
+ }
+ self.learners.append(record)
+ return record
+
+ def to_json(self, now: Optional[datetime] = None) -> str:
+ now = now or datetime.now(timezone.utc)
+ learners = sorted(
+ self.learners,
+ key=lambda l: learner_key(l["github_handle"], l["cohort_id"]),
+ )
+ payload = {
+ "version": 1,
+ "generated_at": now.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z"),
+ "cohorts": self.cohorts,
+ "learners": learners,
+ }
+ return json.dumps(payload, indent=2) + "\n"
+
+ def public_view(self) -> Dict[str, Any]:
+ """Redacted view: never leaks facilitator notes."""
+ return {
+ "version": 1,
+ "learners": [
+ {
+ "github_handle": l["github_handle"],
+ "cohort_id": l["cohort_id"],
+ "path": l["path"],
+ "provision_state": l["provision_state"],
+ "status": l["status"],
+ }
+ for l in self.learners
+ ],
+ }
+
+
+def validate_learner(learner: Any) -> List[str]:
+ problems: List[str] = []
+ if not isinstance(learner, dict):
+ return ["learner must be an object"]
+ if not is_valid_handle(learner.get("github_handle", "")):
+ problems.append("invalid github_handle")
+ if not learner.get("cohort_id"):
+ problems.append("missing cohort_id")
+ if learner.get("path") not in PATHS:
+ problems.append(f"invalid path: {learner.get('path')}")
+ if learner.get("provision_state") not in PROVISION_STATES:
+ problems.append(f"invalid provision_state: {learner.get('provision_state')}")
+ if learner.get("status") not in STATUSES:
+ problems.append(f"invalid status: {learner.get('status')}")
+ return problems
diff --git a/companion/static/styles.css b/companion/static/styles.css
new file mode 100644
index 00000000..ff898543
--- /dev/null
+++ b/companion/static/styles.css
@@ -0,0 +1,259 @@
+/* GLOW companion styles. Accessibility-first: high contrast, visible focus,
+ generous spacing, no information conveyed by color alone. */
+
+:root {
+ --ink: #11181c;
+ --bg: #ffffff;
+ --accent: #0a3d62;
+ --error: #8a1f11;
+ --muted: #44505a;
+ --border: #11181c;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 1.05rem;
+ line-height: 1.6;
+ color: var(--ink);
+ background: var(--bg);
+}
+
+main {
+ max-width: 52rem;
+ margin: 0 auto;
+ padding: 1.5rem 1.25rem 3rem;
+}
+
+header,
+footer {
+ max-width: 52rem;
+ margin: 0 auto;
+ padding: 1rem 1.25rem;
+}
+
+header {
+ border-bottom: 2px solid var(--border);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+footer {
+ border-top: 2px solid var(--border);
+ color: var(--muted);
+}
+
+.brand {
+ font-weight: 700;
+ font-size: 1.2rem;
+ margin: 0;
+}
+
+nav ul {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin: 0;
+ padding: 0;
+ align-items: center;
+}
+
+a {
+ color: var(--accent);
+}
+
+a:focus,
+button:focus,
+input:focus,
+select:focus,
+.skip-link:focus {
+ outline: 3px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.skip-link {
+ position: absolute;
+ left: -999px;
+ top: 0;
+ background: var(--bg);
+ padding: 0.5rem 1rem;
+ border: 2px solid var(--accent);
+}
+
+.skip-link:focus {
+ left: 0.5rem;
+ top: 0.5rem;
+ z-index: 10;
+}
+
+h1 {
+ font-size: 1.9rem;
+ line-height: 1.25;
+}
+
+h2 {
+ font-size: 1.35rem;
+ margin-top: 2rem;
+}
+
+.field {
+ margin: 1.25rem 0;
+}
+
+label,
+legend {
+ display: block;
+ font-weight: 600;
+ margin-bottom: 0.35rem;
+}
+
+.hint {
+ margin: 0 0 0.4rem;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+input[type="text"],
+input[type="password"],
+select {
+ width: 100%;
+ max-width: 28rem;
+ padding: 0.6rem 0.7rem;
+ font-size: 1rem;
+ border: 2px solid var(--border);
+ border-radius: 4px;
+}
+
+fieldset {
+ border: 2px solid var(--border);
+ border-radius: 4px;
+ padding: 1rem;
+}
+
+.radio {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.4rem 0;
+}
+
+.radio label {
+ margin: 0;
+ font-weight: 400;
+}
+
+button {
+ font-size: 1rem;
+ padding: 0.65rem 1.4rem;
+ background: var(--accent);
+ color: #ffffff;
+ border: 2px solid var(--accent);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+button:hover {
+ background: #082f4a;
+}
+
+.link-button {
+ background: none;
+ border: none;
+ color: var(--accent);
+ text-decoration: underline;
+ padding: 0;
+ font-size: 1rem;
+ cursor: pointer;
+}
+
+.inline-form {
+ display: inline;
+}
+
+.error-summary {
+ border: 3px solid var(--error);
+ padding: 1rem 1.25rem;
+ margin: 1.5rem 0;
+}
+
+.error-summary h2 {
+ margin-top: 0;
+ color: var(--error);
+}
+
+.error-summary a {
+ color: var(--error);
+ font-weight: 600;
+}
+
+.field-error {
+ color: var(--error);
+ font-weight: 600;
+ margin: 0 0 0.4rem;
+}
+
+.flash {
+ max-width: 52rem;
+ margin: 1rem auto 0;
+ padding: 0 1.25rem;
+}
+
+.flash ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.flash-success {
+ border-left: 6px solid var(--accent);
+ background: #eef4f9;
+ padding: 0.75rem 1rem;
+}
+
+.filter-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 0.75rem;
+ margin: 1rem 0;
+}
+
+.filter-form label {
+ margin: 0;
+}
+
+.filter-form select {
+ width: auto;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-top: 1rem;
+}
+
+caption {
+ text-align: left;
+ margin-bottom: 0.5rem;
+ color: var(--muted);
+}
+
+th,
+td {
+ border: 1px solid var(--border);
+ padding: 0.55rem 0.7rem;
+ text-align: left;
+ vertical-align: top;
+}
+
+thead th {
+ background: #eef4f9;
+}
diff --git a/companion/templates/base.html b/companion/templates/base.html
new file mode 100644
index 00000000..f8f1fc66
--- /dev/null
+++ b/companion/templates/base.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ {% block title %}GIT Going with GitHub{% endblock %} - GLOW companion
+
+
+
+ Skip to main content
+
+ GIT Going with GitHub
+
+
+
+
+
+ {% set messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+
+ {% for category, message in messages %}
+ {{ message }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/companion/templates/dashboard.html b/companion/templates/dashboard.html
new file mode 100644
index 00000000..a0beedca
--- /dev/null
+++ b/companion/templates/dashboard.html
@@ -0,0 +1,66 @@
+{% extends "base.html" %}
+{% block title %}Facilitator dashboard{% endblock %}
+{% block content %}
+Cohort dashboard
+
+ This dashboard renders the owned roster of record. It is a read-only view; the
+ same data lives in the private admin repository, so this page can disappear
+ without losing anything.
+
+
+Summary
+
+ {{ summary.total }} learner(s) total.
+ Provisioned: {{ summary.get('provision_provisioned', 0) }}.
+ Pending: {{ summary.get('provision_pending', 0) }}.
+ Failed: {{ summary.get('provision_failed', 0) }}.
+ Healed: {{ summary.get('provision_healed', 0) }}.
+
+
+
+
+Learners
+{% if learners %}
+
+
+ Roster of {{ learners|length }} learner(s){% if cohort_filter %} in cohort {{ cohort_filter }}{% endif %}.
+ Columns: GitHub username, cohort, path, provisioning state, and progression status.
+
+
+
+ GitHub username
+ Cohort
+ Path
+ Provisioning
+ Status
+ Learning Room
+
+
+
+ {% for l in learners %}
+
+ {{ l.github_handle }}
+ {{ l.cohort_id }}
+ {{ l.path }}
+ {{ l.provision_state }}
+ {{ l.status }}
+
+ {% if l.learning_room_repo %}{{ l.learning_room_repo }}{% else %}Not yet provisioned{% endif %}
+
+
+ {% endfor %}
+
+
+{% else %}
+No learners match this view yet.
+{% endif %}
+{% endblock %}
diff --git a/companion/templates/error.html b/companion/templates/error.html
new file mode 100644
index 00000000..4a36a1c8
--- /dev/null
+++ b/companion/templates/error.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}Error {{ code }}{% endblock %}
+{% block content %}
+Something went wrong (error {{ code }})
+{{ message }}
+Return to registration
+{% endblock %}
diff --git a/companion/templates/login.html b/companion/templates/login.html
new file mode 100644
index 00000000..4b8f8ee5
--- /dev/null
+++ b/companion/templates/login.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% block title %}Facilitator sign in{% endblock %}
+{% block content %}
+Facilitator sign in
+Enter your facilitator access token to view the cohort dashboard.
+
+{% if error %}
+
+
There is a problem
+
{{ error }}
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/companion/templates/register.html b/companion/templates/register.html
new file mode 100644
index 00000000..95940ddf
--- /dev/null
+++ b/companion/templates/register.html
@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% block title %}Register{% endblock %}
+{% block content %}
+Register for the workshop
+
+ Welcome. Tell us your GitHub username and which cohort you are joining. We will
+ create your private Learning Room and invite you to it.
+
+
+{% if errors %}
+
+
There is a problem
+
+ {% for field, message in errors %}
+ {{ message }}
+ {% endfor %}
+
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/companion/templates/register_success.html b/companion/templates/register_success.html
new file mode 100644
index 00000000..5a61cf5c
--- /dev/null
+++ b/companion/templates/register_success.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+{% block title %}Registration received{% endblock %}
+{% block content %}
+You are registered
+{% if handle %}
+Thank you, {{ handle }} . Your registration is recorded.
+{% else %}
+Thank you. Your registration is recorded.
+{% endif %}
+What happens next
+
+ Your private Learning Room repository is created automatically, usually within half an hour.
+ You receive a repository invitation in your GitHub notifications inbox.
+ Accept the invitation, open the first challenge issue, and reply with ack to begin.
+
+
+ If you do not see an invitation within an hour, contact your facilitator. Nothing
+ is lost: provisioning is safe to re-run.
+
+Register another learner
+{% endblock %}
diff --git a/companion/tests/test_app.py b/companion/tests/test_app.py
new file mode 100644
index 00000000..10a9e74f
--- /dev/null
+++ b/companion/tests/test_app.py
@@ -0,0 +1,185 @@
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app import create_app # noqa: E402
+
+
+class CompanionAppTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.roster_path = os.path.join(self.tmp.name, "roster.json")
+ self.app = create_app(
+ {
+ "TESTING": True,
+ "SECRET_KEY": "test-secret",
+ "ROSTER_PATH": self.roster_path,
+ "FACILITATOR_TOKEN": "letmein",
+ "SESSION_COOKIE_SECURE": False,
+ "RATE_LIMIT_MAX": 100,
+ }
+ )
+ self.client = self.app.test_client()
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def _csrf(self, path):
+ resp = self.client.get(path)
+ html = resp.get_data(as_text=True)
+ marker = 'name="csrf_token" value="'
+ start = html.index(marker) + len(marker)
+ end = html.index('"', start)
+ return html[start:end]
+
+ def test_index_redirects_to_register(self):
+ resp = self.client.get("/")
+ self.assertEqual(resp.status_code, 302)
+ self.assertIn("/register", resp.headers["Location"])
+
+ def test_healthz(self):
+ resp = self.client.get("/healthz")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.get_json()["status"], "ok")
+
+ def test_register_page_is_accessible_shell(self):
+ resp = self.client.get("/register")
+ html = resp.get_data(as_text=True)
+ self.assertIn('lang="en"', html)
+ self.assertIn("Skip to main content", html)
+ self.assertIn(' One sentence: Every blind and low vision learner who starts this workshop should reach a real, merged open source contribution through an experience so reliable, so accessible, and so quietly delightful that the technology disappears and only their growing confidence remains.
+
+## Table of contents
+
+- [What golden means](#what-golden-means)
+- [The existential risk: GitHub Classroom is going away](#the-existential-risk-github-classroom-is-going-away)
+- [Design principles that never bend](#design-principles-that-never-bend)
+- [Architecture options for life after Classroom](#architecture-options-for-life-after-classroom)
+- [The recommended path: GitHub-native core, Flask companion at the edges](#the-recommended-path-github-native-core-flask-companion-at-the-edges)
+- [The decoupling contract: own your state](#the-decoupling-contract-own-your-state)
+- [The five quality pillars](#the-five-quality-pillars)
+- [Failure modes and graceful recovery](#failure-modes-and-graceful-recovery)
+- [Phased roadmap](#phased-roadmap)
+- [Definition of golden: the release gate](#definition-of-golden-the-release-gate)
+- [Guardrails and non-negotiables](#guardrails-and-non-negotiables)
+
+## What golden means
+
+Golden is not "feature complete." Golden is a standard of care. A golden release of this workshop is:
+
+- **Dependable.** A facilitator can open a cohort and trust that every link resolves, every workflow fires, every challenge can be completed, failed, and recovered. No surprises on go-live day.
+- **Accessible by construction.** Not retrofitted. Every artifact, every automation message, every fallback page is authored to be read with or without a screen reader, navigated by keyboard alone, and understood without sight. Accessibility is the product, not a checkbox.
+- **Flexible.** The learning experience survives a vendor sunset, an API change, a renamed button, or a student who arrives with a different tool than expected. The curriculum already teaches "tools change, exploration is the skill." The infrastructure must live that value too.
+- **Error free where it counts.** Zero broken learner-facing paths. Automation either succeeds, or fails loudly to a human with a clear recovery action. Silent failure is the only unacceptable outcome.
+- **Magical and delightful.** The small things: a welcome that uses the learner's name, a validation bot that congratulates instead of scolds, a recovery path that feels like a safety net rather than a punishment, an audio companion when screen reader fatigue sets in. Delight is the sum of a hundred respected details.
+
+If a change does not move the project toward all five at once, question it.
+
+## The existential risk: GitHub Classroom is going away
+
+The entire Learning Room model currently assumes GitHub Classroom exists. Today the flow is: a student accepts a Classroom assignment link, Classroom clones a template repository into a private student repository, and a chain of GitHub Actions drives registration, Day 2 release, autograding, progression, and the instructor dashboard. See [admin/GITHUB_CLASSROOM_ARCHITECTURE.md](admin/GITHUB_CLASSROOM_ARCHITECTURE.md) for the current production design.
+
+This is a single point of failure. If GitHub Classroom is deprecated, removed, or changes its assignment model, the workshop's onboarding spine breaks. The good news: most of the value does not actually live in Classroom. Classroom does exactly two things for us:
+
+1. **Provisioning.** It copies a template repository into a per-student private repository and grants access.
+2. **Roster mapping.** It associates a GitHub identity with a roster entry.
+
+Everything else that makes this workshop work already lives in places we control: the curriculum in [docs/](docs/), the automation in the Learning Room workflows ([learning-room/.github/workflows/](learning-room/.github/workflows/)), the autograders, the progression bot, the registration flow, and the support hub. Classroom is a thin provisioning layer, not the brain.
+
+The golden response is therefore not panic. It is **deliberate decoupling**: replace the two things Classroom does for us with infrastructure we own, keep everything else exactly where it is, and lose nothing the learner depends on.
+
+## Design principles that never bend
+
+These are the constitution. Every architectural choice below is measured against them.
+
+1. **Accessibility is non-negotiable.** Any replacement for Classroom must be fully operable with NVDA, JAWS, and VoiceOver, by keyboard alone. A provisioning UI that a blind learner cannot use is not a candidate, no matter how convenient it is for facilitators.
+2. **Prefer infrastructure the learner already trusts.** GitHub itself is the safest home. A learner who can navigate github.com can already operate most of our flow. New surfaces add new accessibility risk and new failure modes.
+3. **Own your state.** Never again let a single vendor feature hold the cohort hostage. Roster, progress, and provisioning state must be reconstructable from data we control.
+4. **Degrade gracefully, never silently.** Every automated step has a documented manual fallback that a facilitator can run by hand. The automation is an accelerator, not a dependency.
+5. **One arc, many tools.** The curriculum is one journey from browser to github.dev to VS Code. The infrastructure must not fracture that arc, regardless of which provisioning method is in play.
+
+## Architecture options for life after Classroom
+
+Three credible paths exist. Each is evaluated against the principles above.
+
+### Option A: GitHub-native provisioning (no Classroom)
+
+Replace Classroom's provisioning with a GitHub App or an Actions-driven bot that, on registration, creates a private repository from the existing template and invites the student. The roster lives in a private admin repository (as the instructor dashboard already does). Everything downstream stays identical because student repositories look exactly the same as they do today.
+
+Strengths: stays entirely inside GitHub, the surface learners already navigate accessibly. Reuses the existing template, autograders, progression bot, and dashboard sync with near-zero change. Lowest accessibility risk. Lowest new-failure-mode risk.
+
+Weaknesses: requires a GitHub App or a bot token with repository-creation scope and careful permission hygiene. Repository creation rate limits and org seat limits need planning for large cohorts.
+
+### Option B: Flask companion application
+
+A small Flask service handles registration, roster, provisioning orchestration (calling the GitHub API to create student repositories), Day 2 release, and a facilitator dashboard. The learner-facing curriculum still lives in GitHub repositories.
+
+Strengths: full control over registration UX, dashboards, and cohort logic. Can present a single accessible front door. Can store richer state and analytics than issue comments allow.
+
+Weaknesses: a new hosted service to secure, deploy, monitor, and make accessible. New attack surface (OWASP Top 10 applies in full: auth, secrets, injection, CSRF, rate limiting). A self-hosted dependency that can go down on go-live day. Higher accessibility testing burden because we now own HTML, focus order, and live regions ourselves.
+
+### Option C: Hybrid (recommended)
+
+GitHub-native provisioning and automation form the dependable core. A thin, optional Flask companion sits at the edges only where GitHub genuinely cannot deliver a good experience: a polished accessible registration landing page, a facilitator cohort dashboard, and bulk operations. The companion is stateless about anything critical: if it disappears, the GitHub-native core still completes the full learner journey.
+
+This is the golden choice. It honors "prefer infrastructure the learner already trusts" for the critical path, while allowing a delightful, fully accessible front door where it adds real value, and it never makes the learner's success depend on a service we have to keep alive.
+
+## The recommended path: GitHub-native core, Flask companion at the edges
+
+The plain-language architecture, in order of how a learner experiences it:
+
+1. **Front door (optional Flask companion or a GitHub Pages form).** An accessible registration page collects the learner's GitHub handle and preferences. If the Flask companion is unavailable, the existing issue-form registration in the repository is the fallback front door and loses nothing essential.
+2. **Provisioning (GitHub-native).** A GitHub App or Actions bot creates the private Learning Room repository from the template, replacing what Classroom did. The roster entry is written to the private admin repository. This is the only piece that truly changes when Classroom departs.
+3. **The Learning Room (unchanged).** Same private student repository, same seeded challenge issues, same autograders, same progression bot, same PR validation. Because the repository shape is identical, the entire downstream automation in [learning-room/.github/workflows/](learning-room/.github/workflows/) keeps working.
+4. **Progression and release (GitHub-native).** Day 2 release, autograder pass/fail comments, and the next-challenge progression all continue to run on the deterministic text signals already documented (`ack`, `day1-complete`). These are intentionally simple so a facilitator can test and recover them by hand.
+5. **Facilitator visibility (Flask companion or private admin issues).** The instructor dashboard already syncs to private admin repository issues. The Flask companion, when present, reads the same roster and renders a richer accessible dashboard. When absent, the admin issues are the dashboard.
+
+The principle holding this together: **the critical path runs on GitHub; the companion only ever makes a good thing nicer, never makes a working thing necessary.**
+
+### What changes and what does not
+
+This table summarizes the migration impact so the scope is honest and small.
+
+| Capability | Today (Classroom) | Golden (post-Classroom) | Change size |
+|---|---|---|---|
+| Provisioning student repos | Classroom clones template | GitHub App or Actions bot clones template | New, contained |
+| Roster identity mapping | Classroom roster | Private admin repository roster record | Small |
+| Registration intake | Issue form plus Classroom link | Accessible front door plus same issue-form fallback | Small |
+| Challenge seeding | Template issues | Unchanged template issues | None |
+| Autograding | Autograder workflows | Unchanged autograder workflows | None |
+| Progression bot | Progression workflow | Unchanged progression workflow | None |
+| Day 2 release | `day2-release.yml` signals | Unchanged signal logic | None |
+| Instructor dashboard | Dashboard sync to admin repo | Same sync, optional richer companion view | None to small |
+
+The lesson of this table is the whole strategy: by owning the template and the automation already, the project has quietly insulated itself from Classroom. The migration is a provisioning swap, not a rewrite.
+
+## The decoupling contract: own your state
+
+To make a vendor sunset a non-event forever, the project commits to three owned sources of truth:
+
+- **Roster of record.** A single canonical roster (in the private admin repository, optionally mirrored by the companion) that maps GitHub handle to cohort, day, and status. Reconstructable without any third party.
+- **Progress of record.** Challenge status derived from signals the project controls (issue and PR state, labels, the deterministic text signals). Never read progress from a vendor that could remove the API.
+- **Provisioning of record.** A repeatable, idempotent provisioning action that, given a roster entry, guarantees a correctly seeded student repository. Running it twice is safe. Running it after a partial failure heals the state.
+
+If all three are owned and reconstructable, no future vendor change can strand a cohort. That is what flexibility means in practice.
+
+## The five quality pillars
+
+### Pillar 1: Accessibility you can feel
+
+- Every learner-facing surface passes screen reader review on NVDA, JAWS, and VoiceOver and is fully keyboard operable.
+- If a Flask companion ships, it meets WCAG 2.2 AA: semantic landmarks, correct heading order, labeled form controls, visible focus, live-region announcements for dynamic updates, and no keyboard traps. The project's own accessibility agents and guides are the in-house review team for it.
+- Automation messages (bot comments, validation feedback) are written for a screen reader ear: clear, front-loaded, no decorative noise.
+
+### Pillar 2: Reliability you can trust
+
+- The critical learner path has no single vendor point of failure after the Classroom swap.
+- Every automated workflow has a documented manual fallback in the facilitator runbook ([admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md](admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md)).
+- Provisioning is idempotent and self-healing. Re-running never corrupts a student repository.
+- The go-live release gate ([GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md)) must pass before any cohort opens.
+
+### Pillar 3: Flexibility that outlasts vendors
+
+- State is owned and reconstructable (the decoupling contract above).
+- The provisioning method is swappable behind a stable internal contract, so the next vendor change is a localized edit, not a crisis.
+- The curriculum keeps teaching exploration as a skill, so learners are resilient to UI drift even when our docs lag reality.
+
+### Pillar 4: Error-free learner paths
+
+- Zero broken links, zero dead assignment URLs, zero orphaned challenges in a shipped cohort.
+- Automation fails loud, not silent: a watchdog detects stalled provisioning or stuck progression and raises a human-visible alert.
+- Security is part of correctness: any Flask companion is reviewed against the OWASP Top 10, secrets never live in client code or public repos, tokens are least-privilege and rotatable.
+
+### Pillar 5: Delight in the details
+
+- The welcome uses the learner's name and affirms belonging from the first message.
+- The validation bot celebrates progress and frames failures as the next step, never as a verdict.
+- Recovery feels like a safety net: a learner who breaks something is guided back without shame.
+- Audio companions exist for every chapter so a tired learner can switch modes. Honor the existing preference to update transcripts and the site without regenerating audio unless a podcast release is explicitly in scope.
+
+## Failure modes and graceful recovery
+
+Golden software assumes things break and plans the catch. For each likely failure, there is a defined, accessible recovery.
+
+- **Provisioning fails for one student.** The idempotent provisioning action is re-run for that roster entry. The watchdog flags any student without a healthy repository before Day 1 begins.
+- **The companion service is down on go-live day.** The GitHub-native front door and core path carry the entire workshop. The companion's absence is invisible to learners.
+- **A GitHub UI label or button moves.** The curriculum's exploration skills cover the learner in the moment; a curriculum issue is filed so the docs catch up for everyone.
+- **A token expires or is revoked.** Least-privilege tokens are documented and rotatable; the runbook lists exactly which capability each token enables so recovery is fast and scoped.
+- **A learner falls behind or breaks their repository.** Tiered recovery (already part of the QA runbook) restores them to a known-good state with branch and PR evidence, no data loss.
+
+The rule: a learner should never be the one who discovers a failure. The watchdog and the facilitator find it first.
+
+## Phased roadmap
+
+A bold vision delivered in safe increments.
+
+1. **Phase 0: Harden the present.** Pass the full go-live gate on the current Classroom-based flow. You cannot migrate cleanly from an unstable base.
+2. **Phase 1: Prove the decoupling contract.** Stand up the owned roster, owned progress, and an idempotent provisioning action that creates a student repository from the template without Classroom. Validate with test accounts end to end.
+3. **Phase 2: GitHub-native provisioning in production.** Make the GitHub-native path the default for a real test cohort. Keep Classroom available as a parallel fallback during transition.
+4. **Phase 3: Accessible front door and facilitator dashboard.** Add the optional Flask companion (or GitHub Pages form) only after the core path is proven. Subject it to the same accessibility and security bar as the curriculum itself.
+5. **Phase 4: Retire the Classroom dependency.** Once the GitHub-native path has run a full cohort cleanly, remove the hard dependency on Classroom and document the new architecture as canonical.
+6. **Phase 5: Continuous delight.** Iterate on the hundred small details: warmer automation copy, faster recovery, richer audio companions, sharper accessibility.
+
+Each phase ships only when the previous phase is golden. No phase weakens accessibility, reliability, or learner trust to move faster.
+
+## Definition of golden: the release gate
+
+A cohort is golden only when all of the following hold. This complements, and does not replace, the detailed [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md).
+
+- The critical learner path completes end to end with no GitHub Classroom dependency, or with Classroom and the GitHub-native path both proven.
+- Provisioning is idempotent and re-running it heals partial failures.
+- Owned roster, owned progress, and owned provisioning are all reconstructable without a third party.
+- Every learner-facing surface passes NVDA, JAWS, and VoiceOver review and is fully keyboard operable.
+- Any Flask companion passes WCAG 2.2 AA and an OWASP Top 10 security review, with least-privilege, rotatable secrets.
+- Every automated workflow has a tested manual fallback in the runbook.
+- A watchdog surfaces stalled provisioning or progression to a human before a learner notices.
+- Zero broken learner-facing links or dead assignment URLs in the shipped cohort.
+- Automation copy is warm, screen-reader-first, and frames failure as the next step.
+
+## Guardrails and non-negotiables
+
+- **Never trade accessibility for convenience.** A faster facilitator tool that a blind learner cannot use is not shipped.
+- **Never let a vendor own the critical path again.** Provisioning, roster, and progress stay owned and reconstructable.
+- **Never fail silently.** Loud, human-visible failure with a clear recovery action, every time.
+- **Never make delight an afterthought.** The warm welcome, the kind validation message, the safety-net recovery: these are requirements, not polish.
+- **Keep the GLOW name and branding** unless explicitly asked to change it.
+- **Minimize audio regeneration.** Prefer transcript and site-only updates unless a podcast release is explicitly in scope.
+
+---
+
+Golden is a direction, not a destination. Measure every pull request, every workflow, every word of bot copy against this question: does it make the journey more dependable, more accessible, more flexible, more error free, and more delightful for the learner who needs it most? If yes, ship it. If no, return here and try again.
+
+## Authoritative Sources
+
+Use these official references when you need the current source of truth for facts in this document.
+
+- [GitHub Docs, home](https://docs.github.com/en)
+- [About GitHub Classroom](https://docs.github.com/en/education/manage-coursework-with-github-classroom/teach-with-github-classroom/about-github-classroom)
+- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
+
+### Section-Level Source Map
+
+Use this map to verify facts for each major section in this file.
+
+- **What golden means, Design principles, The five quality pillars, Guardrails:** [GitHub Docs, home](https://docs.github.com/en), [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
+- **The existential risk, Architecture options, The recommended path, The decoupling contract, Failure modes, Phased roadmap, Definition of golden:** [About GitHub Classroom](https://docs.github.com/en/education/manage-coursework-with-github-classroom/teach-with-github-classroom/about-github-classroom), [GitHub Docs, home](https://docs.github.com/en)
diff --git a/html/SPEC.html b/html/SPEC.html
new file mode 100644
index 00000000..693b72bc
--- /dev/null
+++ b/html/SPEC.html
@@ -0,0 +1,914 @@
+
+
+
+
+
+
+ SPEC.md - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ SPEC.md
+Complete project specification for GIT Going with GitHub (GLOW). This is the technical source of truth: what the system is, what every component does, how data and state flow, the interfaces between parts, and the requirements every piece must meet. It implements the vision in golden.md . Where this spec and reality disagree, fix one of them and note it here.
+Table of contents
+
+1. Overview
+GLOW is a two-day, accessibility-first workshop that teaches blind and low vision technologists to navigate and contribute to open source on GitHub using a screen reader and keyboard alone. It is delivered as:
+
+A curriculum of 22 chapters plus appendices in docs/ , published to HTML, EPUB, and an audio podcast series.
+A Learning Room : a per-student private repository, created from a template, that drives 16 core challenges plus 5 bonus challenges through GitHub-native automation (a PR validation bot named Gandalf, a Student Progression Bot, and a suite of autograders).
+A registration and cohort system that intakes learners, provisions their Learning Room, releases Day 2 content, and gives facilitators a live status dashboard.
+
+The current production system depends on GitHub Classroom for provisioning. This spec defines both the present system and the target system in which Classroom is replaced by owned, GitHub-native provisioning, per golden.md .
+2. Goals and non-goals
+Goals
+
+A learner completes the full arc from first GitHub navigation to a real, review-ready open source contribution.
+Every learner-facing surface is fully operable with NVDA, JAWS, and VoiceOver, by keyboard alone.
+Provisioning, roster, and progress are owned and reconstructable, with no single vendor as a point of failure.
+Facilitators can open a cohort, run it, and recover from any failure using documented runbooks.
+The system degrades gracefully: every automated step has a manual fallback.
+
+Non-goals
+
+Email delivery is not a dependency. All flows complete using the GitHub web notification inbox.
+The system does not require learners to join an organization, hold a paid plan, or change Actions settings.
+The system does not aim to replace VS Code, Copilot, or GitHub itself; it teaches their accessible use.
+Real-time chat, grading-for-credit, and LMS integration are out of scope.
+
+3. Personas and primary journeys
+Personas
+
+Learner. New-to-GitHub, uses assistive technology, may not code. Needs belonging, clarity, and a forgiving path.
+Facilitator. Runs a cohort, seeds challenges, monitors progress, recovers stuck learners.
+Maintainer. Owns the curriculum, automation, content pipeline, and this spec.
+
+Primary learner journey
+
+Register through an accessible front door (Flask companion, GitHub Pages form, or issue form fallback).
+Receive a provisioned private Learning Room repository.
+Acknowledge readiness (ack), complete Day 1 challenges, signal day1-complete.
+Receive Day 2 release, complete Day 2 challenges and the capstone.
+Open or prepare a real upstream contribution; continue asynchronously with support hub access.
+
+Primary facilitator journey
+
+Sync and validate the Learning Room template.
+Open a cohort; provisioning creates student repositories.
+Seed Challenge 1 (and Challenge 10 for Day 2) per student.
+Monitor the dashboard; intervene on watchdog alerts.
+Run teardown after the cohort.
+
+4. System architecture
+The architecture separates a dependable GitHub-native core (the critical learner path) from an optional companion at the edges. The companion can vanish without breaking any learner.
+ ACCESSIBLE FRONT DOOR
+ (Flask companion OR GitHub Pages form OR issue form fallback)
+ |
+ v
+ REGISTRATION + ROSTER (owned)
+ private admin repo roster record <----+ companion mirror (optional)
+ |
+ v
+ PROVISIONING SUBSYSTEM (GitHub-native)
+ idempotent action: template ----> per-student private Learning Room repo
+ |
+ v
+ THE LEARNING ROOM (per student)
+ Gandalf (PR bot) | Student Progression Bot | Autograders | Challenge issues
+ |
+ v
+ PROGRESSION + DAY 2 RELEASE (deterministic text signals)
+ |
+ v
+ FACILITATOR DASHBOARD (admin issues + optional companion view)
+Architectural rules:
+
+The critical path (front door fallback, provisioning, Learning Room, progression, release) runs entirely on GitHub.
+The companion only ever renders owned state more nicely; it never holds state the learner depends on.
+Each arrow is a documented contract (Section 8) with a manual fallback.
+
+5. Component catalog
+This table is the index of moving parts. Each component has an owner, a trigger, and a fallback.
+
+
+
+Component
+Location
+Trigger
+Responsibility
+Manual fallback
+
+
+
+Registration workflow
+.github/workflows/registration.yml
+Registration issue opened
+Validate, redact, label, store intake, post links
+Facilitator labels and replies by hand
+
+
+Day 2 release
+.github/workflows/day2-release.yml
+Schedule, dispatch
+Post Day 2 link when day1-complete signal present
+Facilitator posts Day 2 link
+
+
+Dashboard sync
+.github/workflows/instructor-dashboard-sync.yml
+Schedule, events
+Upsert per-student status issue in admin repo
+Facilitator reads enrollment issues
+
+
+Student grouping
+.github/workflows/student-grouping.yml
+Schedule, dispatch
+Cohort grouping support
+Manual roster grouping
+
+
+Skills progression
+.github/workflows/skills-progression.yml
+Issue events
+Track skills progression
+Manual tracking
+
+
+Learning Room validation
+.github/workflows/learning-room-validation.yml
+PR, dispatch
+Validate template integrity
+Manual template review
+
+
+Authoritative sources validation
+.github/workflows/validate-authoritative-sources.yml
+PR
+Enforce source maps in content
+Run validator script locally
+
+
+Gandalf (PR bot)
+learning-room/.github/workflows/pr-validation-bot.yml
+PR events, comments
+Welcome, validate PR, answer help
+Facilitator comments
+
+
+Student Progression Bot
+learning-room/.github/workflows/student-progression.yml
+Issue closed, dispatch
+Create next challenge issue
+Dispatch with start_challenge
+
+
+Autograders (8)
+learning-room/.github/workflows/autograder-*.yml
+Issue, PR, push, schedule
+Verify objective challenge evidence
+Facilitator verifies manually
+
+
+Content validation
+learning-room/.github/workflows/content-validation.yml
+PR
+Validate student content edits
+Manual review
+
+
+Provisioning action (target)
+new, GitHub App or Actions bot
+Roster entry created
+Create and seed Learning Room repo idempotently
+Classroom invite during transition
+
+
+Content pipeline
+.github/workflows/ build-* and deploy-*
+Push, dispatch
+Build HTML, EPUB, diagrams, Pages
+Run build scripts locally
+
+
+Flask companion (optional)
+new service
+HTTP
+Accessible front door and dashboard
+GitHub Pages form and admin issues
+
+
+The three Learning Room automation systems
+
+Gandalf (PR Validation Bot). Welcomes first-time contributors, validates PR structure, responds to help requests, posts real-time feedback on every push. Idempotent comment updates keyed by a marker string so it never spams a thread.
+Student Progression Bot. On close of a Challenge N issue, creates the next challenge issue; on workflow_dispatch with start_challenge, seeds a specific challenge. Concurrency-guarded per issue. See challenge-progression.js .
+Autograders. Eight workflows verifying objective evidence: issue filed, branch commit, PR link, merge conflicts resolved, local commit, template validity, capstone, and a watchdog. Each posts a single, kind, idempotent pass or fail comment and a separate error notice on workflow failure so the learner is never blamed for infrastructure faults.
+
+6. Data model and state of record
+The decoupling contract from golden.md requires three owned, reconstructable sources of truth. This section defines their schemas.
+6.1 Roster of record
+Canonical store: one record per learner in the private admin repository (optionally mirrored by the companion). Logical schema:
+
+
+
+Field
+Type
+Notes
+
+
+
+github_handle
+string
+Primary identity key
+
+
+cohort_id
+string
+Cohort the learner belongs to
+
+
+path
+enum
+day1-day2 or day2-only
+
+
+learning_room_repo
+string
+Owner/name of provisioned repo, null until provisioned
+
+
+provision_state
+enum
+pending, provisioned, failed, healed
+
+
+status
+enum
+awaiting-ack, active-day1, day1-complete, day2-released, needs-info
+
+
+registered_at
+timestamp
+Intake time
+
+
+last_signal_at
+timestamp
+Last deterministic signal observed
+
+
+notes
+string
+Facilitator notes, redacted in any public surface
+
+
+Reconstruction rule: the roster can be rebuilt from registration issues plus provisioning results without any third party.
+6.2 Progress of record
+Derived, never authored by a vendor. Progress is computed from signals the project controls:
+
+Challenge issue state (open or closed) and titles (Challenge N).
+PR state and closing-keyword links (Closes #N).
+Labels (enrolled, day1-complete, day2-eligible, day2-released).
+Deterministic text signals in comments: ack, day1-complete.
+
+Reconstruction rule: re-derivable by replaying issue and PR events in the student repository.
+6.3 Provisioning of record
+A log of provisioning attempts and outcomes per learner, sufficient to prove a repository is correctly seeded and to safely re-run. Fields: github_handle, attempt_at, result (created, already-exists, seeded, healed, error), repo, template_sha, error_detail.
+Reconstruction rule: running the idempotent provisioning action against the roster reproduces a healthy state for every learner.
+7. Provisioning subsystem (the Classroom replacement)
+This is the only piece that fundamentally changes when Classroom departs. Everything downstream is unchanged because the resulting student repository is byte-shaped identically to a Classroom-created one.
+7.1 Responsibilities
+
+Given a roster entry, create a private repository from Community-Access/learning-room-template.
+Grant the learner access.
+Confirm all required automation files are present (the workflow set listed in the go-live gate).
+Record the outcome in the provisioning log.
+Be idempotent: re-running heals partial failures and never corrupts an existing repository.
+
+7.2 Implementation options and decision
+
+Option A, GitHub App (decided for production). A GitHub App with least-privilege permissions (repository administration to create, contents to seed, metadata). Scoped, auditable, installable, and not tied to any human account.
+Option B, Actions bot (Phase 1 spike only). A scheduled or dispatched workflow in the admin repo using a least-privilege fine-grained token with repo-creation scope. Faster to stand up; acceptable only as a throwaway validation of seeding logic, then discarded.
+
+Both must implement the same internal contract so the method is swappable via PROVISIONING_MODE.
+Decision: build the GitHub App for production; use the PAT path only as an optional Phase 1 spike. The deciding factor is not rate limits or convenience, it is the first principle in golden.md : never let a single point of failure hold a cohort hostage. A PAT is bound to a facilitator's account, so that person leaving, resetting credentials, or changing 2FA can break provisioning. The App's properties (not tied to a human, short-lived installation tokens, fine-grained least-privilege) are the literal embodiment of the golden vision, and the extra one-time setup is small.
+The pros and cons that drove the decision:
+
+
+
+Factor
+GitHub App
+Actions bot (PAT)
+
+
+
+Identity
+Purpose-built, survives staff change
+Bound to a human account, single point of failure
+
+
+Token lifetime
+Short-lived installation token (~1 hour), minted on demand
+Long-lived (months); larger leak blast radius
+
+
+Permission granularity
+Fine-grained (Administration, Contents, Metadata only)
+Account-level even when fine-grained
+
+
+Rate limit ceiling
+Scales with org (floor 5,000/hour, scaling up)
+Flat 5,000/hour
+
+
+Audit trail
+Clean App identity per action
+Everything looks like the token owner
+
+
+Setup cost
+~30 to 60 minutes one-time (App, App ID, PEM secret, token mint step)
+Fastest; afternoon prototype
+
+
+Key custody
+Holds a signing key; must be in Secrets and rotatable
+One token to store and rotate
+
+
+Because the downstream system cannot tell which mode created a repository, spiking with a PAT first and graduating to the App loses nothing.
+7.2a GitHub App permission set
+Grant only these. Anything beyond this list is over-privileged and fails the security review in Section 13.
+
+
+
+Permission
+Level
+Why
+
+
+
+Repository administration
+Write
+Create the per-student private repository from the template
+
+
+Contents
+Write
+Seed and heal repository content (workflows, issue templates)
+
+
+Metadata
+Read
+Mandatory baseline for any App
+
+
+Issues
+Write
+Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot)
+
+
+App configuration rules:
+
+Install the App only on the Community-Access organization, scoped to the template and student repositories.
+Store PROVISIONING_APP_ID and PROVISIONING_APP_PRIVATE_KEY in GitHub Secrets; never in code or public repos.
+Mint a short-lived installation token at the start of each provisioning run; never persist it.
+Document a private-key rotation procedure and rotate on any suspected exposure.
+
+7.2b Provisioning algorithm (idempotent, serial)
+Provisioning runs serially with a small delay and exponential backoff, not parallel fan-out, to stay clear of GitHub secondary (abuse) rate limits on content-creating requests. The algorithm is idempotent on (github_handle, cohort_id), so a re-run resumes a half-finished batch instead of duplicating or corrupting work.
+for each roster entry where provision_state in (pending, failed):
+ key = (github_handle, cohort_id)
+ expected_repo = name_for(key)
+
+ 1. If expected_repo already exists:
+ - Verify required workflow files and template SHA.
+ - If complete: mark provisioned (or healed), log already-exists, continue.
+ - If incomplete: re-seed missing files, mark healed, log healed.
+ Else:
+ - Create private repo from template at the pinned template SHA.
+ - Log created.
+
+ 2. Grant the learner access (idempotent; skip if already granted).
+
+ 3. Seed required content if not already present
+ (workflows, issue templates). Log seeded.
+
+ 4. Verify required workflow set is present (go-live gate list).
+ - On success: provision_state = provisioned (or healed).
+ - On failure: provision_state = failed, write error_detail,
+ surface to watchdog. Do NOT leave a half-seeded repo silently.
+
+ 5. Wait a short delay (1 to a few seconds) before the next entry.
+ On HTTP 403/429 (secondary limit), back off exponentially and retry
+ the same entry; never skip it.
+Invariants: at-most-one repository per key; pinned template SHA; learner has access; required workflows present; every outcome written to the provisioning log; any failure visible to a human before a learner notices.
+7.3 Provisioning contract
+
+Input: roster entry (github_handle, cohort_id, path).
+Output: learning_room_repo, provision_state, provisioning log entry.
+Idempotency key: (github_handle, cohort_id).
+Guarantees: at-most-one repository per key; correct template SHA; learner has access; required workflows present.
+Failure behavior: on any error, set provision_state = failed, write error_detail, and surface to the watchdog. Never leave a half-seeded repo silently.
+
+7.4 Rate and scale considerations
+For any realistic size of this workshop, documented hourly rate limits are a non-issue. The real risk is GitHub secondary (abuse) rate limits, which trigger on bursts of content-creating requests fired in parallel. The serial-with-delay-and-backoff algorithm in Section 7.2b avoids them, and idempotency means hitting a limit just means run it again, it heals.
+Design target: 1 to 100 learners. That is what a high-touch, accessibility-first, facilitator-led workshop actually is. A cohort large enough to hit real rate limits would also be too large to run with the recover-every-stuck-learner quality bar that defines this project. The constraint that bites first is facilitator attention, not API quota.
+This table is the planning guidance by cohort size.
+
+
+
+Cohort size
+Reality
+What to do
+
+
+
+1 to 30 (likely case)
+Trivial; serial creation finishes in minutes
+Serial loop, 1 to 2 second delay, backoff on error
+
+
+30 to 100
+Comfortable within an hour
+Same pattern; rely on idempotent re-run to heal mid-batch failures
+
+
+100 to 300
+Approaching where bursting would trip secondary limits
+Throttle to a steady rate, chunk the batch, resume via idempotency; the App ceiling helps
+
+
+300+
+Plan deliberately
+Stagger provisioning over time windows, pre-flight seat and quota checks, monitor for 403s
+
+
+Two design choices make cohort size a permanent non-worry, and both are already specified:
+
+Idempotent provisioning keyed on (github_handle, cohort_id) (Section 7.2b). A re-run resumes a half-finished batch rather than duplicating or corrupting it.
+Provision on registration, not big-bang. Creating repositories as learners register (a trickle) rather than all at once on go-live morning (a burst) means you essentially never approach a burst limit regardless of total size.
+
+Operational rules:
+
+Pre-flight check org seat and repository quotas before a cohort opens.
+Template sync and smoke validation (Prepare-LearningRoomTemplate.ps1, Test-LearningRoomTemplate.ps1) run before any provisioning.
+
+8. Automation contracts
+Each arrow in the architecture is a stable contract. Contracts are intentionally simple text and label signals so facilitators can test and recover them by hand.
+
+
+
+Contract
+Producer
+Consumer
+Signal
+Idempotent
+Manual fallback
+
+
+
+Enroll
+Registration workflow
+Roster
+enrolled label, roster record
+Yes
+Apply label, add record
+
+
+Acknowledge
+Learner
+Roster, dashboard
+comment ack
+Yes
+Facilitator marks acked
+
+
+Day 1 complete
+Learner
+Day 2 release
+comment day1-complete or label
+Yes
+Facilitator posts Day 2 link
+
+
+Day 2 release
+Release workflow
+Learner
+comment with Day 2 link, day2-released label
+Yes
+Facilitator posts link
+
+
+Next challenge
+Progression bot
+Learner
+new Challenge N+1 issue
+Yes (per issue)
+Dispatch with start_challenge
+
+
+Autograde
+Autograder
+Learner
+single pass/fail comment keyed by marker
+Yes
+Facilitator verifies and comments
+
+
+Dashboard
+Dashboard sync
+Facilitator
+upserted status issue
+Yes
+Read enrollment issues
+
+
+Provision
+Provisioning action
+Roster, learner
+repo created and seeded
+Yes (per key)
+Classroom invite during transition
+
+
+Contract invariants:
+
+Every bot comment is updated in place via a marker string, never duplicated.
+Every workflow that can fail posts a separate, blameless error notice on failure.
+Every signal is plain text or a label so it is human-testable and human-recoverable.
+
+9. Curriculum subsystem
+
+22 chapters (docs/00-pre-workshop-setup.md through docs/22-what-comes-next.md ) plus appendices A through Z and AA through AC.
+16 core challenges and 5 bonus challenges, indexed in docs/CHALLENGES.md , each with instructions, evidence requirements, and a reference solution in docs/solutions/ .
+Each chapter is authored screen-reader-first: every step keyboard-accessible, every concept explained without sight, an "If You Get Stuck" section, and a Section-Level Source Map enforced by the authoritative-sources validator.
+The arc is one journey: browser, then github.dev, then desktop VS Code with Accessibility Agents. Every Day 1 skill maps to a Day 2 agent command.
+
+Curriculum requirements:
+
+Source maps required on in-scope content; podcasts and non-content paths excluded (per repo memory and the validator).
+Every reference URL must resolve; broken links fail the content gate.
+Tools-change resilience: chapters teach exploration so learners survive UI drift.
+
+10. Content pipeline: docs, HTML, EPUB, audio
+The pipeline turns Markdown into every delivery format.
+
+Audio policy: minimize MP3 regeneration. Prefer transcript-only and site-only updates unless a podcast release is explicitly in scope. The catalog currently covers 54 companion episodes plus planned Challenge Coach episodes.
+Pipeline requirements:
+
+HTML must build from current Markdown before any cohort opens.
+The RSS feed validates before publish.
+All generated HTML preserves heading structure, descriptive links, and table semantics.
+
+11. Optional Flask companion
+The companion is strictly optional and lives at the edges. It must add delight without becoming a dependency.
+11.1 Scope
+
+Accessible registration landing page (writes to the owned roster).
+Facilitator cohort dashboard (reads owned roster and progress).
+Bulk facilitator operations (trigger provisioning, re-seed, recover) as thin wrappers over the owned provisioning action.
+
+11.2 Hard constraints
+
+Stateless about anything critical. If the companion is down, the GitHub-native front door and admin-issue dashboard carry the full workshop.
+WCAG 2.2 AA: semantic landmarks, correct heading order, labeled controls, visible focus, live-region announcements, no keyboard traps.
+OWASP Top 10 reviewed: authenticated facilitator actions, CSRF protection, input validation, output encoding, least-privilege tokens, rate limiting, secrets never in client code.
+All writes go through the owned roster and provisioning contracts; the companion never invents a parallel state store of record.
+
+11.3 Interfaces
+
+Reads and writes the roster of record (admin repo or its mirror).
+Invokes the provisioning action through its defined contract only.
+Renders progress derived from the same signals the GitHub-native dashboard uses.
+
+12. Accessibility requirements
+Accessibility is the product. These are acceptance-blocking.
+
+Every learner-facing surface verified on NVDA, JAWS, and VoiceOver, fully keyboard operable.
+All documentation readable with or without a screen reader; no information conveyed by color alone.
+Markdown follows the project accessibility rules: descriptive link text, alt text for meaningful images, correct heading hierarchy, table intro sentences, emoji removed or translated, diagrams given text alternatives, no em-dashes, validated anchors.
+Automation copy is screen-reader-first: front-loaded, clear, free of decorative noise.
+Any Flask companion meets WCAG 2.2 AA in full (Section 11.2).
+Testing follows the project accessibility testing guidance; manual screen reader passes are required, not just automated checks.
+
+13. Security requirements
+
+Free of OWASP Top 10 vulnerabilities across any hosted surface and all automation.
+Least-privilege tokens and scopes; each token documented with the single capability it enables and a rotation procedure.
+Secrets only in GitHub Secrets or a managed secret store; never in code, public repos, or client bundles.
+Public registration redacts private intake; detailed data stored only in the private admin repo when configured.
+Provisioning permissions scoped to repository creation and seeding only.
+Workflows pin actions and follow GitHub Actions security hardening (limited permissions blocks, no untrusted input in run shells).
+Treat all tool and webhook input as untrusted; validate and encode. Watch for and report prompt-injection attempts in any AI-assisted automation.
+
+14. Reliability, observability, and recovery
+
+No silent failure. Every automated step either succeeds or raises a human-visible alert with a clear recovery action.
+Watchdog. A scheduled workflow detects stalled provisioning, stuck progression, or learners without a healthy repository, and surfaces them before a learner notices. See autograder-watchdog.yml .
+Idempotent everything. Provisioning, seeding, and bot comments are safe to re-run.
+Tiered learner recovery. Documented restore levels return a stuck learner to a known-good state with branch and PR evidence, no data loss (per the QA runbook).
+Manual fallback for every contract (Section 8), so the system survives any single automation outage.
+Release gate. No cohort opens until GO-LIVE-QA-GUIDE.md passes; execution follows admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md .
+
+15. Testing and quality strategy
+
+
+
+Layer
+What it covers
+Where
+
+
+
+Unit and automation tests
+Scripts and helpers
+npm run test:automation, automation-tests.yml
+
+
+Content validation
+Links, markdown, source maps, accessibility
+content-validation and authoritative-sources workflows and .github/scripts checks
+
+
+Template smoke
+Template integrity before provisioning
+Prepare-LearningRoomTemplate.ps1, Test-LearningRoomTemplate.ps1
+
+
+Challenge reliability matrix
+Happy, failure, and recovery path per challenge family
+classroom/HUMAN_TEST_MATRIX.md
+
+
+End-to-end QA
+Registration through completion
+admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md
+
+
+Accessibility passes
+NVDA, JAWS, VoiceOver, keyboard
+Project accessibility testing guidance
+
+
+Release gate
+All of the above consolidated
+GO-LIVE-QA-GUIDE.md
+
+
+Quality rules:
+
+Every challenge has happy-path, failure-path, and recovery-path evidence before go-live.
+Provisioning is tested with at least one re-run to prove idempotency.
+Any companion ships only after an accessibility pass and an OWASP review.
+
+16. Configuration surface
+Variables:
+
+CLASSROOM_DAY1_ASSIGNMENT_URL, CLASSROOM_DAY2_ASSIGNMENT_URL (transitional; retired after Classroom removal).
+PRIVATE_STUDENT_DATA_REPO.
+ENABLE_PUBLIC_CLASSROOM_INTAKE_EXPORT, ENABLE_PUBLIC_REGISTRATION_EXPORT.
+New for owned provisioning: LEARNING_ROOM_TEMPLATE_REPO, ADMIN_ROSTER_REPO, PROVISIONING_MODE (github-app or actions-bot).
+
+Secrets:
+
+PRIVATE_STUDENT_DATA_TOKEN, INSTRUCTOR_DASHBOARD_TOKEN.
+New for owned provisioning: PROVISIONING_APP_ID and PROVISIONING_APP_PRIVATE_KEY (App mode) or PROVISIONING_TOKEN (bot mode), least-privilege and rotatable.
+
+17. Repository layout
+
+
+
+Path
+Purpose
+
+
+
+docs/
+Curriculum chapters, appendices, challenges, solutions
+
+
+learning-room/
+Source of the per-student template: workflows, scripts, issue templates
+
+
+.github/workflows/
+Cohort-level automation and content pipeline
+
+
+classroom/
+Deployment guide, assignments, test matrix, grading guide, teardown
+
+
+admin/
+Facilitator operations, QA runbooks, architecture, podcasts
+
+
+scripts/
+Generation and classroom preparation scripts
+
+
+html/ , epub/ , podcasts/
+Generated delivery formats
+
+
+golden.md
+Vision and quality north star
+
+
+SPEC.md
+This specification
+
+
+GO-LIVE-QA-GUIDE.md
+Release gate
+
+
+18. Migration plan: from Classroom to owned provisioning
+Aligned to the phased roadmap in golden.md .
+
+Phase 0, harden the present. Pass the full go-live gate on the current Classroom flow.
+Phase 1, prove decoupling. Implement the owned roster, owned progress derivation, and the idempotent provisioning action. Validate end to end with test accounts. Classroom remains primary.
+Phase 2, production provisioning. Run a real test cohort on GitHub-native provisioning with Classroom kept as parallel fallback. Prove idempotency with a re-run.
+Phase 3, accessible front door. Add the optional companion (or Pages form) under the full accessibility and security bar.
+Phase 4, retire Classroom. After a clean full cohort, remove the hard Classroom dependency, retire CLASSROOM_* variables, and mark owned provisioning canonical.
+Phase 5, continuous delight. Iterate on copy, recovery speed, audio companions, and accessibility.
+
+No phase ships until the prior phase is golden. No phase weakens accessibility, reliability, or learner trust.
+19. Acceptance criteria
+The system is acceptable for a cohort when:
+
+The full learner journey completes end to end with provisioning that does not require GitHub Classroom (or with both paths proven during transition).
+Provisioning is idempotent; a re-run heals partial failures and never corrupts a repo.
+Roster, progress, and provisioning are owned and reconstructable without a third party.
+Every learner-facing surface passes NVDA, JAWS, and VoiceOver review and is fully keyboard operable.
+Any companion passes WCAG 2.2 AA and an OWASP Top 10 review with least-privilege, rotatable secrets.
+Every automation contract has a tested manual fallback.
+The watchdog surfaces stalled provisioning or progression before a learner notices.
+Zero broken learner-facing links or dead assignment URLs.
+Automation copy is warm, screen-reader-first, and frames failure as the next step.
+The go-live release gate passes.
+
+20. Open questions
+
+GitHub App versus Actions bot for production provisioning: Resolved. Build a GitHub App for production; PAT path is a Phase 1 spike only (Section 7.2).
+Maximum cohort size for rate-limit and quota planning: Resolved. Design for 1 to 100 learners with serial, idempotent, provision-on-registration behavior (Section 7.4).
+Where does the companion (if built) get hosted, and what is its uptime expectation given it must be non-critical?
+Should the owned roster mirror live in the companion database, or stay solely in admin-repo records for a single source of truth?
+Which Challenge Coach audio episodes are in scope for the next release, given the minimize-regeneration policy?
+
+
+This spec is a living contract. When provisioning moves off Classroom, when the companion ships, or when a challenge changes, update the relevant section and the acceptance criteria together. Measure every change against golden.md : more dependable, more accessible, more flexible, more error free, more delightful.
+Implementation status
+The owned, GitHub-native core of the Hybrid plan is implemented and tested:
+
+Authoritative Sources
+Use these official references when you need the current source of truth for facts in this document.
+
+Section-Level Source Map
+Use this map to verify facts for each major section in this file.
+
+Overview, Goals and non-goals, Personas, System architecture, Component catalog, Curriculum subsystem, Content pipeline, Repository layout: GitHub Docs, home
+Data model, Provisioning subsystem, Automation contracts, Configuration surface, Migration plan, Acceptance criteria, Implementation status: Authenticating as a GitHub App installation , Generate a repository from a template (REST)
+Optional Flask companion, Accessibility requirements: Web Content Accessibility Guidelines (WCAG) 2.2
+Security requirements, Reliability and recovery, Testing and quality strategy: OWASP Top Ten , GitHub Docs, home
+
+
+
+
+
+
+
diff --git a/html/admin/COHORT_PROVISIONING.html b/html/admin/COHORT_PROVISIONING.html
index b9c6202d..d26c0aec 100644
--- a/html/admin/COHORT_PROVISIONING.html
+++ b/html/admin/COHORT_PROVISIONING.html
@@ -64,6 +64,9 @@ Cohort Provisioning -- Deprecated
The previous "multi-player sandbox" provisioning model (shared repo + batch issue scripts) has been replaced by GitHub Classroom. Each student now gets their own private repository automatically when they accept a Classroom invite link, and the Student Progression Bot handles challenge sequencing within each student's repo.
See the Workshop Deployment Guide for all setup instructions.
+
+Looking ahead, beyond Classroom. The owned, GitHub-native replacement for Classroom provisioning is specified in SPEC.md and operated per OWNED_PROVISIONING.md . That subsystem removes the single-vendor dependency by owning the roster, progress, and provisioning of record.
+
Authoritative Sources
Use these official references when you need the current source of truth for facts in this chapter.
diff --git a/html/admin/HYBRID_DEPLOYMENT_GUIDE.html b/html/admin/HYBRID_DEPLOYMENT_GUIDE.html
new file mode 100644
index 00000000..f0f8b34d
--- /dev/null
+++ b/html/admin/HYBRID_DEPLOYMENT_GUIDE.html
@@ -0,0 +1,443 @@
+
+
+
+
+
+
+ Hybrid Deployment Guide - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ Hybrid Deployment Guide
+This is the step-by-step guide to deploying the Hybrid architecture from
+golden.md and SPEC.md : the owned, GitHub-native
+provisioning core that replaces GitHub Classroom, plus the optional accessible Flask
+companion. For the conceptual model and day-to-day operations, see
+OWNED_PROVISIONING.md . For the legacy Classroom-based flow,
+see ../classroom/README.md . To review every piece of the
+Hybrid deliverable from one place, start at HYBRID_REVIEW_INDEX.md .
+Follow the phases in order. Each phase ends with a verification step. Do not advance
+until the current phase verifies, exactly as the phased roadmap in golden.md requires.
+Table of contents
+
+What you are deploying
+
+
+
+Piece
+Where it runs
+Required?
+
+
+
+Provisioning scripts
+This repository, .github/scripts/provisioning/
+Yes
+
+
+Provisioning workflow
+GitHub Actions in this repository
+Yes
+
+
+Roster of record (roster.json)
+A private admin repository
+Yes
+
+
+Provisioning identity
+A GitHub App in the Community-Access org
+Yes (production)
+
+
+Learning Room template
+Community-Access/learning-room-template
+Yes (already exists)
+
+
+Flask companion
+A small host you control (or none)
+No, optional
+
+
+The critical path is the first five rows. The companion is a convenience at the edges
+and can be skipped entirely; the issue-form front door and admin-issue dashboard carry
+the workshop without it.
+Prerequisites
+
+Owner or admin access to the Community-Access GitHub organization.
+The Learning Room template repository exists and passes template validation
+(scripts/classroom/Test-LearningRoomTemplate.ps1).
+A private admin repository you control, to hold the roster and provisioning log.
+Node.js 20 or newer for local runs and tests.
+Python 3.12 or newer if you deploy the companion.
+The gh CLI authenticated, for the convenience commands below.
+
+Confirm your toolchain:
+node --version
+python --version
+gh auth status
+Phase 1: Prepare the admin roster repository
+The roster of record lives in a private repository so intake data is never public.
+
+Create (or choose) a private repository, for example Community-Access/glow-admin.
+Add a starter roster at its root. Copy the shape from
+examples/roster.example.json , or start empty:
+{ "version" : 1 , "cohorts" : [ ] , "learners" : [ ] }
+
+Commit it as roster.json. The provisioning run will also create and maintain
+provisioning-log.json next to it.
+
+Verification: the file validates locally.
+node -e "require('./.github/scripts/provisioning/roster').parseRoster(require('fs').readFileSync('roster.json','utf8')); console.log('roster ok')"
+Phase 2: Create the provisioning GitHub App
+A GitHub App is the production identity because it is not tied to a human account,
+mints short-lived tokens, and uses fine-grained least-privilege permissions
+(SPEC.md section 7.2). Create it once.
+
+In the organization, go to Settings, Developer settings, GitHub Apps, New GitHub App.
+Name it (for example GLOW Provisioning). Set a homepage URL (the repo is fine).
+Disable the webhook (this App is called by Actions, not by webhooks).
+Grant only these repository permissions, nothing more:
+Administration: Read and write (create student repositories).
+Contents: Read and write (seed and heal repository content).
+Metadata: Read-only (mandatory baseline).
+Issues: Read and write (optional; only if the App seeds the first issue).
+
+
+Create the App, then note the numeric App ID.
+Generate a private key; a .pem file downloads. Keep it secret.
+Install the App on the Community-Access organization, scoped to the template
+repository and the student repositories (or all repositories if simpler for now).
+Open the installation and note the numeric Installation ID (it is in the
+installation settings URL).
+
+Verification: you now hold three values: App ID, Installation ID, and the PEM key.
+
+Set these on this repository (Settings, Secrets and variables, Actions). Using an
+Environment named provisioning is recommended so you can add required reviewers.
+Repository or environment variables:
+
+
+
+Variable
+Value
+
+
+
+PROVISIONING_MODE
+github-app
+
+
+LEARNING_ROOM_TEMPLATE_REPO
+Community-Access/learning-room-template
+
+
+PROVISIONING_STUDENT_OWNER
+Community-Access
+
+
+ADMIN_ROSTER_REPO
+Community-Access/glow-admin (your admin repo)
+
+
+Secrets:
+
+
+
+Secret
+Value
+
+
+
+PROVISIONING_APP_ID
+The App ID from Phase 2
+
+
+PROVISIONING_APP_INSTALLATION_ID
+The Installation ID from Phase 2
+
+
+PROVISIONING_APP_PRIVATE_KEY
+The full contents of the .pem file
+
+
+PRIVATE_STUDENT_DATA_TOKEN
+A token that can check out and push to the admin repo
+
+
+Using gh for the App secrets (run from this repository):
+gh secret set PROVISIONING_APP_ID --body "123456"
+gh secret set PROVISIONING_APP_INSTALLATION_ID --body "98765432"
+gh secret set PROVISIONING_APP_PRIVATE_KEY < glow-provisioning.private-key.pem
+Security rules: the PEM lives only in Secrets, never in code or a public repo. Mint
+tokens on demand and never persist them (the workflow already does this). Document and
+rehearse key rotation; rotate on any suspected exposure.
+Phase 4: Deploy and smoke-test provisioning
+The provisioning workflow ships in this repository at
+../.github/workflows/provision-learning-rooms.yml .
+Merging this branch deploys it. It runs on a 30-minute schedule and on demand.
+Smoke test with a single test account before any real cohort:
+
+Add one test learner to the admin roster.json (a throwaway GitHub account you
+control), provision_state set to pending.
+In the Actions tab, run Provision Learning Rooms with dry_run checked.
+Confirm it lists exactly your test learner.
+Run it again with dry_run unchecked. Confirm:
+A private student repository is created from the template.
+The test account receives a repository invitation.
+The roster entry flips to provisioned and provisioning-log.json records
+created.
+
+
+Prove idempotency: run it a second time. The log should record already-exists
+and make no changes.
+
+You can also run it locally against a checkout of the admin repo:
+LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \
+PROVISIONING_STUDENT_OWNER=Community-Access \
+PROVISIONING_MODE=github-app \
+PROVISIONING_APP_ID=123456 \
+PROVISIONING_APP_INSTALLATION_ID=98765432 \
+PROVISIONING_APP_PRIVATE_KEY="@glow-provisioning.private-key.pem" \
+node .github/scripts/provisioning/provision-cli.js --roster roster.json --log provisioning-log.json
+Verification: a healthy student repository exists, the invite arrived, the roster and
+log updated, and a re-run heals rather than duplicates.
+Phase 5: Deploy the optional companion
+Skip this phase entirely if you are not running the companion. The workshop is fully
+functional without it.
+The companion lives in ../companion/ . See its
+README for full detail. Production deployment outline:
+
+Provision a small host with Python 3.12+ and TLS (a reverse proxy terminating
+HTTPS in front of the app).
+Install dependencies and a WSGI server:
+cd companion
+python -m venv .venv
+.venv/bin/pip install -r requirements.txt gunicorn
+
+Provide configuration via the environment (never in code):
+export COMPANION_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32) )')"
+export COMPANION_FACILITATOR_TOKEN="a-strong-random-token"
+export COMPANION_ROSTER_PATH="/srv/glow/roster.json"
+export COMPANION_SECURE_COOKIES=1
+
+Run behind gunicorn (bind to localhost; let the proxy handle TLS and the public port):
+.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
+
+Wire the roster: the companion writes registrations to COMPANION_ROSTER_PATH. A
+separate scheduled job (or a small commit hook) must sync that file to the admin
+repository so provisioning picks it up. The companion deliberately does not push to
+GitHub itself; it only owns the local roster file through the shared contract.
+
+Verification:
+
+GET /healthz returns {"status":"ok"}.
+The registration page loads, and a screen reader pass (NVDA, JAWS, or VoiceOver)
+confirms landmarks, labeled fields, the error summary, and live-region messages.
+Facilitator sign-in with the token reaches the dashboard; a bad token is rejected.
+Response headers include Content-Security-Policy and X-Frame-Options: DENY.
+A registration appears in roster.json with provision_state pending.
+
+Phase 6: Go-live checklist
+Do not open a cohort until every item holds. This complements the release gate in
+../GO-LIVE-QA-GUIDE.md .
+
+Operating a cohort
+
+Provision on registration, not big-bang. Let the 30-minute schedule trickle new
+learners in. This keeps you far below any rate limit regardless of cohort size.
+Watch the roster and log. failed entries and error log lines are your signal
+to intervene; re-running provisioning heals pending and failed entries.
+Recover a stuck learner. Fix the root cause (seat, name clash, permission), then
+re-run provisioning. Only pending and failed entries are retried; healthy learners
+are untouched.
+Day 2 and progression continue to run on the existing deterministic text signals
+(ack, day1-complete); the owned provisioning swap does not change them.
+
+Rollback and fallback
+Because the owned core is additive and the downstream system cannot tell which method
+created a repository, rollback is low-risk.
+
+Disable owned provisioning. Turn off the schedule on the provisioning workflow
+(or set its environment to require manual approval) and fall back to the Classroom
+invite during the transition window. No learner data is lost; the roster is still the
+source of truth.
+Companion outage. None of the critical path depends on it. Point learners at the
+issue-form front door and read the admin-issue dashboard until it returns.
+Bad App credential. Re-issue the App private key, update
+PROVISIONING_APP_PRIVATE_KEY, and re-run. Idempotency means the re-run heals.
+
+Reference: configuration surface
+See SPEC.md section 16 for the authoritative list. Summary:
+
+
+
+Name
+Type
+Purpose
+
+
+
+PROVISIONING_MODE
+variable
+github-app (production) or actions-bot (spike)
+
+
+LEARNING_ROOM_TEMPLATE_REPO
+variable
+owner/name of the template
+
+
+PROVISIONING_STUDENT_OWNER
+variable
+Org or user that owns student repos
+
+
+ADMIN_ROSTER_REPO
+variable
+owner/name of the private admin repo
+
+
+PROVISIONING_APP_ID
+secret
+GitHub App ID (App mode)
+
+
+PROVISIONING_APP_INSTALLATION_ID
+secret
+Installation ID (App mode)
+
+
+PROVISIONING_APP_PRIVATE_KEY
+secret
+PEM private key (App mode)
+
+
+PROVISIONING_TOKEN
+secret
+Fine-grained PAT (actions-bot spike only)
+
+
+PRIVATE_STUDENT_DATA_TOKEN
+secret
+Checkout and push to the admin repo
+
+
+COMPANION_SECRET_KEY
+env
+Flask session signing key (companion)
+
+
+COMPANION_FACILITATOR_TOKEN
+env
+Facilitator sign-in token (companion)
+
+
+COMPANION_ROSTER_PATH
+env
+Path to roster.json (companion)
+
+
+Authoritative Sources
+Use these official references when you need the current source of truth for facts in
+this document.
+
+Section-Level Source Map
+Use this map to verify facts for each major section in this file.
+
+What you are deploying, Prerequisites, Phase 1, Operating a cohort: Generate a repository from a template (REST) , Rate limits for the REST API
+Phase 2: Create the provisioning GitHub App: Registering a GitHub App , Authenticating as a GitHub App installation
+Phase 3: Configure variables and secrets, Reference: configuration surface: Using secrets in GitHub Actions
+Phase 4, Phase 5, Phase 6, Rollback and fallback: Authenticating as a GitHub App installation , Using secrets in GitHub Actions
+
+
+
+
+
+
+
diff --git a/html/admin/HYBRID_REVIEW_INDEX.html b/html/admin/HYBRID_REVIEW_INDEX.html
new file mode 100644
index 00000000..084d3548
--- /dev/null
+++ b/html/admin/HYBRID_REVIEW_INDEX.html
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+ Hybrid Plan: Review Index - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ Hybrid Plan: Review Index
+This is the single entry point for reviewing everything built for the Hybrid plan
+(Option C): a dependable, GitHub-native provisioning core that replaces GitHub Classroom,
+plus an optional, fully accessible Flask companion at the edges. It is described in
+golden.md (vision) and specified in SPEC.md (technical spec).
+Most functional files stay where the repository expects them (workflows under
+.github/workflows/, Node scripts under .github/scripts/) so the system keeps working.
+This page points you to all of them, grouped by concern, in a suggested reading order.
+Suggested review order
+
+Start with the vision and spec so the rest has context.
+Read the deployment guide to see how it is operated end to end.
+Skim the provisioning core (the critical path).
+Skim the optional companion (the edges).
+Confirm the tests and CI wiring.
+
+
+1. Planning and specification (repo root)
+
+
+
+File
+What it is
+
+
+
+golden.md
+Vision document for the Hybrid plan.
+
+
+SPEC.md
+Technical specification, including an Implementation status section.
+
+
+roster.schema.json
+JSON Schema for the owned roster of record.
+
+
+2. Documentation (admin/)
+
+3. Provisioning core (.github/scripts/provisioning/) — the critical path
+These stay in place because the workflow and CLI load them from here.
+
+
+
+File
+Responsibility
+
+
+
+.github/scripts/provisioning/roster.js
+Owned roster contract: parse, validate, upsert, serialize, redact. Keyed on (github_handle, cohort_id).
+
+
+.github/scripts/provisioning/progress.js
+Derives learner progress from owned signals.
+
+
+.github/scripts/provisioning/github-app-auth.js
+RS256 GitHub App JWT + installation-token minting (Node crypto, no deps).
+
+
+.github/scripts/provisioning/github-client.js
+Minimal REST client; fetch adapter (CLI) + fromOctokit adapter (workflows).
+
+
+.github/scripts/provisioning/provision-core.js
+Idempotent, serial, backoff-aware provisioning algorithm.
+
+
+.github/scripts/provisioning/provision-cli.js
+Standalone CLI runner.
+
+
+.github/scripts/provisioning/__tests__/
+6 test files, 55 tests, run with node --test.
+
+
+4. Optional accessible companion (companion/) — the edges
+Fully self-contained Flask app. It writes a local roster.json; a separate sync job
+commits it to the admin repo. It deliberately does not push to GitHub itself.
+
+
+
+Path
+What it is
+
+
+
+companion/README.md
+Companion overview, run, and security/accessibility notes.
+
+
+companion/app.py
+Flask application factory and routes.
+
+
+companion/roster_store.py
+Python mirror of the owned roster contract.
+
+
+companion/templates/
+WCAG 2.2 AA templates (base, register, success, login, dashboard, error).
+
+
+companion/static/styles.css
+Accessible styling.
+
+
+companion/requirements.txt
+Python dependencies (Flask).
+
+
+companion/tests/
+2 test files, 25 tests (python -m unittest).
+
+
+5. CI wiring and commands
+
+
+
+File
+What changed
+
+
+
+.github/workflows/provision-learning-rooms.yml
+New provisioning workflow: scheduled + manual dry_run; github-app and actions-bot modes.
+
+
+.github/workflows/automation-tests.yml
+Added provisioning + companion test steps.
+
+
+package.json
+Added test:provisioning script.
+
+
+Run the suites locally:
+
+npm run test :automation
+
+
+npm run test :provisioning
+
+
+cd companion
+python -m venv .venv && .venv/bin/pip install -r requirements.txt
+.venv/bin/python -m unittest discover -s tests
+Commits on this branch
+The work landed in two commits on feature/hybrid-owned-provisioning:
+
+d6dd05ddc — provisioning core, companion, tests, docs, schema, CI wiring.
+89bc204bd — Hybrid deployment guide + cross-link.
+
+This index adds one more documentation commit. Nothing was moved out of its
+conventional location, so the workflows and CLI continue to resolve their imports.
+Authoritative Sources
+Use these official references when you need the current source of truth for facts in
+this document.
+
+Section-Level Source Map
+Use this map to verify facts for each major section in this file.
+
+
+
+
+
+
+
diff --git a/html/admin/OWNED_PROVISIONING.html b/html/admin/OWNED_PROVISIONING.html
new file mode 100644
index 00000000..e9cdf542
--- /dev/null
+++ b/html/admin/OWNED_PROVISIONING.html
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+ Owned, GitHub-native Provisioning - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ Owned, GitHub-native Provisioning
+This is the operator guide for the GitHub-native provisioning subsystem: the owned
+replacement for GitHub Classroom described in golden.md and specified
+in SPEC.md sections 6 and 7. It explains the data model, how to run
+provisioning, how to recover from failure, and how the optional Flask companion fits
+in at the edges. For first-time setup and deployment, follow the
+Hybrid Deployment Guide first. To review the whole
+deliverable from one place, see HYBRID_REVIEW_INDEX.md .
+The whole point of this subsystem is captured in one promise: a vendor sunset is a
+non-event. Provisioning, roster, and progress are owned and reconstructable, so no
+future change to GitHub Classroom can strand a cohort.
+Table of contents
+
+Why this exists
+GitHub Classroom does only two things for this workshop: it copies a template
+repository into a per-student private repository, and it maps a GitHub identity to a
+roster entry. Everything else already lives in infrastructure the project controls.
+This subsystem replaces those two things with code we own, so the critical learner
+path no longer depends on a single vendor feature.
+The three owned sources of truth
+The decoupling contract requires three owned, reconstructable records. See SPEC.md
+section 6 for the full schemas.
+
+Roster of record. One canonical JSON document mapping each learner handle to
+cohort, path, provisioning state, and progression status. Lives in the private
+admin repository as roster.json. Schema and validation live in
+roster.js and
+roster.schema.json . An example is in
+examples/roster.example.json .
+Progress of record. Never authored by a vendor. Derived from deterministic
+signals the project controls (challenge issue state, PR closing keywords, labels,
+and the plain-text signals ack and day1-complete) by
+progress.js .
+Provisioning of record. An append-only log of provisioning attempts and
+outcomes (provisioning-log.json), sufficient to prove a repository is correctly
+seeded and to safely re-run.
+
+Reconstruction rule: running the idempotent provisioning action against the roster
+reproduces a healthy state for every learner, with no third party involved.
+How provisioning works
+The provisioning subsystem is plain Node with no third-party dependencies (it uses
+built-in crypto and global fetch). The pieces are:
+
+The algorithm (SPEC.md section 7.2b) runs serially with a short delay and exponential
+backoff, not a parallel fan-out, to stay clear of GitHub secondary rate limits. For
+each pending or failed learner it: checks whether the repository exists; creates it
+from the template if not; ensures the learner is a collaborator; verifies the required
+workflow set is present; and records the outcome. Every step is safe to repeat.
+One-time setup
+Provisioning supports two modes via the PROVISIONING_MODE variable.
+Production: GitHub App (github-app)
+A GitHub App is the production identity because it is not tied to a human account,
+mints short-lived tokens, and uses fine-grained least-privilege permissions.
+
+Create a GitHub App in the Community-Access organization with only these
+permissions: Repository administration (write), Contents (write), Metadata (read),
+and optionally Issues (write). Nothing more.
+Install the App on the organization, scoped to the template and student repositories.
+Generate a private key (PEM). Store these as repository or environment secrets:
+PROVISIONING_APP_ID
+PROVISIONING_APP_PRIVATE_KEY (the PEM contents)
+PROVISIONING_APP_INSTALLATION_ID
+
+
+Set repository variables:
+PROVISIONING_MODE = github-app
+LEARNING_ROOM_TEMPLATE_REPO = Community-Access/learning-room-template
+PROVISIONING_STUDENT_OWNER = Community-Access
+ADMIN_ROSTER_REPO = the private admin repository holding roster.json
+
+
+Provide PRIVATE_STUDENT_DATA_TOKEN, a token that can check out and push to the
+admin roster repository.
+
+Phase 1 spike only: Actions bot (actions-bot)
+For an early validation spike you may use a least-privilege fine-grained PAT in
+PROVISIONING_TOKEN with PROVISIONING_MODE = actions-bot. Because the downstream
+system cannot tell which mode created a repository, spiking with a PAT and graduating
+to the App loses nothing. Do not use the PAT path for a real cohort: a PAT is bound to
+a person and is a single point of failure.
+Running a cohort
+Provisioning is designed to run as a trickle, on registration, rather than as a
+big-bang on go-live morning. The scheduled workflow picks up newly registered learners
+every 30 minutes; you can also run it on demand.
+
+Dry run (no changes). In the Actions tab, run Provision Learning Rooms with
+the dry_run input checked. It lists who would be provisioned.
+Provision. Run the same workflow with dry_run unchecked. It provisions every
+pending or failed learner, commits the updated roster.json and
+provisioning-log.json back to the admin repository, and prints a summary.
+Local run. From a checkout of the admin repo:
+LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \
+PROVISIONING_STUDENT_OWNER=Community-Access \
+PROVISIONING_MODE=actions-bot PROVISIONING_TOKEN=*** \
+node .github/scripts/provisioning/provision-cli.js \
+ --roster roster.json --log provisioning-log.json
+Add --dry-run to preview without making changes.
+
+
Idempotency and self-healing
+The provisioning action is idempotent on (github_handle, cohort_id):
+
+Running it twice is safe. An existing, complete repository is recorded as
+already-exists and left untouched.
+A re-run resumes a half-finished batch instead of duplicating work.
+If a repository exists but is missing required workflows, the run heals it (when a
+content-seeding capability is available) or fails loudly so the watchdog and the
+facilitator see it before a learner does.
+
+Always prove idempotency before go-live by running provisioning twice and confirming
+the second run reports already-exists for healthy learners and makes no changes.
+Failure modes and recovery
+
+
+
+Symptom
+What it means
+Recovery
+
+
+
+One learner is failed in the roster
+Repo creation or verification failed for that entry
+Fix the cause (seat, permission, name clash), then re-run provisioning. Only pending/failed entries are retried.
+
+
+provisioning-log.json shows error with a rate-limit detail
+Secondary rate limit during a burst
+Re-run. The algorithm already backs off; idempotency means a re-run heals.
+
+
+Repo exists but missing workflows
+Partial seed
+Re-run; the verify gate re-checks and heals or flags.
+
+
+App token mint fails
+Bad App ID, key, or installation ID
+Re-check the three App secrets; rotate the private key if exposure is suspected.
+
+
+No learner should ever be the first to discover a failure. The watchdog and the
+facilitator find it first.
+The optional Flask companion
+The companion in ../companion/ is strictly optional and lives at the
+edges. It renders the owned roster more nicely (an accessible registration front door
+and a facilitator dashboard) but never holds state the learner depends on. If it is
+down, the GitHub-native issue-form front door and the admin-issue dashboard carry the
+entire workshop. See ../companion/README.md .
+Security and least privilege
+
+Grant only the four App permissions listed above. Anything more fails the security
+review in SPEC.md section 13.
+Store the App ID, installation ID, and private key only in GitHub Secrets. Never in
+code, never in a public repository.
+Mint the installation token at the start of each run and never persist it.
+Document and rehearse private-key rotation; rotate on any suspected exposure.
+The companion authenticates facilitator actions, protects forms with per-session
+CSRF tokens, validates and encodes all input, and sets strict security headers.
+
+Local development and testing
+
+npm run test :provisioning
+
+
+cd companion
+python -m venv .venv && .venv/bin/pip install -r requirements.txt
+.venv/bin/python -m unittest discover -s tests
+Authoritative Sources
+Use these official references when you need the current source of truth for facts in
+this document.
+
+Section-Level Source Map
+Use this map to verify facts for each major section in this file.
+
+Why this exists, The three owned sources of truth: golden.md , SPEC.md
+How provisioning works, Idempotency and self-healing: Generate a repository from a template (REST) , Rate limits for the REST API
+One-time setup, Security and least privilege: Authenticating as a GitHub App installation , Choosing permissions for a GitHub App
+Running a cohort, Failure modes and recovery, The optional Flask companion, Local development and testing: SPEC.md , golden.md
+
+
+
+
+
+
+
diff --git a/html/companion/index.html b/html/companion/index.html
new file mode 100644
index 00000000..53b86a26
--- /dev/null
+++ b/html/companion/index.html
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+ GLOW Companion (optional, accessible front door) - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ GLOW Companion (optional, accessible front door)
+The companion is the optional Flask service that sits at the edges of the Hybrid
+architecture in golden.md (Option C) and SPEC.md
+section 11. It makes a good thing nicer; it never makes a working thing necessary.
+If this service is ever down, learners can still register and complete the entire
+workshop on GitHub through the issue-form front door, and facilitators can still read
+the admin-issue dashboard. The companion is non-critical by design.
+What it provides
+
+An accessible registration landing page that writes to the owned roster.
+A facilitator cohort dashboard that reads the owned roster and derived progress.
+A redacted roster.json endpoint for facilitator tooling.
+
+What it deliberately does not do
+
+It does not invent a parallel store of record. The only state it touches is the
+owned roster JSON, through the same contract the provisioning subsystem uses.
+It does not provision repositories directly. Provisioning is the GitHub-native
+subsystem's job (see ../admin/OWNED_PROVISIONING.md ).
+
+Accessibility (WCAG 2.2 AA)
+Every template is authored screen-reader-first and keyboard-operable:
+
+Semantic landmarks (header, nav, main, footer) and a visible skip link.
+Correct heading order and labeled form controls with hint and error associations
+via aria-describedby and aria-invalid.
+An error summary with in-page links to each invalid field.
+Live-region announcements for flash messages (role="status", aria-live).
+Visible focus styles and no keyboard traps.
+No information conveyed by color alone; no emoji.
+A data table with a caption, scope-d headers, and an intro sentence.
+
+Run a manual screen reader pass (NVDA, JAWS, VoiceOver) before any cohort, per the
+project accessibility testing guidance.
+Security (OWASP-aware)
+
+Output is auto-escaped by Jinja2.
+Per-session CSRF tokens protect every state-changing POST.
+Facilitator actions require sign-in; the token is compared in constant time.
+Input is validated (GitHub handle regex, cohort, path enum) before any write.
+Best-effort per-IP rate limiting on registration and sign-in.
+Strict security headers: a restrictive Content-Security-Policy (no inline
+scripts), X-Content-Type-Options, X-Frame-Options, and Referrer-Policy.
+Secrets come only from the environment; cookies are HttpOnly, SameSite=Lax,
+and Secure by default.
+
+Configuration
+All configuration is environment-driven:
+
+
+
+Variable
+Purpose
+Default
+
+
+
+COMPANION_SECRET_KEY
+Flask session signing key
+random per process (set this in production)
+
+
+COMPANION_ROSTER_PATH
+Path to the owned roster.json
+roster.json
+
+
+COMPANION_FACILITATOR_TOKEN
+Facilitator sign-in token
+empty (dashboard locked until set)
+
+
+COMPANION_SECURE_COOKIES
+Set 0 only for local HTTP testing
+1
+
+
+COMPANION_RATE_LIMIT_MAX
+Requests per window per IP
+10
+
+
+COMPANION_RATE_LIMIT_WINDOW
+Rate-limit window in seconds
+300
+
+
+PORT
+Port for the built-in dev server
+5000
+
+
+Run locally
+cd companion
+python -m venv .venv
+.venv/bin/pip install -r requirements.txt
+
+export COMPANION_SECRET_KEY="dev-only-not-a-real-secret"
+export COMPANION_FACILITATOR_TOKEN="dev-token"
+export COMPANION_SECURE_COOKIES=0
+export COMPANION_ROSTER_PATH="../admin/examples/roster.example.json"
+
+.venv/bin/python app.py
+
+For production, serve app:app behind a WSGI server (for example gunicorn) and a
+TLS-terminating reverse proxy, set a strong COMPANION_SECRET_KEY, and point
+COMPANION_ROSTER_PATH at a working copy of the admin roster that a separate job
+commits back to the admin repository.
+Test
+cd companion
+.venv/bin/python -m unittest discover -s tests
+The suite covers routing, CSRF enforcement, input validation, facilitator auth,
+rate limiting, roster redaction, and the accessible page shell.
+Routes
+
+
+
+Method
+Path
+Auth
+Purpose
+
+
+
+GET
+/
+none
+Redirects to /register
+
+
+GET, POST
+/register
+none
+Accessible registration form; writes the roster
+
+
+GET
+/register/success
+none
+Confirmation and next steps
+
+
+GET, POST
+/login
+none
+Facilitator sign-in
+
+
+POST
+/logout
+session
+Sign out
+
+
+GET
+/dashboard
+facilitator
+Cohort dashboard
+
+
+GET
+/roster.json
+facilitator
+Redacted roster JSON
+
+
+GET
+/healthz
+none
+Liveness probe
+
+
+
+
+
+
+
+
diff --git a/html/golden.html b/html/golden.html
new file mode 100644
index 00000000..f92df90a
--- /dev/null
+++ b/html/golden.html
@@ -0,0 +1,299 @@
+
+
+
+
+
+
+ golden.md - GIT Going with GitHub
+
+
+
+
+
+
+ Skip to main content
+
+
+ golden.md
+The north star for GIT Going with GitHub (GLOW). This document defines what "golden" means for this project, names the single biggest risk to its future, and lays out a bold, dependable, accessible path to get there. It is a vision document and a decision record. When a choice is hard, return here.
+
+One sentence: Every blind and low vision learner who starts this workshop should reach a real, merged open source contribution through an experience so reliable, so accessible, and so quietly delightful that the technology disappears and only their growing confidence remains.
+
+Table of contents
+
+What golden means
+Golden is not "feature complete." Golden is a standard of care. A golden release of this workshop is:
+
+Dependable. A facilitator can open a cohort and trust that every link resolves, every workflow fires, every challenge can be completed, failed, and recovered. No surprises on go-live day.
+Accessible by construction. Not retrofitted. Every artifact, every automation message, every fallback page is authored to be read with or without a screen reader, navigated by keyboard alone, and understood without sight. Accessibility is the product, not a checkbox.
+Flexible. The learning experience survives a vendor sunset, an API change, a renamed button, or a student who arrives with a different tool than expected. The curriculum already teaches "tools change, exploration is the skill." The infrastructure must live that value too.
+Error free where it counts. Zero broken learner-facing paths. Automation either succeeds, or fails loudly to a human with a clear recovery action. Silent failure is the only unacceptable outcome.
+Magical and delightful. The small things: a welcome that uses the learner's name, a validation bot that congratulates instead of scolds, a recovery path that feels like a safety net rather than a punishment, an audio companion when screen reader fatigue sets in. Delight is the sum of a hundred respected details.
+
+If a change does not move the project toward all five at once, question it.
+The existential risk: GitHub Classroom is going away
+The entire Learning Room model currently assumes GitHub Classroom exists. Today the flow is: a student accepts a Classroom assignment link, Classroom clones a template repository into a private student repository, and a chain of GitHub Actions drives registration, Day 2 release, autograding, progression, and the instructor dashboard. See admin/GITHUB_CLASSROOM_ARCHITECTURE.md for the current production design.
+This is a single point of failure. If GitHub Classroom is deprecated, removed, or changes its assignment model, the workshop's onboarding spine breaks. The good news: most of the value does not actually live in Classroom. Classroom does exactly two things for us:
+
+Provisioning. It copies a template repository into a per-student private repository and grants access.
+Roster mapping. It associates a GitHub identity with a roster entry.
+
+Everything else that makes this workshop work already lives in places we control: the curriculum in docs/ , the automation in the Learning Room workflows (learning-room/.github/workflows/ ), the autograders, the progression bot, the registration flow, and the support hub. Classroom is a thin provisioning layer, not the brain.
+The golden response is therefore not panic. It is deliberate decoupling : replace the two things Classroom does for us with infrastructure we own, keep everything else exactly where it is, and lose nothing the learner depends on.
+Design principles that never bend
+These are the constitution. Every architectural choice below is measured against them.
+
+Accessibility is non-negotiable. Any replacement for Classroom must be fully operable with NVDA, JAWS, and VoiceOver, by keyboard alone. A provisioning UI that a blind learner cannot use is not a candidate, no matter how convenient it is for facilitators.
+Prefer infrastructure the learner already trusts. GitHub itself is the safest home. A learner who can navigate github.com can already operate most of our flow. New surfaces add new accessibility risk and new failure modes.
+Own your state. Never again let a single vendor feature hold the cohort hostage. Roster, progress, and provisioning state must be reconstructable from data we control.
+Degrade gracefully, never silently. Every automated step has a documented manual fallback that a facilitator can run by hand. The automation is an accelerator, not a dependency.
+One arc, many tools. The curriculum is one journey from browser to github.dev to VS Code. The infrastructure must not fracture that arc, regardless of which provisioning method is in play.
+
+Architecture options for life after Classroom
+Three credible paths exist. Each is evaluated against the principles above.
+Option A: GitHub-native provisioning (no Classroom)
+Replace Classroom's provisioning with a GitHub App or an Actions-driven bot that, on registration, creates a private repository from the existing template and invites the student. The roster lives in a private admin repository (as the instructor dashboard already does). Everything downstream stays identical because student repositories look exactly the same as they do today.
+Strengths: stays entirely inside GitHub, the surface learners already navigate accessibly. Reuses the existing template, autograders, progression bot, and dashboard sync with near-zero change. Lowest accessibility risk. Lowest new-failure-mode risk.
+Weaknesses: requires a GitHub App or a bot token with repository-creation scope and careful permission hygiene. Repository creation rate limits and org seat limits need planning for large cohorts.
+Option B: Flask companion application
+A small Flask service handles registration, roster, provisioning orchestration (calling the GitHub API to create student repositories), Day 2 release, and a facilitator dashboard. The learner-facing curriculum still lives in GitHub repositories.
+Strengths: full control over registration UX, dashboards, and cohort logic. Can present a single accessible front door. Can store richer state and analytics than issue comments allow.
+Weaknesses: a new hosted service to secure, deploy, monitor, and make accessible. New attack surface (OWASP Top 10 applies in full: auth, secrets, injection, CSRF, rate limiting). A self-hosted dependency that can go down on go-live day. Higher accessibility testing burden because we now own HTML, focus order, and live regions ourselves.
+Option C: Hybrid (recommended)
+GitHub-native provisioning and automation form the dependable core. A thin, optional Flask companion sits at the edges only where GitHub genuinely cannot deliver a good experience: a polished accessible registration landing page, a facilitator cohort dashboard, and bulk operations. The companion is stateless about anything critical: if it disappears, the GitHub-native core still completes the full learner journey.
+This is the golden choice. It honors "prefer infrastructure the learner already trusts" for the critical path, while allowing a delightful, fully accessible front door where it adds real value, and it never makes the learner's success depend on a service we have to keep alive.
+The recommended path: GitHub-native core, Flask companion at the edges
+The plain-language architecture, in order of how a learner experiences it:
+
+Front door (optional Flask companion or a GitHub Pages form). An accessible registration page collects the learner's GitHub handle and preferences. If the Flask companion is unavailable, the existing issue-form registration in the repository is the fallback front door and loses nothing essential.
+Provisioning (GitHub-native). A GitHub App or Actions bot creates the private Learning Room repository from the template, replacing what Classroom did. The roster entry is written to the private admin repository. This is the only piece that truly changes when Classroom departs.
+The Learning Room (unchanged). Same private student repository, same seeded challenge issues, same autograders, same progression bot, same PR validation. Because the repository shape is identical, the entire downstream automation in learning-room/.github/workflows/ keeps working.
+Progression and release (GitHub-native). Day 2 release, autograder pass/fail comments, and the next-challenge progression all continue to run on the deterministic text signals already documented (ack, day1-complete). These are intentionally simple so a facilitator can test and recover them by hand.
+Facilitator visibility (Flask companion or private admin issues). The instructor dashboard already syncs to private admin repository issues. The Flask companion, when present, reads the same roster and renders a richer accessible dashboard. When absent, the admin issues are the dashboard.
+
+The principle holding this together: the critical path runs on GitHub; the companion only ever makes a good thing nicer, never makes a working thing necessary.
+What changes and what does not
+This table summarizes the migration impact so the scope is honest and small.
+
+
+
+Capability
+Today (Classroom)
+Golden (post-Classroom)
+Change size
+
+
+
+Provisioning student repos
+Classroom clones template
+GitHub App or Actions bot clones template
+New, contained
+
+
+Roster identity mapping
+Classroom roster
+Private admin repository roster record
+Small
+
+
+Registration intake
+Issue form plus Classroom link
+Accessible front door plus same issue-form fallback
+Small
+
+
+Challenge seeding
+Template issues
+Unchanged template issues
+None
+
+
+Autograding
+Autograder workflows
+Unchanged autograder workflows
+None
+
+
+Progression bot
+Progression workflow
+Unchanged progression workflow
+None
+
+
+Day 2 release
+day2-release.yml signals
+Unchanged signal logic
+None
+
+
+Instructor dashboard
+Dashboard sync to admin repo
+Same sync, optional richer companion view
+None to small
+
+
+The lesson of this table is the whole strategy: by owning the template and the automation already, the project has quietly insulated itself from Classroom. The migration is a provisioning swap, not a rewrite.
+The decoupling contract: own your state
+To make a vendor sunset a non-event forever, the project commits to three owned sources of truth:
+
+Roster of record. A single canonical roster (in the private admin repository, optionally mirrored by the companion) that maps GitHub handle to cohort, day, and status. Reconstructable without any third party.
+Progress of record. Challenge status derived from signals the project controls (issue and PR state, labels, the deterministic text signals). Never read progress from a vendor that could remove the API.
+Provisioning of record. A repeatable, idempotent provisioning action that, given a roster entry, guarantees a correctly seeded student repository. Running it twice is safe. Running it after a partial failure heals the state.
+
+If all three are owned and reconstructable, no future vendor change can strand a cohort. That is what flexibility means in practice.
+The five quality pillars
+Pillar 1: Accessibility you can feel
+
+Every learner-facing surface passes screen reader review on NVDA, JAWS, and VoiceOver and is fully keyboard operable.
+If a Flask companion ships, it meets WCAG 2.2 AA: semantic landmarks, correct heading order, labeled form controls, visible focus, live-region announcements for dynamic updates, and no keyboard traps. The project's own accessibility agents and guides are the in-house review team for it.
+Automation messages (bot comments, validation feedback) are written for a screen reader ear: clear, front-loaded, no decorative noise.
+
+Pillar 2: Reliability you can trust
+
+The critical learner path has no single vendor point of failure after the Classroom swap.
+Every automated workflow has a documented manual fallback in the facilitator runbook (admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md ).
+Provisioning is idempotent and self-healing. Re-running never corrupts a student repository.
+The go-live release gate (GO-LIVE-QA-GUIDE.md ) must pass before any cohort opens.
+
+Pillar 3: Flexibility that outlasts vendors
+
+State is owned and reconstructable (the decoupling contract above).
+The provisioning method is swappable behind a stable internal contract, so the next vendor change is a localized edit, not a crisis.
+The curriculum keeps teaching exploration as a skill, so learners are resilient to UI drift even when our docs lag reality.
+
+Pillar 4: Error-free learner paths
+
+Zero broken links, zero dead assignment URLs, zero orphaned challenges in a shipped cohort.
+Automation fails loud, not silent: a watchdog detects stalled provisioning or stuck progression and raises a human-visible alert.
+Security is part of correctness: any Flask companion is reviewed against the OWASP Top 10, secrets never live in client code or public repos, tokens are least-privilege and rotatable.
+
+Pillar 5: Delight in the details
+
+The welcome uses the learner's name and affirms belonging from the first message.
+The validation bot celebrates progress and frames failures as the next step, never as a verdict.
+Recovery feels like a safety net: a learner who breaks something is guided back without shame.
+Audio companions exist for every chapter so a tired learner can switch modes. Honor the existing preference to update transcripts and the site without regenerating audio unless a podcast release is explicitly in scope.
+
+Failure modes and graceful recovery
+Golden software assumes things break and plans the catch. For each likely failure, there is a defined, accessible recovery.
+
+Provisioning fails for one student. The idempotent provisioning action is re-run for that roster entry. The watchdog flags any student without a healthy repository before Day 1 begins.
+The companion service is down on go-live day. The GitHub-native front door and core path carry the entire workshop. The companion's absence is invisible to learners.
+A GitHub UI label or button moves. The curriculum's exploration skills cover the learner in the moment; a curriculum issue is filed so the docs catch up for everyone.
+A token expires or is revoked. Least-privilege tokens are documented and rotatable; the runbook lists exactly which capability each token enables so recovery is fast and scoped.
+A learner falls behind or breaks their repository. Tiered recovery (already part of the QA runbook) restores them to a known-good state with branch and PR evidence, no data loss.
+
+The rule: a learner should never be the one who discovers a failure. The watchdog and the facilitator find it first.
+Phased roadmap
+A bold vision delivered in safe increments.
+
+Phase 0: Harden the present. Pass the full go-live gate on the current Classroom-based flow. You cannot migrate cleanly from an unstable base.
+Phase 1: Prove the decoupling contract. Stand up the owned roster, owned progress, and an idempotent provisioning action that creates a student repository from the template without Classroom. Validate with test accounts end to end.
+Phase 2: GitHub-native provisioning in production. Make the GitHub-native path the default for a real test cohort. Keep Classroom available as a parallel fallback during transition.
+Phase 3: Accessible front door and facilitator dashboard. Add the optional Flask companion (or GitHub Pages form) only after the core path is proven. Subject it to the same accessibility and security bar as the curriculum itself.
+Phase 4: Retire the Classroom dependency. Once the GitHub-native path has run a full cohort cleanly, remove the hard dependency on Classroom and document the new architecture as canonical.
+Phase 5: Continuous delight. Iterate on the hundred small details: warmer automation copy, faster recovery, richer audio companions, sharper accessibility.
+
+Each phase ships only when the previous phase is golden. No phase weakens accessibility, reliability, or learner trust to move faster.
+Definition of golden: the release gate
+A cohort is golden only when all of the following hold. This complements, and does not replace, the detailed GO-LIVE-QA-GUIDE.md .
+
+The critical learner path completes end to end with no GitHub Classroom dependency, or with Classroom and the GitHub-native path both proven.
+Provisioning is idempotent and re-running it heals partial failures.
+Owned roster, owned progress, and owned provisioning are all reconstructable without a third party.
+Every learner-facing surface passes NVDA, JAWS, and VoiceOver review and is fully keyboard operable.
+Any Flask companion passes WCAG 2.2 AA and an OWASP Top 10 security review, with least-privilege, rotatable secrets.
+Every automated workflow has a tested manual fallback in the runbook.
+A watchdog surfaces stalled provisioning or progression to a human before a learner notices.
+Zero broken learner-facing links or dead assignment URLs in the shipped cohort.
+Automation copy is warm, screen-reader-first, and frames failure as the next step.
+
+Guardrails and non-negotiables
+
+Never trade accessibility for convenience. A faster facilitator tool that a blind learner cannot use is not shipped.
+Never let a vendor own the critical path again. Provisioning, roster, and progress stay owned and reconstructable.
+Never fail silently. Loud, human-visible failure with a clear recovery action, every time.
+Never make delight an afterthought. The warm welcome, the kind validation message, the safety-net recovery: these are requirements, not polish.
+Keep the GLOW name and branding unless explicitly asked to change it.
+Minimize audio regeneration. Prefer transcript and site-only updates unless a podcast release is explicitly in scope.
+
+
+Golden is a direction, not a destination. Measure every pull request, every workflow, every word of bot copy against this question: does it make the journey more dependable, more accessible, more flexible, more error free, and more delightful for the learner who needs it most? If yes, ship it. If no, return here and try again.
+Authoritative Sources
+Use these official references when you need the current source of truth for facts in this document.
+
+Section-Level Source Map
+Use this map to verify facts for each major section in this file.
+
+What golden means, Design principles, The five quality pillars, Guardrails: GitHub Docs, home , Web Content Accessibility Guidelines (WCAG) 2.2
+The existential risk, Architecture options, The recommended path, The decoupling contract, Failure modes, Phased roadmap, Definition of golden: About GitHub Classroom , GitHub Docs, home
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 89154e0d..459d7e8b 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,8 @@
"build": "npm run build:podcasts && npm run build:html",
"watch:html": "node scripts/build-html.js --watch",
"clean": "node -e \"require('fs').rmSync('html', {recursive: true, force: true})\"",
- "test:automation": "node --test .github/scripts/__tests__/*.test.js"
+ "test:automation": "node --test .github/scripts/__tests__/*.test.js",
+ "test:provisioning": "node --test .github/scripts/provisioning/__tests__/*.test.js"
},
"keywords": [
"github",
diff --git a/roster.schema.json b/roster.schema.json
new file mode 100644
index 00000000..b19e9c56
--- /dev/null
+++ b/roster.schema.json
@@ -0,0 +1,80 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://github.com/Community-Access/git-going-with-github/roster.schema.json",
+ "title": "GLOW Roster of Record",
+ "description": "Canonical, vendor-independent roster for the GIT Going with GitHub workshop. Maps a learner GitHub handle to cohort, path, provisioning state, and progression status. Reconstructable from registration issues plus provisioning results without any third party. Implements SPEC.md section 6.1.",
+ "type": "object",
+ "required": ["version", "cohorts", "learners"],
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "type": "integer",
+ "const": 1,
+ "description": "Roster schema version. Bump only on a breaking shape change."
+ },
+ "generated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Optional timestamp of the last write. Informational only; never a source of truth."
+ },
+ "cohorts": {
+ "type": "array",
+ "description": "Cohorts known to the roster.",
+ "items": {
+ "type": "object",
+ "required": ["cohort_id"],
+ "additionalProperties": false,
+ "properties": {
+ "cohort_id": { "type": "string", "minLength": 1 },
+ "label": { "type": "string" },
+ "opens_at": { "type": "string", "format": "date-time" }
+ }
+ }
+ },
+ "learners": {
+ "type": "array",
+ "description": "One record per learner. See SPEC.md section 6.1.",
+ "items": { "$ref": "#/definitions/learner" }
+ }
+ },
+ "definitions": {
+ "learner": {
+ "type": "object",
+ "required": ["github_handle", "cohort_id", "path", "provision_state", "status"],
+ "additionalProperties": false,
+ "properties": {
+ "github_handle": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$",
+ "description": "Primary identity key. A valid GitHub username."
+ },
+ "cohort_id": { "type": "string", "minLength": 1 },
+ "path": {
+ "type": "string",
+ "enum": ["day1-day2", "day2-only"],
+ "description": "Which arc the learner is enrolled in."
+ },
+ "learning_room_repo": {
+ "type": ["string", "null"],
+ "description": "owner/name of the provisioned repository. Null until provisioned."
+ },
+ "provision_state": {
+ "type": "string",
+ "enum": ["pending", "provisioned", "failed", "healed"],
+ "description": "State of the owned provisioning of record."
+ },
+ "status": {
+ "type": "string",
+ "enum": ["awaiting-ack", "active-day1", "day1-complete", "day2-released", "needs-info"],
+ "description": "Learner progression status derived from deterministic signals."
+ },
+ "registered_at": { "type": ["string", "null"], "format": "date-time" },
+ "last_signal_at": { "type": ["string", "null"], "format": "date-time" },
+ "notes": {
+ "type": "string",
+ "description": "Facilitator notes. Redacted in any public surface."
+ }
+ }
+ }
+ }
+}