diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 78a0dd9..d9f4e85 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -165,35 +165,50 @@ jobs:
# header requires the two paths to match on everything that determines
# what gets published - and this replaced the INTAKE_ENDPOINT guard.
#
- # The contact form posts to the same-origin path /api/intake, which only
- # works if a CloudFront behaviour routes /api/* to the HTTP API origin
- # AGENTS.md §7 records. Nothing in the build can know whether it exists.
+ # It ASSERTS A POSITIVE. The first version excluded one status code and
+ # passed on everything else; `adversarial-reviewer` round 2 measured it
+ # passing on a refused connection (curl -w already prints 000, so the
+ # `|| echo 000` double-appended and made $code "000000") and on a real 501.
+ # It would also have passed the case that matters most: with the /api/*
+ # behaviour MISSING, the POST falls to the S3 default behaviour and
+ # CloudFront answers 403 for a disallowed method - indistinguishable from
+ # the handler's Origin refusal, which is the one distinction this check
+ # exists to draw.
#
- # 404 means not routed. 403 means routed and REFUSED, which is the correct
- # answer here: the handler checks the Origin header and this request sends
- # none, so it is rejected before any DynamoDB write or any email. That is
- # why the probe is safe to run against production.
+ # With the correct Origin and an empty submission the handler validates,
+ # rejects, and redirects 303 to /contact/could-not-send/ - BEFORE any
+ # DynamoDB write and before any email, which is what makes it safe against
+ # production. Probed on four cases: refused, 501, 403, and the real 303.
#
# It warns rather than failing: the site is already deployed by this point,
# and failing the job would not un-deploy it.
- name: Intake route check
run: |
+ url="https://adr.smlcompany.ca/api/intake"
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
--max-time 15 \
+ -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" || echo 000)
- case "$code" in
- 404|000)
- echo "WARNING: POST /api/intake returned $code."
- echo "The contact form posts there. 404 means the CloudFront /api/*"
- echo "behaviour is missing; 000 means the request did not complete."
- echo "See docs/06-deployment.md's cutover checklist."
- ;;
- *)
- echo "POST /api/intake -> $code (routed; 403 is the Origin check)"
- ;;
- esac
+ --data 'deploy-route-probe=1' "$url")
+ rc=$?
+ location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
+ --max-time 15 \
+ -H "Origin: https://adr.smlcompany.ca" \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ --data 'deploy-route-probe=1' "$url" 2>/dev/null || true)
+ if [ "$rc" -ne 0 ]; then
+ echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
+ echo "The site is deployed and the contact form is unverified."
+ echo "See docs/06-deployment.md's cutover checklist."
+ elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
+ echo "POST /api/intake -> 303 -> $location (routed, validating)"
+ else
+ echo "WARNING: POST /api/intake returned $code, expected 303 to"
+ echo "/contact/could-not-send/; redirect was '${location:-none}'."
+ echo "404 means the CloudFront /api/* behaviour is missing. 403 can"
+ echo "mean the same thing, or the handler refusing the Origin."
+ echo "See docs/06-deployment.md's cutover checklist."
+ fi
- name: Summary
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
diff --git a/AGENTS.md b/AGENTS.md
index 922dc6a..3f47dde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -772,7 +772,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
| # | Question | Blocks |
|---|---|---|
-| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling, `FEES.mediation.overtimeStartsAfterSessionHours` records it, and `docs/07` carries it in full. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
+| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling and `docs/07` carries it in full. ⚠️ **THIS ROW NAMED A CONSTANT THAT NO LONGER EXISTS** — `FEES.mediation.overtimeStartsAfterSessionHours` was deleted the same day as dead data: nothing read it, so reversing it would have changed nothing and failed nothing, which is Q22's shape at constant scope. **Where the ruling actually lives:** the trigger is rendered on `/fees/` from `halfDay.hours` / `fullDay.hours`, and `FEES.mediation.reservation` carries the half that publishes as prose. Found by `adversarial-reviewer` round 2 — §9 is what a later implementer reads to find where a ruling is recorded, so pointing it at a deleted identifier is the same defect one layer up. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
| ~~Q58~~ | **RULED 2026-08-31 — `hours` IS THE SESSION, AND THE AMBIGUITY WAS IN `docs/07` RATHER THAN IN ANY COPY. Pouya owned it in terms:** *"the ambiguity is mine… My `docs/07` wording said "up to 3.5 h, including 2 h preparation", which is genuinely unclear: 3.5 was meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours in the room. Your arithmetic caught it: if prep sat inside, 3.5 and 7 wouldn't be exactly 2×, because preparation doesn't scale with session length. The intended reading is the market's, and my wording obscured it."* **THE CORRECTED CARD, in his words:** *"Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation. $2,000. Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation. $4,000."* His reason for 3 and 6: *"the market convention — Patey and Zuber both publish "all or part of 3 hours" and "all or part of 6 hours", and those were the comparables the rate was set against. Selling 1.5 hours of room time as a half day would be an outlier nobody would recognise."* ⚠️ **ONE PROVENANCE NOTE, and it is R14's rule rather than a doubt about the ruling:** `docs/07`'s committed extract records Patey and Zuber at **3 h** and **6 h** but **does not carry the phrase "all or part of"** — so `docs/07` cites the hours, not the phrase, and the phrase is not attributed to them anywhere in the repository. The hours corroborate the ruling on their own, and ADR Chambers' roster rate in the same table is the clearest corroboration of the *shape*: *"one half hour of preparation time per party **and** up to three hours of mediation"* — preparation counted separately from a three-hour session. **APPLIED:** `docs/07`'s two card rows and its §All parameters confirmed (which prescribed the flat *"including 2 hours"*, the form `/for-parties/` then shipped); `FEES.mediation.*.hours` 3.5 → 3 and 7 → 6 with the semantics in the constant's doc comment; `/for-parties/` now states the session length interpolated from the constant and the preparation allowance **as a cap**. **The preparation allowance is CAPPED and must be published as capped** — *"including **up to** 2 hours"*, never the flat form and never "preparation included". **`/fees/` is UNBLOCKED for build step 9.** **The question as raised is preserved below.** **DOES `hours` IN THE MEDIATION RATE CARD MEAN THE LENGTH OF THE DAY, OR THE BILLED ENVELOPE INCLUDING PREPARATION?** `docs/07-fees.md` reads *"Half day — **up to 3.5 h, including 2 h preparation**"* and *"Full day — up to 7 h, including 3 h preparation"*. Taken at face value, 3.5 is the whole billed envelope and the **time in the room is 1.5 h** for a half day and **4 h** for a full day. **Against that reading:** 3.5 and 7 are exactly 2×, which they would not be if preparation sat inside them (1.5 vs 4 is not 2×). So either the card's wording is wrong in the one document that is the authority on money, or `FEES.mediation.*.hours` in `src/data/site.ts` does not mean what a page would naturally publish it as. **This was one sentence from shipping.** A draft of `/for-parties/` answered *"What happens on the day?"* with *"A half day is about 3.5 hours"* — the envelope presented as the day, to the reader least able to check it. The sentence was removed rather than resolved by inference; the page now says only that you book a half day or a full day. **What is needed:** one line from Pouya saying which the 3.5 and 7 are. Then `docs/07`'s two rows or `site.ts`'s field gains the correction, and the semantics go in the constant's doc comment (a warning is there now). **`/fees/` at build step 9 publishes this table and cannot be built without the answer.** Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing.** No page stated a duration while the question was open — the one draft sentence that did was removed rather than reconciled, which is why the ruling had nothing to correct in public copy |
| ~~Q57~~ | **CLOSED 2026-08-31 — NO SEVENTH UNDERTAKING, AND THE PAGE IS COMPLETE AS IT STANDS.** Pouya: *"`/process/` stating when conflicts are run and what the check needs is complete. A reader assumes the outcome, and the obvious undertaking ("if a conflict is found I decline") adds nothing a reader doesn't already infer. Your restraint was right — §4's gate held. Record it closed rather than open, so it stops appearing in the live list."* **So this is a closure, not a deferral:** the answer is that the page says nothing further, which was one of the two outcomes the question named. §4 gains no seventh conduct undertaking and `CONDUCT_UNDERTAKINGS` still holds six. **APPLIED:** the `TODO(pouya)` is removed from `src/pages/process.astro` §Conflicts and replaced with the ruling, so a later reader finds the decision where the question was rather than an open marker; the file header's *"see the TODO below"* is corrected to cite this closure. `src/` now carries **zero** live `TODO(pouya)` markers. **The question as raised is preserved below.** **WHAT HAPPENS WHEN A CONFLICTS CHECK TURNS SOMETHING UP?** `/process/` §Conflicts ships saying **when** the check runs (the intake call, before anything is agreed) and **what it needs** (full legal names of the parties, the parent or affiliate actually behind the dispute, counsel on each side). It stops there, and the stop is deliberate: **any sentence naming the outcome is a SEVENTH conduct undertaking**, and §4's gate for that class is one line — *"an undertaking may be published only where Pouya has made it in terms. Not 'would obviously agree to', not 'follows from the process' — said."* *"If a conflict appears I decline the appointment"* is exactly what that gate refuses to let this repository infer, however obvious it looks. **What is needed:** one sentence from Pouya, in his words, or a decision that the page says nothing further. `TODO(pouya)` sits at `src/pages/process.astro` §Conflicts. Raised at build step 6, 2026-08-30 | **Nothing.** The section shipped accurate and unchanged; what closed is whether anything more was owed |
| ~~Q56~~ | **RULED 2026-08-30 — MEDIATION IS *NOT* SCOPED COMMERCIAL.** Pouya: *"Correct the four 'Commercial Mediation' surfaces to 'Mediation'; leave §4's mediation row unscoped."* **And the asymmetry with arbitration is DESIGNED, not an oversight — the reason is now recorded beside both §4 rows so nobody tidies them into a matching pair.** **Arbitration is scoped commercial because of a LEGAL GATE:** Q39 — family arbitration in Ontario requires prescribed training, and Pouya has excluded it by choice. **Mediation has no such gate**; he mediates commercial, construction, insurance/SABS, shareholder and cross-cultural matters, and the practice pages say so. So the site-wide "commercial" framing was **under-describing a wider offering**, which is why it is corrected rather than ratified as a positioning choice. `/practice/insurance/`'s SABS framing needs no reconciliation: it was never outside the offering. **The question as raised is preserved below.** ⚠️ **IS THE MEDIATION OFFERING SCOPED COMMERCIAL, OR NOT? THE SITE SAID BOTH.** §4 Offerings rows arbitration three times, each **scoped commercial**; the mediation row is `**Mediation** — sole mediator`, **unscoped**. Shipped output scopes it anyway: `/mediation/`'s `
` is *"Commercial Mediation"*, its `Service` node is `name: "Commercial mediation"`, and `/` and `/about/` both say *"I mediate commercial disputes"*. Meanwhile **`/practice/insurance/` offers mediation in matters that are not commercial on any ordinary reading** — *"Disputes between an insured person and an insurer under the Statutory Accident Benefits Schedule"*, with *"What I offer is private mediation"*. An individual claimant against their own insurer is not a commercial dispute. **Nothing here is a false claim** — scoping a page to commercial mediation asserts *less* than the unscoped §4 row grants, and narrower than the row is always safe. **The problem is that the two halves cannot both be the whole picture**: either the practice takes non-commercial mediations (and the site-wide "commercial" framing under-describes it, including on the page an appointing body reads), or it does not (and `/practice/insurance/` is offering something outside the offering). **What is needed:** either a §4 Offerings row scoping mediation, with `/practice/insurance/`'s SABS framing reconciled to it — or a decision that mediation is deliberately unscoped, in which case the site-wide "commercial" wording is a positioning choice and should be recorded as one rather than read as a limit. **Pre-existing, not created 2026-08-30** — but this change set newly wrote the claim onto `/med-arb/` and it has been removed again pending this answer. Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing — ruled the same day it was raised.** No page was wrong; the register was silent where the site was specific, and the ruling makes the site match the register rather than the other way round |
@@ -782,7 +782,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
| ~~Q3~~ | **ANSWERED 2026-08-26.** Email `info@smlcompany.ca`. No public phone — "By scheduled call". Location: Toronto · Ontario · By appointment | — |
| ~~Q4 / Q14~~ | **ANSWERED 2026-08-26.** Rate card confirmed by Pouya — see D14 and `docs/07-fees.md` | — |
| ~~Q13~~ | **ANSWERED 2026-08-26.** Self-hosted Gitea with Gitea Actions | — |
-| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only; where it begins is §9 Q59, open.** ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
+| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only. Where it begins was §9 Q59, now RULED AND CLOSED 2026-08-31: the session cap**, i.e. the fourth hour of a half day and the seventh of a full day. *(This clause read "where it begins is §9 Q59, open" for the rest of that day, eleven lines below the row recording the closure — found by `adversarial-reviewer` round 2.)* ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
| ~~Q25~~ | **ANSWERED 2026-08-26. Struck.** The §4 row permitting the boutique to be named is removed; D16 governs alone | — |
| ~~Q26~~ | **ANSWERED 2026-08-26.** §4 now carries an explicit **Licence status — NOT ESTABLISHED** row, and its opening no longer asserts that LSO rules apply. The register's rationale stands on the fabricated-credentials history alone | — |
| ~~Q24~~ | **ANSWERED 2026-08-26.** `AWS-Hosting-Guide.md` copied into `docs/reference/` — it is the only record of how the hand-built infrastructure was created. Scanned for credentials before copying: no access keys, no secrets, no account ID | — |
@@ -1083,7 +1083,9 @@ fix marker present, `git stash` empty, and the reviewer's own probe — a
`DELIBERATELY WRONG CARD TEXT` headline it had injected into the OG endpoint —
restored to `entry.data.title`.
-### Review — `adversarial-reviewer` alone, per D20. Round 1: 16 findings, all acted on
+### Review — `adversarial-reviewer` alone, per D20. Two rounds, 25 findings, all acted on
+
+**Round 1: 16 findings.**
**Two blocking, and both were on pages built in this run.**
@@ -1229,6 +1231,100 @@ character that reached a pattern by accident; here the control range **is** what
is being matched, and it is the part of the function that stops a submitted value
forging an email header.
+### Round 2: 9 findings, and EIGHT OF NINE were defects in round 1's own fixes
+
+D19 caps the loop at two rounds and this is why the second one exists. The
+previous measurement behind that cap was *"most of round two's were defects in
+round one's fixes"*; this run reproduced it almost exactly, and the hit rate was
+predictable from inside round 1 — the `og:proof` repair had already been caught
+being a tautology and re-fixed before round 2 started.
+
+**BLOCKING — the fix for finding 9 would have lost every inquiry.** Round 1
+converted the hand-typed no-retainer notice into `requireEnv('NO_RETAINER_NOTICE')`
+— a module-scope throw — **and added the variable to no document**, while the
+fix's own comment claimed *"`docs/06` names it."* It did not:
+`grep -c NO_RETAINER_NOTICE docs/06-deployment.md` returned **0**, and the
+deployment list named five variables for a handler that required six. An operator
+working the cutover checklist sets five, the Lambda throws at cold start on
+**every** invocation, API Gateway answers 5xx, and every inquiry from counsel is
+lost from the moment `/api/*` is wired — **loud in CloudWatch, silent to Pouya**,
+who has no reason to submit his own form. `docs/06` and `docs/05` now name all
+six; the comment that asserted the documentation existed is what made it
+invisible, and it is corrected rather than deleted.
+
+**The intake route check I added to replace the stale guard could not fail.**
+`code=$(curl … -w '%{http_code}' … || echo 000)` — `curl -w` already prints `000`
+on a failed transfer, so the `||` double-appended and `$code` became `000000`,
+making the failure arm unreachable. And the pass arm accepted anything that was
+not literally `404`. The case that matters most was the one it waved through:
+**with the `/api/*` behaviour missing, CloudFront answers 403 for a method the
+default behaviour does not allow — indistinguishable from the handler's Origin
+refusal**, which is the single distinction the check exists to draw. It now sends
+the correct `Origin` and asserts a positive: `303` with a `Location` ending
+`/contact/could-not-send/`, which the handler returns **before any DynamoDB write
+and before any email**, and which is what makes the probe safe against
+production. Probed on four cases — refused connection, 501, 403, and the real
+303. **The old version passed the first three.** Fixed in both deploy paths.
+
+**Removing `priceRange` left three live statements saying it was present or
+pending**, one of them the stated justification for `/fees/` emitting no `Offer`
+node — so that reasoning rested on a field that no longer existed, which is how
+the next reader re-adds one. **Deleting `overtimeStartsAfterSessionHours` left §9
+naming it** — Current Truth pointing at an identifier that does not exist, the
+same "flag that looks like a control" shape one layer up — and left the Q15–Q17
+row saying *"where it begins is §9 Q59, open"* eleven lines below the row
+recording it closed. **The Google fix was applied to §Where it is stored and not
+to §Who can see it**, so the page answered *"who can see the names of the
+opposing parties I gave you?"* with **"Nobody else has access"** under that
+heading and **"Google"** two sections earlier.
+
+⚠️ **AND THE VARIABLE REMOVAL WAS JUSTIFIED WITH A PATH-SCOPED GREP, WHICH IS
+BOTH OF `CLAUDE.md`'s SWEEP RULES AT ONCE.** `git grep PUBLIC_INTAKE_ENDPOINT -- src/`
+was offered as proof of a repo-wide removal — and `git grep` additionally cannot
+see untracked files, which at that moment was most of the new work. The unscoped
+sweep found `docs/06`'s required-variables table still instructing an operator to
+set both, the OIDC example still setting them, and **`.env.example` still holding
+the full execute-api hostname — which falsifies a live claim in
+`src/data/intake.ts` that *"§7 remains the only place it lives"***. That claim is
+corrected, `docs/06`'s rows are gone, the OIDC example is marked superseded, and
+`deploy-local.sh`'s three-day-old *"Q22 records that it does NOT yet exist"* about
+the deploy credential is corrected against §7's PROVISIONED row.
+
+⚠️ **`.env.example` ITSELF IS NOT EDITED, AND THAT IS A LIMIT RATHER THAN A
+CHOICE: this environment denies read access to it, and nothing here may edit a
+file it cannot read.** Batched for Pouya — delete `PUBLIC_INTAKE_ENDPOINT`,
+`PUBLIC_BOOKING_URL` and `PUBLIC_ANALYTICS_*`, none of which anything reads.
+
+**Two smaller ones, both comments that pointed at nothing.** `og-card.ts` told a
+reader to run `npm run og:proof` to see every card in `dist/og-proof/` — wrong
+path, and the sheet requires `-- --sheet`, so the one documented mitigation for
+that file's own stated overflow hazard was wrong in both the path and the
+command. And **`og:image:alt` was the page ``, not the card's headline**,
+on 20 pages: `/fees/` emitted *"Fees · Mediation and Arbitration Rates · Pouya
+Lajevardi"* against a card reading *"Published in full, including what overruns
+cost."* Now `OG_CARDS[path]?.headline ?? title`.
+
+**What round 2 confirmed, which is the other half of its value.** All 56 `.btn`
+instances across all 22 pages measured with composited backgrounds: `/fees/`'s
+ghost button is **16.81:1**, every ghost and gold button ≥ 11.09:1, and the
+on-cream appearance is unchanged. The consent checkbox's accessible name computed
+from the AX tree is `CONSENT_TEXT` alone. Radio labels hit-test at **70.6 × 44**
+and **76.7 × 44**, clickable at all four corners. `og:proof` was exercised in a
+sandbox with five synthetic article pages: correct ``s gave *"25 card
+headlines matched (5 of them articles)"*, and one wrong `` produced both the
+mismatch and the coverage shortfall, exit 1. Zero text-contrast failures on all
+22 pages. All five MDX bodies compile against the installed `@mdx-js/mdx`.
+
+**Declined: none of the nine.** One partially satisfied, again: the D19 comment
+ratio. The named blocks were trimmed — `Button.astro`'s Lighthouse anecdote,
+`global.css`'s duplicate of the same story, `schema.ts`'s 25 lines for an absent
+field, `Prose.astro`'s 15 for zero, `contact.astro`'s 22 for 16 — and **the ratio
+held at 0.44**, because round 2's own nine fixes each carry their reason. The
+trims removed roughly what the fixes added. Recorded as the one item this entry
+does not claim to have satisfied, for the second round running.
+
+**There is no round 3 — D19.** Round 2's findings are fixed and this ships.
+
### `README.md`'s script table was missing a control, and had been for two days
It listed seven commands and **not `npm run check:claims`** — the one that runs on
diff --git a/backend/intake/handler.mjs b/backend/intake/handler.mjs
index 642b47a..a14ee20 100644
--- a/backend/intake/handler.mjs
+++ b/backend/intake/handler.mjs
@@ -97,7 +97,14 @@ const RESPONSE_TIME = requireEnv('RESPONSE_TIME');
* the constant would never have reached this email and nothing would have
* failed — the silent-drift shape §4 flags for the whole commitment class.
* Found by `adversarial-reviewer`. The deploy step sets it from
- * `NO_RETAINER_NOTICE` in `src/data/site.ts`; `docs/06` names it.
+ * `NO_RETAINER_NOTICE` in `src/data/site.ts`.
+ *
+ * ⚠️ AND THAT SENTENCE USED TO END "`docs/06` names it", WHICH IT DID NOT.
+ * This variable became a `requireEnv` and reached no document — so the
+ * deployment list said five variables while this file required six, and the
+ * function would have thrown at cold start on every invocation. **The comment
+ * asserting the documentation existed is what made it invisible.** `docs/06` and
+ * `docs/05` now name all six. Found by `adversarial-reviewer` round 2.
*/
const NO_RETAINER_NOTICE = requireEnv('NO_RETAINER_NOTICE');
diff --git a/docs/04-seo-spec.md b/docs/04-seo-spec.md
index b26dc3d..6ed5210 100644
--- a/docs/04-seo-spec.md
+++ b/docs/04-seo-spec.md
@@ -128,7 +128,7 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
| Type | Where | Notes |
|---|---|---|
| `Person` | `/about/`, referenced site-wide | **Emitted:** `name`, `url`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (**Q.Med, Q.Arb** — both, since 2026-08-29), `sameAs` (LinkedIn), `email`, `image`. **Emitted on `/about/` only:** `memberOf` — the four §4 memberships as `Organization` nodes (Q53, ruled 2026-08-28). `/` shows no memberships, so its Person node omits it: structured data represents the page it sits on. **Withheld:** `worksFor` — Q49(b) declined the row 2026-08-28 and Pouya confirmed the reading 2026-08-29, so it is settled rather than pending; `provider → Person → worksFor` would assert a same-entity claim §4 does not row. *(This enumeration listed `worksFor` as emitted while the same cell said it was withheld, and omitted `url` and `email`, which are — wrong in both directions. The enumeration is the part an implementer copies. Found by `adversarial-reviewer`.)* **CHANGED 2026-08-28 — Q47.** This row read *"`jobTitle` = 'Director of Firm Operations'; omit `worksFor`"*, which put the boutique title on a node whose `url` is this ADR practice's `/about/` — so a consumer could attach it to this entity. Pouya's ruling reframes the field: `jobTitle` describes **this practice**, not the boutique role, which D16 keeps unnamed. The visible role line is unchanged and still reads "Director of Firm Operations at a Toronto litigation and ADR boutique". **THE VALUE IS `PRACTICE_JOB_TITLE` IN `src/data/site.ts` AND THIS ROW DOES NOT RESTATE IT** — §7's rule, applied to a string with a live revert trigger on it: this row carried the literal text for one pass, and `adversarial-reviewer` noted it would go stale the moment the constant moved. Cite, do not copy. **`worksFor` IS WITHHELD** — set for one pass under Q47, then reverted: `ProfessionalService.provider` is this Person, so `provider → Person → worksFor` asserts the same-entity claim `schema.ts` explicitly declines, and §4 says "alongside the practice" where the ruling says "operates through". **`memberOf` is emitted** — see the sentence above; Q53 closed 2026-08-28. *(This cell asserted `memberOf` was both emitted and withheld for one pass, which is the defect it already records itself being caught for on `worksFor`, in the opposite direction. The enumeration is the part an implementer copies.)* See `src/data/schema.ts` |
-| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` **Mediation / Commercial arbitration / Mediation-arbitration (med-arb)** — *scoped 2026-08-28 on `claims-auditor`'s finding; this row instructed the unscoped class form "Mediation/Arbitration" that Q39 struck and that `schema.ts` deliberately does not follow. Family arbitration carries prescribed training and has its own NOT OFFERED row, so unscoped "Arbitration" is the struck universal in a field nobody reads. Do not widen these strings without a §4 row to widen them from* — `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
+| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` **Mediation / Commercial arbitration / Mediation-arbitration (med-arb)** — *scoped 2026-08-28 on `claims-auditor`'s finding; this row instructed the unscoped class form "Mediation/Arbitration" that Q39 struck and that `schema.ts` deliberately does not follow. Family arbitration carries prescribed training and has its own NOT OFFERED row, so unscoped "Arbitration" is the struck universal in a field nobody reads. Do not widen these strings without a §4 row to widen them from* — `provider` → Person, ⚠️ **`priceRange` DECLINED 2026-08-31 — this row said *"once `/fees/` is real"*, the page became real at step 9, the field went in, and it came out the same day.** Its own defence rejected a `min`/`max` over `FEES` because *"a range whose ends mean different units is a range that misinforms"* — and the ends it chose had different units too: the floor was the hourly rate, the ceiling a flat documents-only fee. The floor misinformed in the direction that matters, because the least anyone pays for the headline service is **$2,000**. **Nothing on the site states a price in machine-readable form**, and no `Offer` node either: every figure on `/fees/` is conditional on session length, party count or format, and schema.org's `Offer` models one price for one item. This row gates the field; it does not require it. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
| `Service` | **`/mediation/`, `/arbitration/`, `/med-arb/`** and each practice page | `serviceType`, `provider` → Person, `areaServed`. **The Person node travels in the same `@graph`** so `provider: {'@id'}` resolves in one document rather than relying on a crawler joining two — `homeGraph`'s reasoning, applied. `serviceType` is scoped where §4 scopes it: *Commercial arbitration*, never a bare "Arbitration". No `BreadcrumbList` on the three — one hop from the root, no visible breadcrumb, and this spec requires the markup to match the visible one |
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md
index 2ee8261..3c60213 100644
--- a/docs/05-backend-spec.md
+++ b/docs/05-backend-spec.md
@@ -374,4 +374,4 @@ Plausible or Fathom, cookieless, no consent banner.
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
-- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and `RESPONSE_TIME` set. It throws at cold start on any missing one, deliberately
+- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
diff --git a/docs/06-deployment.md b/docs/06-deployment.md
index d298fe3..d69f386 100644
--- a/docs/06-deployment.md
+++ b/docs/06-deployment.md
@@ -162,10 +162,31 @@ they are useful for debugging.
| `AWS_REGION` | `AGENTS.md` §7 — Region |
| `S3_BUCKET` | §7 — S3 bucket |
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
-| `INTAKE_ENDPOINT` | §7 — Intake API |
-| `BOOKING_URL` | *(empty — parked, R6)* |
-The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
+⚠️ **`INTAKE_ENDPOINT` AND `BOOKING_URL` WERE ROWS HERE AND ARE GONE, 2026-08-31.**
+Build step 8 moved the intake form to the same-origin path `/api/intake`, after
+which nothing in the build read either one — and both deploy paths were still
+refusing to run without `INTAKE_ENDPOINT`. **The removal was made in the two
+scripts and not here**, so this table went on telling an operator to set a
+variable no guard checked and no build consumed. Found by
+`adversarial-reviewer` round 2, whose sharper point is about the evidence: the
+removal was justified with `git grep PUBLIC_INTAKE_ENDPOINT -- src/` — a
+**path-scoped** grep offered as proof of a repo-wide removal, and `git grep`
+additionally cannot see untracked files, which at that moment was most of the new
+work. That is `CLAUDE.md`'s *a sweep is a command, not a claim* and *sweep the
+vocabulary, not only the subject*, both at once.
+
+⚠️ **AND ONE PLACE STILL CARRIES IT: `.env.example` sets
+`PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname, and
+`PUBLIC_BOOKING_URL`.** That falsifies a live claim in `src/data/intake.ts` —
+which said the endpoint id *"is not restated in the repo either"* — and the claim
+has been corrected there rather than left standing. **The file itself was not
+edited: this environment denies read access to it, and nothing in this repo may
+edit a file it cannot read.** It is in the batched list for Pouya: delete both
+lines, and `PUBLIC_ANALYTICS_*` with them, since `ANALYTICS` is a literal in
+`src/data/site.ts`.
+
+The three values above fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here** —
§7 is the single source of truth for operational facts, and the copy that goes
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
@@ -438,10 +459,23 @@ Then invalidate `/*`.
this behaviour the form 404s.
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
replacing the hand-built `adr-intake-handler` §7 records. It needs
- `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and
- `RESPONSE_TIME` set; it throws at cold start on any missing one, on
- purpose. `RESPONSE_TIME` must be `CONTACT.responseTime` verbatim — it is
- a public commitment (§4, Q27) and must read identically on `/contact/`,
+ **SIX** variables — `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`,
+ `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`; it throws at cold
+ start on any missing one, on purpose. ⚠️ **This list said five until
+ 2026-08-31 and the handler required six.** `NO_RETAINER_NOTICE` became a
+ `requireEnv` in the same change set and was added to no document, so an
+ operator working this list would have set five, and the Lambda would
+ have thrown on **every** invocation — API Gateway answering 5xx and every
+ inquiry from counsel lost from the moment `/api/*` was wired. Loud in
+ CloudWatch, silent to Pouya, who has no reason to submit his own form.
+ Found by `adversarial-reviewer` round 2.
+ **Two of the six must be verbatim from `src/data/site.ts`:**
+ `RESPONSE_TIME` from `CONTACT.responseTime` and `NO_RETAINER_NOTICE`
+ from `NO_RETAINER_NOTICE`. Both are published commitments — the first is
+ §4/Q27's two-business-day response, the second is the no-retainer notice
+ `docs/01` §`/contact/` requires, **including its fourth clause about
+ not itself creating a conflict check**, which a hand-typed copy in the
+ handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
in the confirmation email, and in the bio.
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
(`docs/05`). Not expressible in handler code, and it is carrying load
diff --git a/docs/reference/github-actions-oidc.yml.example b/docs/reference/github-actions-oidc.yml.example
index fc57929..81483ab 100644
--- a/docs/reference/github-actions-oidc.yml.example
+++ b/docs/reference/github-actions-oidc.yml.example
@@ -49,12 +49,20 @@ jobs:
run: npm run build
env:
PUBLIC_SITE_URL: https://adr.smlcompany.ca
- PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
- PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
+ # SUPERSEDED 2026-08-31 — do not copy these two lines. Build step 8
+ # moved the intake form to the same-origin path /api/intake, after
+ # which nothing in the build read either variable; both were removed
+ # from the live workflow and from scripts/deploy-local.sh. Kept visible
+ # rather than deleted because this whole file is a historical
+ # alternative, and a silent edit to it would make it disagree with the
+ # entry that recorded it.
+ # PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
+ # PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
- # rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and
-# BOOKING_URL — are recorded in docs/06-deployment.md.
+ # rest — AWS_REGION, S3_BUCKET and CLOUDFRONT_DISTRIBUTION_ID — are recorded
+# in docs/06-deployment.md. (INTAKE_ENDPOINT and BOOKING_URL were listed here
+# and are no longer required by either deploy path; see above.)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh
index bc50206..a91d08f 100755
--- a/scripts/deploy-local.sh
+++ b/scripts/deploy-local.sh
@@ -36,8 +36,12 @@
# PUBLIC_BOOKING_URL went with it: `CONTACT.bookingUrl` is `null` in source while
# R6 keeps booking parked, and nothing read that variable either.
#
-# Credentials: use the scoped deploy user. AGENTS.md Q22 records that it does
-# NOT yet exist. NEVER run this as user/pouya — see AGENTS.md §10.
+# Credentials: use the scoped deploy user, `adr-sml-deploy`. AGENTS.md §7 records
+# it as PROVISIONED, with one inline policy verified by nine
+# simulate-principal-policy checks; Q22 closed on execution 2026-08-28.
+# (This comment said it "does NOT yet exist" for three days after it did —
+# found by `adversarial-reviewer` round 2.)
+# NEVER run this as user/pouya — see AGENTS.md §10.
set -euo pipefail
@@ -123,27 +127,52 @@ aws cloudfront create-invalidation \
# deploy that succeeds while the form posts into a 404 is the failure the old
# guard was reaching for and could not see.
#
-# 404 means not routed. 403 means routed and REFUSED, which is the correct answer
-# to this request: the handler checks the Origin header and this curl sends none,
-# so it is rejected before any DynamoDB write or any email. That makes 403 a pass
-# and is why this probe is safe to run against production.
+# ⚠️ IT ASSERTS A POSITIVE, AND THE FIRST VERSION ASSERTED THE ABSENCE OF ONE
+# CODE. That version was `code=$(curl ... || echo 000)` and passed on anything
+# that was not literally 404. Two defects, both measured by
+# `adversarial-reviewer` round 2:
+#
+# - `curl -w '%{http_code}'` ALREADY prints 000 on a failed transfer, so
+# `|| echo 000` double-appended and $code became `000000` — the 000 arm was
+# unreachable and a connection failure reported success.
+# - If the /api/* behaviour is MISSING, the POST falls through to the S3
+# default behaviour and CloudFront answers 403 for a disallowed method —
+# indistinguishable from the handler's Origin refusal, which is the one
+# distinction the check exists to draw. It also passed on a real 501.
+#
+# So it now sends the correct Origin and asserts the answer it should get:
+# the handler validates, finds an empty submission, and redirects 303 to
+# /contact/could-not-send/. That happens BEFORE any DynamoDB write and before
+# any email, which is what makes the probe safe against production.
echo "==> Intake route check"
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
--max-time 15 \
+ -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" || echo 000)
-case "$code" in
- 404|000)
- echo >&2
- echo "WARNING: POST /api/intake returned $code." >&2
- echo "The contact form posts there. 404 means the CloudFront /api/* behaviour" >&2
- echo "is missing; 000 means the request did not complete. The site is" >&2
- echo "deployed and the form is not wired — see docs/06's cutover checklist." >&2
- ;;
- *)
- echo " POST /api/intake -> $code (routed; 403 is the Origin check refusing a probe)"
- ;;
-esac
+ "https://adr.smlcompany.ca/api/intake")
+rc=$?
+location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
+ --max-time 15 \
+ -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)
+if [ "$rc" -ne 0 ]; then
+ echo >&2
+ echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
+ echo "The contact form posts there. The site is deployed and the form is" >&2
+ echo "unverified — see docs/06-deployment.md's cutover checklist." >&2
+elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
+ echo " POST /api/intake -> 303 -> $location (routed, validating, rejecting an empty probe)"
+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
+fi
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
diff --git a/src/components/Button.astro b/src/components/Button.astro
index b05276a..f1c9691 100644
--- a/src/components/Button.astro
+++ b/src/components/Button.astro
@@ -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);
diff --git a/src/components/Prose.astro b/src/components/Prose.astro
index 3344a11..7935957 100644
--- a/src/components/Prose.astro
+++ b/src/components/Prose.astro
@@ -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 `
` 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 ``
- 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 ``.
- 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 `
` 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 `` component. No article uses one yet, so
+ it arrives with the first that does, exactly as `` does above. */
diff --git a/src/components/SEO.astro b/src/components/SEO.astro
index 16d92c5..c2fa847 100644
--- a/src/components/SEO.astro
+++ b/src/components/SEO.astro
@@ -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