Skip to content

chore(agents): web#66 operator decisions (Turnstile + check-team) - #960

Merged
mashkovd merged 6 commits into
mainfrom
agents/web-66-decisions
Aug 31, 2026
Merged

mashkovd merged 6 commits into
mainfrom
agents/web-66-decisions

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Operator decisions for issue-66-turnstile-on-contact-submit-forms-harden. .status.yaml untouched — the approve CWFT flips it.

The proposal's two answers on check-team cancel out. It keeps the endpoint anonymous to preserve the live availability feedback, and then — to stop enumeration — has it return { available: true } unconditionally. That does not preserve the feature; it makes it lie to every user, authenticated included, who then hits the collision at submit instead. A check that always says yes is worse than no check, because it still looks like one.

So: gate it on the credential the worker already has. handleFormSubmit (index.js:785-789) already requires github_auth.login + github_auth.sig and verifies with hmacVerify against the signature minted at OAuth callback (index.js:588). check-team takes the same pair — truthful answers for verified callers, one uniform response for everyone else, enumeration closed outright. No new mechanism, and deliberately no gate stronger than the one already on /api/submit.

Checked before deciding rather than assuming the UX claim: RequestAccessSection.vue renders GithubAuth directly above the form and always mounts it, and RequestAccessForm.vue already refuses to submit without authData (js.submit.github_required). The only thing lost is live feedback for someone typing a team name before signing in — and signing in is already required to get further. The frontend change (don't call check-team unauthenticated; say why) is in scope.

Sequencing matters here in a way that can take the public site down. The proposal's fail-closed behaviour is accepted as written — missing TURNSTILE_SECRET_KEY rejects, siteverify outage rejects, no allow-on-failure path. But worker secrets are not in git and not in deploy.yml; wrangler.toml says they come from the Cloudflare dashboard or wrangler secret put. Merging before the secret exists means /api/contact and /api/submit reject every request — a full outage of both forms, not a degraded mode. Operator prerequisite ahead of the implementer's PR: create the Turnstile widget for the mctl.ai zone, put the secret, then merge. The sitekey is public and belongs committed in [vars], not in a second secret step.

Managed (non-intrusive) Turnstile mode accepted; no Turnstile on check-team (bad UX on every debounced keystroke, and moot once it is identity-gated). The 8h localStorage HMAC that both endpoints now lean on stays out of scope and is filed as mctlhq/mctl-web#70 — including the part the proposal did not note, that the signature covers only the login string, so its expiry is enforced entirely by the client holding it.

…C identity

The proposal's two answers cancel out: it keeps check-team anonymous to
preserve live availability feedback, then makes it return available:true
unconditionally so it stops leaking. That does not preserve the feature, it
makes it lie to everyone — a check that always says yes is worse than no
check, because it looks like one.

Gate it instead on the login+sig HMAC that handleFormSubmit already
requires and verifies. Verified callers keep the truthful answer;
everyone else gets one uniform response. No new mechanism, and no gate
stronger than the one already on /api/submit.

Also flags the prerequisite that can take the site down: with the accepted
fail-closed behaviour, merging before TURNSTILE_SECRET_KEY exists in the
worker env means both public forms reject every request. Worker secrets
are not in git and not in deploy.yml — they come from the Cloudflare
dashboard or wrangler secret put.
@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 39s —— View job


✅ Code Review Complete

Reviewing PR #960: chore(agents): web#66 operator decisions (Turnstile + check-team)

Task List:

  • Fetch diff using pinned commit SHAs
  • Read the changed tasks.md file
  • Review for correctness and security issues
  • Assess operational safety considerations
  • Post review verdict

Review Summary

Files Changed (4 total):

  • issue-198-pin-github-ssh-host-keys-in-gitops-reade/.status.yaml — status update
  • issue-65-stop-returning-the-github-access-token-t/.status.yaml — status update
  • issue-66-turnstile-on-contact-submit-forms-harden/tasks.md — Main change: +80 lines of operator decisions
  • issue-67-security-yml-fail-closed-exit-code-1-sca/.status.yaml — status update

Review Analysis

Changed Content: The PR adds a new "Operator decisions" section (80 lines) to issue-66-turnstile-on-contact-submit-forms-harden/tasks.md documenting 7 key decisions.

Security & Correctness

✓ Decision 1 (check-team auth gating): Sound reasoning

  • Correctly identifies that always-returning {available: true} breaks the feature (lies to authenticated users too)
  • Solution of reusing existing HMAC credential is valid — leverages proven credential already on /api/submit
  • Appropriately notes check-team is less sensitive than submit, so same gate is reasonable
  • Properly acknowledges credential weakness but correctly scopes hardening to separate issue fix(workflows): bump mctl-agents image to 1.7.0 in implement CWFT #70
  • Frontend implication is correct: don't call unauthenticated, show user feedback

✓ Decision 4 (fail-closed behavior): Appropriate security posture

  • Explicit rejection on missing secret or network error
  • Correctly refuses to add soft-failure mode that would defeat the purpose

✓ Decision 6 (sitekey is public): Important catch

  • Prevents common mistake of storing public sitekey as secret
  • Clear guidance: belongs in [vars] (committed), not secret store

Operational Safety

✓ Decision 5 (CRITICAL SEQUENCING): Correctly identifies outage risk

  • Well-highlighted: "this one can take the public site down if ignored"
  • Clear sequencing: create widget → provision secret → merge
  • Correctly identifies that deploying code before secret exists = total outage, not degraded mode
  • Emphasizes this as operator prerequisite ahead of implementer PR

Scope & Boundaries

✓ Appropriately scoped

Verdict

✅ APPROVED — No P1/P2 findings (0 P3 nits)

Decision document is well-reasoned, appropriately emphasizes critical sequencing, and resolves conflicts with clear security rationale. Good to merge.


| PR #960

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). Decision document is well-reasoned, appropriately emphasizes critical sequencing, and resolves conflicts with clear security rationale. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

No significant issues found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d5ab08d59

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +178 to +180
1. **`check-team`: gate it on the identity the codebase already has —
reject both the always-`available: true` response and a new session
mechanism.**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile the task checklist with the approved decisions

The approved direction rejects Turnstile and the always-available response for check-team, but the executable checklist still requires both in tasks 7–8 and 11, while T4 still asserts { available: true } for every valid name and no test covers the new HMAC gate. An implementer following the unchecked tasks will therefore deliver exactly the behavior this decision rejects; rewrite those tasks and tests to require login/sig, truthful responses for verified callers, and uniform non-revealing failures for unverified callers.

Useful? React with 👍 / 👎.

Comment on lines +244 to +246
6. **The sitekey is public** — it belongs in `[vars]` in `wrangler.toml` or
the frontend config, committed, not in a secret store. Do not
accidentally treat it as sensitive and add a second provisioning step.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provision the sitekey to the Nuxt frontend

