Alpha Release Plan
Plan · done

Implementation Plan, Milestone Alpha

EXPERTA

Implementation plan for Milestone Alpha of EXPERTA, the Fractional Factorial Design Translator.

Plan
replication_materials/plans/2026-10-17-alpha-release-plan.md
Specification
replication_materials/plans/2026-10-14-ff-design-translator-design.md
Status
Done. Merged into main on 2026-10-18 (pull request 1). Live on 2026-10-18 at experta-doe.vercel.app with the keys and the review page; Checkpoint B complete (smoke test cost 0.045 USD).
Tasks
9, each test-first and committed on its own
Maintained by
Alan R. Vazquez (Tecnológico de Monterrey) and Fadel M. Megahed (Miami University)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

1Context

Milestones 1, 2 and 3 (3A, pre-3B, 3B and 3C) are merged into main (main at 03ec302, 2026-10-17). On 2026-10-17 Fadel M. Megahed decided to put an alpha version online for Alan R. Vazquez and for other quality engineering and DoE users, with a page that collects the answers to the open questions for the authors. The approved design is 2026-10-17-alpha-release-design.md (decisions A1 to A18). This plan builds it. Two checkpoints in the plan make the outside changes (repository, Vercel, keys); each of them needs the approval of the author.

Goal: EXPERTA runs at https://experta-doe.vercel.app with ensemble translation; the rate limit and the spend caps hold across all server instances; a Content Security Policy is enforced and tested; a "Review questions" page files each answer of the authors as a GitHub issue; and "Delete all" resets step 1 (3C Rulings 29 and 30).

Architecture: The counters move behind a small KvClient port (one eval call). Production uses Upstash Redis over REST; the unit tests run the same Lua scripts on ioredis-mock (Lua through fengari); local runs keep the in-memory counters. The translate handler reserves the worst-case cost of each voter before the call and settles it after. Security headers join the isolation headers in next.config.ts, so next start in the browser tests sends the same policy as Vercel. The review page is a static page with a client component; a server route checks the key and talks to the GitHub REST API with a fine-grained token. A separate Playwright configuration checks the deployed site.

Tech Stack: Next.js 16.3.8 (App Router), React 19.3.0, TypeScript 5.9.3, zod 4.6.5, WebR 0.6.0, vitest 4.1.11, Playwright 1.63.0 (Chromium) with @axe-core/playwright 4.13.0; new: @upstash/redis 1.39.0, ioredis-mock 8.13.1 and ioredis 5.11.1 (tests only); Vercel (Pro, Fluid compute, Node.js 22), Upstash Redis from the Vercel Marketplace, GitHub REST API (2022-11-28).

Spec: replication_materials/plans/2026-10-14-ff-design-translator-design.md (version 5; Task 9 makes version 6), Sections 1.4, 5.6, 7.7, 7.9, 9.5, 9.7, 9.10, 11, 13 and 17.1. The design replication_materials/plans/2026-10-17-alpha-release-design.md gives decisions A1 to A18; where this plan refines one, the decision table below says so.

