feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
Five items of Pouya's production run, 2026-09-01.
Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.
Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.
Four launch blockers found by reading the running system:
- handler.mjs wrote pk/sk; the live table's key is submissionId with no
sort key, so every submission would have failed validation silently
- the Lambda invoke permission is scoped to the old route path
- 22 of 23 pages 403 without the router function
- there was no 404 page; src/pages/404.astro adds it
Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.
Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca1c2524e1
commit
bd282aa47d
@@ -257,6 +257,31 @@ const PATTERNS = [
|
||||
'reached a public page once.',
|
||||
re: /anyone (may|can) be appointed an arbitrator|nothing in law gates|no (licence|license|designation) is (required|needed) to (be appointed|arbitrate|act as an arbitrator)/gi,
|
||||
},
|
||||
{
|
||||
id: 'sole-administrator-q62',
|
||||
rule: 'Q62 — /legal/privacy/ claims sole administrative access to the intake table, and that is FALSE.',
|
||||
incident:
|
||||
'The page shipped "nobody else has access to the table. There is no team, ' +
|
||||
'no assistant and no external administrator." The AWS account has an ' +
|
||||
'`admins` IAM group carrying AdministratorAccess with TWO members, and ' +
|
||||
'`simulate-principal-policy` returns allowed for dynamodb:GetItem, Query ' +
|
||||
'and Scan on the table for both ' +
|
||||
'[verified 2026-09-01 — docs/reference/intake-table-access-verification.md]. ' +
|
||||
'THE REACHED-DIST CONDITION IS WHY THIS PATTERN EXISTS AT ALL: the ' +
|
||||
'sentence was in dist/legal/privacy/index.html, `build`, `check` and this ' +
|
||||
'script all exited 0 over it, and the only thing between it and a live ' +
|
||||
'privacy policy was a TODO(pouya) in a JSX comment — which Astro strips, ' +
|
||||
'so `grep -rn "TODO(pouya)" dist/` returned nothing. The gate was human ' +
|
||||
'memory. Found by `adversarial-reviewer`, 2026-09-01. ' +
|
||||
'DELETE THIS PATTERN when Q62 is ruled: either the access is removed and ' +
|
||||
'the sentence becomes true, or the sentence is rewritten. It is a tripwire ' +
|
||||
'on one specific published falsehood, not a rule about a class.',
|
||||
/* `\s+`, NOT LITERAL SPACES: `compressHTML` collapses whitespace between
|
||||
tags and leaves it alone inside a text node, so the published bytes read
|
||||
"nobody else has access to\n the table". A literal-space version
|
||||
runs, prints `ok`, and exits 0 with the breach in `dist/`. */
|
||||
re: /nobody\s+else\s+has\s+access\s+to\s+the\s+table|there\s+is\s+no\s+team,\s+no\s+assistant\s+and\s+no\s+external\s+administrator/gi,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -341,6 +366,11 @@ const FIXTURES = {
|
||||
'C.Med-Arbitrators',
|
||||
'C.Med-Arbitration',
|
||||
],
|
||||
'sole-administrator-q62': [
|
||||
/* The two published clauses, verbatim from dist/legal/privacy/. */
|
||||
'nobody else has access to the table',
|
||||
'There is no team, no assistant and no external administrator.',
|
||||
],
|
||||
'struck-universal-q39': [
|
||||
'Anyone may be appointed an arbitrator in Ontario',
|
||||
'nothing in law gates the role',
|
||||
@@ -348,6 +378,16 @@ const FIXTURES = {
|
||||
},
|
||||
/* Every one of these is real published or spec-approved copy on this site. */
|
||||
mustNotMatch: [
|
||||
/* NEGATIVE FIXTURES FOR `sole-administrator-q62` — the approved copy nearest
|
||||
to the two clauses it catches. The pattern is deliberately anchored on the
|
||||
two published sentences rather than on the ideas in them, because the
|
||||
replacement wording is not yet decided and a looser pattern would fire on
|
||||
whatever Q62's ruling produces. These four are what "nearest" means: the
|
||||
same page's true sentences about the same subject. */
|
||||
'The table is reachable by the function that writes to it.',
|
||||
'Two accounts hold administrative access to the AWS account, and the function that writes to the table cannot read it.',
|
||||
'Nobody else has access to my mailbox.',
|
||||
'There is no team. Every inquiry is read by me.',
|
||||
'I act as a neutral. I do not act for a party in a matter I take, and each party should have their own legal advice.',
|
||||
'I run a process, I do not run a case for anybody in it.',
|
||||
'I will not run a process whose shape nobody agreed to in advance.',
|
||||
|
||||
+21
-6
@@ -69,7 +69,7 @@ case "$caller" in
|
||||
echo >&2
|
||||
echo "REFUSING: that is the broadly-permissioned personal user." >&2
|
||||
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
|
||||
echo "deploy user (Q22: not yet created)." >&2
|
||||
echo "deploy user, adr-sml-deploy — PROVISIONED, AGENTS.md §7." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -157,7 +157,7 @@ location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' \
|
||||
"https://adr.smlcompany.ca/api/intake" 2>/dev/null || true)
|
||||
"https://adr.smlcompany.ca/api/intake" || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
||||
@@ -169,10 +169,25 @@ else
|
||||
echo >&2
|
||||
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
||||
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can mean the" >&2
|
||||
echo "same thing — CloudFront rejecting a method the default behaviour does" >&2
|
||||
echo "not allow — or the handler refusing the Origin. Either way the form is" >&2
|
||||
echo "not verified working. See docs/06-deployment.md's cutover checklist." >&2
|
||||
# 404 IS AMBIGUOUS BETWEEN THREE CAUSES and the distribution's custom error
|
||||
# response hides the one string that would separate them: API Gateway's
|
||||
# {"message":"Not Found"} is replaced by /404.html, because custom error
|
||||
# responses are distribution-wide. So name the causes and the one command that
|
||||
# tells them apart. Corrected 2026-09-01 by `adversarial-reviewer`; the earlier
|
||||
# text named only the CloudFront behaviour.
|
||||
echo "404 means one of three things, and \`aws apigatewayv2 get-routes" >&2
|
||||
echo "--api-id <id> --query 'Items[].RouteKey'\` separates them in one call:" >&2
|
||||
echo " - the CloudFront /api/* behaviour is missing (docs/09 Part 3);" >&2
|
||||
echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2
|
||||
echo " - the route exists and the distribution's 404 mapping is showing you" >&2
|
||||
echo " /404.html instead of the API's own body." >&2
|
||||
echo "403 means CloudFront rejected the method, or the handler refused the" >&2
|
||||
echo "Origin — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2
|
||||
echo "because a policy that drops Origin turns every real submission into a" >&2
|
||||
echo "403. 500 means the Lambda invoke permission for this route is missing" >&2
|
||||
echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2
|
||||
echo "Either way the form is not verified working. See docs/09-cutover-" >&2
|
||||
echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2
|
||||
fi
|
||||
|
||||
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Prints the intake Lambda's six environment variables as the JSON that
|
||||
* `aws lambda update-function-configuration --environment` takes.
|
||||
*
|
||||
* ⚠️ THIS EXISTS SO THAT TWO PUBLISHED COMMITMENTS ARE NEVER RETYPED INTO A
|
||||
* SHELL COMMAND. `RESPONSE_TIME` and `NO_RETAINER_NOTICE` are read from
|
||||
* `src/data/site.ts` — the same constants `/contact/` renders — because a
|
||||
* hand-typed copy of the notice inside the handler had already dropped a clause
|
||||
* once (`docs/05`, and the handler's own comment on the constant). A deploy
|
||||
* procedure that asks an operator to paste a sentence is the same defect one
|
||||
* step further out, and the notice contains an EN DASH in "mediator–party",
|
||||
* which is exactly the character a retype loses.
|
||||
*
|
||||
* Resource names come from `AGENTS.md` §7 and are passed in, not defaulted from
|
||||
* a second copy here — except the two that are pure site facts.
|
||||
*
|
||||
* usage: node scripts/intake-env.mjs --table <name> --notify <addr> --from <addr>
|
||||
* node scripts/intake-env.mjs ... --shell # export lines instead
|
||||
*/
|
||||
import { CONTACT, NO_RETAINER_NOTICE, SITE } from '../src/data/site.ts';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : args[i + 1];
|
||||
};
|
||||
|
||||
const table = flag('table');
|
||||
const notify = flag('notify');
|
||||
const from = flag('from');
|
||||
const missing = [
|
||||
['--table', table],
|
||||
['--notify', notify],
|
||||
['--from', from],
|
||||
]
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing: ${missing.join(' ')}`);
|
||||
console.error(
|
||||
'usage: node scripts/intake-env.mjs --table <dynamodb-table> ' +
|
||||
'--notify <address> --from <ses-verified-address> [--shell]',
|
||||
);
|
||||
console.error('Resource names are in AGENTS.md §7.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* The site origin is not a deploy-time choice: the handler compares the request
|
||||
Origin against it and redirects to pages ON it, so it must be the canonical
|
||||
origin `astro.config.mjs` builds against. */
|
||||
const origin = SITE.url.replace(/\/$/, '');
|
||||
|
||||
const vars = {
|
||||
INTAKE_TABLE: table,
|
||||
SITE_ORIGIN: origin,
|
||||
NOTIFY_TO: notify,
|
||||
MAIL_FROM: from,
|
||||
RESPONSE_TIME: CONTACT.responseTime,
|
||||
NO_RETAINER_NOTICE,
|
||||
};
|
||||
|
||||
/* Guards, not decoration. Each one is a failure this project has already had or
|
||||
has written down as the next one. */
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new Error(
|
||||
`${k} resolved empty — the handler throws at cold start on that`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!/^https:\/\//.test(origin)) {
|
||||
throw new Error(`SITE_ORIGIN must be an https origin, got ${origin}`);
|
||||
}
|
||||
/* The clause a hand-copy dropped. `docs/01` §/contact/ requires it, so its
|
||||
absence is a published-disclosure defect rather than a typo. */
|
||||
if (!NO_RETAINER_NOTICE.includes('create a conflict check')) {
|
||||
throw new Error(
|
||||
'NO_RETAINER_NOTICE is missing its fourth clause about not itself creating ' +
|
||||
'a conflict check — docs/01 §/contact/ requires it. Do not deploy this.',
|
||||
);
|
||||
}
|
||||
if (!/–/.test(NO_RETAINER_NOTICE)) {
|
||||
throw new Error(
|
||||
'NO_RETAINER_NOTICE no longer contains the en dash in "mediator–party". ' +
|
||||
'Either the constant changed deliberately, or something re-typed it.',
|
||||
);
|
||||
}
|
||||
if (!/\btwo business days\b/.test(CONTACT.responseTime)) {
|
||||
throw new Error(
|
||||
`RESPONSE_TIME is "${CONTACT.responseTime}" — AGENTS.md §4/Q27 is a ` +
|
||||
'two-business-day commitment. If the commitment changed, /contact/, the ' +
|
||||
'bio and this all move together.',
|
||||
);
|
||||
}
|
||||
|
||||
if (args.includes('--shell')) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
console.log(`export ${k}=${JSON.stringify(v)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify({ Variables: vars }));
|
||||
}
|
||||
@@ -89,6 +89,16 @@ async function collectPages(dir = DIST) {
|
||||
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||
out.push(rel ? `/${rel}/` : '/');
|
||||
}
|
||||
/* ⚠️ `index.html` ALONE MISSED THE 404 PAGE, so the budget was measured on
|
||||
22 pages of 23 while the header above claims it enumerates the site.
|
||||
`404.astro` is emitted as `dist/404.html`, outside `build.format:
|
||||
'directory'`. The path pushed here is a URL this script SERVES, so it is
|
||||
`/404.html` — the form CloudFront's custom error response fetches — and
|
||||
`resolveFile()` resolves it on the `extname` branch. `og-proof.mjs` needs
|
||||
the `OG_CARDS` key `/404/` for the same file; the two differ on purpose. */
|
||||
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||
out.push(`/${entry.name}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ async function pages(dir = DIST) {
|
||||
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||
out.push({ path: rel ? `/${rel}/` : '/', file: full });
|
||||
}
|
||||
/* ⚠️ `index.html` ALONE MISSED A WHOLE PAGE. `build.format: 'directory'`
|
||||
puts every route at `<dir>/index.html` — except the ones Astro emits
|
||||
outside the convention, and `404.astro` becomes `dist/404.html`. So this
|
||||
script enumerated 22 pages of 23, and the symptom was backwards: it
|
||||
reported the 404 page's card as ORPHANED ("generated, but no built page
|
||||
references it") rather than reporting the page as unchecked. `path` here
|
||||
is an `OG_CARDS` key, which is `Astro.url.pathname` — `/404/`, not
|
||||
`/404.html`. `scripts/lighthouse.mjs` had the same blind spot and needs
|
||||
the URL form instead; see the note there. */
|
||||
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||
out.push({ path: `/${entry.name.replace(/\.html$/, '')}/`, file: full });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user