If the implementer chooses the explicitly permitted [vars] option, Wrangler exposes the value only to the mctl-landing-form Worker, whereas tasks 3 and 9–11 require the browser widget to read runtimeConfig.public.turnstileSiteKey from the Nuxt build. The Worker verification path consumes only the secret, so putting the sitekey in wrangler.toml does not make it available to the prerendered frontend; the widget then cannot issue tokens and the fail-closed contact and access endpoints reject every submission. Require the sitekey in committed Nuxt frontend configuration or its build environment and remove the Wrangler alternative.

Useful? React with 👍 / 👎.

Comment on lines +197 to +199
- Unverified callers get a single uniform response that reveals nothing,
identical for existing and non-existing names, and identical to the
rate-limited and misconfigured cases.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve distinct rate-limit and configuration errors

Requiring an unverified response to be identical to rate-limited and misconfigured responses conflicts with T5's required 429 plus Retry-After and task 8's required 500 for genuine configuration or Backstage errors. If implemented literally, clients cannot tell whether to reauthenticate, back off, or report an operator outage, even though distinguishing these failure classes reveals nothing about whether the queried team exists. Require uniform responses across existing and non-existing names within each failure class, while retaining separate authentication, rate-limit, and server-error statuses.

Useful? React with 👍 / 👎.

… decisions

Codex, and it is the second time in this wave I have made the same mistake:
appending operator decisions while leaving the executable checklist saying
the opposite. OPERATOR.md is explicit that the four proposal files are all
the implementer sees, so a decision that contradicts task 8 is not a
documentation nit — the implementer follows the task.

- Tasks 7, 8, 11 and T4 rewritten for the identity gate: no Turnstile on
  check-team, hmacVerify on login+sig, truthful answers for verified
  callers, a name-independent 401 otherwise, and useTeamValidation sending
  the signature rather than a Turnstile token.
- Decision on uniformity corrected. I had asked for one response identical
  across the unverified, rate-limited and misconfigured cases; that would
  leave a caller unable to tell reauthenticate from back-off from operator
  outage, for no privacy gain, since none of those distinctions says
  anything about whether the queried name exists. Uniformity is required
  within each class.
- Decision on the sitekey corrected. I offered wrangler.toml [vars] as an
  alternative to the frontend config; Wrangler vars reach only the Worker,
  which consumes just the secret, so the sitekey would never reach the
  browser bundle — the widget would not render and the fail-closed
  endpoints would reject everything. The proposal's own task 3
  (NUXT_PUBLIC_TURNSTILE_SITE_KEY via nuxt.config.ts) was right.
@mashkovd

Copy link
Copy Markdown
Member Author

All three taken — and the first one is a repeat, which is the part worth naming.

P1 (checklist contradicts the decisions) — correct, and this is the second time in this wave I have done exactly this: appended an operator-decisions section while leaving tasks.md's executable checklist stating the opposite. OPERATOR.md is explicit that the four proposal files are all the implementer sees, so this is not a documentation nit — an implementer working the checklist would have shipped precisely the behaviour the decision rejects. Tasks 7, 8, 11 and T4 are rewritten: no Turnstile on check-team, hmacVerify on login+sig, truthful answers for verified callers, a name-independent 401 otherwise, and useTeamValidation sending the signature instead of a Turnstile token.

P1 (sitekey provisioning) — correct, and the mistake was mine, not the proposal's. Its task 3 already had NUXT_PUBLIC_TURNSTILE_SITE_KEY via nuxt.config.ts; my decision 6 then offered wrangler.toml [vars] as an alternative. Wrangler vars reach only the mctl-landing-form Worker, and the Worker consumes just the secret — the sitekey put there never reaches the prerendered bundle, so the widget never renders, no token is issued, and the fail-closed endpoints reject every submission. That would have been a self-inflicted outage of both public forms. The alternative is removed and the frontend path is now the only one.

P2 (collapsing failure classes) — correct. I asked for the unverified response to be identical to the rate-limited and misconfigured ones. That conflicts with T5's 429 + Retry-After and task 8's 500, and buys nothing: whether the caller should reauthenticate, back off, or report an outage says nothing about whether the queried name exists. Uniformity is now required within each failure class — the 401 must be byte-identical for existing and non-existing names and must not echo the name — while 401/429/500 stay distinguishable.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 10s —— View job


Re-review of Codex findings

I've examined the diff carefully against Codex's P1/P2 findings. All three findings are based on misreadings of the updated tasks — the reconciliation is complete.


Analysis of Codex Findings

Codex P1: "Reconcile the task checklist with the approved decisions"

Codex claims tasks 7–8 and 11 still require Turnstile while decisions reject it. ✗ Invalid.

The diff updates these tasks:

  • Task 7 (line 53–57): Changed to "No Turnstile on this endpoint" + rate limiting only
  • Task 8 (line 59–77): Changed from {available: true} uniformly → HMAC identity gating with truthful answers for verified callers, 401 for unverified
  • Task 11 (line 96–103): Changed from sending turnstile_token to sending login + sig, skips call when unsigned
  • T4 test (line 148–157): Changed from uniform-response test to identity-gate + non-disclosure test

Tasks reconciled with decisions. ✓


Codex P1: "Provision the sitekey to the Nuxt frontend"

Codex warns that wrangler.toml [vars] won't reach the Nuxt build. ✗ Partially invalid; already addressed.

  • Task 3 (line 15–19) specifies .env.example + runtimeConfig.public ✓
  • Operator decision 6 (line 270–279) explicitly warns against the wrangler alternative: "Wrangler vars reach only the mctl-landing-form Worker... The sitekey put there would never reach the prerendered browser bundle"

The decision document teaches the implementer why the proposed wrangler shortcut fails. No reconciliation needed; the guidance is already in scope.


Codex P2: "Preserve distinct rate-limit and configuration errors"

Codex claims the 401 requirement conflicts with task 5 & 8's 429 and 500. ✗ Invalid.

  • Operator decision 1 (line 218–225): "Uniformity is required within each failure class, not across them. Rate limiting keeps its 429 + Retry-After and genuine server errors keep their 500... a caller still has to be able to tell 'reauthenticate' from 'back off' from 'operator outage'"
  • Updated task 8 DoD (line 72–77): "within each other failure class the response is likewise name-independent: rate limit stays 429 + Retry-After, genuine Backstage/config failures stay 500. Do NOT collapse these classes"

Distinctions preserved. ✓


Verdict

No P1/P2 findings (0 P3). Decision document is well-reasoned, tasks are properly reconciled with operator decisions, and sequencing risks are appropriately emphasized. Good to merge.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). Decision document is well-reasoned, tasks properly reconciled with operator decisions, sequencing risks appropriately emphasized. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

