SPEC — Frequently Asking Questions
SPEC — Frequently Asking Questions
Distilled from code 2026-10-01 (pre-spec behavior tagged
(T.0)). Encoding and section shapes: FORMAT.md. Sole mutator: thespecskill.?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.tscreateSubmitQuestionHandler(wired inindex.ts). Callerassets/js/ask.js:169. - I.2:
POSTnotify-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/bynpm run build:article-summaries. - I.7: Frontend globals
FAQComments(assets/js/comments.js),FAQAsk(assets/js/ask.js),FAQAdminModeration(assets/js/admin-moderation.js), eachinit(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.htmlwhensite.supabase.urlandpublishable_keyset.
§V Invariants
- V.1: Every
[functions.<name>]insupabase/config.tomlsetsverify_jwt = false(ES256 user JWTs fail the legacy gateway check); each function authenticates itself (user JWT viaauth.getUser, or HMAC). Guard:tests/edge-functions.test.js(every function dir: config block, uncommentedverify_jwt = false, auth comment,auth.getUser/verifyNotifyTokenin 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; noprofilesrow 403. Guard:supabase/functions/submit-question/handler_test.ts“V.2 …”. (T.0, T.6) - V.3: Question quota = saved
questionsrows by user in rolling 24 h; limit 3 ifprofiles.premiumelse 1; admins get no bypass (migration 015). One rule, two runtimes: edgesubmit-question/logic.tsPREMIUM_DAILY_QUESTIONS/REGULAR_DAILY_QUESTIONS/QUOTA_WINDOW_HOURS(applied inhandler.ts); SQLget_question_quota/can_submit_question_today(supabase-schema.sql, newest defining migration). Change both together.ask.jsshows the SQL quota; ifget_question_quotafails 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 → savedflaggedfor humans. Fuzzy match ≥70 or AI overlap confidence ≥80 caps score at 70 + flagpossible_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-Tokenvalid 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_logchecked 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.jsschema blocks (subscriptions, notification_log only). (T.0) - V.10:
article-summaries.jsonmatches_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.ymlgitignored + untracked; no JWT-shaped string in tracked files; agents never read.env.dev*. Guard:tests/secrets.test.js,.claude/hooks/guard.py+.claude/settings.jsondeny 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
onAuthStateChangewhen 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 testandnpm run test:denoare 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/**/*.mdopens with the LIVING header and (except the index)Reconciled: YYYY-MM-DD; everydocs/design/+docs/journal/doc carries a FROZEN (or WORKING DOC) marker; relative links and backtickeddocs/…paths inREADME.md,AGENTS.md,CLAUDE.md,ARCHITECTURE.md,SPEC.md,.github/copilot-instructions.md,docs/live/resolve;docs/live/README.mdindexes 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 e2escenarioadmin_signed-out. (T.9, B.4) - V.19: Frontend stays split: every
FAQ*global a loadedassets/jsmodule uses is defined by a<script>earlier on the same built page;/assets/css/main.cssis built fromassets/css/main.scssimporting every_sass/partial (plain CSS) in order, noassets/css/main.csssource;assets/js/*.js,_sass/*anddrafts.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; behaviourtests/{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 onprofiles; 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 definerfunction setssearch_path = '';record_send_failureexecutable byservice_roleonly. 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 linkedprofiles.subscribed = false; second call returns false. Guard:tests/unsubscribe.test.js, e2eunsubscribe_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 tosupabase/), 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;-Calso 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.pyALLOWED (GH #238 cases) +test_pure_wildcards_judged_by_expansion(tmp dir with.env,secret/x), both copies; CI viatests/guard-hook.test.js,tests/guard-template.test.js. (T.20, B.10) - V.26: Repo hook and guide template are ONE program:
guard.pyidentical outside the ADAPT block,test_guard.pyidentical 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,-sas its own word only:-Sfsis a search value) and every other option on the known-harmless lists, matched exactly:SUMMARY_SAFE(no value, or attached),SUMMARY_SAFE_VALUEDonly 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_PATHSentries are also selected by any directory above them (-- confselectsconf/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, anycd) earns no pathspec exemption;<rev>:<path>arguments of content-printing subcommands (CONTENT_SUBS:show,grep,diff,log,blame, …; notrev-parse) count only if git resolves them, from the repo root, or from git’s working directory for.//../; git running elsewhere → block without probing.blameof a protected file with a revision andlog -L(patch flag) print old lines → block. One model (DEFAULT_PATCH,SUMMARY_OPTS) for every subcommand. Guard:test_guard.pyGH #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-bmay 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-awareshell_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 .envkeeps.env); no table of value-taking options: an option not inKNOWN_FLAGSfollowed 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); andgit add/git commitwith 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 inmain(); leadingVAR=xand wrappers (command,env,sudo,time,nice,nohup,timeout) are skipped to find git/gh, but only with wrapper options known per wrapper (env -Sruns 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.pymessage 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.pytest_block_explains_itself, both copies. (T.20, B.13) V.30: A quoted heredoc fed to a data sink (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 namedcat,tee,git commit -F -) is data: its body is dropped before any check.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:
.envrcand.env_*/.env-*are secret file names, like.envand.env.*(SECRET_FILES,SECRET_EXAMPLESincl..env-localso an untrusted.env-*glob aftercdblocks). Guard:test_guard.pyBLOCKED.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.mdsince 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.devlookup; hook + deny rules stop agents reading.env.dev*;tests/secrets.test.jsfails 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). TouchesCLAUDE.md,.gitignore,scripts/vault/. - B.2 [2026-10-01]
npm testred: 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. Touchestests/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 assearch_path = '', missed 2 tables + 4 RPCs; README said 1 question/day + AI moderation “planned”; copilot-instructions + visual guide had dead links; SPEC §C pointed atdocs/accessibility.md. Cause: 2026-10-01 docs split moved docs intodocs/live/unaudited (“Reconciled: not yet”); nothing checked headers or links. Fix → V.17 (T.10): each live doc audited against code, newoperations.md+content-authoring.mddistilled from FROZEN docs,tests/docs.test.js. Audit also found code bugs → GH #209, #210 (T.13), #215. Touchesdocs/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 v2getUser()with no session resolves{user:null, error: AuthSessionMissingError};admin-moderation.jsthrew anyres.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): treatAuthSessionMissingErroras signed out. Touchesassets/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. Touchessupabase/migrations/018_*,supabase/supabase-schema.sql. - B.6 [2026-10-02]
record_send_failurecallable 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. Touchesunsubscribe.md,assets/js/unsubscribe.js,_layouts/default.html,supabase/functions/notify-subscribers/index.ts. - B.8 [2026-10-02] Repo
submit-questioninsertedretry_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 undeployedcuriosityscore). 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.jschecks payload columns against the schema. Touchessupabase/functions/submit-question/handler.ts. - B.9 [2026-10-02]
submit-questionreadsarticle-summaries.jsonwithDeno.readTextFile, but the file was never bundled: deploys upload only code reachable by imports; non-code files needstatic_filesinconfig.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_filesentry + test. Touchessupabase/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_componentfnmatch’ed every glob part againstSECRET_EXAMPLES/SECRET_DIRS; a pure wildcard matches all of them, beforeis_secret_pathcould 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..HEADblocked as a history read although it prints only names and counts (GH #213). Cause:reads_history_contentdecided “prints contents?” separately per subcommand with inconsistent summary-flag sets; thediffbranch had none, so any revision blocked. Fix → V.27 (T.20): one model,DEFAULT_PATCH+SUMMARY_OPTS(prefixes and=valuesvialong_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’sHISTORY_SECRET_PATHSfix 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. Touchesguides/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-mexemption), quoted-heredoc bodies were scanned as words, andgit tag -m/--message=/gh --bodyhad 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, agit commit -F -body);.envrcand.env_prodwere not secret names. Cause: quoted heredoc bodies fed to data sinks were scanned like shell code;SECRET_FILESmatched only.envand.env.<x>. Fix →V.30RETIRED [2026-10-06], V.31 (T.20): firststrip_data_heredocsdropped 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.pyrefuses 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.htmlbuilt terms as<abbr>with a click handler only (no focus, Enter does nothing);_layouts/post.htmloutput 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')mentionspremium);ask.jsfallback tocan_submit_question_todayremoved; on RPC error the form shows “could not be checked” and the server enforces (V.3). Guard:tests/ask.test.js. - D.5:
REQUIRED_KEYSinscripts/vault/lib.shderived from whatscripts/set-dev-secrets.sh+scripts/setup-test-users.shrequire (test intests/secrets.test.js), not from a real.env.dev(none on disk 2026-10-01). Confirm on firststore-env-dev.shrun. - D.6: V.3 single source. Today: TS constants ≡ SQL literals, drift-tested (T.7). True single source = submit-question calls
get_question_quotathrough a client carrying the user JWT (RPC readsauth.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 sandboxfilesystem.denyReadfor 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 -wcan’t write). Undecided until the field test reports.