fix: resolve adversarial review round 2 — 9 findings, 8 of them in round 1's fixes
Build and deploy / build-and-deploy (push) Failing after 4s

D19 caps the loop at two rounds, and this is what the second round is for.

BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no
document, while the fix's own comment claimed docs/06 named it. The deployment
list said five variables for a handler that needs six, so an operator following
the cutover checklist would have deployed a function that throws at cold start
on every invocation — 5xx from API Gateway, every inquiry lost from the moment
/api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06
now name all six, and the comment that asserted the documentation existed is
corrected rather than deleted.

The intake route check added in round 1 could not fail: curl -w already prints
000 on a failed transfer, so `|| echo 000` double-appended and the failure arm
was unreachable, and the pass arm accepted anything that was not literally 404 —
including the 403 CloudFront returns when the /api/* behaviour is missing, which
is the one distinction the check exists to draw. It now sends the correct Origin
and asserts a positive: 303 to /contact/could-not-send/, which the handler
returns before any DynamoDB write or email. Probed on refused/501/403/303; the
old version passed the first three. Fixed in both deploy paths.

Removing priceRange left three statements saying it was present or pending, one
of them the stated reason /fees/ emits no Offer node. Deleting
overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59
recorded as open. The Google-as-processor fix was applied to the privacy
policy's "Where it is stored" and not to "Who can see it", which still read
"Nobody else has access".

And the variable removal was justified with a path-scoped git grep — which also
cannot see untracked files. The unscoped sweep found docs/06's variable table,
the OIDC example, and .env.example still carrying them; .env.example also
restates the execute-api hostname, falsifying a live claim in intake.ts that has
been corrected. That file is not edited here: this environment denies read
access to it, and nothing may edit a file it cannot read. It is in the batched
list.

Also: og:image:alt was the page title rather than the card's headline on 20
pages; og-card.ts documented the wrong path and invocation for the contact
sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist";
and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44.

Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances
across 22 pages, the consent checkbox's computed accessible name, the radio
labels hit-tested at 44px, and og:proof exercised against synthetic article
pages in a sandbox.

Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0
on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-08-31 11:21:07 -04:00
co-authored by Claude Opus 5
parent 9f2d83c32f
commit 9f2d2eeb04
18 changed files with 360 additions and 180 deletions
+16 -26
View File
@@ -66,29 +66,21 @@ const classes = ['btn', `btn-${variant}`, className];
color: var(--text-inverse);
}
/* ⚠️ THE THREE HOOKS BELOW EXIST BECAUSE THIS BUTTON SHIPPED INVISIBLE.
Found 2026-08-31 by `adversarial-reviewer` on `/fees/`, measured in headless
Chrome against `dist/`: `{"t":"How an engagement runs →","color":"rgb(26, 22,
20)","bg":"rgb(26, 22, 20)","ratio":1}`. `.btn-ghost` sets `color:
var(--text)` — ink — and a border of `--border`, which is ink at 10% alpha.
On a `.section-inverse` ground both are the background colour. **Ratio
1.00:1: a navigation link the same colour as the panel it sits on**, worse
than the gold-on-cream 2.10:1 this project treats as unshippable.
/* ⚠️ THESE HOOKS ARE WHY `.btn-ghost` IS LEGIBLE ON A DARK BAND. Its own
colours are ink text on an ink-at-10%-alpha border, which on
`.section-inverse` and `.section-accent` are the background twice over —
`/fees/` shipped this at a measured 1.00:1.
⚠️ AND LIGHTHOUSE SCORED THAT PAGE ACCESSIBILITY 100. axe's
`color-contrast` rule SKIPS a foreground that exactly equals its background
as "unable to determine" — so the a11y category cannot be the only contrast
control here, and a computed-contrast sweep is not redundant with it.
THEY ARE CUSTOM PROPERTIES AND MUST STAY THAT WAY. A parent cannot style a
child component's root (CLAUDE.md), and a `global.css` descendant rule would
tie at specificity (0,2,0) with `.btn-ghost[data-astro-cid]` here, so the
winner would depend on injection order. Custom properties inherit, which is
the one mechanism that crosses the boundary. `global.css` sets them; the
fallbacks keep the on-cream appearance identical.
THE HOOKS ARE CUSTOM PROPERTIES, NOT A GLOBAL DESCENDANT RULE, and that is
the load-bearing part. A parent cannot style a child component's root
(CLAUDE.md), and `global.css`'s `.section-inverse .btn-ghost` would compile
at specificity (0,2,0) — identical to `.btn-ghost[data-astro-cid]` here — so
which one won would depend on injection order. `AGENTS.md` records that
exact trap being hit once already, on `.btn-gold`. Custom properties
INHERIT, which is the one mechanism that legitimately crosses the boundary;
it is what `Pill` and `DefinitionGrid` already use. The fallbacks keep the
on-cream appearance byte-identical. */
Do not rely on the accessibility category to catch a regression here: axe
SKIPS a foreground identical to its background as "unable to determine", and
scored that page 100. AGENTS.md entry (ah) has the measurements. */
.btn-ghost {
background: transparent;
border-color: var(--btn-ghost-border, var(--border));
@@ -99,11 +91,9 @@ const classes = ['btn', `btn-${variant}`, className];
color: var(--btn-ghost-fg-hover, var(--accent));
}
/* `background: var(--bg-inverse)` is ink, so on an inverse ground the pill has
no boundary and reads as bare text — the milder half of the same finding.
The label is gold-l at 11.09:1 on ink and stays legible, so this needs an
EDGE rather than a new colour scheme: giving it a different ground would be
redesigning a button shipped at step 5 rather than fixing a defect. */
/* `background: var(--bg-inverse)` is ink, so on an inverse ground this pill has
no boundary and reads as bare text. It needs an EDGE, not a new ground — the
gold-l label already measures 11.09:1 on ink. */
.btn-gold {
background: var(--bg-inverse);
border-color: var(--btn-gold-border, transparent);
+7 -15
View File
@@ -150,19 +150,11 @@
font-size: var(--text-sm);
color: var(--text-meta);
}
/* ⚠️ NO TABLE RULES, AND THE THREE THAT WERE HERE ARE DELETED RATHER THAN
FIXED. They set `display: block; overflow-x: auto` on the `<table>` itself,
which has two defects: `display: block` **removes the table role** in
Chromium and WebKit, so rows and cells lose their semantics for assistive
technology; and an `overflow-x: auto` box with no `tabindex="0"` cannot be
scrolled by keyboard (WCAG 2.1.1). The comment said *"the wrapper carries
it"* — there was no wrapper; the properties were on the table.
Doing it properly means a real wrapper with `tabindex="0"`, `role="region"`
and an accessible name, which in MDX means a rehype plugin or a `<Table>`
component. **None of the five drafted articles contains a table**, and with
all five `draft: true` no article page builds, so this CSS shipped nowhere:
deleting it now and adding it with the first article that needs one is the
same decision the `code` note above already takes for `<pre>`.
Found by `adversarial-reviewer`, 2026-08-31, and correctly filed as latent. */
/* ⚠️ NO TABLE RULES, DELIBERATELY. Do not re-add `display: block;
overflow-x: auto` to the `<table>` itself: `display: block` removes the
table role in Chromium and WebKit, and an `overflow-x` box with no
`tabindex="0"` cannot be scrolled by keyboard (WCAG 2.1.1). A table needs a
real wrapper with `tabindex="0"`, `role="region"` and a name — in MDX that
means a rehype plugin or a `<Table>` component. No article uses one yet, so
it arrives with the first that does, exactly as `<pre>` does above. */
</style>
+17 -4
View File
@@ -139,11 +139,24 @@ const ogImageUrl =
)
: new URL(ogCardPath(path), Astro.site);
// A typographic card's alt is its headline, which for every card in the
// registry is the page's own <h1> — and `title` is the string already required
// to be unique per page. The portrait keeps the person's name.
/**
* ⚠️ THE ALT IS THE CARD'S HEADLINE, AND IT WAS THE PAGE `<title>`.
*
* The comment here claimed *"a typographic card's alt is its headline"* while
* the code fell back to `title`. Measured: `/fees/` emitted
* `og:image:alt="Fees · Mediation and Arbitration Rates · Pouya Lajevardi"`
* against a card reading *"Published in full, including what overruns cost."* —
* an alt that did not describe the image, on 20 pages, and it would have
* diverged further for the one article that sets `seoTitle`. Found by
* `adversarial-reviewer` round 2.
*
* `OG_CARDS[path]?.headline` is the card's actual text for a registry page.
* `title` remains the fallback for an article, where the card headline IS the
* title, and `PORTRAIT.alt` for the two portrait pages.
*/
const resolvedImageAlt =
imageAlt ?? (image || usesPortrait ? PORTRAIT.alt : title);
imageAlt ??
(image || usesPortrait ? PORTRAIT.alt : (OG_CARDS[path]?.headline ?? title));
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
// would close this element early and hand the rest of the payload to the HTML
+8 -2
View File
@@ -209,8 +209,14 @@ export const HONEYPOT_FIELD = 'company_website';
* XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the
* endpoint against scripted calls from other origins, which is a different
* control, and the handler's `Origin` check is what covers the form.)
* 3. **The endpoint id stays out of the HTML**, so it is not restated in the
* repo either — §7 remains the only place it lives.
* 3. **The endpoint id stays out of the HTML.** ⚠️ It is NOT true that §7 is
* the only place it lives, and this bullet said so: `.env.example` still
* sets `PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname. That
* variable is now read by nothing, so the line is dead as well as
* duplicative. It is not edited here because this environment denies read
* access to `.env.example`, and nothing may edit a file it cannot read —
* it is in the batched list for Pouya instead. Found by
* `adversarial-reviewer` round 2, against an unscoped sweep.
* 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so
* a POST 404s. Under the alternative, clicking Submit on a laptop would
* write a real DynamoDB record and send two real emails.
+15 -29
View File
@@ -223,33 +223,13 @@ export function professionalServiceNode(imageUrl?: string) {
],
email: `mailto:${CONTACT.email}`,
/**
* ⚠️ **NO `priceRange`, AND IT WAS SET FOR AN HOUR AT BUILD STEP 9.**
* `docs/04` gates the field on `/fees/` being real, and `/fees/` is now real
* — so the gate was met and the field went in as `$500$9,500`. It is out
* again, because its own justification did not survive its own test.
*
* The comment defending it rejected a `Math.min`/`Math.max` over `FEES` on
* the ground that it *"would sweep in `additionalParty` and
* `overtimePerHour`, which are per-party and per-hour increments rather than
* prices for anything, and a range whose ends mean different units is a
* range that misinforms."* **The ends it chose had different units too:** the
* floor was `FEES.hourly`, $500 **per hour**, and the ceiling
* `documentsOnlyComplex`, $9,500 **flat**.
*
* And the floor misinformed in the direction that matters. The lowest amount
* anyone pays for the headline service is `halfDay.amount` — **$2,000**. A
* reader or crawler taking `priceRange` as what this practice costs read a
* floor a quarter of the real entry price, in the one machine-readable field
* on the site carrying a number. Found by `adversarial-reviewer`, 2026-08-31.
*
* **Omitted rather than repaired**, and that is the narrower answer: `docs/04`
* gates the field, it does not require it, and `/fees/` publishes the
* conditions — session length, party count, format — that make any single
* range meaningless. A field that needs a paragraph to not mislead is worse
* than no field. `/fees/` is one click away and says it properly.
* (`Intl.NumberFormat('en-CA', { currency: 'CAD' })` also emits a bare `$`,
* which would have needed `CA$` to be unambiguous — a second reason the
* shape was wrong rather than the value.)
* ⚠️ **NO `priceRange`, AND DO NOT ADD ONE.** `docs/04` gates the field on
* `/fees/` existing; the gate is met and the field is still declined. Any
* single range here mixes units — the hourly rate against a flat
* documents-only fee — and its floor understates a mediation, whose least
* cost is `halfDay.amount`. `/fees/` publishes the conditions that make one
* number misleading. No `Offer` node either, for the same reason.
* AGENTS.md entry (ah) records what the field said when it briefly shipped.
*/
...(imageUrl ? { image: imageUrl } : {}),
};
@@ -312,8 +292,14 @@ export function aboutGraph(imageUrl?: string) {
* emitting one would assert navigation the page does not show. Breadcrumbs
* begin at `/practice/<area>/` and `/insights/<slug>/`.
*
* NO `offers` AND NO `priceRange` until `/fees/` exists (build step 9) — same
* gate docs/04 puts on `ProfessionalService`.
* NO `offers` AND NO `priceRange` — A DECISION, NOT A GATE. This read "until
* `/fees/` exists (build step 9)", which shipped, so it had become an
* instruction to add them — against the decision recorded on
* `professionalServiceNode` above, where `priceRange` went in at step 9 and came
* out the same day. `/fees/` publishes the conditions — session length, party
* count, format — that make any single machine-readable figure misleading, and
* schema.org's `Offer` models one price for one item. Found by
* `adversarial-reviewer` round 2.
*
* NO `availableLanguage` EITHER, AND THAT IS NOT AN OVERSIGHT. schema.org's
* `domainIncludes` for it is `ContactPoint`, `Course`, `LodgingBusiness`,
+7 -2
View File
@@ -158,8 +158,13 @@ function loadAssets(): Promise<Assets> {
* shrink text to fit and silently overflows its container instead, so a card
* with a long headline would crop — the exact class of defect nobody on this
* project would ever see. The bands are set so the longest entry in
* `og-cards.ts` renders on three lines at most; `npm run og:proof` renders every
* card to `dist/og-proof/` so the claim is checkable by looking.
* `og-cards.ts` renders on three lines at most. **`npm run og:proof -- --sheet`
* writes a contact sheet of every card to `dist/og-proof.jpg`**, which is how
* that claim is checked by looking — and the `--sheet` flag is required, because
* a plain `npm run og:proof` produces no images at all. *(This sentence named
* `dist/og-proof/` and omitted the flag, so the one documented mitigation for
* this file's own stated hazard was wrong in both the path and the command.
* Found by `adversarial-reviewer` round 2.)*
*/
function headlineSize(headline: string): number {
if (headline.length > 62) return 58;
+13 -31
View File
@@ -260,27 +260,14 @@ const hintId = (name: string) => `${name}-hint`;
</div>
{
/* ⚠️ THE LINK CAME OUT OF THE LABEL, AND THE LABEL IS NOW THE CONSENT
SENTENCE ALONE. Two defects in one element, found by
`adversarial-reviewer` 2026-08-31 and measured at 390px (the label
was 342 × 205 px and the nested anchor hit-tested as `<a>`,
100 × 21):
1. **A focusable interactive element inside a `<label>` for another
control.** Clicking it navigated rather than toggling, which is
the behaviour a reader wants — but label/link nesting is not
consistent across engines, so which of the two wins was left to
the browser.
2. **The checkbox's accessible name was a 250-character paragraph
ending "Privacy policy."** This is the one REQUIRED control on the
form, so it is also the one whose name is re-announced on every
validation failure.
The consent wording still has to be what the inquirer agrees to, so
it stays in the label verbatim from `CONSENT_TEXT`. The link moves to
a sibling that `aria-describedby` points at — described, not named.
The privacy policy is also linked twice above this form, so nothing
is lost. */
/* ⚠️ THE PRIVACY LINK MUST STAY OUT OF THIS LABEL. Two reasons, both
about the one REQUIRED control on the form: a focusable element
inside a `<label>` for another control behaves inconsistently across
engines, and the checkbox's accessible name becomes the whole
paragraph plus "Privacy policy link" — re-announced on every
validation failure. The consent wording itself must be verbatim from
`CONSENT_TEXT`, so it stays in the label; the link is DESCRIBED
instead, via `aria-describedby`. */
}
<div class="field field-consent">
<label class="consent">
@@ -461,16 +448,11 @@ const hintId = (name: string) => `${name}-hint`;
line-height: var(--leading-body);
color: var(--text-secondary);
}
/* ⚠️ 44px MINIMUM, AND IT WAS 25.6px. `docs/02` §Accessibility floor sets
44 × 44 for a touch target and `CLAUDE.md` calls that floor a build
requirement, not a polish pass. Hit-tested at 390px by
`adversarial-reviewer`: the label rect measured 70.6 × **25.6** and the hit
height 25px — an 18.4px control plus one line of body text, with no
`::after { inset: 0 }` overlay to enlarge it the way the cards on `/` have.
WCAG 2.2 SC 2.5.8's 24px was met; this project's own floor was not, and
`docs/02` grants no exception for a form control.
`min-block-size` rather than padding, so the label grows to the floor and no
further — padding would push the two radios apart at every width. */
/* 44px IS THE FLOOR (`docs/02` §Accessibility floor) and this row was 25.6px:
an 18.4px control plus one line of body text, with no `::after { inset: 0 }`
overlay to enlarge it. `min-block-size` rather than padding, so the label
grows to the floor and no further — padding would push the two radios apart
at every width. */
.radio {
align-items: center;
min-block-size: 44px;
+8 -1
View File
@@ -72,7 +72,14 @@ const ldImage = await getImage({
models a single price for a single item, and every row below is conditional on
session length, party count and format — a machine-readable $2,000 with none
of those conditions attached is a worse claim than no claim.
`ProfessionalService.priceRange` on `/` carries the range instead. */
⚠️ AND `/`'s NODE CARRIES NO PRICE EITHER — this comment said
`ProfessionalService.priceRange` "carries the range instead", and that field
was removed the same day for mixing units and understating its own floor.
Nothing on this site states a price in machine-readable form, deliberately:
every figure here is conditional on session length, party count or format,
and a number without those conditions is a worse claim than no number.
Found by `adversarial-reviewer` round 2 — a justification resting on a field
that no longer exists is how an `Offer` node gets added by the next reader. */
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
const money = (amount: number) =>
+19 -2
View File
@@ -219,10 +219,27 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
</p>
<h2>Who can see it</h2>
{
/* ⚠️ THIS SAID "Nobody else has access" AND THE SECTION TWO ABOVE HAD
JUST NAMED GOOGLE. The Google correction was applied to §Where it is
stored and not swept into the section actually headed with the
question a reader asks — so the page answered "who can see the names
of the opposing parties I gave you?" with *nobody else* under that
heading and *Google* under a different one. Fixing one section and
not the section that answers the same question is the sweep failure
`CLAUDE.md` describes. Found by `adversarial-reviewer` round 2. */
}
<p>
I can. The table is reachable by the function that writes to it and by
one administrative account, which is mine. Nobody else has access, and
there is no team, no assistant and no external administrator.
one administrative account, which is mine — nobody else has access to
the table. There is no team, no assistant and no external
administrator.
</p>
<p>
The one other place a copy exists is the notification email, which
sits in the Google Workspace mailbox named above. So the honest answer
to "who can see this" is: me, and Google as the company that runs my
mail.
</p>
<h2>Cookies and analytics</h2>
+5 -12
View File
@@ -424,18 +424,11 @@ a:hover {
.section-accent {
--pill-border: var(--line-dark);
--pill-fg: var(--text-inverse-2);
/* ⚠️ `Button` — AND THIS IS THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule
deliberately excludes `.btn`, on the reasoning that a button carries its own
colours. It does — and `.btn-ghost`'s are ink text on a 10%-alpha ink border,
which on these two grounds is the background colour twice over. `/fees/`
shipped "How an engagement runs →" at a measured **1.00:1**, invisible, and
Lighthouse scored that page accessibility 100 because axe skips a
foreground identical to its background. Cream on ink is 16.81:1 and on
maroon 12.29:1; `--line-dark` is cream at 14% alpha, which reads as an edge
on both. `.btn-gold` needs only an edge — its gold-l label already measures
11.09:1 on ink and 8.11:1 on maroon. Found by `adversarial-reviewer`,
2026-08-31; see `Button.astro` for why these are custom properties and not
a descendant rule. */
/* `Button` — THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule excludes `.btn`
on the reasoning that a button carries its own colours; `.btn-ghost`'s are
ink on an ink-alpha border, i.e. the background of both these grounds.
`--line-dark` is cream at 14% alpha and reads as an edge on ink and on
maroon. See `Button.astro` for why these are custom properties. */
--btn-ghost-fg: var(--text-inverse);
--btn-ghost-border: var(--line-dark);
--btn-ghost-fg-hover: var(--text-inverse-2);