Severity: P2
File: platform-gitops/agents-state/mctl-web/proposals/issue-66-turnstile-on-contact-submit-forms-harden/tasks.md
Approximate Line: 69-70 (Task 8)
Concrete Failure Scenario: Task 8 specifies gating the check-team endpoint on the existing HMAC identity and explicitly suggests requiring login and sig via "query params". Because check-team is typically a GET request, passing these values in the URL query string causes the sig (which acts as a bearer authentication token valid for 8 hours) to be logged in cleartext by intermediate proxies, CDNs (like Cloudflare access logs), browser histories, and potentially leaked via Referer headers. An attacker with access to these logs could harvest the signatures to impersonate users and submit unauthorized access requests.
Proposed Fix: Explicitly forbid passing the login and sig credentials in URL query parameters. Update Task 8 to require sending these credentials either via HTTP Headers (e.g., a standard Authorization: Bearer <sig> or custom headers) or by changing the check-team endpoint to accept a POST request with the credentials in a JSON body (matching the behavior of the submit endpoint).

Severity: P3
File: platform-gitops/agents-state/mctl-web/proposals/issue-66-turnstile-on-contact-submit-forms-harden/tasks.md
Approximate Line: 104-114 (Tasks 10 & 11)
Concrete Failure Scenario: Tasks 10 and 11 instruct the frontend to include the turnstile_token in form submissions but fail to specify resetting the Turnstile widget when backend processing fails after the token has been consumed (e.g., a Backstage 500 error in handleFormSubmit). Turnstile tokens are strictly single-use. If a legitimate submission fails due to a downstream server error and the user clicks "Submit" again to retry, the form will send the already-spent token. The backend will reject it with a timeout-or-duplicate error and return a 400 "Verification failed" response, which permanently blocks the user from retrying until they manually refresh the page.
Proposed Fix: Update Tasks 10 and 11 to mandate that the frontend onSubmit handlers catch API errors and invoke the Turnstile reset() function (provided by the new useTurnstile composable) whenever a submission request fails after reaching the backend.

agy P2 (correct, and a defect this decisions PR introduced): decision 1
permitted passing `login`/`sig` to `check-team` as query params. `sig` is
an 8-hour bearer and `check-team` is a GET, so that would have put a live
credential into Cloudflare access logs, browser history and outbound
Referer headers — an exposure the endpoint does not have today, added by
a change meant to harden it.

- check-team moves GET -> POST with `{ name, github_auth: { login, sig } }`,
  reusing the shape handleFormSubmit already validates; handler signature
  becomes (request, env, origin). T4 now asserts a query-string identity
  is rejected, so the door cannot be reopened silently.
- agy P3: tasks 10/11 require reset() when a submit fails after reaching
  the backend. Turnstile tokens are single-use, so one downstream 500
  otherwise leaves the user permanently unable to retry without a reload.

Canon reconciled, not just the checklist: requirements.md still carried
the rejected acceptance criteria (anonymous callers allowed through with
Turnstile, uniform `{available:true}`), design.md section 3 still
described that same rejected design, and Alternatives #2 still listed
identity-gating as "Dropped" when it is what was adopted. All three
rewritten. This is the third time in this wave the appended-decision
pattern left contradictory canon behind; the two files codex did not
quote were still wrong.

Tasks 1 and 2 marked done: Turnstile widget `mctl-ai-forms` created
(hostname mctl.ai, managed, sitekey 0x4AAAAAAEjFjEMuTRSzlzQc) and
TURNSTILE_SECRET_KEY uploaded to the mctl-landing-form Worker. The
sequencing hazard in decision 5 is therefore closed before implementation
starts rather than left as a merge-time prerequisite.
@mashkovd

Copy link
Copy Markdown
Member Author

Both agy findings taken — and both are defects this PR introduced, not pre-existing ones.

P2 (credentials in the query string) — correct, and the sharper point is the direction of travel. check-team today is GET /api/github/check-team?name=… (cloudflare-worker/index.js:124, handler :740) and carries no credential in its URL. My decision 1 offered "query params" as an acceptable way to pass login/sig. sig is an 8-hour bearer, so that would have written a live credential into Cloudflare access logs, browser history and outbound Referer headers — a change meant to harden the endpoint would have created an exposure it does not have. Fixed by moving the endpoint GET → POST with { name, github_auth: { login, sig } }, reusing the shape handleFormSubmit already validates at :785-789. handleCheckTeam goes from (url, env, origin) to (request, env, origin). Moving name into the body is a bonus — the queried tenant name stops being logged too. T4 now asserts that a valid identity supplied only as ?login=&sig= still gets the 401, so the query-param path cannot be quietly restored later with every review still reading green.

P3 (single-use token, no reset) — taken. Folded into tasks 10 and 11 rather than left as a note: the onSubmit error path must call reset() whenever the request reached the backend and failed. Without it one transient Telegram 500 leaves the user holding a spent token and every retry returns 400 "Verification failed" until they reload by hand — a blip presents as a permanently dead form.

Separately, and this is the part worth naming. Chasing P2 through the proposal showed the canon was still wrong in the two files nobody had quoted. requirements.md still had acceptance criteria mandating the rejected behaviour (anonymous callers allowed through with Turnstile, uniform {available:true}), design.md section 3 still described that same rejected design in full, and Alternatives #2 still listed identity-gating as "Dropped" when it is precisely what was adopted. Codex quoted tasks.md, I fixed tasks.md, and stopped. That is the third instance in this wave of the same mistake — fixing the file that was cited instead of reconciling the canon — and the implementer reads all four files, so the checklist agreeing with the decisions was never sufficient. All three sections rewritten.

Operator prerequisite now closed, ahead of implementation rather than at merge. Turnstile widget mctl-ai-forms created on the mctl.ai zone (managed mode, pre-clearance off), sitekey 0x4AAAAAAEjFjEMuTRSzlzQc, and TURNSTILE_SECRET_KEY uploaded to the mctl-landing-form Worker. Tasks 1 and 2 are marked done with an explicit "do not create a second widget, do not re-put the secret" — a fresh wrangler secret put with a different value would invalidate the deployed one. localhost/127.0.0.1 were deliberately left off the production widget; local dev uses Cloudflare's documented test keys instead of widening where the production sitekey is valid.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 27s —— View job


Code Review Complete

  • Fetch diff using pinned commit SHAs (git diff 9f121af9…ad881984)
  • Read the changed files (design.md, requirements.md, tasks.md)
  • Review for correctness, security, and operational safety issues per CLAUDE.md conventions
  • Post inline findings (1 P3)
  • Submit PR review verdict

Summary

This commit (ad881984) is a follow-up fix addressing the two P1-equivalent findings from the prior Antigravity review round: check-team credentials no longer travel in the query string (endpoint moves GET → POST, login/sig in the JSON body), and TURNSTILE_SECRET_KEY + the widget are confirmed provisioned before merge. Both design.md/requirements.md/tasks.md were kept consistent with each other, and T4 now asserts the query-string credential path is explicitly rejected so it can't be quietly reopened later. The Turnstile-reset-on-failure gap (P3 from the prior round) is also now folded into tasks 10 and 11.

