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.0as a dependency (published 2026-09-21),ioredis-mock@8.13.1(2025-10-29) andioredis@5.11.1(2026-06-04) as devDependencies.ioredis-mockloadsioredis/built/Commandat run time, so it needsioredis; its peer range is^5, so notioredis6. No other new package; the tests declare the fewioredis-mocktypes they use. Implementers do not runnpm install. - Dropbox. The repository is in Dropbox, which locks
node_moduleswhile 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
.envvalue, 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/translatefrom the recorded fixtures (routeTranslate) and/api/reviewwithpage.route. The only live LLM call of this plan is the smoke test of Checkpoint B. - No network in unit tests. Redis is
ioredis-mockbehind theKvClientport; GitHub is a stubfetch. - Client and server. Client components never import
lib/ops/*,lib/review/handler.ts,lib/review/github.ts,lib/review/config.ts,lib/review/key.tsor 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 usesaria-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 checkmust pass at the end of each task that changes code (about 10 minutes;npm run check -- --fastskips 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 commitcommands 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.
- 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).
- 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. - 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.
- 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. - "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(addrefs/,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 }[](linesNAME=value; skip comments, empty values, names that do not matchSECRET_NAME, values shorter thanMIN_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 ofgit log -p --all --format=commit:%H: track the commit fromcommit:<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.envif present, runs the git command withmaxBuffer512 MB, printsNo 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.tsand 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 namesanthropic,google,github,private_key; thesk-ant-line does not also giveopenai. - 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.tsonly 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.baseURLfromFFD_BASE_URL; the file throws"Set FFD_BASE_URL to the address of the deployed site."when the variable is missing; nowebServer.helpers.ts:SLOW_PROFILE = { cpuRate: 4, latencyMs: 150, downloadKbps: 1638.4, uploadKbps: 675 };applySlowProfile(page: Page): Promise<void>(CDPEmulation.setCPUThrottlingRateandNetwork.emulateNetworkConditionswith bytes per second = kbps × 1024 / 8);footerTimeline(page: Page): Promise<{ t: number; text: string }[]>(an init script with aMutationObserveron the engine status element recordsperformance.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 withR engine: loading (to the firstR engine: preparing the R packages, both included).- Outputs (committed by the controller at Checkpoint A):
replication_materials/tests/perf/alpha-deployed-files.csv(columnspath,bytes,sha256_match,content_type,content_encoding,cache_control) andreplication_materials/tests/perf/alpha-slow-start.csv(columnstrial,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 havecontent-security-policyequal tocontentSecurityPolicy()(Task 5),referrer-policy: no-referrerandx-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.wasmhascontent-typestarting withapplication/wasm; write the CSV; /apphascrossOriginIsolated === true.
- for
site.spec.ts"a design filled in by hand":/app→ "Fill in the form yourself" → "Add factor" five times → namesX1toX7(as indesign.spec.ts) → Runs16→ "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 indownloads.spec.ts) gives a file whose first line starts withRun,Std,X1.slow-start.spec.ts(skipped unlessFFD_SLOW_RUNSis set; default trials 20): each trial uses a new browser context,applySlowProfile,footerTimeline, opens/app, waits for "R engine: ready" (timeout 600,000 ms); recordsready_ms,loading_ms,max_progress_gap_ms = maxLoadingGap, andstalled_shown(any text with "loading has stopped"); assertsready_ms >= 19_000(5 MB, a floor below any compressed size ofR.wasm, at 1,638.4 kbit/s, × 0.8): a faster start means the throttle did not reach the worker, which loadsR.wasm; writes the CSV.limits.spec.ts(skipped unlessFFD_LIVE_LIMITS=1): POST/api/translatewith{ "turns": [{ "role": "user", "text": "Screen 7 factors in 16 runs." }] }. If the first reply is not status 503 withkind: "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 503unavailable, request 11 gives 429rate_limitedwith aretry-afterheader.
-
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.
- Run
node scripts/secret-scan.mjsin the worktree. Expected: no finding. A finding stops the checkpoint. - Create the empty private repository:
gh repo create fmegahed/experta --private --description "EXPERTA: Fractional Factorial Design Translator", and add it asorigin. No push yet. - In the worktree:
vercel linkto a new projectexperta-doe(scopefadel-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). - Set in Production:
TRANSLATE_MODE=ensemble,RATE_LIMIT_PER_MIN=10,RATE_LIMIT_PER_DAY=100, the fourDAILY_SPEND_CAP_USD_*=5,MAX_REQUEST_CHARS=4000(each withprintf %s <value> | vercel env add <NAME> production). No key. - The author installs Upstash for Redis from the Vercel Marketplace and connects it to
experta-doefor Production and Preview (any time before Checkpoint B). The controller lists the variable names withvercel env ls(names only) and records which name set the integration made. - Push
main(03ec302):git push -u origin main. Vercel builds it for production. Wait until the deployment is READY; record the build time and whetherprebuildran (build log). - Run
FFD_BASE_URL=https://experta-doe.vercel.app npx playwright test -c playwright.deployed.config.ts site.spec.ts, then the same withFFD_SLOW_RUNS=20andslow-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); whetheroutput: "standalone"changed the build; theR.wasmcontent type and encoding; the 20 trials. - If the function bundle contains
public/runtimes/, addoutputFileTracingExcludesfor it in a small fix task before Task 3. Any other failure stops the plan and goes to the author. - 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.tsore2e/privacy.spec.ts(where the current lines are asserted)
Interfaces:
- Produces:
interface KvClient { eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown> }andupstashKv(url: string, token: string): KvClient(new Redis({ url, token, enableTelemetry: false }), AP13) inlib/ops/kv.ts.RATE_SCRIPT,RESERVE_SCRIPT,SETTLE_SCRIPTinlib/ops/scripts.ts.RATE_SCRIPT: KEYS minute key, day key; ARGV per-minute, per-day, minute TTL, day TTL; returns1when the day count is at its limit,2when the minute count is at its limit, else increments both (with their TTLs) and returns0; 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(firstUPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN, thenKV_REST_API_URLandKV_REST_API_TOKEN);chooseStore(env: Record<string, string | undefined>, makeKv = upstashKv): StoreChoicewith: Redis variables andRATE_LIMIT_SALTof at least 32 characters →redis; Redis variables without such a salt →none; no Redis variables andVERCEL === "1"→none; otherwisememory. Inlib/ops/stores.ts.interface RateLimiter { check(key: string, now?: number): Promise<RateLimitResult> }(async now);createRateLimiter(limits, maxKeys?)(memory, unchanged rules, plussize());createRedisRateLimiter(kv: KvClient, limits: { perMinute: number; perDay: number }, salt: string, prefix = "rl"): RateLimiterwith keys{prefix}:m:{hash}:{minute}(TTL 120 s) and{prefix}:d:{hash}:{day}(TTL 172,800 s), andretryAfterSecondsas the memory limiter computes it;unavailableRateLimiter(): RateLimiter(rejects withStoreUnavailableError);addressKey(address: string, salt: string): string(HMAC-SHA256 hex,node:crypto);clientIpunchanged.MESSAGES.store_unavailable = "Interpretation is not available now. You can fill in the form yourself."(AP4).fakeKv(): KvClient & { redis: RedisMock }(callsredis.eval(script, keys.length, ...keys, ...args)) andfailingKv(): KvClient(rejects withnew Error("down")) infake-kv.ts.
-
ratelimit.test.ts: the existing memory cases withawait; forcreateRedisRateLimiter(fakeKv(), { perMinute: 3, perDay: 5 }, "s".repeat(32)): requests 1 to 3 in one minute allowed, the 4th refused withwindow: "minute"andretryAfterSecondsto the next minute; in the next minute 2 more allowed, the next refused withwindow: "day"; a refused request does not raise a count; two limiters on the samefakeKv(two instances) share the counts; the keys inredis.keys("*")containaddressKey("203.0.113.7", salt)and never203.0.113.7;addressKeygives 64 hex characters, equal for equal input, different for another salt.stores.test.ts: the four rows ofchooseStore(Upstash names, KV names, a 31-character salt →none,VERCEL: "1"without Redis →none, an empty environment →memory);chooseStorenever callsmakeKvfornoneandmemory.handler.test.ts: a limiter whosecheckrejects (failingKvorunavailableRateLimiter()) 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 exactstore_unavailabletext; 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> }.capReachedgoes.toMicroUsd(usd: number): number(Math.ceil(usd * 1e6));BLOCKED_MICRO_USD = 9_007_199_254_740.- Rules for both trackers:
reserveis true only when the cap is a finite number,usdis finite and at least 0, andspent + reserve <= cap(in micro-dollars, for the UTC day ofnow); then the reserve is added.settleaddstoMicroUsd(real) - toMicroUsd(reserved)(may be positive, AP2); arealUsdthat is not finite or is negative sets the day's total toBLOCKED_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 withStoreUnavailableError).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, elseINCRBYandEXPIRE.llmReserveUsage(turns: readonly Turn[]): Usage={ inputTokens: 2 * b + MAX_OUTPUT_TOKENS, outputTokens: 2 * MAX_OUTPUT_TOKENS }withb = 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 }. Inlib/translate/reserve.ts.
-
spend.test.ts, for the memory tracker and forcreateRedisSpendTracker(fakeKv(), caps)(onedescribe.each): caps{ openai: 5, anthropic: 5, google: 5, jev: 5 }; three reserves of 2.0 givetrue, true, falseandspent4.0;settle("openai", 2.0, 0.5)givesspent2.5 and then a reserve of 2.0 fits;settlewith real 2.5 on a reserve of 2.0 adds 0.5;settlewithNaNor −1 blocks the voter: a reserve of 0 is then false, and on the next UTC day a reserve fits again; aNaNcap refuses every reserve; a reserve ofNaNor −1 is false; two trackers on onefakeKvshare the total.reserve.test.ts: for the coating turns,llmReserveUsage(turns).inputTokensis2 * callBound(threadMessages(turns)).inputTokens + 8192andoutputTokensis 16384;jevReserveUsage(turns, "jev-1.13.0").inputTokensequals the UTF-8 byte length that the test computes fromjevRequest; 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 withcapReachedbefore); - 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 inleft_outwithspend_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_unavailablereply, 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.
- a voter whose reserve does not fit is left out with
-
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), everyreplication_materials/tests/e2e/*.spec.ts(importtestandexpectfrom./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"inlib/site.ts.CSP_DIRECTIVES: Record<string, string[]>,contentSecurityPolicy(): string(directives joined with;),SECURITY_HEADERS: { key: string; value: string }[]= CSP,Referrer-Policy: no-referrer, andX-Robots-Tag: noindexwhileRELEASE_STAGE === "alpha". Inlib/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>): voidthrows"NEXT_PUBLIC_FFD_TEST_HOOKS must not be set in a production build on Vercel."whenenv.VERCEL_ENV === "production"andenv.NEXT_PUBLIC_FFD_TEST_HOOKSis not empty.next.config.tscalls it and sends[...ISOLATION_HEADERS, ...SECURITY_HEADERS]onISOLATED_SOURCES.export const maxDuration = 60;inapp/api/translate/route.ts.helpers/test.ts:test= Playwright'stestwith an automatic fixture that (1) adds an init script that pushes eachsecuritypolicyviolationevent (violatedDirective,blockedURI) towindow.__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 calledallowCspViolations().expectis re-exported.
-
security-headers.test.ts:contentSecurityPolicy()containsobject-src 'none',frame-ancestors 'none',base-uri 'self','wasm-unsafe-eval'; itsconnect-srcis exactly'self'; no directive allows*,http:orhttps:;SECURITY_HEADERShasreferrer-policyno-referrerandx-robots-tagnoindex.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 exportsmaxDuration60 andruntime"nodejs".csp.spec.ts: the responses of/and/apphave the CSP ofcontentSecurityPolicy(). A positive control: a test that callsallowCspViolations(), adds<img src="https://example.com/x.png">to/, and expectswindow.__cspViolationsto hold one entry with directiveimg-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 tolib/http.tsif 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. Inlib/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 }withReviewAnswer = { 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 exceptother, which keeps all). Inlib/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. Inlib/review/github.ts.readReviewConfig(env): { key: string; token: string; repo: string } | null(REVIEW_KEYof at least 24 characters,GITHUB_REVIEW_TOKEN,GITHUB_REVIEW_REPOof the formowner/name).keyMatches(given: string | null, expected: string): boolean(timingSafeEqualof the SHA-256 digests of both).handleReview(request: Request, deps: ReviewDeps): Promise<Response>withReviewDeps = { config: { key; token; repo } | null; limiter: RateLimiter; fetch: typeof fetch; now?: () => number; commit: string | null }. GET and POST; key in the headerx-review-key.app/api/review/route.tswires it (runtime = "nodejs",dynamic = "force-dynamic", limiter{ perMinute: 30, perDay: 300 }with prefixrvon 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 ofregions, the first with title "4 runs, 1 to 3 factors"; 35 glossary questions in file order;otheris last and the only one withhasChoices: false; every text has no advice word.answer.test.ts:checkAnswerrefuses (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, andotherwithout a comment; acceptsDiseño ✓ 😀in a comment; trims the name.issueForgives the exact title and labels forglossary.wlpwith "change", and its first body line is the marker.readMarkerreads 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;otherkeeps all its answers.github.test.ts(stubfetchthat records calls):createIssueposts tohttps://api.github.com/repos/fmegahed/experta/issueswith the four headers and the JSON body;listReviewIssuesfollows pages of 100, 100, 3 and drops an item withpull_request; a non-2xx status rejects.handler.test.ts(stub GitHub, memory limiter): GET without the key → 401bad_key; with a key of another length → 401; config null → 503not_configured; GET with the key → 200{ answers }fromlatestAnswers, and a second GET within 60 s makes no GitHub call; POST with the key and a valid answer → 201{ number }, the issue matchesissueFor, and the next GET lists issues again; POST with an invalid answer → 400 with its message; a body over 16 KB → 400invalid; GitHub 422 → 502github_failed; 31 POSTs in a minute → 429 withretry-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/reviewwith 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 ofapp/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.hashstarts with#key=, store the key insessionStorage(ffd:review-key) and callhistory.replaceState(null, "", "/review"); otherwise read the stored key. All storage access intry/catch. - With a key: GET
/api/reviewwith the headerx-review-key. A 401 removes the stored key and shows the state without a key, with the messagebad_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
localStorageasffd:review-name), the progress line "{answered} of 54 questions have an answer.", and a check box "Show only questions without an answer". - One
h2for each group and one card (fieldsetwith alegendthat is the title) for each question: the context, the question, a radio group "Answer" (Agree, Change, Not sure) unlesshasChoicesis false, a field "Comment", a button "File answer" (aria-disabledwhile busy), and the latest answer as "Latest answer: {label}, {name}, {date}" (forother: "{n} comments filed"). - File answer:
checkAnswerin the page first (message under the field, linked witharia-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/reviewhasx-review-keyand no URL that contains the key; no request has arefererthat 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;
/reviewwithout a key shows the paragraph and no radio group.- In
a11y.spec.ts: the axe sweep and the custom checks pass on/reviewwith 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:
StepPanelpropgeneration: number; the step key is`${generation}:${project?.id ?? "none"}`.Workspacekeepsgenerationin state and increases it inclearAllafter a successfulstore.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/translatewith 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(addalpha-slow-start.csvto the sources),config/runtime-budgets.json(derived), the spec (version 6),README.md,.env.example,replication_materials/process/process.json(evente29),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 atexperta-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 variablesFFD_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.
- 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:
-
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.
- The author has connected Upstash (Checkpoint A, item 5). The controller sets
RATE_LIMIT_SALTfor Production and Preview fromnode -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))" | vercel env add RATE_LIMIT_SALT <env>(never printed), andGITHUB_REVIEW_REPO=fmegahed/expertafor Production. - The author makes a fine-grained GitHub token (only
fmegahed/experta; Issues read and write) and adds it asGITHUB_REVIEW_TOKEN, and addsREVIEW_KEY(at least 24 random characters) for Production. - The controller creates the labels with
gh label create:alpha-review,guarantee,glossary,r23,references,other,withdrawn. - Push
main. Wait for READY. Runsite.spec.tswithFFD_EXPECT_CSP=1, thenlimits.spec.tswithFFD_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/reviewwithout a key (questions, no controls). - The author adds the four provider keys and their model variables for Production. The controller redeploys the production deployment.
- 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
withdrawnto that issue and closes it. - 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.