SPEC — Frequently Asking Questions

Distilled from code 2026-10-01 (pre-spec behavior tagged (T.0)). Encoding and section shapes: FORMAT.md. Sole mutator: the spec skill. ? after a rule = read from code, not yet pinned by a test; treat as provisional.

§G Goal

Jekyll blog answering everyday assumptions with data. Readers sign in (Supabase magic link), react, comment, subscribe to new-post email, submit questions that AI pre-moderates before an admin sees them. Premium users get 3 questions/day + /drafts/.

§C Context

Key Value
Stack Jekyll via github-pages gem (theme minima; Gemfile.lock gitignored), Ruby 3.1 in CI; vanilla ES5 IIFE JS in assets/js/ (no bundler, no TS); Supabase Postgres + Edge Functions (Deno TS, // @ts-nocheck ok); supabase-js 2.101.1 from jsDelivr with SRI (_config.yml supabase.sdk_url/sdk_sri); Node 24 for tests/scripts (node --test, jsdom); Deno 2 for Edge Function unit tests (npm run test:deno).
Hosting GitHub Pages via .github/workflows/jekyll.yml on push to main; domain CNAME = frequently-asking-questions.com. Prod build uses --drafts.
State / data Supabase tables: profiles, questions, question_votes, comments, reactions, subscriptions, admin_users, notification_log, notification_recipients. Full schema supabase/supabase-schema.sql (001–009 squashed in 370ab2c); deltas supabase/migrations/010…017.
Environments DEV lbefrzvyznaoaglldluk deleted 2026-10-02 (replacement = local stack, T.15; _config.dev.yml still names it, only for build markup). Local DB tests: supabase db start via podman (docs/live/dev-environment.md). PROD pxqcmkzvyapuhkqqlxgx (agents read-only: .mcp.json read_only=true). Never write prod without the user’s explicit OK + a fresh backup.
Privacy / network Browser → Supabase (auth, RLS’d tables) + jsDelivr (SDK). submit-question sends question text + article summaries to OpenAI (OPENAI_MODEL, default gpt-4o-mini). notify-subscribers sends subscriber emails via SMTP. Subscriber emails stored in subscriptions.
Secrets Dev: .env.dev (gitignored; backup in Bitwarden item frequently-asking-questions .env.dev, scripts/vault/). Prod build/notify: GitHub Actions secrets (SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, NOTIFY_SECRET, NOTIFY_SUBSCRIBERS_EDGE_FUNCTION_URL, CHECK_STATUS_EDGE_FUNCTION_URL) + optional Actions variable SITE_URL. Edge Function secrets: supabase secrets set. Committed config holds publishable keys only (_config.dev.yml).
Served URLs (never move) CNAME; post permalinks /:year/:month/:day/:title/; /guides/agentic-repo-prep/GUIDE.md, /guides/agentic-repo-prep/prep-repo.tar.gz; assets/**; /feed.xml, /sitemap.xml; supabase/migrations/* filenames (applied-migration contract).
a11y / design docs/live/accessibility.md, docs/live/visual-design-guide.md, .github/copilot-instructions.md (palette, typography, focus states).

§I Interfaces

  • I.1: POST /functions/v1/submit-question {question_text, is_retry?} + Authorization: Bearer <user JWT> → {success, question:{id|null,status,ai_score,ai_user_response,ai_rewrite}, moderation:{next_step,matching_strategy,related_articles}}; errors 400/401/403/429/500/502. — supabase/functions/submit-question/handler.ts createSubmitQuestionHandler (wired in index.ts). Caller assets/js/ask.js:169.
  • I.2: POST notify-subscribers {title, excerpt?, url, post_slug, post_date?} + X-Notify-Token: <unix_ts>.<hex HMAC-SHA256(NOTIFY_SECRET, ts)> → 200 / 207 partial / 500. — supabase/functions/notify-subscribers/index.ts. Caller .github/workflows/jekyll.yml:114.
  • I.3: check-notification-status, same HMAC header. — supabase/functions/check-notification-status/index.ts. Caller .github/workflows/check-scheduled-posts.yml.
  • I.4: moderate-question {question_id, action: approve|flag|reject|address, moderation_note?, addressed_post?} + HMAC header. — supabase/functions/moderate-question/index.ts. No in-repo caller; kept for server-to-server use, config block added (T.8, was D.1).
  • I.5: RPCs — can_submit_question_today(), get_question_quota() ({used,total,remaining,can_submit}), create_profile(username), get_question_vote_counts(q_id), get_reaction_counts(slug), is_admin(), unsubscribe(...), record_send_failure(p_subscriber_id). — supabase/supabase-schema.sql, supabase/migrations/015_premium_quota_no_admin_bypass.sql.
  • I.6: supabase/functions/submit-question/article-summaries.json = [{slug,title,summary,url}], generated from _posts/ by npm run build:article-summaries.
  • I.7: Frontend globals FAQComments (assets/js/comments.js), FAQAsk (assets/js/ask.js), FAQAdminModeration (assets/js/admin-moderation.js), each init(url, publishableKey[, slug]); helpers they need: FAQUtil (faq-util.js), FAQAuthErrors (auth-errors.js), FAQCommentThread (comment-thread.js), FAQAskQuestions (ask-questions.js), FAQAdminModerationView (admin-moderation-view.js), loaded by <script> before their user (V.19). Supabase client from _layouts/default.html when site.supabase.url and publishable_key set.

§V Invariants

  • V.1: Every [functions.<name>] in supabase/config.toml sets verify_jwt = false (ES256 user JWTs fail the legacy gateway check); each function authenticates itself (user JWT via auth.getUser, or HMAC). Guard: tests/edge-functions.test.js (every function dir: config block, uncommented verify_jwt = false, auth comment, auth.getUser/verifyNotifyToken in source). (T.0, T.8)
  • V.2: submit-question rejects: non-POST 405; missing bearer 401; invalid JSON 400; trimmed question_text <10 or >500 chars 400; unknown user 401; no profiles row 403. Guard: supabase/functions/submit-question/handler_test.ts “V.2 …”. (T.0, T.6)
  • V.3: Question quota = saved questions rows by user in rolling 24 h; limit 3 if profiles.premium else 1; admins get no bypass (migration 015). One rule, two runtimes: edge submit-question/logic.ts PREMIUM_DAILY_QUESTIONS/REGULAR_DAILY_QUESTIONS/QUOTA_WINDOW_HOURS (applied in handler.ts); SQL get_question_quota/can_submit_question_today (supabase-schema.sql, newest defining migration). Change both together. ask.js shows the SQL quota; if get_question_quota fails it shows “could not be checked” and leaves enforcement to the server (D.4). Guard: tests/quota-rule.test.js “V.3 …” (TS ≡ SQL), supabase/functions/submit-question/handler_test.ts “V.3 …”. (T.0, T.7)
  • V.4: AI routing: score >80 → approved; <20 → rejected; 20–80 first attempt → nothing saved, rewrite offered, no slot used; 20–80 retry → saved flagged for humans. Fuzzy match ≥70 or AI overlap confidence ≥80 caps score at 70 + flag possible_duplicate_topic. AI overlap check only sees fuzzy candidates ≥20 (top 24); none → no check. Guard: supabase/functions/submit-question/handler_test.ts “V.4 …”, logic_test.ts. (T.0, T.6)
  • V.5: X-Notify-Token valid only if ts ∈ [now−300 s, now+30 s] and HMAC matches by constant-time compare; raw secret never sent; empty secret → reject. One verifier: supabase/functions/_shared/notify-token.ts, imported by notify-subscribers, check-notification-status, moderate-question. Guard: supabase/functions/_shared/notify-token_test.ts (Deno, npm run test:deno, CI), tests/edge-functions.test.js “no function defines its own verifyNotifyToken”. (T.0, T.5)
  • V.6: One notification per post_slug: notification_log checked first; row written when ≥1 send succeeded or 0 subscribers; all-sends-failed → 500, no log, so CI retries. Guard: tests/subscription.test.js “checks notification_log for duplicate prevention” (source text only). (T.0)
  • V.7: Future-dated posts never notified (workflow skips; function skips if post_date > now). ? Guard: unguarded. (T.0)
  • V.8: Subscriber auto-blacklisted after 3 consecutive failed sends; counters untouched when every send failed (outage). ? Guard: unguarded. (T.0)
  • V.9: RLS enabled on every table; only Edge Functions use the service-role key. Guard: tests/subscription.test.js schema blocks (subscriptions, notification_log only). (T.0)
  • V.10: article-summaries.json matches _posts/. Guard: .github/workflows/jekyll.yml:49, .github/workflows/test.yml. (T.0)
  • V.11: No secrets in git: .env.dev, .env.dev.*, _config_production.yml gitignored + untracked; no JWT-shaped string in tracked files; agents never read .env.dev*. Guard: tests/secrets.test.js, .claude/hooks/guard.py + .claude/settings.json deny rules (best effort, not a sandbox). (T.0, B.1)
  • V.12: Raw guide exports (guides/*/GUIDE.md, prep-repo.tar.gz) match their sources. Guard: tests/raw-guide.test.js. (T.0)
  • V.13: Welcome page waits on onAuthStateChange when URL hash holds tokens (avoids hash-token race). Guard: tests/comments.test.js “Welcome page — uses onAuthStateChange…”. (T.0)
  • V.14: Built site has feed.xml, sitemap.xml, posts at /:year/:month/:day/:title/. Guard: tests/build-output.test.js. (T.0)
  • V.15: npm test and npm run test:deno are green on every push and PR. Guard: .github/workflows/test.yml. (T.1, B.2)
  • V.16: Admin dashboard data access gated by is_admin() in RLS, not only in UI. ? Guard: unguarded. (T.0)
  • V.17: Every docs/live/**/*.md opens with the LIVING header and (except the index) Reconciled: YYYY-MM-DD; every docs/design/ + docs/journal/ doc carries a FROZEN (or WORKING DOC) marker; relative links and backticked docs/… paths in README.md, AGENTS.md, CLAUDE.md, ARCHITECTURE.md, SPEC.md, .github/copilot-instructions.md, docs/live/ resolve; docs/live/README.md indexes every live doc. Truth of content: check + review, not this guard. Guard: tests/docs.test.js. (T.10, B.3)
  • V.18: Signed-out visitor (supabase-js getUser() → AuthSessionMissingError) gets the sign-in prompt, never an init error; other auth errors still show the error. Guard: tests/admin-moderation.test.js, npm run e2e scenario admin_signed-out. (T.9, B.4)
  • V.19: Frontend stays split: every FAQ* global a loaded assets/js module uses is defined by a <script> earlier on the same built page; /assets/css/main.css is built from assets/css/main.scss importing every _sass/ partial (plain CSS) in order, no assets/css/main.css source; assets/js/*.js, _sass/* and drafts.html ≤400 lines, over-limit allowlist (T.14 files: moba-sim-labs.js, chart-fullscreen.js, drafts.html) shrink-only. Guard: tests/split-modules.test.js; behaviour tests/{comments,ask,admin-moderation,admin-moderation-dashboard}.test.js. (T.12)
  • V.20: Users cannot write profiles.premium: anon/authenticated have no table-wide INSERT/UPDATE on profiles; column grants = insert (id, username), update (username, subscribed). Guard: tests/rls-grants.test.js (schema + migration 018 text), supabase/tests/grants_test.sql (roles, local DB). (T.13, B.5)
  • V.21: Every security definer function sets search_path = ''; record_send_failure executable by service_role only. Guard: tests/rls-grants.test.js, supabase/tests/grants_test.sql. (T.13, B.6)
  • V.22: Every new-post email unsubscribe link reaches unsubscribe(token): link /unsubscribe/?token=<subscriptions.id>; legacy /?unsubscribe=<id> redirects there (_layouts/default.html); RPC only after a click (mail scanners open links); RPC also sets the linked profiles.subscribed = false; second call returns false. Guard: tests/unsubscribe.test.js, e2e unsubscribe_legacy-link. (T.13, B.7)
  • V.23: Every column an Edge Function selects/inserts/updates via a literal supabase-js payload exists in supabase/supabase-schema.sql (= PROD). Before a function deploy, diff the deployed source (supabase functions download) against the repo’s last-deployed state. Guard: tests/function-columns.test.js (variable payloads not parsed). (B.8)
  • V.24: Every file an Edge Function reads at runtime (new URL("./x", import.meta.url)) is listed in its [functions.<name>] static_files (paths relative to supabase/), else it is not deployed. Guard: tests/edge-functions.test.js “every file read at runtime is in static_files”. (B.9)
  • V.25: Guard hook (.claude/hooks/guard.py + guide template copy) never blocks on a pure-wildcard path part (*, [a-z]*, ?, incl. the * it substitutes for an unknown glued $var) by name alone when its real expansion can be checked: Bash only; no $/backtick left after known assignments are applied, and no shell word with both a space and a glob ("a b"/*); relative globs only if no directory change could happen (cd/pushd/popd/chdir/-C/--directory/eval/exec/source, searched with quotes, backslashes and line continuations removed; -C also glued, -Cdir); and, if the command may read recursively (-r/-R/-a, --recursive/--archive/--directories, tar/zip/rsync/find/…), no match is a directory. Otherwise, and for every file-tool glob, the old name check applies; >500 matches blocks. Disguised names keep literal text (.en[v], secr?t, .en$X) and stay blocked. Guard: test_guard.py ALLOWED (GH #238 cases) + test_pure_wildcards_judged_by_expansion (tmp dir with .env, secret/x), both copies; CI via tests/guard-hook.test.js, tests/guard-template.test.js. (T.20, B.10)
  • V.26: Repo hook and guide template are ONE program: guard.py identical outside the ADAPT block, test_guard.py identical up to the repo-specific cases, same ADAPT setting names (template defaults: HISTORY_SECRET_PATHS = []). A guard fix lands in both or CI fails. Guard: tests/guard-template.test.js (3 drift checks). (T.20, B.12)
  • V.27: With HISTORY_HAS_SECRETS, a git history read blocks iff its output can hold file contents: a patch flag (-p, --patch*, -U, --word-diff, --raw, …), or a default-patch command (show, diff <rev>, diff-index) without a summary format (--stat, --numstat, --shortstat, --dirstat, --name-only, --name-status, --summary, --compact-summary, --no-patch, --quiet, -s as its own word only: -Sfs is a search value) and every other option on the known-harmless lists, matched exactly: SUMMARY_SAFE (no value, or attached), SUMMARY_SAFE_VALUED only as --opt=value (any other option, or a value given as the next word, may eat the summary flag, --author --stat, or print lines, --check: fail closed). HISTORY_SECRET_PATHS entries are also selected by any directory above them (-- conf selects conf/app.md); an empty list exempts every pathspec; with a non-empty list, a command where git runs elsewhere (git -C, --work-tree, --git-dir, any cd) earns no pathspec exemption; <rev>:<path> arguments of content-printing subcommands (CONTENT_SUBS: show, grep, diff, log, blame, …; not rev-parse) count only if git resolves them, from the repo root, or from git’s working directory for .//../; git running elsewhere → block without probing. blame of a protected file with a revision and log -L (patch flag) print old lines → block. One model (DEFAULT_PATCH, SUMMARY_OPTS) for every subcommand. Guard: test_guard.py GH #213 cases, both copies. (T.20, B.11)
  • V.28: Literal message text is data: values of the message options in MESSAGE_OPTS, an exact (tool, subcommand) table (git commit|tag|merge|notes|stash -m/--message; gh pr|issue create|edit --title/--body, pr|issue comment/pr review --body, pr merge --subject/--body, release create|edit --title/--notes; elsewhere -b may read a file: gh attestation verify -b), in commands with no redirection at all (no unquoted </> anywhere in any word: notes.txt> -m), that the shell doesn’t expand ('…', "…" without $/backtick, bare word, "$(cat <<'EOF' … EOF)" with no delimiter line inside the body) become a placeholder before any check (strip_literal_messages, quote/heredoc-aware shell_words). Words are judged after shell unquoting ('--' is --; $/backtick outside single quotes, unquoted glob or brace {a,b} → unknowable, the walk ends) and walked as git/gh parse them: each value consumed once (-m -m .env keeps .env); no table of value-taking options: an option not in KNOWN_FLAGS followed by a word starting with - ends the walk, else that word is skipped (value or argument, never a message option), so no arity guess can hide a word (-F -m .env, -p -n --notes .env); any redirection (> -m .env) or backslash-newline word (--trailer \⏎ -m .) voids it for the whole command (fail closed). A value containing backslash-newline is never literal, and messages are stripped before bash’s line joining; a non-literal message value ends the walk (an expansion may yield 0..n words); and git add/git commit with an unquoted $/backtick blocks with a “quote it” explanation (word splitting can yield any pathspec, even .: -m $(true) -m ., add src/$f.py); stripping happens before anything else, including the glob-trust state in main(); leading VAR=x and wrappers (command, env, sudo, time, nice, nohup, timeout) are skipped to find git/gh, but only with wrapper options known per wrapper (env -S runs a command string: unknown → no exemption), and the program word must be one path without spaces. Anything expanded ($(cat .env), unquoted heredoc), -F/--body-file, pathspecs and chained commands stay checked. Guard: test_guard.py message ALLOWED/BLOCKED cases, both copies. (T.20, B.13)
  • V.29: Every guard block names what matched and why (`.env` is a secret file name, the glob expands to …, … matches N paths), and its footer carries the decision rule: a real path/history the command touches → stop and ask; a word that is only text the command never opens → collateral, rephrase; never disguise a path. AGENTS.md, CLAUDE.md, template CLAUDE.md, SKILL.md and the guide post state the same rule. Guard: test_guard.py test_block_explains_itself, both copies. (T.20, B.13)
  • V.30: A quoted heredoc fed to a data sink (cat, tee, git commit -F -) is data: its body is dropped before any check. RETIRED 2026-10-06 (T.20, B.14): three Codex review rounds in a row each found a new way past this exemption (a file the same command then runs; a heredoc operator in a comment; a shell function named cat, a pipe before the sink, a trailing-space delimiter). Root cause: proving that text is never executed needs a full bash model. The exemption is removed; every heredoc body is checked again; prose that names secret paths is written with the Write/Edit tool. The BLOCKED rows it produced stay as regression cases. See D.7.
  • V.31: .envrc and .env_* / .env-* are secret file names, like .env and .env.* (SECRET_FILES, SECRET_EXAMPLES incl. .env-local so an untrusted .env-* glob after cd blocks). Guard: test_guard.py BLOCKED .envrc, .env_prod, .env-local, cd config && cat .env-*, both copies. (T.20, B.14)
  • V.32: Post reader aids work by keyboard and read correctly: every glossary term the glossary script inserts is an <a class="glossary-term" href="#glossary-<slug>">, never inside another link; adjacent footnote references always render with a visible separator (<sup class="footnote-sep">,</sup>, read by screen readers too). Guard: tests/a11y-glossary-footnotes.test.js. (B.15)

§T Tasks

Status: ✅ done · 🔶 partial · 📋 planned · ❌ not started. Est: S (<1h) · M (1–3h) · L (3–6h).

# Task Files Depends Est Status
T.0 Existing site as of 2026-10-01 (baseline) — — — ✅
T.1 Characterization tests + CI: fix stale test paths, test.yml runs build + npm test tests/, .github/workflows/test.yml T.0 M ✅
T.2 Secrets: redact committed key, Bitwarden store/fetch, guard hook, tests/secrets.test.js CLAUDE.md, scripts/vault/, .claude/, tests/secrets.test.js T.0 M ✅
T.3 Rotate dev service-role key after project resumes; re-run store-env-dev.sh (human). Resolved instead by deleting the DEV project 2026-10-02 (key invalid with it; hostname no longer resolves) — T.2 S ✅
T.4 History scan: gitleaks detect --redact (human); record hits in §B. Ran 2026-10-02 (gitleaks git, v8.30.1): 9 hits, none live: CLAUDE.md ×3 (B.1 key + DEV recipe, DEV deleted), _config.dev.yml ×3 (DEV anon/publishable, public), _config.yml ×1 (PROD publishable, public), docs/live/dev-environment.md ×1 (DEV test password), tests/unsubscribe.test.js ×1 (fake UUID). Triaged in .gitleaksignore; a scan must show 0 new .gitleaksignore T.2 S ✅
T.5 Extract shared verifyNotifyToken (copied ×3) into supabase/functions/_shared/; Deno unit tests → real guard for V.5 supabase/functions/_shared/notify-token.ts, supabase/functions/*/index.ts T.1 M ✅
T.6 Extract pure submit-question logic (validation, fuzzy score, routing) + Deno tests → guards V.2, V.4 supabase/functions/submit-question/ T.1 L ✅
T.7 One source for quota rule (V.3): function calls SQL or shared constant; drop ask.js fallback once 015 confirmed in prod (done 2026-10-02; single source → D.6) submit-question/index.ts, assets/js/ask.js:15, migrations T.6 M ✅
T.8 Extend V.1 guard to every function dir; decide D.1 tests/edge-functions.test.js, supabase/config.toml T.1 S ✅
T.9 Playwright smoke harness: serve-dev, password sign-in per test user, visit /ask/ /welcome/ /admin/moderation/, screenshot + text dump; doc docs/live/e2e_testing.md. Done: harness npm run e2e, signed-out scenarios pass. Open: run signed-in scenarios with --require-dev once DEV resumes scripts/e2e/ dev project resumed L 🔶
T.10 Audit remaining docs/*.md against code; distil current truth into docs/live/, freeze the rest; guard V.17 docs/, README.md, .github/copilot-instructions.md, tests/docs.test.js T.1 L ✅
T.11 Continuity: bug label, GH issues for found-not-fixed, release HANDOFF when a release starts docs/templates/handoff.md, AGENTS.md — S ✅
T.12 Split files >400 lines along real seams (assets/js/comments.js 713, admin-moderation.js 614, moba-sim-labs.js 614, ask.js 584, chart-fullscreen.js 517, drafts.html 409, assets/css/main.css 3433). Done: shared faq-util.js (esc/$/timeAgo/getUser/getProfile/auth boot), comments.js 380 (+ auth-errors.js, comment-thread.js), ask.js 372 (+ ask-questions.js), admin-moderation.js 389 (+ admin-moderation-view.js), main.css → main.scss + 12 _sass/ partials, built CSS rule-for-rule identical; guard V.19. Rest → T.14 assets/, _sass/, _includes/comments.html, ask.md, admin/moderation.md T.9 L ✅
T.13 RLS hardening: profiles column-level UPDATE (no self-set premium, GH #209); record_send_failure revoke EXECUTE from public + search_path = '' (GH #210); new §V + schema-text guard; + unsubscribe link (GH #215). Done in repo: migration 018, schema, /unsubscribe/, V.20–V.22. Open: apply 018 to PROD after a backup, deploy notify-subscribers supabase/migrations/018_*, supabase/supabase-schema.sql, supabase/tests/, tests/rls-grants.test.js, unsubscribe.md, assets/js/unsubscribe.js T.1 M 🔶
T.14 Rest of T.12, deferred 2026-10-02 (owner): split on the next substantive change, not proactively. moba-sim-labs.js 614 (seams: model / analytics / chart helpers; touches the published post’s script tags + one MOBASimLabs API), chart-fullscreen.js 517 (site-wide, untested: characterisation tests first, T.22; mobile-ratio seam alone leaves >400), drafts.html 409 (inline script → assets/js/drafts.js). Guard meanwhile: V.19 shrink-only ratchet, so any change that grows them forces the split assets/js/moba-sim-labs.js, assets/js/chart-fullscreen.js, drafts.html, _posts/2026-04-03-the-5-minute-surrender-fallacy.md T.12 M 📋
T.15 Replace deleted DEV project: local stack (supabase start, podman) seeded with schema + test users; point _config.dev.yml / e2e at it; run signed-in e2e (rest of T.9). Retire or rewrite scripts/sync-prod-to-dev.sh (broken + its target is gone, GH #219) _config.dev.yml, scripts/e2e/, scripts/setup-test-users.sh, scripts/sync-prod-to-dev.sh, docs/live/dev-environment.md T.9 M 📋
T.16 Explicit Data API grants (GH #225): Supabase drops automatic anon/authenticated/service_role grants (new projects since 2026-05-30; new objects in existing projects from 2026-10-30; existing objects unaffected — github.com/orgs/supabase/discussions/45329). Add explicit table/function grants to supabase-schema.sql (fresh projects are locked today) and a guard that every migration creating a table/function grants it supabase/supabase-schema.sql, supabase/migrations/, tests/rls-grants.test.js T.13 M 📋
T.17 welcome.md inline script duplicates auth/profile/subscription logic now in assets/js/faq-util.js (FAQUtil): reuse it so the copies can’t drift (cf. B.4). Keep V.13’s source-text guards working welcome.md, assets/js/faq-util.js, tests/comments.test.js T.12 S 📋
T.18 Drafts leak: _drafts/ posts appear in public /feed.xml (GH #201) and draft HTML is reachable by URL (D.2). Decide D.2, then exclude drafts from the feed (+ test in tests/build-output.test.js) .github/workflows/jekyll.yml, feed.xml / _config.yml, tests/build-output.test.js — M 📋
T.19 OpenAI abuse / cost limits for submit-question (GH #160): per-user quota exists (V.3); add global/IP rate limit + spend alerting, never log keys supabase/functions/submit-question/ T.6 M 📋
T.20 Guard hook false positives + template gap (GH #213, #214): allow summary-only git diff, bare * globs, the word “secrets” in messages; port HISTORY_SECRET_PATHS to the guide template + rebuild tarball. Every change needs a BLOCKED test case too. Done: bare * / pure-wildcard globs (GH #238, B.10, V.25); summary-only git output (GH #213, B.11, V.27); template = repo hook + drift test (GH #214, B.12, V.26); literal messages are data + self-explaining blocks (B.13, V.28, V.29) .claude/hooks/guard.py, guides/agentic-repo-prep/prep-repo/templates/ — M ✅
T.21 Verify the scheduled-posts workflow (GH #106): does check-scheduled-posts.yml run daily and notify for posts whose date arrived? Add a test or a dry-run mode + doc .github/workflows/check-scheduled-posts.yml, supabase/functions/check-notification-status/ — S 📋
T.22 Characterisation tests for assets/js/chart-fullscreen.js (loaded on every page, 517 lines, no tests): jsdom tests of the fullscreen open/close, orientation and mobile-ratio paths, so T.14’s split (or any rework) has a safety net assets/js/chart-fullscreen.js, tests/chart-fullscreen.test.js — S 📋

§B Bugs / Backprop

  • B.1 [2026-10-01] Dev Supabase service-role JWT in tracked CLAUDE.md since 6e96a02 (2026-04-01). Cause: browser-login recipe pasted the literal key into a checked-in doc. Fix → V.11 (T.2): literal replaced by .env.dev lookup; hook + deny rules stop agents reading .env.dev*; tests/secrets.test.js fails on any tracked JWT-shaped string. Key stays in history → rotation T.3; closed 2026-10-02 by deleting the DEV project (gitleaks: 8 hits / 403 commits, CLAUDE.md = this key; rest pending human triage, T.4). Touches CLAUDE.md, .gitignore, scripts/vault/.
  • B.2 [2026-10-01] npm test red: 4 fail, 51 cancelled. Cause: tests read paths removed by 370ab2c (migrations 001–009 squashed, docs/supabase-schema.sql → supabase/) and 4876da7 (the-moba-fallacy → _drafts/); CI never ran tests, so nobody saw it. Fix → V.15 (T.1): schema tests retargeted; obsolete migration-file blocks replaced by squash end-state block; fixture post derived from _posts/; test.yml. Touches tests/subscription.test.js, tests/build-output.test.js.
  • B.3 [2026-10-02] LIVING docs wrong: security checklist called notify auth a “Bearer NOTIFY_SECRET” (HMAC since), listed all definer RPCs as search_path = '', missed 2 tables + 4 RPCs; README said 1 question/day + AI moderation “planned”; copilot-instructions + visual guide had dead links; SPEC §C pointed at docs/accessibility.md. Cause: 2026-10-01 docs split moved docs into docs/live/ unaudited (“Reconciled: not yet”); nothing checked headers or links. Fix → V.17 (T.10): each live doc audited against code, new operations.md + content-authoring.md distilled from FROZEN docs, tests/docs.test.js. Audit also found code bugs → GH #209, #210 (T.13), #215. Touches docs/live/, README.md, .github/copilot-instructions.md, ARCHITECTURE.md, _config.yml (comment).
  • B.4 [2026-10-02] Signed-out /admin/moderation/ showed “Could not initialize moderation dashboard.” instead of the sign-in prompt. Cause: supabase-js v2 getUser() with no session resolves {user:null, error: AuthSessionMissingError}; admin-moderation.js threw any res.error. jsdom stubs returned {user:null} without the error, so tests never saw it. Found by the first T.9 e2e run. Fix → V.18 (T.9): treat AuthSessionMissingError as signed out. Touches assets/js/admin-moderation.js, tests/admin-moderation.test.js.
  • B.5 [2026-10-02] Any signed-in user could set own profiles.premium (GH #209; reproduced on a local DB built from the schema). Cause: update policy has no column limit and Supabase default grants give anon/authenticated table-wide INSERT/UPDATE. Fix → V.20 (T.13): migration 018 column grants. Touches supabase/migrations/018_*, supabase/supabase-schema.sql.
  • B.6 [2026-10-02] record_send_failure callable by anon (PUBLIC EXECUTE default), search_path = public (GH #210). Fix → V.21 (T.13): revoke from public/anon/authenticated, grant service_role, search_path = ''. Touches same.
  • B.7 [2026-10-02] Email unsubscribe link /?unsubscribe=<id> did nothing (GH #215). Cause: link + RPC added without a frontend handler; no test followed the link. Fix → V.22 (T.13): /unsubscribe/ page + legacy redirect; email links to the page. Touches unsubscribe.md, assets/js/unsubscribe.js, _layouts/default.html, supabase/functions/notify-subscribers/index.ts.
  • B.8 [2026-10-02] Repo submit-question inserted retry_offered, a column migration 016 dropped on PROD; deploying the repo would have failed every saved question (500). Deployed v13 (2026-04-04) did not have it; repo and deployment had silently diverged (repo also has the undeployed curiosity score). Found by diffing the downloaded PROD source before the first deploy of this run. Cause: d999a8e dropped the column without the matching function edit being committed; Deno tests stub the DB. Fix → V.23: line removed; tests/function-columns.test.js checks payload columns against the schema. Touches supabase/functions/submit-question/handler.ts.
  • B.9 [2026-10-02] submit-question reads article-summaries.json with Deno.readTextFile, but the file was never bundled: deploys upload only code reachable by imports; non-code files need static_files in config.toml (https://supabase.com/changelog/32815-add-static-files-to-edge-functions). The loader swallows the error and uses [], so related-article matching and the duplicate-topic cap (V.4) were silently off in PROD, likely since the feature shipped. Found when the deploy’s upload list showed 3 files. Fix → V.24: static_files entry + test. Touches supabase/config.toml, tests/edge-functions.test.js.
  • B.10 [2026-10-03] Guard hook blocked harmless Bash as “touches a secret path” (GH #238; ≥10× over 2026-10-02/03): loop vars ("$d"/index.html), ls $R/*, quoted git globs ('draft/*'), grep patterns ([a-z]*). Cause: is_secret_component fnmatch’ed every glob part against SECRET_EXAMPLES/SECRET_DIRS; a pure wildcard matches all of them, before is_secret_path could check the real expansion. Fix → V.25 (T.20): pure-wildcard parts skip the name match; expansion decides. Same in guide template, tarball rebuilt. Touches .claude/hooks/guard.py, .claude/hooks/test_guard.py, guides/agentic-repo-prep/prep-repo/templates/, guides/agentic-repo-prep/prep-repo.tar.gz.
  • B.11 [2026-10-05] git diff --stat main..HEAD blocked as a history read although it prints only names and counts (GH #213). Cause: reads_history_content decided “prints contents?” separately per subcommand with inconsistent summary-flag sets; the diff branch had none, so any revision blocked. Fix → V.27 (T.20): one model, DEFAULT_PATCH + SUMMARY_OPTS (prefixes and =values via long_opt). Touches .claude/hooks/guard.py, .claude/hooks/test_guard.py, template copies.
  • B.12 [2026-10-05] Guide template guard allowed git log -p -- <file> for an ordinary file whose old versions held a key (GH #214); B.1’s HISTORY_SECRET_PATHS fix reached only the repo hook. Cause: two hand-maintained copies of one program, nothing enforcing sameness. Fix → V.26 (T.20): template rebuilt from the repo hook (only ADAPT differs; HISTORY_SECRET_PATHS = []), history test made portable (in-process, forces the setting), drift test; SKILL.md + guide post document the setting; GUIDE.md + tarball rebuilt. Touches guides/agentic-repo-prep/, _posts/2026-10-01-what-does-a-repo-need-before-you-hand-it-to-an-ai-agent.md, tests/guard-template.test.js.
  • B.13 [2026-10-05] Commit messages, tag notes and PR bodies that merely mentioned a secret word blocked (“command touches a secret path”), and the block didn’t say which word, so agents couldn’t tell collateral from a real hit. Cause: the guard scanned literal message text as shell code and paths: <email> in a Co-Authored-By line read as a redirection (voiding the -m exemption), quoted-heredoc bodies were scanned as words, and git tag -m/--message=/gh --body had no exemption. Fix → V.28, V.29 (T.20): literal message values are replaced before any check; every block names its match and carries the stop-vs-collateral rule. Touches .claude/hooks/, template copies, AGENTS.md, CLAUDE.md, guides/agentic-repo-prep/, the guide post.
  • B.14 [2026-10-06] Transcript audit of every guard block in lottery-of-choice + video-downloader sessions: 8 of 12 blocks in 6 sessions were prose naming secret paths (reviewer prompts and teammate briefs via cat > f <<'EOF', doc edit scripts, a git commit -F - body); .envrc and .env_prod were not secret names. Cause: quoted heredoc bodies fed to data sinks were scanned like shell code; SECRET_FILES matched only .env and .env.<x>. Fix → V.30 RETIRED [2026-10-06], V.31 (T.20): first strip_data_heredocs dropped data-sink heredoc bodies; Codex rounds 1–3 each found a bypass → removed (#245, 0d53fca; D.7): every heredoc body checked, prose naming secret paths via Write/Edit, bypass rows stay BLOCKED. Kept: SECRET_FILES + \.envrc, \.env[_-].+ (V.31); test_guard.py refuses to run outside a git work tree (else 7 false failures). Touches .claude/hooks/, template copies, guides/agentic-repo-prep/, the guide post.
  • B.15 [2026-10-09] Glossary terms not keyboard-operable, and adjacent footnote refs read as one number (“45” for 4, 5), on every post (GH #256; found by article-review-loop R3 on #253). Cause: _includes/glossary.html built terms as <abbr> with a click handler only (no focus, Enter does nothing); _layouts/post.html output kramdown’s back-to-back <sup> refs unchanged. Fix → V.32: terms are links to their glossary entry, text inside links skipped (no nested links); the post layout inserts a comma between adjacent refs. Touches _includes/glossary.html, _layouts/post.html, docs/live/accessibility.md.

§D Deferred / undecided

  • D.1: resolved 2026-10-02 (T.8): keep moderate-question; [functions.moderate-question] added. Retire later = delete dir + block.
  • D.2: Prod build runs jekyll build --drafts (.github/workflows/jekyll.yml:67), so draft HTML is publicly reachable by URL; /drafts/ premium gating is client-side only; drafts also appear in /feed.xml (GH #201). Intended? → T.18.
  • D.3: HMAC tokens are replayable inside the 5-min window. Harmless for notify (V.6 dedup); moderate-question replay re-applies the same action. Accept?
  • D.4: resolved 2026-10-02: 015 confirmed on PROD (read-only pg_get_functiondef('public.get_question_quota') mentions premium); ask.js fallback to can_submit_question_today removed; on RPC error the form shows “could not be checked” and the server enforces (V.3). Guard: tests/ask.test.js.
  • D.5: REQUIRED_KEYS in scripts/vault/lib.sh derived from what scripts/set-dev-secrets.sh + scripts/setup-test-users.sh require (test in tests/secrets.test.js), not from a real .env.dev (none on disk 2026-10-01). Confirm on first store-env-dev.sh run.
  • D.6: V.3 single source. Today: TS constants ≡ SQL literals, drift-tested (T.7). True single source = submit-question calls get_question_quota through a client carrying the user JWT (RPC reads auth.uid()); needs 015 in prod (D.4) and one extra round trip. Switch, or keep the drift test?
  • D.7: Enforce secret protection at the effect level instead of parsing command text? A text guard has to predict what bash will do, so its exemptions leak and its matches block prose (B.13, B.14, V.30). Candidate split, being field-tested in video-downloader (2026-10-06): Claude Code OS sandbox filesystem.denyRead for shell reads (child processes included), permission deny rules for file tools, a staged-content secret scan in pre-commit + CI for commits, and the text guard reduced to a no-exemption nudge. Known gaps: hooks/MCP/! commands run outside the sandbox; on Linux wildcard denyRead entries expand to concrete paths (files created later may be uncovered); some tests skip inside the sandbox (git hash-object -w can’t write). Undecided until the field test reports.