One inline P3 nit posted: task 8 specifies 401 for a missing/invalid github_auth signature on check-team, while the (unchanged) T3 documents handleFormSubmit returning 403 for the same failure mode today — worth a one-line note on whether that split is deliberate so the implementer doesn't have to guess.

Verdict: APPROVED — no P1/P2 findings, 1 P3 nit (non-blocking).

* verified caller → today's truthful answer, unchanged
(`{available:false}` for an existing tenant, `{available:true}`
for a free name);
* missing or invalid signature → **401**, one fixed body, identical

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Task 8 requires 401 for a missing/invalid github_auth signature on check-team, but T3 (unchanged by this diff) documents that handleFormSubmit returns 403 for the identical failure mode (invalid github_auth.sig) today. Both endpoints now gate on the same hmacVerify(login, sig, GITHUB_OAUTH_HMAC_KEY) check, so an implementer copying the existing pattern from handleFormSubmit may reasonably default to 403 for check-team too. Worth a one-line note on whether this is a deliberate status-code split (401 for 'no/invalid identity' vs 403 reserved for something else) or should just match the existing 403 precedent, so the implementer doesn't have to guess.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (1 P3). Both previously flagged issues (credentials-in-URL, sitekey-in-wrangler-vars, missing Turnstile reset) are correctly fixed in this commit. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

Finding 1

  • Severity: P2
  • File: tasks.md, approximate lines 52-53
  • Concrete failure scenario: Task 8 instructs the developer to parse the POST JSON body for /api/github/check-team as { name, github_auth: { login, sig } } and explicitly verify it using hmacVerify(github_auth.login, github_auth.sig, env.GITHUB_OAUTH_HMAC_KEY). If a client (e.g., an anonymous attacker probing the endpoint) sends a JSON body that completely omits the github_auth object (e.g., {"name": "test"}), attempting to read github_auth.login will throw a TypeError: Cannot read properties of undefined. In Cloudflare Workers, unhandled exceptions result in a 500 Internal Server Error. This explicitly violates the requirement in requirements.md that missing or invalid identities must return a fixed 401 response to prevent enumeration.
  • Proposed fix: Instruct the developer to use safe object traversal and handle malformed JSON bodies gracefully. For example, add a check: const { login, sig } = body?.github_auth || {}; if (!login || !sig) return new Response(..., { status: 401 });, and ensure request.json() is wrapped in a try/catch block to return a 400/401 on invalid JSON rather than crashing with a 500.

Finding 2

  • Severity: P3
  • File: design.md, approximate lines 76-88
  • Concrete failure scenario: The design document explicitly notes that the verifyTurnstileToken function keeps the network call mockable for tests "via dependency injection (pass a fetchImpl param defaulting to global fetch)". However, the provided Javascript code snippet omits this parameter from the function signature and hardcodes the fetch call. If a developer implements the snippet exactly as written, the unit tests described in Task T1 will fail or inadvertently make live external network requests to the Cloudflare API.
  • Proposed fix: Update the code snippet's signature to include the parameter: export async function verifyTurnstileToken(token, secret, remoteip, fetchImpl = fetch) {, and change the inner API call to use the injected dependency: const res = await fetchImpl(TURNSTILE_VERIFY_URL, { method: 'POST', body });.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad88198470

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +128 to +130
- **The endpoint moves from `GET` to `POST`** (route at
`cloudflare-worker/index.js:124`, handler at `:740`), taking
`{ name, github_auth: { login, sig } }` as a JSON body;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a safe migration for the method change

When the frontend and Worker deploy independently, changing this route from GET to POST has no safe one-sided state: the new frontend's POST fails against the old GET-only Worker, while the old frontend's GET fails against the new POST-only Worker. Task 13 currently permits a frontend-first rollout, and the rollback section permits leaving the new frontend deployed after rolling back the Worker, so both documented procedures can break availability checks; require an atomic rollout or a transitional route that supports both methods without accepting credentials in the URL.

Useful? React with 👍 / 👎.

Comment on lines +132 to +134
`(request, env, origin)`. The credentials must never appear in the
query string: `sig` is a bearer valid for 8 hours, and a URL carries
it into Cloudflare access logs, browser history, and outbound

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat the HMAC signature as non-expiring

The signature is not actually valid for only eight hours: the documented verification is hmacVerify(login, sig, key), so no expiry is signed or checked by the Worker; AUTH_TTL only controls when the browser discards its localStorage copy. A copied signature remains replayable until the HMAC key is rotated, including by a direct API client, so the threat model and follow-up should describe it as an unbounded bearer rather than relying on an eight-hour exposure window.

Useful? React with 👍 / 👎.

Comment on lines +88 to +92
- WHILE `/api/github/check-team` is identity-gated THE SYSTEM SHALL NOT
require a Turnstile token on that endpoint — the issue's "at minimum
Turnstile + per-IP rate limit" fallback applies only where no identity
is available, and a challenge fired on every debounced keystroke is
both worse UX and a weaker gate than a signature.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the rejected check-team fallback

Although the task checklist was reconciled, the fresh remaining conflict is in this requirements document's Open questions section: lines 133–144 still say the proposal deliberately keeps check-team unauthenticated and uses Turnstile plus a uniform response, directly contradicting this new identity-gated, no-Turnstile acceptance criterion. Remove or clearly mark that paragraph as rejected so an implementer does not treat it as unresolved reviewer guidance and restore the discarded behavior.

Useful? React with 👍 / 👎.

Three codex P2s and one claude P3 on ad88198, all correct.

1. "8-hour bearer" was factually wrong, in all three files and in my own
   PR comment. hmacSign signs the bare login — no timestamp, no nonce —
   and hmacVerify recomputes over that same bare login. AUTH_TTL=8h
   (useAuth.ts:6,37) only governs when the browser discards its own copy;
   the Worker never sees exp. A leaked sig is replayable indefinitely by
   any HTTP client, revocable only by rotating GITHUB_OAUTH_HMAC_KEY,
   which logs out every user at once. Corrected everywhere, including the
   user story that claimed a leaked credential "cannot be replayed after
   that TTL" — it can. This strengthens the query-string decision rather
   than weakening it, and it materially raises what mctl-web#70 is worth.

2. GET -> POST has no safe one-sided deploy state, and task 13's
   soft-launch flag does not cover a method change: new frontend POSTing
   at old GET-only Worker fails, old frontend GETting at new POST-only
   Worker fails. Task 13 now requires either an atomic rollout or a
   transitional Worker accepting both methods (GET keeping today's
   anonymous semantics and never growing query-param credentials), with
   the choice named in the PR description. The Rollback section carried
   the matching bug — it said the frontend is "harmless to leave in
   place" during a Worker rollback, which is true of the additive
   turnstile_token field and false of the method change. Exception added.