2Global Constraints

  • Versions. Pin every npm package exactly (.npmrc: save-exact=true, legacy-peer-deps=true). Three new packages, installed by the controller in the execution setup (one Dropbox pause): @upstash/redis@1.39.0 as a dependency (published 2026-09-21), ioredis-mock@8.13.1 (2025-10-29) and ioredis@5.11.1 (2026-06-04) as devDependencies. ioredis-mock loads ioredis/built/Command at run time, so it needs ioredis; its peer range is ^5, so not ioredis 6. No other new package; the tests declare the few ioredis-mock types they use. Implementers do not run npm install.
  • Dropbox. The repository is in Dropbox, which locks node_modules while it syncs (EBUSY). Only the controller installs packages, after the user pauses Dropbox.
  • Outside contact. Only the controller makes outside changes, only at Checkpoint A and Checkpoint B, and only after the author approves each one: creating the GitHub repository, each push, Vercel settings and variables, labels, each deploy, each live call. Subagents make no network call to Vercel, Upstash, GitHub or an LLM provider. The deployed-site tests run only at the checkpoints.
  • Secrets. Never print a secret: no .env value, key, token, salt or review key in a command, an output, a log line, a test name, a commit, the ledger or a page. A command that stores a secret in Vercel reads it from a pipe or from the author's own input. The server never logs the address, the request text, the review key or a comment.
  • No LLM call in tests. Unit tests use stubs; browser tests answer /api/translate from the recorded fixtures (routeTranslate) and /api/review with page.route. The only live LLM call of this plan is the smoke test of Checkpoint B.
  • No network in unit tests. Redis is ioredis-mock behind the KvClient port; GitHub is a stub fetch.
  • Client and server. Client components never import lib/ops/*, lib/review/handler.ts, lib/review/github.ts, lib/review/config.ts, lib/review/key.ts or the translate server modules at run time.
  • JSON imports. Every module that a Playwright spec imports, directly or through other modules, imports JSON only with with { type: "json" }.
  • Fixed texts. Every new message is a fixed string in one module with a unit test that asserts the exact text. No advice words ("try", "instead", "recommend", "consider", "should"); the test builds its whole-word pattern with new RegExp(\\b${word}\b`)`.
  • Wording. Plain academic wording (spec 9.7): no marketing, no exclamation marks. Labels exactly as this plan gives them. Comments and documents about 80% ASD-STE100.
  • Accessibility. WCAG 2.1 AA plus the WCAG 2.2 target size and focus-not-obscured additions. One global announcer (lib/a11y/announcer.ts). Every control has a visible label. A busy button uses aria-disabled="true" and ignores clicks (3B Task 7 pattern). No focus drops to the page body.
  • Fail closed. A counter that cannot be read or written refuses the translation. A cost that is not finite or is negative blocks the voter until the next UTC day. A missing cap counts as reached.
  • Tests. All tests are in replication_materials/tests/. npm run check must pass at the end of each task that changes code (about 10 minutes; npm run check -- --fast skips the browser tests during a task).
  • Commits. One commit per task unless a task says otherwise. Every commit message ends with this line (the git commit commands in the tasks show only the subject line): Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

3Review Focus

These are the five inputs or conditions that a person is most likely to meet and that the spec does not test directly. Each has tests in the task that owns its code.

  1. The store fails in the middle of a request: Redis does not answer at the rate check, at a reserve, or at the settle after a translation that already ran. The user gets one fixed message or the finished result; nothing spends past a cap; "Fill in the form yourself" still works. Tests: Task 3 (rate check), Task 4 (reserve and settle).
  2. Parallel translations near a cap on two instances: two handler instances that share one store reserve at the same time. At most one reserve fits under the cap; the other voter is left out with spend_cap. Tests: Task 4.
  3. Unusual review answers: an empty name, a comment of 4,001 characters, "Change" without a comment, an unknown question id, accents and emoji, and a comment that contains the review marker or Markdown. The server refuses with one fixed message, or files an issue whose marker the read-back trusts only on the first line. Tests: Task 6.
  4. The review key at awkward moments: the link without a key, a wrong key, a stored key that the authors changed (the server answers 401), and requests that must never carry the key in a URL or a Referer. Tests: Task 7.
  5. "Delete all" in every state of step 1: typed text and no project, a translation in flight, a clarification question, and the first interpretation that remounts step 1 (Ruling 20). Step 1 is empty after "Delete all", no project appears later, and the focus rules of 3C still hold. Tests: Task 8.

4Decisions made during planning

The plan makes these decisions. Each one has a reason and a cost if it is wrong. The authors can change any of them before execution.

# Decision Reason Cost if wrong
AP1 The counters are Lua scripts run with EVAL (one atomic step each). The unit tests run the same scripts on ioredis-mock. Upstash serves EVAL over REST; REST has no WATCH, so a check and an increment are atomic only in a script. Measured: ioredis-mock 8.13.1 runs both scripts with the expected results (Facts). Two dev packages.
AP2 The reserve of an LLM voter covers two calls (the first call and the repair retry, llm-voter.ts): input 2 × b + 8192 tokens and output 2 × 8192 tokens, where b is callBound(threadMessages(turns)).inputTokens and 8192 is MAX_OUTPUT_TOKENS. The reserve of Jev is the UTF-8 bytes of its request as input tokens. A voter makes at most two calls. The retry input is the thread, the first answer (at most 8192 tokens) and the repair list. A repair list longer than b makes the settle positive; near a cap, parallel requests can then pass it by that excess.
AP3 An LLM voter settles at the spec 13.1 charge: usage + unread. A Jev call that the deadline cut off keeps its reserve; any other Jev call settles at its usage. This refines design 4.2 ("a voter that the deadline cut off keeps the full reserve"). unread is already the spec's upper bound for the call in flight. Keeping the full reserve would also charge a retry that never started. Jev reports no bound for a cut-off call. None for the cap; the cost record is the spec's bound.
AP4 A store that is missing or fails gives 503 { kind: "unavailable", message: MESSAGES.store_unavailable } = "Interpretation is not available now. You can fill in the form yourself." The client already shows the message of kind unavailable, so it needs no change. "Until tomorrow" (paused) would be wrong. One message.
AP5 The rate-limit keys hold HMAC-SHA256(RATE_LIMIT_SALT, address) in hex. Without a salt of at least 32 characters, the Redis store is not used (store kind none). Design 4.2 (privacy). A short salt makes the hash easy to reverse for IPv4. One variable.
AP6 The review route has its own limits (30 per minute, 300 per day per address, key prefix rv), a body cap of 16 KB, and answers that only a request with the key can read. Each instance keeps the issue list for 60 seconds and drops it after its own POST. An issue with the label withdrawn does not count. "Other comments" has no choices and needs a comment. The progress count leaves out "Other comments" (54 questions). Two authors file about 55 answers. The read-back would otherwise call GitHub at each page load. withdrawn lets the authors remove the smoke-test answer and any answer filed by mistake. Limits in one constant.
AP7 The deployed-site checks have their own Playwright configuration (playwright.deployed.config.ts, FFD_BASE_URL required) and never run in npm run check. The rate-limit check stops before its second request if the first answer is not "unavailable" (that is, if keys are set). The checks need a deployed site; a live LLM call needs approval. One configuration file.
AP8 The slow-device profile is the Lighthouse mobile default: CPU 4× slower, latency 150 ms, download 1,638.4 kbit/s, upload 675 kbit/s, set with the Chrome DevTools Protocol. The measured gap is the longest time between two changes of the footer text during the download ("R engine: loading (p%), x of y MB"); the text changes at each 0.1 MB, so this gap is an upper bound of the gap between progress messages. Production has no test hooks, so startWorker of perf.spec.ts is not available. The footer is what the user sees. A test checks that the throttle reaches the worker (a start takes at least 19 s, the time for 5 MB at this speed × 0.8). A slower or faster profile changes the stall limit.
AP9 The stall rule stays as it is ("3 × the longest progress gap, rounded up to whole seconds"); scripts/derive-budgets.mjs adds the slow-profile CSV to its sources. One rule for every measured source. None.
AP10 package.json engines.node becomes ">=22.14.0 <23", and the Vercel project uses Node.js 22.x. Design 4.1 (Node 22). A range without an upper end lets Vercel pick a newer major. None.
AP11 The secret scan reads the added lines of every commit (git log -p --all) and reports: (a) each .env value whose name matches KEY|TOKEN|SECRET|SALT|PASSWORD and that has at least 12 characters, and (b) the fixed key patterns of Facts. It prints the variable or pattern name, the commit and the path, never the value, and exits with status 1 when it finds one. Design 4.5. Model ids and limits in .env are not secret and are in the code. A key type with no pattern and no .env value is not found.
AP12 The controller links Vercel from the worktree (vercel link), whose .gitignore has .vercel/ (Task 1). Vercel is connected to the empty repository before the first push, so the push of main makes the first production deploy from Git. A CLI deploy uploads the folder by .vercelignore, not .gitignore, so it would upload refs/ and alan_feedback/. None.
AP13 The Upstash SDK runs with enableTelemetry: false. The SDK otherwise sends platform and version details with each command. None.
AP14 The controller installs the three packages once, before Task 1, while Dropbox is paused, and commits the two package files. One Dropbox pause; subagents cannot ask the user. None.

5Facts measured during planning (2026-10-17)

Fact Value
Vercel names experta.vercel.app serves another site ("Value Investing Lab"). experta-doe, experta-ffd, experta-app, experta-design, experta-alpha answered 404 DEPLOYMENT_NOT_FOUND. Vercel CLI 64.1.0 is logged in as fmegahed (scope fadel-megaheds-projects).
Vercel documentation (read 2026-10-17) Vercel overwrites x-forwarded-for to prevent IP spoofing. A route sets its limit with export const maxDuration.
Packages @upstash/redis 1.39.0 (2026-09-21): eval(script, keys, args). ioredis-mock 8.13.1 (2025-10-29): needs ioredis (require("ioredis/built/Command")) and uses fengari for Lua. ioredis 5.11.1 (2026-06-04); 6.0.0 (2026-07-31) is outside the peer range ^5.
Lua probe on ioredis-mock (alpha-probes/probe.mjs) Rate script with 3 per minute: results 0,0,0,2 (the 4th refused, by the minute window); the minute key has TTL 120 and count 3. Reserve script, three reserves of USD 2.0 under a cap of USD 5.0 (micro-dollars): 1,1,0, spent 4,000,000; after a settle of −1,500,000: 2,500,000, and a new reserve of 2.0 fits. INCRBY of 9,007,199,254,740 returns that integer.
Repository 138 commits on all refs; .git is 40 MB; the largest blob is 4.4 MB (igraph_2.3.1.tgz of the runtime archive); the runtime archive has 30 tracked files, so the Vercel build needs no package download. public/runtimes/ (git-ignored) has 199 files, 71 MB; R.wasm is 18,062,845 bytes.
Translation costs An LLM voter makes at most two calls (runLlmVoter: first call and repair retry); MAX_OUTPUT_TOKENS is 8192; callBound(messages) gives the bytes of instructions, messages and schema as input tokens. config/pricing.json: jev-1.13.0 input USD 0.042 per million tokens, output 0. Today unread is zero for Jev (translate.ts).
Privacy notice The landing, workspace and privacy pages are static; currentPrivacyNotice() reads TRANSLATE_MODE at build time; a missing mode gives the ensemble notice.
Privacy page Two lines become false with Redis: "It has no accounts and no database." and "The rate limit counts the requests of each IP address in memory".
Engine start perf.spec.ts measures progress gaps through the test hooks (startWorker), which production does not have. The footer text (engineStatusText) shows the loaded megabytes to one decimal.
Step 1 DescribeStep aborts its request and drops the answer when it unmounts (live.current); StepPanel keys each step by project?.id ?? "none", so "Delete all" with no project does not remount step 1.

The probe script and its output are copied to replication_materials/plans/alpha-probes/.

6File structure

File Responsibility
scripts/lib/secret-scan.mjs, scripts/secret-scan.mjs Pure finder of secrets in diff text; the command that scans all commits (AP11)
playwright.deployed.config.ts, replication_materials/tests/deployed/*.spec.ts Checks of the deployed site: headers, runtime files, a form-filled design, the slow-profile start, the live rate limit (AP7, AP8)
lib/ops/kv.ts KvClient port and the Upstash adapter
lib/ops/scripts.ts The three Lua scripts
lib/ops/stores.ts Store choice from the environment (design 4.2 table) and the store error
lib/ops/ratelimit.ts Async rate limiters (memory and Redis), addressKey, clientIp
lib/ops/spend.ts Async spend trackers with reserve and settle (memory and Redis)
lib/translate/reserve.ts Worst-case usage of an LLM voter and of Jev (AP2)
lib/translate/handler.ts, lib/translate/service.ts, content/messages.json Reserve and settle in the request; store wiring; store_unavailable
lib/security-headers.ts, lib/build-guard.ts, next.config.ts, app/api/translate/route.ts CSP, Referrer-Policy, X-Robots-Tag; the test-hook guard; maxDuration
lib/review/questions.ts The review questions as data, with fixed ids
lib/review/answer.ts Answer schema and checks, issue text, marker, read-back (shared by server and client; no secrets)
lib/review/github.ts, lib/review/config.ts, lib/review/key.ts, lib/review/handler.ts, app/api/review/route.ts GitHub calls, configuration, key check, the route logic
app/review/page.tsx, components/review/ReviewPage.tsx, components/review/QuestionCard.tsx The review page
components/shell/Workspace.tsx, components/shell/StepPanel.tsx Generation key for "Delete all" (Rulings 29, 30)
app/privacy/page.tsx The privacy lines about the store and the host
replication_materials/tests/e2e/helpers/test.ts The Playwright test with the CSP guard; every spec imports it

7Execution setup (controller, before Task 1)


8Task 1: Repository hygiene and the secret scan

Files:

  • Create: scripts/lib/secret-scan.mjs, scripts/secret-scan.mjs
  • Modify: .gitignore (add refs/, alan_feedback/, .vercel/), package.json (engines.node, AP10)
  • Add: public/brand/tecnologico-de-monterrey-blue.png (already on disk, untracked)
  • Test: replication_materials/tests/unit/secret-scan.test.ts

Interfaces:

  • Produces:
    • SECRET_NAME = /KEY|TOKEN|SECRET|SALT|PASSWORD/, MIN_SECRET_LENGTH = 12, KEY_PATTERNS: { name: string; pattern: RegExp }[] with exactly: openai /sk-(?!ant-)[A-Za-z0-9_-]{20,}/, anthropic /sk-ant-[A-Za-z0-9_-]{20,}/, google /AIza[0-9A-Za-z_-]{35}/, github /(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{20,})/, private_key /-----BEGIN [A-Z ]*PRIVATE KEY-----/.
    • secretValues(envText: string): { name: string; value: string }[] (lines NAME=value; skip comments, empty values, names that do not match SECRET_NAME, values shorter than MIN_SECRET_LENGTH; strip one pair of surrounding quotes).
    • type Finding = { kind: "value" | "pattern"; name: string; commit: string; path: string }.
    • scanLog(logText: string, values: { name: string; value: string }[]): Finding[] for the text of git log -p --all --format=commit:%H: track the commit from commit:<sha> lines and the path from +++ b/<path> lines; test only added lines (+ but not +++); one finding per (kind, name, commit, path).
  • scripts/secret-scan.mjs: reads .env if present, runs the git command with maxBuffer 512 MB, prints No secrets found in {n} commits. or one line per finding {kind} {name} {commit} {path}, and exits with status 1 when it finds one. It never prints a value.
    • secretValues("OPENAI_API_KEY=abcdefghijklmnop\nOPENAI_MODEL=gpt-6.1-sol\n# X_KEY=zzzzzzzzzzzzzzzz\nRATE_LIMIT_SALT=\"0123456789abcdef\"\nJEV_API_KEY=short\n") is [{ name: "OPENAI_API_KEY", value: "abcdefghijklmnop" }, { name: "RATE_LIMIT_SALT", value: "0123456789abcdef" }].
    • A log with commit:aaa, +++ b/lib/x.ts and the added line +const k = "abcdefghijklmnop"; gives [{ kind: "value", name: "OPENAI_API_KEY", commit: "aaa", path: "lib/x.ts" }]; the same text on a removed line (-) or a context line ( ) gives none.
    • Added lines with sk-ant- + 24 letters, AIza + 35 characters, ghp_ + 36 letters and -----BEGIN RSA PRIVATE KEY----- give the pattern names anthropic, google, github, private_key; the sk-ant- line does not also give openai.
    • Two equal added lines in one file and commit give one finding; JSON.stringify(findings) never contains the value.
  • git add .gitignore package.json scripts replication_materials/tests/unit/secret-scan.test.ts public/brand/tecnologico-de-monterrey-blue.png
    git commit -m "chore: ignore refs, alan_feedback and .vercel, pin Node 22, add the secret scan and the Tec PNG (A15, AP10, AP11)"

9Task 2: Checks of the deployed site

Files:

  • Create: playwright.deployed.config.ts, replication_materials/tests/deployed/site.spec.ts, replication_materials/tests/deployed/slow-start.spec.ts, replication_materials/tests/deployed/limits.spec.ts, replication_materials/tests/deployed/helpers.ts
  • Modify: vitest.config.ts only if its unit include would pick up the new folder (it does not now)

Interfaces:

  • playwright.deployed.config.ts: testDir: "replication_materials/tests/deployed", Chromium, workers: 1, timeout: 7_200_000, use.baseURL from FFD_BASE_URL; the file throws "Set FFD_BASE_URL to the address of the deployed site." when the variable is missing; no webServer.
  • helpers.ts: SLOW_PROFILE = { cpuRate: 4, latencyMs: 150, downloadKbps: 1638.4, uploadKbps: 675 }; applySlowProfile(page: Page): Promise<void> (CDP Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions with bytes per second = kbps × 1024 / 8); footerTimeline(page: Page): Promise<{ t: number; text: string }[]> (an init script with a MutationObserver on the engine status element records performance.now() and the text at each change; the function reads the list); maxLoadingGap(timeline): number (longest time between consecutive entries from the first text that starts with R engine: loading ( to the first R engine: preparing the R packages, both included).
  • Outputs (committed by the controller at Checkpoint A): replication_materials/tests/perf/alpha-deployed-files.csv (columns path,bytes,sha256_match,content_type,content_encoding,cache_control) and replication_materials/tests/perf/alpha-slow-start.csv (columns trial,ready_ms,loading_ms,max_progress_gap_ms,stalled_shown).
    • site.spec.ts "pages and runtime files":
      • for /, /app, /privacy, /workers/webr-worker.mjs: status 200, cross-origin-opener-policy: same-origin, cross-origin-embedder-policy: require-corp;
      • when FFD_EXPECT_CSP=1: these responses also have content-security-policy equal to contentSecurityPolicy() (Task 5), referrer-policy: no-referrer and x-robots-tag: noindex;
      • for each file under the local public/runtimes/ (walk the folder; expect 199): a GET from the base URL gives status 200, and the body has the length and SHA-256 of the local file; webr/R.wasm has content-type starting with application/wasm; write the CSV;
      • /app has crossOriginIsolated === true.
    • site.spec.ts "a design filled in by hand": /app → "Fill in the form yourself" → "Add factor" five times → names X1 to X7 (as in design.spec.ts) → Runs 16 → "Generate design" → the main region shows /16 runs · Resolution IV$/ within 600 s and 16 rows; the CSV item of the Download menu (labels as in downloads.spec.ts) gives a file whose first line starts with Run,Std,X1.
    • slow-start.spec.ts (skipped unless FFD_SLOW_RUNS is set; default trials 20): each trial uses a new browser context, applySlowProfile, footerTimeline, opens /app, waits for "R engine: ready" (timeout 600,000 ms); records ready_ms, loading_ms, max_progress_gap_ms = maxLoadingGap, and stalled_shown (any text with "loading has stopped"); asserts ready_ms >= 19_000 (5 MB, a floor below any compressed size of R.wasm, at 1,638.4 kbit/s, × 0.8): a faster start means the throttle did not reach the worker, which loads R.wasm; writes the CSV.
    • limits.spec.ts (skipped unless FFD_LIVE_LIMITS=1): POST /api/translate with { "turns": [{ "role": "user", "text": "Screen 7 factors in 16 runs." }] }. If the first reply is not status 503 with kind: "unavailable", stop with the message "Keys are set; this check would make LLM calls." Otherwise send requests 2 to 11 in the same minute: requests 2 to 10 give 503 unavailable, request 11 gives 429 rate_limited with a retry-after header.
  • git add playwright.deployed.config.ts replication_materials/tests/deployed
    git commit -m "test: checks of the deployed site: headers, runtime files, a design by hand, slow-profile start, live rate limit (A7, AP7, AP8)"

10Checkpoint A: repository and first deploy (controller and author)

Each numbered item that changes something outside the worktree needs the author's approval first. Record each result in the ledger.

  1. Run node scripts/secret-scan.mjs in the worktree. Expected: no finding. A finding stops the checkpoint.
  2. Create the empty private repository: gh repo create fmegahed/experta --private --description "EXPERTA: Fractional Factorial Design Translator", and add it as origin. No push yet.
  3. In the worktree: vercel link to a new project experta-doe (scope fadel-megaheds-projects); set Node.js 22.x and framework Next.js; connect the Git repository (vercel git connect, or the dashboard if the Vercel GitHub app needs access; that step is the author's).
  4. Set in Production: TRANSLATE_MODE=ensemble, RATE_LIMIT_PER_MIN=10, RATE_LIMIT_PER_DAY=100, the four DAILY_SPEND_CAP_USD_*=5, MAX_REQUEST_CHARS=4000 (each with printf %s <value> | vercel env add <NAME> production). No key.
  5. The author installs Upstash for Redis from the Vercel Marketplace and connects it to experta-doe for Production and Preview (any time before Checkpoint B). The controller lists the variable names with vercel env ls (names only) and records which name set the integration made.
  6. Push main (03ec302): git push -u origin main. Vercel builds it for production. Wait until the deployment is READY; record the build time and whether prebuild ran (build log).
  7. Run FFD_BASE_URL=https://experta-doe.vercel.app npx playwright test -c playwright.deployed.config.ts site.spec.ts, then the same with FFD_SLOW_RUNS=20 and slow-start.spec.ts (about 90 minutes; run in the background with a time limit and an exit marker). Record: the function bundle size (Vercel deployment output); whether output: "standalone" changed the build; the R.wasm content type and encoding; the 20 trials.
  8. If the function bundle contains public/runtimes/, add outputFileTracingExcludes for it in a small fix task before Task 3. Any other failure stops the plan and goes to the author.
  9. Commit the two CSVs in the worktree: git commit -m "test: measurements of the first deploy and of the slow-profile start (A7, A18)".

11Task 3: Shared store and the rate limit

Files:

  • Create: lib/ops/kv.ts, lib/ops/scripts.ts, lib/ops/stores.ts, replication_materials/tests/unit/ops/fake-kv.ts, replication_materials/tests/types/ioredis-mock.d.ts
  • Modify: lib/ops/ratelimit.ts, lib/translate/handler.ts (rate check), lib/translate/service.ts, content/messages.json, app/privacy/page.tsx
  • Test: replication_materials/tests/unit/ops/ratelimit.test.ts, replication_materials/tests/unit/ops/stores.test.ts, replication_materials/tests/unit/translate/handler.test.ts, replication_materials/tests/unit/translate/messages.test.ts, replication_materials/tests/unit/privacy.test.ts or e2e/privacy.spec.ts (where the current lines are asserted)

Interfaces:

  • Produces:
    • interface KvClient { eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown> } and upstashKv(url: string, token: string): KvClient (new Redis({ url, token, enableTelemetry: false }), AP13) in lib/ops/kv.ts.
    • RATE_SCRIPT, RESERVE_SCRIPT, SETTLE_SCRIPT in lib/ops/scripts.ts. RATE_SCRIPT: KEYS minute key, day key; ARGV per-minute, per-day, minute TTL, day TTL; returns 1 when the day count is at its limit, 2 when the minute count is at its limit, else increments both (with their TTLs) and returns 0; a refused request does not count (as the probe).
    • class StoreUnavailableError extends Error; type StoreChoice = { kind: "redis"; kv: KvClient; salt: string } | { kind: "memory" } | { kind: "none" }; redisEnv(env): { url: string; token: string } | null (first UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, then KV_REST_API_URL and KV_REST_API_TOKEN); chooseStore(env: Record<string, string | undefined>, makeKv = upstashKv): StoreChoice with: Redis variables and RATE_LIMIT_SALT of at least 32 characters → redis; Redis variables without such a salt → none; no Redis variables and VERCEL === "1" → none; otherwise memory. In lib/ops/stores.ts.
    • interface RateLimiter { check(key: string, now?: number): Promise<RateLimitResult> } (async now); createRateLimiter(limits, maxKeys?) (memory, unchanged rules, plus size()); createRedisRateLimiter(kv: KvClient, limits: { perMinute: number; perDay: number }, salt: string, prefix = "rl"): RateLimiter with keys {prefix}:m:{hash}:{minute} (TTL 120 s) and {prefix}:d:{hash}:{day} (TTL 172,800 s), and retryAfterSeconds as the memory limiter computes it; unavailableRateLimiter(): RateLimiter (rejects with StoreUnavailableError); addressKey(address: string, salt: string): string (HMAC-SHA256 hex, node:crypto); clientIp unchanged.
    • MESSAGES.store_unavailable = "Interpretation is not available now. You can fill in the form yourself." (AP4).
    • fakeKv(): KvClient & { redis: RedisMock } (calls redis.eval(script, keys.length, ...keys, ...args)) and failingKv(): KvClient (rejects with new Error("down")) in fake-kv.ts.
    • ratelimit.test.ts: the existing memory cases with await; for createRedisRateLimiter(fakeKv(), { perMinute: 3, perDay: 5 }, "s".repeat(32)): requests 1 to 3 in one minute allowed, the 4th refused with window: "minute" and retryAfterSeconds to the next minute; in the next minute 2 more allowed, the next refused with window: "day"; a refused request does not raise a count; two limiters on the same fakeKv (two instances) share the counts; the keys in redis.keys("*") contain addressKey("203.0.113.7", salt) and never 203.0.113.7; addressKey gives 64 hex characters, equal for equal input, different for another salt.
    • stores.test.ts: the four rows of chooseStore (Upstash names, KV names, a 31-character salt → none, VERCEL: "1" without Redis → none, an empty environment → memory); chooseStore never calls makeKv for none and memory.
    • handler.test.ts: a limiter whose check rejects (failingKv or unavailableRateLimiter()) gives status 503 and { kind: "unavailable", message: "Interpretation is not available now. You can fill in the form yourself." }, and the translate stub is not called.
    • messages.test.ts: the exact store_unavailable text; the advice-word test covers it.
    • Privacy (the test that holds these lines): the page has the lines of Step 3 and no longer "It has no accounts and no database." or "in memory".
    • "The server keeps no request text. It has no accounts."
    • "The rate limit counts the requests of each network address for one minute and for one day (UTC). The count is kept in a Redis database (Upstash) under a keyed hash of the address, not the address, and is deleted after at most two days. The address is not written to the log."
    • "The host, Vercel, keeps its standard request logs, which include the IP address."
  • git add lib/ops lib/translate content/messages.json app/privacy replication_materials/tests
    git commit -m "feat: shared rate limit in Redis with hashed addresses, store choice, fail closed (A3, AP1, AP4, AP5)"

12Task 4: Spend caps with a reservation

Files:

  • Create: lib/translate/reserve.ts
  • Modify: lib/ops/spend.ts, lib/translate/handler.ts, lib/translate/service.ts
  • Test: replication_materials/tests/unit/ops/spend.test.ts, replication_materials/tests/unit/translate/reserve.test.ts, replication_materials/tests/unit/translate/handler.test.ts

Interfaces:

  • Consumes: KvClient, RESERVE_SCRIPT, SETTLE_SCRIPT, StoreUnavailableError, chooseStore, fakeKv, failingKv (Task 3); callBound (llm-voter.ts); threadMessages (prompt.ts); jevRequest (jev-voter.ts); costUsd, utcDate.
  • Produces:
    • interface SpendTracker { reserve(voter: VoterId, usd: number, now?: number): Promise<boolean>; settle(voter: VoterId, reservedUsd: number, realUsd: number, now?: number): Promise<void>; spent(voter: VoterId, now?: number): Promise<number> }. capReached goes.
    • toMicroUsd(usd: number): number (Math.ceil(usd * 1e6)); BLOCKED_MICRO_USD = 9_007_199_254_740.
    • Rules for both trackers: reserve is true only when the cap is a finite number, usd is finite and at least 0, and spent + reserve <= cap (in micro-dollars, for the UTC day of now); then the reserve is added. settle adds toMicroUsd(real) - toMicroUsd(reserved) (may be positive, AP2); a realUsd that is not finite or is negative sets the day's total to BLOCKED_MICRO_USD.
    • createSpendTracker(caps) (memory); createRedisSpendTracker(kv: KvClient, caps: Record<VoterId, number>, prefix = "sp") with key {prefix}:{voter}:{yyyy-mm-dd} and TTL 172,800 s; unavailableSpendTracker() (rejects with StoreUnavailableError). RESERVE_SCRIPT: KEYS spend key; ARGV reserve, cap, TTL; returns 1 or 0 (as the probe). SETTLE_SCRIPT: KEYS spend key; ARGV delta, block flag, TTL; block → SET key BLOCKED EX ttl, else INCRBY and EXPIRE.
    • llmReserveUsage(turns: readonly Turn[]): Usage = { inputTokens: 2 * b + MAX_OUTPUT_TOKENS, outputTokens: 2 * MAX_OUTPUT_TOKENS } with b = callBound(threadMessages(turns)).inputTokens; jevReserveUsage(turns: readonly Turn[], modelId: string): Usage = { inputTokens: <UTF-8 bytes of JSON.stringify({ ...jevRequest(turns) without candidates, model: modelId })>, outputTokens: 0 }. In lib/translate/reserve.ts.
    • spend.test.ts, for the memory tracker and for createRedisSpendTracker(fakeKv(), caps) (one describe.each): caps { openai: 5, anthropic: 5, google: 5, jev: 5 }; three reserves of 2.0 give true, true, false and spent 4.0; settle("openai", 2.0, 0.5) gives spent 2.5 and then a reserve of 2.0 fits; settle with real 2.5 on a reserve of 2.0 adds 0.5; settle with NaN or −1 blocks the voter: a reserve of 0 is then false, and on the next UTC day a reserve fits again; a NaN cap refuses every reserve; a reserve of NaN or −1 is false; two trackers on one fakeKv share the total.
    • reserve.test.ts: for the coating turns, llmReserveUsage(turns).inputTokens is 2 * callBound(threadMessages(turns)).inputTokens + 8192 and outputTokens is 16384; jevReserveUsage(turns, "jev-1.13.0").inputTokens equals the UTF-8 byte length that the test computes from jevRequest; a longer request gives larger values for both.
    • handler.test.ts:
      • a voter whose reserve does not fit is left out with spend_cap (as with capReached before);
      • after a valid run, each LLM voter's total equals costUsd(usage + unread) (the reserve is gone);
      • a Jev vote with reason: "deadline" keeps its reserve;
      • two handlers that share one fakeKv (two instances), with a translate stub that waits on a promise and a cap that fits one reserve, run in parallel: one result has the voter, the other has it in left_out with spend_cap;
      • when no LLM reserve fits, the reply is paused, and a Jev reserve made in that request is released (spent("jev") back to its value before);
      • a reserve that rejects gives the 503 store_unavailable reply, and earlier reserves of that request are released;
      • a settle that rejects after a finished translation still returns status 200 with the result, and the log gets { kind: "error_detail", error: "store" };
      • a translate stub that throws gives 500 service_error, and the reserves stay.
  • git add lib/ops/spend.ts lib/translate replication_materials/tests/unit
    git commit -m "feat: spend caps reserve the worst-case cost of each voter before the call and settle after it (A10, AP2, AP3)"

13Task 5: Security headers and build guards

Files:

  • Create: lib/security-headers.ts, lib/build-guard.ts, replication_materials/tests/e2e/helpers/test.ts, replication_materials/tests/e2e/csp.spec.ts
  • Modify: next.config.ts, lib/site.ts (RELEASE_STAGE), app/api/translate/route.ts (maxDuration), every replication_materials/tests/e2e/*.spec.ts (import test and expect from ./helpers/test)
  • Test: replication_materials/tests/unit/security-headers.test.ts, replication_materials/tests/unit/build-guard.test.ts, replication_materials/tests/unit/translate/route.test.ts, replication_materials/tests/unit/isolation.test.ts (if it pins the header list)

Interfaces:

  • Produces:
    • RELEASE_STAGE: "alpha" in lib/site.ts.
    • CSP_DIRECTIVES: Record<string, string[]>, contentSecurityPolicy(): string (directives joined with ; ), SECURITY_HEADERS: { key: string; value: string }[] = CSP, Referrer-Policy: no-referrer, and X-Robots-Tag: noindex while RELEASE_STAGE === "alpha". In lib/security-headers.ts. The starting directives: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'.
    • assertReleaseBuild(env: Record<string, string | undefined>): void throws "NEXT_PUBLIC_FFD_TEST_HOOKS must not be set in a production build on Vercel." when env.VERCEL_ENV === "production" and env.NEXT_PUBLIC_FFD_TEST_HOOKS is not empty. next.config.ts calls it and sends [...ISOLATION_HEADERS, ...SECURITY_HEADERS] on ISOLATED_SOURCES.
    • export const maxDuration = 60; in app/api/translate/route.ts.
    • helpers/test.ts: test = Playwright's test with an automatic fixture that (1) adds an init script that pushes each securitypolicyviolation event (violatedDirective, blockedURI) to window.__cspViolations, (2) collects console messages of the page and its workers that match /Content Security Policy|Refused to/, and (3) after the test fails it when either list is not empty, unless the test called allowCspViolations(). expect is re-exported.
    • security-headers.test.ts: contentSecurityPolicy() contains object-src 'none', frame-ancestors 'none', base-uri 'self', 'wasm-unsafe-eval'; its connect-src is exactly 'self'; no directive allows *, http: or https:; SECURITY_HEADERS has referrer-policy no-referrer and x-robots-tag noindex.
    • build-guard.test.ts: throws the exact text for { VERCEL_ENV: "production", NEXT_PUBLIC_FFD_TEST_HOOKS: "1" }; does not throw for { VERCEL_ENV: "preview", NEXT_PUBLIC_FFD_TEST_HOOKS: "1" }, { NEXT_PUBLIC_FFD_TEST_HOOKS: "1" } (local test build) or { VERCEL_ENV: "production" }.
    • route.test.ts: the route module exports maxDuration 60 and runtime "nodejs".
    • csp.spec.ts: the responses of / and /app have the CSP of contentSecurityPolicy(). A positive control: a test that calls allowCspViolations(), adds <img src="https://example.com/x.png"> to /, and expects window.__cspViolations to hold one entry with directive img-src. That proves the guard can see a violation.
  • git add lib next.config.ts app/api/translate/route.ts replication_materials/tests
    git commit -m "feat: Content Security Policy, no-referrer and noindex headers, a CSP guard in every browser test, maxDuration 60, test-hook guard (A11)"

14Task 6: Review questions, issue format and the review route

Files:

  • Create: lib/review/questions.ts, lib/review/answer.ts, lib/review/github.ts, lib/review/config.ts, lib/review/key.ts, lib/review/handler.ts, app/api/review/route.ts
  • Test: replication_materials/tests/unit/review/questions.test.ts, answer.test.ts, github.test.ts, handler.test.ts

Interfaces:

  • Consumes: createRedisRateLimiter, createRateLimiter, unavailableRateLimiter, chooseStore, clientIp, fakeKv (Task 3); readCappedText (lib/translate/handler.ts, move it to lib/http.ts if the review route would otherwise import the translate handler); data/guarantee-table.json; content/glossary.json.
  • Produces:
    • type ReviewTopic = "guarantee" | "glossary" | "r23" | "references" | "other"; type ReviewContext = { kind: "text"; paragraphs: string[] } | { kind: "quote"; quote: string; source: string } | { kind: "glossary"; key: string } | { kind: "references"; keys: string[] }; interface ReviewQuestion { id: string; topic: ReviewTopic; group: string; title: string; context: ReviewContext; question: string; hasChoices: boolean }; REVIEW_QUESTIONS: readonly ReviewQuestion[]; REVIEW_GROUPS (the five group headings in order); questionById(id: string): ReviewQuestion | undefined. In lib/review/questions.ts.
    • type ReviewChoice = "agree" | "change" | "not_sure" | "comment"; CHOICE_LABELS = { agree: "Agree", change: "Change", not_sure: "Not sure", comment: "Comment" }; MAX_COMMENT_CHARS = 4000; REVIEW_MESSAGES (below); checkAnswer(raw: unknown): { ok: true; answer: ReviewAnswer } | { ok: false; message: string } with ReviewAnswer = { questionId: string; choice: ReviewChoice; comment: string; name: string }; issueFor(answer: ReviewAnswer, question: ReviewQuestion, meta: { date: string; commit: string | null }): { title: string; body: string; labels: string[] }; readMarker(body: string | null): { questionId: string; choice: ReviewChoice } | null; type StoredAnswer = { questionId: string; choice: ReviewChoice; name: string; date: string; number: number }; latestAnswers(issues: GitHubIssue[], owner: string): Record<string, StoredAnswer[]> (newest first; one entry for each question except other, which keeps all). In lib/review/answer.ts (no secrets; the page imports it).
    • interface GitHubIssue { number: number; title: string; body: string | null; created_at: string; user: { login: string } | null; labels: { name: string }[]; pull_request?: unknown }; createIssue(fetchFn: typeof fetch, repo: string, token: string, issue: { title: string; body: string; labels: string[] }): Promise<{ number: number }>; listReviewIssues(fetchFn, repo, token): Promise<GitHubIssue[]> (GET /repos/{repo}/issues?labels=alpha-review&state=all&per_page=100&page={n} until a page has fewer than 100; drops pull requests); tokenOwner(fetchFn, token): Promise<string> (GET /user). Headers: Authorization: Bearer {token}, Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28, User-Agent: experta-review. In lib/review/github.ts.
    • readReviewConfig(env): { key: string; token: string; repo: string } | null (REVIEW_KEY of at least 24 characters, GITHUB_REVIEW_TOKEN, GITHUB_REVIEW_REPO of the form owner/name). keyMatches(given: string | null, expected: string): boolean (timingSafeEqual of the SHA-256 digests of both).
    • handleReview(request: Request, deps: ReviewDeps): Promise<Response> with ReviewDeps = { config: { key; token; repo } | null; limiter: RateLimiter; fetch: typeof fetch; now?: () => number; commit: string | null }. GET and POST; key in the header x-review-key. app/api/review/route.ts wires it (runtime = "nodejs", dynamic = "force-dynamic", limiter { perMinute: 30, perDay: 300 } with prefix rv on the chosen store, commit = VERCEL_GIT_COMMIT_SHA ?? null).

The questions (exact texts; {…} from the data files):

Id Group (topic) Title Context Question
guarantee.region.{nruns}.{nfac_min}-{nfac_max} (13, from regions) Guarantee table (guarantee) "{nruns} runs, {nfac_min} to {nfac_max} factors" quote: the region quote, source: the region source "Does this source support the claim that the first-ranked catalogue entry of each size in this region is a minimum aberration (MA) design?"
guarantee.sentences Guarantee table "The statement for each class" text: "After the review, the step 3 statement would be:", "MA: "Minimum aberration design from the FrF2 catalogue, entry {name}."", "Good, not necessarily MA: "Design from the FrF2 catalogue, entry {name}. FrF2 lists it as good, not necessarily minimum aberration."", "Full factorial: "Full factorial design ({n} runs)."", "Until the review, every catalogue design gets "Design from the FrF2 catalogue, entry {name}."" "Are these the right sentences for each class?"
guarantee.additions Guarantee table "Center points, replications and resolution-only requests" text: "Center points and replications add runs to the design that FrF2 selects from the catalogue. A request that gives only a resolution lets FrF2 select the smallest design with that resolution." "Does the statement of the class still hold for a design with center points, for a design with replications, and for a design from a request that gives only a resolution?"
guarantee.no-claim Guarantee table "Entries without an MA claim" text: "Until the review, these entries get no MA claim: the 19 entries that share rank 1 (equal stored word length patterns), the 12 rank-1 names outside the region that the documentation names, and the 89 entries with a verified error in the stored word length pattern." "Should these entries also have no MA claim after the review?"
guarantee.refused-defaults Guarantee table "The refused FrF2 defaults 26-17.1 and 27-18.1" text: "FrF2 selects 26-17.1 for 26 factors in 512 runs and 27-18.1 for 27 factors. Each entry stores 19 generators for 17 or 18 added factors. FrF2 builds the design from the first generators, but its alias information names factors that the design does not have, and its list of clear 2FIs is incomplete (Finding 5 of the FrF2 findings). The tool refuses both sizes." "Should the tool keep the refusal? Agree keeps it. Change means: accept the two entries after the tool computes their aliases and clear 2FIs again; give the check in the comment."
glossary.{key} (35, in the order of content/glossary.json) Glossary (glossary) the entry term glossary: the entry key (the page shows text and reference as the glossary page does) "Is this entry correct and clear?"
r23 Clear 2FIs of a full factorial (r23) "Decision R23" text: "In a full factorial design, all k(k-1)/2 two-factor interactions are clear, and every factor has all its 2FIs clear. The report shows them so. Before this decision the report showed none, because a full factorial has no catalogue entry." "Do you agree with this definition?"
references References (references) "The references of the glossary" references: all keys of content/glossary.json references "Are these the right sources, and are the citations correct?"
other Other comments (other) "Other comments" text: "Any comment on the tool, its texts or its results." "Comment" (hasChoices: false)

REVIEW_MESSAGES: bad_key "The review key is not valid."; not_configured "The review service is not configured."; name_required "Enter your name."; name_too_long "The name has more than 100 characters."; comment_required "A comment is required for this answer."; comment_too_long "The comment has more than 4000 characters."; unknown_question "The question is not known."; invalid "The answer does not have the expected form."; github_failed "GitHub did not accept the answer. The answer was not filed."; rate_limited "Too many answers from this address. Wait {seconds} seconds."

The issue (issueFor): title [alpha-review] {questionId}: {choice label}; labels ["alpha-review", topic]; the body's first line is the marker <!-- experta-review {"q":"{questionId}","c":"{choice}"} -->, then the lines **Question ({group}):** {title}, the question text, **Answer:** {choice label}, **Comment:**, the comment with each line prefixed by > (or > (none)), **Reviewer:** {name}, **Date:** {date}, **Commit:** {commit or "local"}. readMarker reads only the first line, so a marker inside a comment does not count. latestAnswers keeps issues whose user.login equals owner, whose labels include alpha-review and not withdrawn, and whose marker names a known question.

    • questions.test.ts: 55 questions; ids unique; 13 region questions in the order of regions, the first with title "4 runs, 1 to 3 factors"; 35 glossary questions in file order; other is last and the only one with hasChoices: false; every text has no advice word.
    • answer.test.ts: checkAnswer refuses (with the exact messages) an empty or 101-character name, "change" without a comment, a 4,001-character comment, an unknown id, choice: "comment" on a question with choices, and other without a comment; accepts Diseño ✓ 😀 in a comment; trims the name. issueFor gives the exact title and labels for glossary.wlp with "change", and its first body line is the marker. readMarker reads it back, and returns null for a body whose marker is only inside the comment. latestAnswers: newest wins; withdrawn, another user, an unknown id and a body without a marker are left out; other keeps all its answers.
    • github.test.ts (stub fetch that records calls): createIssue posts to https://api.github.com/repos/fmegahed/experta/issues with the four headers and the JSON body; listReviewIssues follows pages of 100, 100, 3 and drops an item with pull_request; a non-2xx status rejects.
    • handler.test.ts (stub GitHub, memory limiter): GET without the key → 401 bad_key; with a key of another length → 401; config null → 503 not_configured; GET with the key → 200 { answers } from latestAnswers, and a second GET within 60 s makes no GitHub call; POST with the key and a valid answer → 201 { number }, the issue matches issueFor, and the next GET lists issues again; POST with an invalid answer → 400 with its message; a body over 16 KB → 400 invalid; GitHub 422 → 502 github_failed; 31 POSTs in a minute → 429 with retry-after; no response body and no log line contains the key, the token or the comment.
  • git add lib/review lib/http.ts lib/translate/handler.ts app/api/review replication_materials/tests/unit/review
    git commit -m "feat: review questions as data, the issue format with a marker, and the review route with a key and its own limit (A12-A14, AP6)"

15Task 7: The review page

Files:

  • Create: app/review/page.tsx, components/review/ReviewPage.tsx, components/review/QuestionCard.tsx
  • Modify: replication_materials/tests/e2e/a11y.spec.ts (add /review with and without a key)
  • Test: replication_materials/tests/e2e/review.spec.ts

Interfaces:

  • Consumes: REVIEW_QUESTIONS, REVIEW_GROUPS, CHOICE_LABELS, checkAnswer, REVIEW_MESSAGES, StoredAnswer (Task 6); the glossary entry rendering of app/glossary/page.tsx (reuse its component; extract one if the page has none); announce.
  • Produces: the page at /review (title "Review questions | EXPERTA"; no link to it from any page).

Behaviour:

  • On load: if location.hash starts with #key=, store the key in sessionStorage (ffd:review-key) and call history.replaceState(null, "", "/review"); otherwise read the stored key. All storage access in try/catch.
  • With a key: GET /api/review with the header x-review-key. A 401 removes the stored key and shows the state without a key, with the message bad_key.
  • Without a key: one paragraph "This page files answers only from the link with the review key." and the questions without controls.
  • Heading "Review questions"; the intro "Each answer is filed as an issue in the private GitHub repository of the project. The newest answer to a question counts." With a key, a field "Your name" (kept in localStorage as ffd:review-name), the progress line "{answered} of 54 questions have an answer.", and a check box "Show only questions without an answer".
  • One h2 for each group and one card (fieldset with a legend that is the title) for each question: the context, the question, a radio group "Answer" (Agree, Change, Not sure) unless hasChoices is false, a field "Comment", a button "File answer" (aria-disabled while busy), and the latest answer as "Latest answer: {label}, {name}, {date}" (for other: "{n} comments filed").
  • File answer: checkAnswer in the page first (message under the field, linked with aria-describedby); then POST; on 201 announce "Answer filed as issue {number}." and update the card; on an error show and announce the server message.
    • /review#key=k-123456789012345678901234 → the address bar is /review (no hash); every request to /api/review has x-review-key and no URL that contains the key; no request has a referer that contains the key;
    • the progress line, the five group headings in order, 55 cards, and "Latest answer: Agree, Alan R. Vazquez, 2026-10-20" on the card of the GET stub;
    • Change without a comment shows "A comment is required for this answer." and posts nothing; with a comment it posts { questionId, choice: "change", comment, name } and announces "Answer filed as issue 7.";
    • "Show only questions without an answer" hides the answered card; the name stays after a reload;
    • a GET that answers 401 removes the stored key and shows "The review key is not valid." and no "File answer" button;
    • /review without a key shows the paragraph and no radio group.
    • In a11y.spec.ts: the axe sweep and the custom checks pass on /review with and without a key.
  • git add app/review components/review replication_materials/tests/e2e
    git commit -m "feat: the review questions page: key from the fragment, name, progress, one card per question, read-back (A12-A14)"

16Task 8: "Delete all" resets step 1 (3C Rulings 29 and 30)

Files:

  • Modify: components/shell/Workspace.tsx, components/shell/StepPanel.tsx
  • Test: replication_materials/tests/e2e/shell.spec.ts (or the spec that holds the "Delete all" tests)

Interfaces:

  • Produces: StepPanel prop generation: number; the step key is `${generation}:${project?.id ?? "none"}`. Workspace keeps generation in state and increases it in clearAll after a successful store.clear().
    • Step 1 with no project: type "Screen 5 factors in 8 runs."; "Clear" → "Delete all" → the request field is empty, focus is on "New design" (C14).
    • A translation in flight: route /api/translate with a handler that waits on a promise the test controls; type and select "Interpret request"; "Clear" → "Delete all"; then let the handler answer with the coating fixture. After 2 seconds: no project in the sidebar, storedProjects(page) is empty, and the request field is empty.
    • The first interpretation of a new request still opens step 2 with focus as in 3C (Ruling 20), and an existing clarification test still passes.
  • git add components/shell replication_materials/tests/e2e
    git commit -m "fix: Delete all mounts step 1 again, so the typed text and a translation in flight go (3C Rulings 29 and 30, A16)"

17Task 9: Budgets and documents

Files:

  • Modify: scripts/derive-budgets.mjs (add alpha-slow-start.csv to the sources), config/runtime-budgets.json (derived), the spec (version 6), README.md, .env.example, replication_materials/process/process.json (event e29), scripts/build-spec-page.mjs (ribbon and description "version 6"), replication_materials/plans/spec-page/ (rebuilt)
  • Test: replication_materials/tests/unit/budgets.test.ts (unchanged; it checks the derived file)
    • 13.1: Process "Vercel functions (Fluid compute, Node.js 22). The counters of the rate limit and the spend caps are in Upstash Redis; local runs keep them in memory."; Rate limit adds the hashed key (AP5); Spend caps adds the reservation (A10, AP2, AP3); Headers "Content Security Policy (Task 5 list), COOP, COEP, Referrer-Policy no-referrer; X-Robots-Tag noindex during the alpha"; Deployment "Vercel Git integration: main → production at experta-doe.vercel.app; other branches → previews. Self-hosting (Dockerfile, ChatISA start script) is deferred (A4)."
    • 13.2: rows for the Redis variables (both name sets), RATE_LIMIT_SALT, REVIEW_KEY, GITHUB_REVIEW_TOKEN, GITHUB_REVIEW_REPO, and the deployed-test variables FFD_BASE_URL, FFD_SLOW_RUNS, FFD_LIVE_LIMITS, FFD_EXPECT_CSP.
    • 13.3: the privacy lines of Task 3.
    • A new Section 14.4 "Review questions page (alpha)": decisions A12 to A14 and AP6 in short form.
    • 16: lib/review/, app/review/, app/api/review/, lib/ops/kv.ts, lib/ops/stores.ts, lib/ops/scripts.ts, lib/security-headers.ts, replication_materials/tests/deployed/.
    • 17.1: close with "Done in the alpha release" the rows on the spend-cap overshoot (A10), x-forwarded-for (Vercel overwrites it), the CSP (A11), the static privacy notice (a mode change needs a new deploy), the stall limit (the measured values of Step 1), and the rate-limit values (alpha: the defaults, A9). In the row "Confirm the clear-2FI definition (R23) and the Dark2 purple": the purple is settled (F. M. Megahed, 2026-10-17); R23 goes to the review page. Add a row "Self-hosting: a Dockerfile and the ChatISA start script | Both authors | Public release (A4)".
    • 2.4 or the decision log: a line for this plan's decisions with a pointer to the plan.
  • git add scripts config replication_materials README.md .env.example
    git commit -m "docs: specification version 6 for the alpha release, slow-profile stall budget, deployment section, process event e29"

18Final review and merge (controller)

19Checkpoint B: go live (controller and author)

Each numbered item needs the author's approval first. Record each result in the ledger.

  1. The author has connected Upstash (Checkpoint A, item 5). The controller sets RATE_LIMIT_SALT for Production and Preview from node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))" | vercel env add RATE_LIMIT_SALT <env> (never printed), and GITHUB_REVIEW_REPO=fmegahed/experta for Production.
  2. The author makes a fine-grained GitHub token (only fmegahed/experta; Issues read and write) and adds it as GITHUB_REVIEW_TOKEN, and adds REVIEW_KEY (at least 24 random characters) for Production.
  3. The controller creates the labels with gh label create: alpha-review, guarantee, glossary, r23, references, other, withdrawn.
  4. Push main. Wait for READY. Run site.spec.ts with FFD_EXPECT_CSP=1, then limits.spec.ts with FFD_LIVE_LIMITS=1 (no keys are set yet, so it makes no LLM call; it checks the shared rate limit on the real store), and open /review without a key (questions, no controls).
  5. The author adds the four provider keys and their model variables for Production. The controller redeploys the production deployment.
  6. Live smoke test (a few cents): one ensemble translation of the coating request through the page, and one answer to "Other comments" ("Smoke test of the alpha deployment.") from the review link. Then the controller adds the label withdrawn to that issue and closes it.
  7. Update the ledger, the memory and the plan page (merged, live). The author sends Alan R. Vazquez the link https://experta-doe.vercel.app/review#key=… and the app link.