3. requirements.md had a *second* Open questions block, ahead of the one
   reconciled last commit, still prescribing the rejected design
   (unauthenticated check-team, Turnstile fallback, uniform response).
   Struck through and marked rejected. Fixing the file codex quoted and
   stopping is exactly the failure this PR has now repeated twice.

4. claude P3: the 401-for-both-failure-modes on check-team diverges from
   handleFormSubmit's 401/403 split. Made explicit as deliberate, with
   the reason, so an implementer copying the neighbouring pattern does
   not "align" it.
@mashkovd

Copy link
Copy Markdown
Member Author

All four taken — three codex P2s and the claude P3. One of them corrects a factual claim I had repeated in three files and in my own comment above, so leading with that.

"sig is an 8-hour bearer" is wrong, and it understated the problem. Codex is right: hmacSign signs the bare login string — no timestamp, no nonce — and hmacVerify(login, sig, secret) recomputes over that same bare login. Nothing about expiry is signed, and the Worker never reads exp. AUTH_TTL = 8 * 60 * 60 * 1000 (useAuth.ts:6,37) only decides when the browser discards its own copy, which is no constraint at all on someone who has already copied the value. A leaked sig is replayable by any HTTP client indefinitely, and the only revocation is rotating GITHUB_OAUTH_HMAC_KEY — which invalidates every user at once.

This cuts in the direction of the earlier decision rather than against it: putting a permanent bearer in a query string is worse than putting an 8-hour one there. But the wording was wrong wherever it appeared, and one place it appeared was a user story asserting a leaked credential "cannot be replayed after that TTL" — a false acceptance criterion someone could have signed off against. Corrected in all three files, with the consequence written into the mctl-web#70 pointer: this task makes sig the sole gate on a third endpoint, so the absence of expiry and revocation is now load-bearing in three places. #70 is not the tidy-up it reads as.

GET → POST has no safe one-sided deploy state. Also correct, and task 13's soft-launch flag does not cover it — that flag makes missing tokens tolerable, not a method mismatch. New frontend POSTing at an old GET-only Worker fails; old frontend GETting at a new POST-only Worker fails; and task 13 explicitly permitted frontend-first. Task 13 now requires naming one of two options in the PR description: atomic rollout, or a transitional Worker that accepts both methods — POST identity-gated, GET keeping exactly today's anonymous semantics and never growing query-param credential support — followed by a filed follow-up to delete GET. The Rollback section had the matching defect: it said the frontend is "harmless to leave in place" during a Worker rollback. True of the additive turnstile_token field, false of the method change; a POST-only frontend against a reverted GET-only Worker breaks availability checking outright. Exception added, keyed to whichever rollout option was chosen.

The rejected design was still in requirements.md — in a second Open questions block. This is the part I should have caught. Last commit I reconciled an Open questions section; there were two, and the earlier one still instructed the implementer to take the "at minimum" fallback: unauthenticated check-team, Turnstile, uniform response. Struck through and explicitly marked rejected, pointing at the acceptance criteria as the authority. That is now twice in this PR that I fixed the file a reviewer quoted and stopped there, which is precisely the failure mode I described three comments up. The grep I proposed as a guard would have caught it; I did not run it after the last edit.

claude P3 (401 vs 403): taken as a documentation fix rather than a behaviour change. handleFormSubmit answers 401 for a missing github_auth and 403 for a bad sig; task 8 asks for a single 401 on check-team. That divergence is deliberate — collapsing both is safe for the same reason the split is safe there (neither says anything about the queried name), and it is preferable on the endpoint an enumerator actually probes, where one response means one thing to measure. Written into the DoD with the reasoning and an explicit "do not align this to the neighbouring pattern", plus a note that /api/submit's own status codes are not being touched.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 22s —— View job


Review Complete — Changes Requested (2 P2)

Task List:

  • Fetch diff using pinned commit SHAs (9f121af9...f7e423d5) — full PR diff, not just the latest commit
  • Read changed files (design.md, requirements.md, tasks.md)
  • Review for correctness, security, operational safety per CLAUDE.md
  • Post inline findings
  • Submit PR review verdict

Summary

This PR appends operator decisions to the issue-66 proposal (Turnstile + identity-gated check-team). Verified against the diff that the prior review rounds' findings are correctly reconciled in this final commit: credentials moved out of the URL into a POST body, sitekey provisioning fixed to go through Nuxt config (not wrangler.toml [vars]), Turnstile reset()-on-failure added, the GET→POST rollout hazard gets an explicit migration plan, the sig-has-no-expiry correction is threaded through all three files, and requirements.md's stale "Open questions" paragraph is now struck through and marked rejected.

Two new P2s posted inline, both in tasks.md, neither raised in earlier rounds:

  1. Line 99 — Task 8's DoD mandates 401 for a missing signature but never tells the implementer to guard the destructure. Literally following hmacVerify(github_auth.login, github_auth.sig, ...) against a body that omits github_auth entirely throws, and Workers turn that into a 500 — violating the task's own 401-uniformity requirement.
  2. Line 181 — The two-step migration fallback (chosen if an atomic Worker+frontend deploy isn't possible) has the transitional GET path keep serving fully anonymous, truthful availability answers — the exact enumeration oracle operator decision 1 says must close outright — for as long as the follow-up PR removing GET takes to land. requirements.md's matching rollout criterion has the same gap.

Verdict: REQUEST_CHANGES submitted against commit f7e423d5 (2 P2, 0 P1).

* verified caller → today's truthful answer, unchanged
(`{available:false}` for an existing tenant, `{available:true}`
for a free name);
* missing or invalid signature → **401**, one fixed body, identical

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Task 8's DoD requires 401 for a missing signature, but nothing here requires defensive parsing of the POST body. A request like {"name":"test"} (no github_auth object at all) will cause an unhandled TypeError when the implementer destructures github_auth.login per this task's own instruction, which Cloudflare Workers turns into a 500 -- violating the very 401-uniformity requirement this task defines, and reintroducing a status-code side channel. Add an explicit DoD bullet: safely handle a missing/malformed github_auth or unparsable JSON body and return the same fixed 401, not a 500.

can guarantee it; or
* make the Worker accept **both** methods in this release — `POST`
with the identity gate, and `GET` continuing to serve exactly
today's unauthenticated behaviour — then remove `GET` in a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The two-step migration fallback lets the transitional GET path keep serving 'today's unauthenticated behaviour' -- i.e. the exact truthful, anonymous available:true/false oracle that operator decision 1 (further down this same file) says must be closed outright, not traded for deployment convenience. If an operator picks this option over an atomic deploy, anonymous enumeration is fully live again (rate-limited only, per task 7) for the entire window until the follow-up PR lands, silently reopening the vulnerability this whole proposal exists to close. Requirements.md's matching rollout criterion has the identical gap. Recommend either forbidding this option, or requiring the transitional GET to also fail closed with a uniform response (like the new POST path) instead of reverting to full pre-proposal anonymous semantics, and bounding how long that window may stay open.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 2 P2s. (1) Task 8's DoD requires 401 for a missing signature but doesn't require defensive parsing -- an implementer following the literal instruction to destructure github_auth.login will throw on a body that omits github_auth entirely, producing a 500 instead of the mandated uniform 401. (2) Task 13's two-step migration fallback keeps the transitional GET path serving fully anonymous, truthful availability answers -- the exact enumeration oracle operator decision 1 says must be closed outright -- for an unbounded window until a follow-up PR removes GET; requirements.md has the same gap. Everything else in this round (credentials out of the URL, sitekey via Nuxt not wrangler, Turnstile reset-on-failure, GET/POST rollout safety, sig-has-no-expiry correction, rejected-fallback cleanup in requirements.md) is correctly reconciled across all three files.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

No significant issues found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7e423d5ab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +135 to +137
contract. **The `onSubmit` error path must call the composable's
`reset()` whenever the request reached the backend and failed
(any non-2xx, and any thrown network error after send).** Turnstile

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset Turnstile after every backend submission

Resetting only on failures leaves a successfully verified token cached in the composable even though siteverify has consumed it. If the still-mounted contact form permits another submission, that request reuses the token and is rejected as timeout-or-duplicate; require a reset after every request that may have reached siteverify, including 2xx responses, or explicitly clear/unmount the form after success.

Useful? React with 👍 / 👎.

… keep the oracle

Three P2s on f7e423d (two claude, one codex), all correct. The second is a
defect I introduced in the previous commit while fixing codex's rollout P2.

1. Malformed body -> 500, not 401. Task 8 told the implementer to call
   hmacVerify(github_auth.login, ...); followed literally against
   {"name":"test"} that throws a TypeError and the Workers runtime
   answers 500 — so the uniformity requirement task 8 sets up was
   defeated by task 8's own example, handing back the status-code side
   channel the identity gate exists to remove. Defensive parsing is now
   in the DoD, and T4 table-drives six malformed shapes asserting a
   byte-identical 401. A single "missing signature" case would have
   passed while the destructure still threw on the rest.

2. The transitional GET I added last commit "keeps serving today's
   unauthenticated behaviour" — i.e. it preserves the anonymous truthful
   oracle that operator decision 1 exists to close, for the entire
   migration window, rate-limited only. That trades the vulnerability
   for deployment convenience and is rejected. The shim now answers
   {available:true} unconditionally without calling Backstage: this is
   the uniform-response design rejected as an end state, and it is the
   right tool here for the opposite reason — its cost (the endpoint stops
   being useful) is unacceptable forever and fine for days, while the
   oracle closes the moment the Worker deploys. Old frontends keep
   working degraded; /api/submit still blocks duplicates. T8 asserts the
   shim returns available:true for an *existing* name and never calls
   Backstage — the failure mode guarded against is a shim that "keeps
   working" by keeping the bug. The two-step option now also requires a
   date, not just a follow-up issue number.

3. reset() on failure is not enough: siteverify consumes the token on
   success too, and the contact form stays mounted afterwards, so the
   second message a user sends reuses a spent token and gets
   timeout-or-duplicate. That is the likelier of the two occurrences.
   Reset moves to a finally, unconditionally, with T7 asserting it after
   a *successful* submit.
@mashkovd

Copy link
Copy Markdown
Member Author

All three taken. The middle one is a defect I introduced in the previous commit, while fixing codex's rollout P2 — worth stating plainly, because the fix was worse than the gap it closed.

claude P2, transitional GET (tasks.md:181) — correct, and this is mine. I wrote that the compatibility shim should "keep serving today's unauthenticated behaviour". Today's behaviour is the anonymous truthful oracle that operator decision 1 exists to close. So the shim would have left the vulnerability fully live — rate-limited only — for the entire migration window, and I would have traded the actual fix for deployment convenience while the PR read as if it delivered it.

Fixed by making the shim answer {available: true} unconditionally, without calling Backstage at all. That is the uniform-response design rejected as an end state in decision 1, and it is the right tool here for precisely the reason it was rejected there: its cost is that the endpoint stops being useful (every name reads as free), which is unacceptable forever and perfectly fine for a few days — while its benefit is that the oracle closes the moment the Worker deploys rather than when the follow-up lands. Old frontends keep working in a degraded state, and /api/submit's authoritative duplicate check (lines 806-822) still rejects a taken name, so nobody can provision a duplicate through the degraded path. T8 asserts the shim returns available:true for a name the Backstage stub reports as existing and never calls Backstage — the failure mode being guarded against is a shim that "keeps working" by keeping the bug. The two-step option now also requires a date in the PR description, not just a follow-up issue number; an unbounded transitional window is how a shim becomes permanent.

claude P2, malformed body (tasks.md:99) — correct, and self-inflicted in a subtler way. Task 8 instructed hmacVerify(github_auth.login, …). Followed literally against {"name":"test"} that throws a TypeError, which Workers turns into a 500 — so task 8's own example defeated task 8's own uniformity requirement, handing back exactly the status-code side channel the identity gate exists to remove. Defensive parsing is now an explicit DoD bullet covering an unparsable body, a missing github_auth, a non-object github_auth, and a missing sig. T4 table-drives six shapes and asserts a byte-identical 401 for each, because a single "missing signature" case would pass while the destructure still threw on the others.

codex P2, reset after success (tasks.md:137) — correct. My decision 8 said reset "when a submission fails after reaching the backend". siteverify consumes the token on a successful verification too, and the contact form stays mounted after a successful send — so the second message a user types reuses a spent token and comes back timeout-or-duplicate. That is the likelier of the two occurrences, and I had scoped the fix to the rarer one. Reset moves into a finally, unconditionally; T7 asserts it after a successful submit specifically, since a failure-only test would have left the common path uncovered.

Process note, since this is the round where it became a pattern rather than an incident: the checks were green on f7e423d at the same moment claude was submitting CHANGES_REQUESTED, and the three findings above landed roughly four minutes after the last check went green. Green checks are not a clean review here, and a sweep taken at the moment the checks settle is taken too early.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 6s —— View job


Review Complete — Changes Requested (2 P2, 2 P3)

  • Fetch diff via pinned SHAs (git diff 9f121af9...9b2c5b93, cumulative PR diff)
  • Read changed files (design.md, requirements.md, tasks.md) — full content, not just the diff hunks
  • Review for correctness, security, operational safety
  • Post findings (in the verdict body — see note below)
  • Submit PR review verdict

Summary

This final commit closes out the round from the prior agy review (401-on-malformed-body defensive parsing, reset-on-every-submission for Turnstile including success). Verified against the full diff that these are correctly reconciled across all three files, along with everything from earlier rounds: credentials out of the URL (with a test guarding the query-string path from being quietly reopened), the sig-has-no-expiry correction, and the GET→POST two-step migration correctly rejecting an anonymous-truthful transitional shim in favor of a fail-closed one.

Found 2 new P2 + 2 new P3, all in text that predates this PR's diff entirely (unchanged since the base commit) — this repo's stale-canon pattern, called out three times already in this PR's own comments, striking a fourth time in parts nobody has quoted yet:

  • P2 — design.md's "Platform impact / Backward compatibility" section never mentions the check-team GET→POST breaking change, even though tasks.md dedicates ~40 lines to that exact migration hazard. The one section titled for rollout risk is silent on the biggest rollout risk in the PR.
  • P2 — requirements.md's worker-tests acceptance criterion still says to exercise "the check-team uniform-response behavior" — the rejected design, not the identity-gate/non-disclosure behavior T4 actually specifies.
  • P3 — design.md's "Resource impact" bullet still claims a siteverify call happens on every check-team request (it doesn't — check-team has no Turnstile).
  • P3 — design.md's risk bullet mislabels the check-team external-consumer risk as a "uniform-response change" (it's an identity gate now); the underlying risk is still valid, just mislabeled.

Note on delivery: these 4 findings could not be posted as inline line comments — all 4 lines are unchanged since the base commit (9f121af), so they fall outside every diff hunk in this commit range, and GitHub's review-comment API rejected the line binding (422 "could not be resolved") when attempted. They're included in the verdict body instead.

Verdict: REQUEST_CHANGES submitted against commit 9b2c5b93 (2 P2, 2 P3).

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

No significant issues found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b2c5b93ab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +121 to +123
- **Gate it on identity, not on Turnstile** (operator decision; the
Turnstile-on-check-team variant is recorded as rejected under
alternative 2 below). `handleCheckTeam` requires the same signed identity

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove check-team from the siteverify impact

This adopted design explicitly forbids Turnstile on check-team, but the Platform impact section still says every contact, submit, and check-team request adds a siteverify fetch (design.md:245-247). An implementer or operator relying on that section would infer an extra dependency, latency, and Turnstile outage surface that tasks 7 and 11 explicitly reject; update the impact statement to cover only contact and submit.

Useful? React with 👍 / 👎.

Comment on lines +328 to +330
Worker breaks availability checking outright. If the two-step
both-methods rollout (task 13) was taken, this exception does not
apply — the reverted Worker still answers `GET`. If the atomic rollout

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep frontend rollback coupled after the two-step rollout

Once the new frontend is live it always POSTs, so rolling the Worker back from the transitional both-method release to the previous GET-only version still breaks availability checking; the fact that the reverted Worker answers GET helps only the old frontend. The newly added exemption is fresh evidence that the earlier rollback problem remains: require a paired frontend rollback unless the specific Worker version being restored also accepts POST.

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 2 P2, 2 P3 — all stale-canon gaps in text that predates this PR's diff (unreachable via inline line comments; GitHub rejects binding to a line untouched since the base commit, confirmed by a 422 on attempt), so reported here.

P2 — design.md "Platform impact / Backward compatibility" (line ~231) never mentions the check-team GET→POST breaking change. That bullet only discusses the additive turnstile_token field on contact/submit. The GET→POST migration hazard (no safe one-sided deploy state) gets ~40 lines of dedicated treatment in tasks.md task 13, but the one section titled for exactly this purpose — the canonical place an operator scans for rollout risk — is silent on it. A reader of design.md's Platform impact section alone would underestimate this PR's deploy risk.

P2 — requirements.md line 137-141 ("WHEN worker tests are run...") still says test coverage should exercise "the check-team uniform-response behavior". That's the rejected design (anonymous, always-available:true). The actual behavior, per T4 in tasks.md, is identity-gate + non-disclosure testing. This acceptance-criteria bullet is stale and contradicts the file's own claim two sections later that "the acceptance criteria above are the authority" — this is the fourth instance of the pattern already named three times in this PR's own comments (fixing the file that was quoted and stopping there).

P3 — design.md line 245-248 ("Resource impact") still says one additional outbound siteverify fetch happens "per contact/submit/check-team request". Check-team has no Turnstile per operator decision 2 (design.md:121-128) — it never calls siteverify. Cosmetic (doesn't change any behavior), but factually wrong.

P3 — design.md line 257-262 (Risk bullet) mislabels the check-team risk as "check-team's uniform-response change silently changes contract for... other consumer[s]". The adopted design is identity-gating, not a uniform-response change; an external consumer beyond useTeamValidation.ts would now get 401s, not a shape change. The underlying risk (undiscovered external callers) is still real and worth keeping, just mislabeled.

Everything else in this final commit is well reconciled: credentials confirmed out of the URL with a test guarding the query-string path, defensive parsing added for task 8's DoD (with a table-driven malformed-body test in T4), the sig-has-no-expiry correction threaded through all three files, the GET/POST two-step migration correctly rejects the "shim keeps anonymous truthful answers" version in favor of a fail-closed {available:true}-unconditional shim, and Turnstile reset-on-success (not just reset-on-failure) is now required.

…le was wrong

Six findings on 9b2c5b9 (2 codex P2, 2 claude P2, 2 claude P3). Five are
stale canon in text older than this PR's diff; one is a logic error I
introduced last commit.

The logic error first. My rollback exception said the two-step
both-methods rollout lets you skip reverting the frontend, "because the
reverted Worker still answers GET". Wrong: a restored GET-only Worker
serves *old* frontends, and after migration the live frontend always
POSTs. The two-step protects the forward migration and does nothing for
the rollback. The rule is now simply that a Worker rollback must revert
the frontend with it unless the restored Worker version also accepts
POST — satisfiable only once the both-methods release is itself in the
rollback history, so one step back is safe and two are not. The PR
description must record which Worker versions accept POST.

Stale canon, all of it text this PR had not touched and none of it
reachable by the reviewers' inline comments:

- design.md "Backward compatibility" discussed only the additive
  turnstile_token field and never mentioned GET -> POST, in the one
  section an operator scans for deploy risk. A reader of that section
  alone would have underestimated the rollout hazard that gets forty
  lines in tasks.md.
- design.md "Resource impact" claimed a siteverify fetch per
  contact/submit/check-team. check-team has no Turnstile, so it gains no
  outbound dependency, no latency, and no Turnstile-outage exposure.
- design.md risk bullet described the check-team change as a
  "uniform-response change" and reasoned about a changed response shape.
  The adopted design is identity-gating plus a method change; an
  undiscovered external caller breaks harder and more visibly than that
  bullet implied, and needs a signature it may not be able to produce.
- requirements.md's test criterion still required exercising "the
  check-team uniform-response behavior" — a test suite written to that
  bullet would assert the opposite of what is being built.
- requirements.md's user story asked check-team to "stop revealing which
  team names are already provisioned", without the "to unauthenticated
  callers" qualifier the adopted design turns on.
- The 8h TTL note repeated the browser-side/server-side confusion
  corrected two commits ago.

This is the fourth round in which a fix reconciled the file a reviewer
quoted and left the same contradiction standing elsewhere. This commit
was written by reading design.md and requirements.md end to end and
grepping every check-team mention, not by following the citations.
@mashkovd

Copy link
Copy Markdown
Member Author

All six taken. One is a logic error I introduced last commit; five are stale canon in text older than this PR's diff, which is why the reviewers had to report them in a review body rather than inline.

The logic error, codex on tasks.md:330 — correct, and it is mine. My rollback exception said the two-step both-methods rollout lets you skip reverting the frontend, "because the reverted Worker still answers GET". That reasoning is backwards: a restored GET-only Worker serves old frontends, and after the migration the live frontend always sends POST. The two-step protects the forward migration and does exactly nothing for the rollback. So the exception I wrote to close a rollback hazard reintroduced it under a different condition. The rule is now unconditional — a Worker rollback reverts the frontend with it, unless the specific Worker version being restored also accepts POST — which is satisfiable only once the both-methods release is itself in the rollback history: one step back is safe, two are not. The PR description has to record which Worker versions accept POST, so whoever runs a rollback can tell how far back is safe instead of finding out from a broken form.

The five stale-canon findings — all correct, and the pattern is now the finding. claude's note that this is the fourth instance is right, and I want to be precise about the failure rather than just fix it again. Each round I fixed the file that was quoted and stopped, so each round the same contradiction survived somewhere else, and each round the PR looked reconciled because the cited line was. Specifically this time:

  • design.md "Backward compatibility" discussed only the additive turnstile_token field and never mentioned GET → POST — in the one section an operator actually scans for deploy risk. Forty lines about it in tasks.md do not help someone reading the section titled for that purpose. Added, with the reason a soft-launch flag mitigates the additive change and not the method change.
  • design.md "Resource impact" claimed a siteverify fetch per contact/submit/check-team. check-team carries no Turnstile, so it gains no outbound dependency, no latency, and no Turnstile-outage exposure — the opposite of what that bullet told an operator to plan for.
  • design.md's risk bullet reasoned about a "uniform-response change" and a changed response shape. The adopted design is identity-gating plus a method change, so an undiscovered external caller does not see a new shape — its GET stops being routed, and a bare POST gets a flat 401. It breaks harder and more visibly, and it needs a signature it may not be able to produce. Relabelled; the underlying risk is real and kept.
  • requirements.md's test criterion still required exercising "the check-team uniform-response behavior". A suite written to that bullet would assert the opposite of what is being built — and it sat two sections above the line claiming the acceptance criteria are authoritative.
  • requirements.md's user story asked check-team to "stop revealing which team names are already provisioned" with no qualifier. Under the adopted design it must keep revealing them to a signed caller; unqualified, it reads as an instruction to build the rejected design.

This commit was written by reading design.md and requirements.md end to end and grepping every check-team mention, rather than by following the citations. That is what I should have done in round two.

@claude

claude Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 59s —— View job


Review Complete — Approved (0 P1/P2, 0 P3)

Task List:

  • Fetch diff via pinned commit SHAs (two-dot diff, since the pinned base commit is shallow in this checkout and has no discoverable merge-base with the head — git diff 9f121af9 247e4e6e --stat used instead of triple-dot)
  • Identify what changed in the final commit (247e4e6e) vs. prior reviewed commit (9b2c5b93)
  • Verify the 2 P2 + 2 P3 stale-canon findings from the last review round are actually fixed
  • Review full cumulative diff for new issues
  • Post inline findings (none needed)
  • Submit PR review verdict

Summary

This final commit (247e4e6e) is a narrow, well-targeted fix for exactly the four findings from the prior review round (review #5071488313), touching only design.md, requirements.md, and tasks.md (no .status.yaml changes, matching the PR description). Verified each:

  1. design.md "Platform impact" now covers the check-team GET→POST hazard (new paragraph at line ~246) — no longer silent on the biggest rollout risk.
  2. requirements.md's worker-tests acceptance criterion no longer says "uniform-response behavior" — now explicitly specifies identity-gate + non-disclosure testing, with a "Not 'uniform-response behaviour'" callout so it can't drift back.
  3. design.md's "Resource impact" now explicitly excludes check-team from the siteverify fetch — factually correct per operator decision 2 (no Turnstile on that endpoint).
  4. design.md's risk bullet relabeled from "uniform-response change" to "identity-gating plus a method change" — matches the adopted design.

Also fixed in this commit: the rollback rule in tasks.md/requirements.md previously claimed the two-step both-methods rollout protects a Worker rollback (reverted Worker still answers GET). That's wrong once the frontend has migrated to POST-only — the corrected rule (rollback must revert the frontend too, unless the specific Worker version being restored also accepts POST) is logically sound: rolling back one step to the transitional both-methods Worker is safe, rolling back two steps to the original GET-only Worker is not.

Grepped both files for remaining stale references to the rejected "uniform-response"/anonymous design — the only surviving occurrence is the already-struck-through, explicitly-marked-rejected requirements.md Open Questions paragraph, which is correct (it's a record of what was rejected, not live guidance). No new stale-canon instances found.

Verdict: APPROVE submitted against commit 247e4e6e7f472464a6f34affdfc746a5ff563f03 — no P1/P2/P3 findings.

Process note (transparency)

While testing the inline-comment API during review, I accidentally posted a placeholder comment (body: "test") to design.md:246 — it published immediately as review #5071612515 rather than staying in a draft state. I attempted to delete/edit it via the API (DELETE/PATCH on the comment, DELETE on the review) but those calls weren't permitted in this run. It carries no actual finding — a maintainer may want to delete it manually.

Worker is deployed from the same image/pipeline or separately; if
separate, coordinate the two rollouts).

**The larger compatibility break is not the additive field — it is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high.

No significant issues found.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). This commit cleanly and precisely fixes the 4 stale-canon findings from the prior review round (design.md Platform-impact GET/POST hazard, requirements.md worker-tests criterion, siteverify resource-impact scope, risk-bullet mislabel) plus corrects the two-step rollback rule (transitional GET protects the forward migration, not a rollback past it). No new issues found in the incremental diff. Good to merge.

@mashkovd
mashkovd merged commit 9529ab6 into main Aug 31, 2026
12 checks passed
@mashkovd
mashkovd deleted the agents/web-66-decisions branch September 2, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant