Compare commits
22
Commits
610edc24fd
..
main
@@ -85,10 +85,12 @@ unless the change explains why CSS or progressive HTML could not do the job.
|
||||
**4. Performance.** Budgets in `docs/04-seo-spec.md`: Lighthouse ≥ 95 mobile on
|
||||
all four categories, under 100 KB JS per route, LCP under 2.0 s.
|
||||
|
||||
**Lighthouse itself cannot be run right now** — `@lhci/cli` was removed on
|
||||
2026-08-26 and returns at build step 7 (`AGENTS.md` §7, R11). So do not report
|
||||
"Lighthouse not run" as a finding; it is a known, recorded gap. Review
|
||||
everything that *would* move those numbers by reading the artefact instead:
|
||||
**Lighthouse runs again** — `npm run lighthouse`, since 2026-08-31 (`AGENTS.md`
|
||||
§7). It is a local gate and is not wired into the build, so a change set may
|
||||
legitimately arrive unmeasured; **if the numbers matter to a finding, say so and
|
||||
say they were not run**, rather than either assuming them or treating the absence
|
||||
as the finding. Review everything that *would* move those numbers by reading the
|
||||
artefact as well:
|
||||
base64-inlined images, images without explicit dimensions, runtime font
|
||||
requests, and third-party scripts. The old build is *said* to have inlined ~1 MB
|
||||
of logo PNGs — `AGENTS.md` Q34 is open against that figure, so watch for
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: claims-auditor
|
||||
description: Audits every factual assertion in site copy against the verified claim register in AGENTS.md section 4. Invoked before any page or article is considered complete. This is the professional-conduct guard, not a proofreading pass.
|
||||
description: Audits every factual assertion in site copy against the verified claim register in AGENTS.md section 4. Under D20 this runs ONCE, at cutover, over the whole finished site — not per build step. This is the professional-conduct guard, not a proofreading pass.
|
||||
tools: Read, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
@@ -13,6 +13,34 @@ The site this replaces contained a fictitious founder, invented matter values
|
||||
and a testimonial attributed to a person who does not exist. Your existence is
|
||||
the control that stops that recurring.
|
||||
|
||||
## When you run — D20, and read this before anything else
|
||||
|
||||
**You run ONCE, at cutover, over the whole finished site.** Not per build step.
|
||||
Pouya's ruling, 2026-08-30, recorded in full in `AGENTS.md` D20.
|
||||
|
||||
Three consequences, and they change how you work rather than only when:
|
||||
|
||||
1. **You are the only claims pass this project gets.** There is no second run
|
||||
behind you and no round 3 to catch what you miss. `npm run check:claims` is a
|
||||
greppable tripwire, not a reader. Treat every page as final, because it is.
|
||||
2. **Read the site as a visitor does, not as a diff.** The reason the pass moved
|
||||
here is that the defects worth catching late are the ones that only exist once
|
||||
the pages sit next to each other. The `/med-arb/` ADRIC gloss is the case:
|
||||
every individual claim was true, the quote was verbatim and correctly cited,
|
||||
and the defect was **adjacency** — the sentence beneath the quote answered
|
||||
ADRIC's question with a different designation than ADRIC's own answer. A
|
||||
per-page audit cannot see that. Follow the reading order: `/`, then the nav,
|
||||
then a practice page, then `/about/`. Ask what a visitor now believes.
|
||||
3. **A finding here blocks the cutover.** `docs/06`'s checklist carries it as a
|
||||
blocking item: nothing publishes until your findings are resolved. So the
|
||||
uncertainty-is-a-defect rule still stands, and D19's two-round cap does **not**
|
||||
apply to you — it governs the per-step code review. There is no cap on
|
||||
resolving what this pass finds.
|
||||
|
||||
If you are invoked mid-build anyway, say so in your report: you are auditing a
|
||||
draft rather than the finished site, which is the exact condition D20 says makes
|
||||
this pass weaker.
|
||||
|
||||
## Scope — D19, and it is a hard boundary
|
||||
|
||||
**In scope:**
|
||||
@@ -150,9 +178,25 @@ years in ADR practice, or time-to-award statistic is forbidden outright. The
|
||||
approved stat set is `Q.Med` / `JD + ML` / `EN · FA`, plus `Q.Arb` in a fourth
|
||||
slot.
|
||||
|
||||
**Q.Arb.** Commenced August 2026. Flag anything reading as held, imminent, or
|
||||
nearly complete. The Arbitration page must state plainly what is available now
|
||||
versus what follows designation.
|
||||
**Q.Arb — DO NOT HOLD ITS STATE HERE EITHER. Read §4's row at audit time.**
|
||||
This paragraph said *"Commenced August 2026. Flag anything reading as held,
|
||||
imminent, or nearly complete. The Arbitration page must state plainly what is
|
||||
available now versus what follows designation."* §4 recorded Q.Arb as **HELD** on
|
||||
2026-08-29, struck every stage form — `commenced`, `in progress`, `pathway`,
|
||||
`not yet` — struck the Forbidden row against *"held, imminent, nearly complete"*
|
||||
with it, and dissolved the paired-disclosure condition with an explicit
|
||||
instruction to leave no residue. **Applied literally, the struck text would have
|
||||
flagged correct copy and demanded the struck form**, and an imperative sentence
|
||||
about what a page "must state plainly" is the kind an agent obeys.
|
||||
|
||||
Found by this agent in the D20 cutover pass, 2026-09-01, which read §4 instead —
|
||||
**the fifth stale claim found inside this file**, after the memberships list
|
||||
below, and the shape is identical every time: a fact copied here, corrected in
|
||||
§4, never swept. The rule that follows from five instances is the one the
|
||||
memberships paragraph already states, generalised: **this file holds the
|
||||
questions to ask, not the answers.** Any state that can change — a designation, a
|
||||
membership, a date, a rate — is read from §4 at audit time. If you find yourself
|
||||
about to write a value here, write the §4 pointer instead.
|
||||
|
||||
**Memberships.** **Do not hold a list here. Read the memberships row in
|
||||
`AGENTS.md` §4 at audit time and use what it says.** This paragraph used to
|
||||
|
||||
+34
-17
@@ -19,7 +19,7 @@ does not apply, say which and why before moving on.
|
||||
Reminders** and surface anything live to Pouya before you start.
|
||||
2. Read the specs in `docs/` that bear on this task.
|
||||
3. Restate the task in your own words, and name:
|
||||
- which locked decisions (D1–D19) it touches
|
||||
- which locked decisions (D1–D20) it touches
|
||||
- which specs govern it
|
||||
- which facts it needs from the §4 Verified register
|
||||
4. **Stop and ask if you find a conflict** — between the task and a locked
|
||||
@@ -45,24 +45,36 @@ number.
|
||||
|
||||
## Phase 3 — Adversarial review (this is not optional)
|
||||
|
||||
Invoke **both** review agents on the change, in parallel:
|
||||
Invoke **one** review agent on the change:
|
||||
|
||||
- `adversarial-reviewer` — correctness, accessibility, crawlability,
|
||||
performance, security, simplicity
|
||||
- `claims-auditor` — every factual assertion traced to `AGENTS.md` §4
|
||||
|
||||
**Give them the diff and the specs. Do not give them your reasoning for why the
|
||||
work is correct.** Your rationale anchors the reviewer and produces agreement
|
||||
instead of review. They form their own view from the artefact; that independence
|
||||
is the whole point of the phase.
|
||||
**`claims-auditor` does NOT run here — D20, Pouya, 2026-08-30.** It runs **once,
|
||||
at cutover**, over the whole finished site, as a blocking item on `docs/06`'s
|
||||
checklist. Do not invoke it per step, and do not reinstate it because a change
|
||||
set looks claim-heavy: that judgement was already made against the measured cost,
|
||||
which `AGENTS.md` D20 records in full. If a claim genuinely worries you, the
|
||||
answer is a `TODO(pouya)` and a §9 question — the thing that blocks — not an
|
||||
unscheduled audit.
|
||||
|
||||
Two things carry the claims risk between now and cutover, and neither is
|
||||
optional: **`npm run check:claims` in Phase 5**, and **Pouya reading the copy as
|
||||
it is built**.
|
||||
|
||||
**Give it the diff and the specs. Do not give it your reasoning for why the work
|
||||
is correct.** Your rationale anchors the reviewer and produces agreement instead
|
||||
of review. It forms its own view from the artefact; that independence is the
|
||||
whole point of the phase.
|
||||
|
||||
### Scope — D19
|
||||
|
||||
Review is bounded. **In scope:** `dist/`, `src/`, the specs that direct copy
|
||||
(`docs/01`, `03`, `04`, `07`), and `AGENTS.md` §3, §4, §7, §9, §12. **Out of
|
||||
scope:** the Change Log, the agents' own briefs, `AGENTS.md` prose outside those
|
||||
sections, and the historical accuracy of code comments. Both briefs carry the
|
||||
same boundary — do not widen it in the prompt.
|
||||
sections, and the historical accuracy of code comments. Both agent briefs carry
|
||||
the same boundary — do not widen it in the prompt. It binds the cutover claims
|
||||
pass too, which is the one place `claims-auditor` still runs.
|
||||
|
||||
### The stop signal
|
||||
|
||||
@@ -72,8 +84,9 @@ itself, and noticing it is part of the job — not a licence to skip the phase.
|
||||
The shape to watch for, from the session that produced D19: a Change Log sweep
|
||||
that could not reproduce *because writing it changed the file it counted*.
|
||||
|
||||
If the change touches no user-facing copy, `claims-auditor` may be skipped — say
|
||||
so explicitly.
|
||||
**The claims pass is deferred, not deleted.** Say which review ran in your
|
||||
report. "Reviewed" without naming the agent reads as both, and under D20 it is
|
||||
one.
|
||||
|
||||
## Phase 4 — Resolve
|
||||
|
||||
@@ -113,8 +126,10 @@ npm run check:claims
|
||||
```
|
||||
|
||||
`check:claims` greps the built pages for the §4 Forbidden set — Pouya's ruling,
|
||||
2026-08-29. **Every pattern it catches is a finding neither review agent has to
|
||||
spend a pass on**, which is D19 served directly. It self-tests its own patterns
|
||||
2026-08-29. **Under D20 it is the only per-step claims control there is**, so
|
||||
never skip it and never let a build ship on a stale `dist/`. It is a tripwire,
|
||||
not a program: it catches the §4 breaches that are greppable and it makes no
|
||||
claim about the ones that are not. It self-tests its own patterns
|
||||
before it sweeps and refuses to run against an empty or stale `dist/`, so a pass
|
||||
is a pass on the bytes that would ship. **A match is not yet a finding** — read
|
||||
the context it prints. If a pattern is genuinely wrong, change it deliberately
|
||||
@@ -125,10 +140,12 @@ Then, as applicable to what changed:
|
||||
- Serve `dist/` and confirm the page **renders its full content with JavaScript
|
||||
disabled** — the failure this whole project exists to fix
|
||||
- `curl` the built HTML and confirm real content, not a shell
|
||||
- ~~Lighthouse mobile ≥ 95 on all four categories~~ — **UNAVAILABLE.**
|
||||
`@lhci/cli` was removed on 2026-08-26 and is not re-added until build step 7
|
||||
(`AGENTS.md` R11, §7). Report it as *not run, tool unavailable*. Do not
|
||||
substitute a manual DevTools run and describe it as the same check
|
||||
- **Lighthouse mobile ≥ 95 on all four categories** — `npm run lighthouse`,
|
||||
after `npm run build`, on the pages the change touches or on all of them.
|
||||
Available again since 2026-08-31 (`AGENTS.md` §7). It exits non-zero on a
|
||||
breach, so **read the exit status** rather than the table. Do not substitute a
|
||||
manual DevTools run and describe it as the same check. Note when reporting that
|
||||
the accessibility figure is measured with `prefers-reduced-motion` forced
|
||||
- Every internal link resolves
|
||||
- Metadata present: unique title, description, canonical, OG, JSON-LD
|
||||
- **No scroll-driven animation was eaten by the minifier.** This must return
|
||||
|
||||
@@ -10,13 +10,17 @@ Scope: $ARGUMENTS
|
||||
If no scope is given, review the uncommitted working tree (`git status`,
|
||||
`git diff`).
|
||||
|
||||
Invoke **both** agents in parallel on that scope:
|
||||
Invoke **`adversarial-reviewer`** on that scope.
|
||||
|
||||
- `adversarial-reviewer`
|
||||
- `claims-auditor` — unless nothing user-facing changed, in which case say so
|
||||
**`claims-auditor` is not part of this command — D20, Pouya, 2026-08-30.** The
|
||||
claims pass runs **once, at cutover**, over the whole finished site; `AGENTS.md`
|
||||
D20 records the reasoning and the measured cost of deferring it. Run it here only
|
||||
if Pouya asks for it by name in `$ARGUMENTS` — in which case say in the report
|
||||
that it was run out of band, and against a draft rather than the finished site,
|
||||
because that is the condition D20 says weakens it.
|
||||
|
||||
Give them the diff and the relevant specs from `docs/`. **Do not brief them on
|
||||
why the code is correct** — that anchors the review and turns it into agreement.
|
||||
Give it the diff and the relevant specs from `docs/`. **Do not brief it on why
|
||||
the code is correct** — that anchors the review and turns it into agreement.
|
||||
|
||||
Report findings grouped by severity, most severe first. For each: the defect, the
|
||||
concrete failure it produces, and the fix. Do not fix anything yet — Pouya
|
||||
|
||||
@@ -37,9 +37,16 @@ jobs:
|
||||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
||||
# Job-level so the guard can see it. An empty INTAKE_ENDPOINT does not
|
||||
# fail the build - it ships a live contact form posting to nothing.
|
||||
INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||
# NO INTAKE_ENDPOINT. Build step 8 moved the intake form to the
|
||||
# same-origin path /api/intake, after which nothing in src/ read this
|
||||
# value - `git grep PUBLIC_INTAKE_ENDPOINT -- src/` returned nothing - and
|
||||
# the guard below was blocking a deploy on it. The comment that stood here
|
||||
# said an empty value "ships a live contact form posting to nothing",
|
||||
# which became false in both directions: the form posts to /api/intake
|
||||
# regardless, and what decides whether it works is the CloudFront /api/*
|
||||
# behaviour, which nothing guarded. See scripts/deploy-local.sh, which
|
||||
# carries the post-deploy route check that replaced it.
|
||||
# Found by `adversarial-reviewer`, 2026-08-31.
|
||||
|
||||
steps:
|
||||
# Runs first, before checkout and before any AWS call, so a
|
||||
@@ -66,7 +73,6 @@ jobs:
|
||||
[ -n "$AWS_DEFAULT_REGION" ] || missing="$missing AWS_REGION(var)"
|
||||
[ -n "$S3_BUCKET" ] || missing="$missing S3_BUCKET(var)"
|
||||
[ -n "$CLOUDFRONT_DISTRIBUTION_ID" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID(var)"
|
||||
[ -n "$INTAKE_ENDPOINT" ] || missing="$missing INTAKE_ENDPOINT(var)"
|
||||
[ -n "$AWS_ACCESS_KEY_ID" ] || missing="$missing AWS_ACCESS_KEY_ID(secret)"
|
||||
[ -n "$AWS_SECRET_ACCESS_KEY" ] || missing="$missing AWS_SECRET_ACCESS_KEY(secret)"
|
||||
if [ -n "$missing" ]; then
|
||||
@@ -96,11 +102,11 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
# PUBLIC_SITE_URL only, because it is the one variable
|
||||
# astro.config.mjs reads. PUBLIC_INTAKE_ENDPOINT and
|
||||
# PUBLIC_BOOKING_URL were set here and consumed by nothing;
|
||||
# `CONTACT.bookingUrl` is null in source while R6 keeps booking parked.
|
||||
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
||||
# vars, not env — Gitea expression-context support is the very thing
|
||||
# the guard above exists to not depend on.
|
||||
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
||||
|
||||
# AGENTS.md §4 Forbidden, enforced on the built output before a single
|
||||
# byte is uploaded. Runs here rather than in `npm run check` because it
|
||||
@@ -155,5 +161,69 @@ jobs:
|
||||
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||
--paths "/*"
|
||||
|
||||
# Mirrors the same step in scripts/deploy-local.sh, because that script's
|
||||
# header requires the two paths to match on everything that determines
|
||||
# what gets published - and this replaced the INTAKE_ENDPOINT guard.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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' "$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" || 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}'."
|
||||
# Kept in step with scripts/deploy-local.sh — the two are one
|
||||
# artefact in two places. 404 is ambiguous between three causes and
|
||||
# the distribution's 404 mapping hides API Gateway's own body.
|
||||
echo "404: /api/* behaviour missing (docs/09 Part 3), OR the POST"
|
||||
echo "/api/intake route missing (Part 6.2), OR the route exists and"
|
||||
echo "the 404 mapping replaced the API's body. Separate them with"
|
||||
echo "aws apigatewayv2 get-routes --api-id <id> --query"
|
||||
echo "'Items[].RouteKey' — the --api-id is required; without it the"
|
||||
echo "CLI exits 252 on ParamValidation."
|
||||
echo "403: method rejected, or the handler refused the Origin —"
|
||||
echo "read which origin request policy /api/* carries. Since"
|
||||
echo "2026-09-04 it may be the custom whitelist"
|
||||
echo "adr-sml-api-viewer-address rather than the managed"
|
||||
echo "AllViewerExceptHostHeader; a policy that does not forward"
|
||||
echo "Origin 403s every real submission. Rollback id:"
|
||||
echo "b689b0a8-53d0-40ab-baf2-68738e2966ac."
|
||||
echo "500: the invoke permission for this route is missing (6.1)."
|
||||
echo "See docs/09-cutover-runbook.md Part 7.1."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
|
||||
|
||||
@@ -46,3 +46,9 @@ test-results/
|
||||
|
||||
# generated inventory — safe to share, but not tracked
|
||||
aws-inventory.txt
|
||||
|
||||
# The pre-cutover archive of the old single-file build (docs/09 Part 8.1).
|
||||
# NOT committed: it is ~3.3 MB of the page that carried the fabricated founder
|
||||
# and the invented matter values, and Part 8.3 runs `git tag` eleven lines later.
|
||||
# `docs/06` says to keep it, not to version it.
|
||||
_archive/
|
||||
|
||||
@@ -15,3 +15,18 @@ docs/reference/
|
||||
# measured contrast ratios can be scanned down the page — see docs/02. Prettier
|
||||
# collapses that alignment, which is the one thing the file is for.
|
||||
src/styles/tokens.css
|
||||
|
||||
# ⚠️ MDX IS IGNORED, AND THE FIRST REASON IS THAT PRETTIER BREAKS IT.
|
||||
# Measured 2026-08-31: `npm run format` rewrote an MDX JSX comment from
|
||||
# `{/* … */}` to `{/_ … _/}` — it read the asterisks as markdown emphasis — and
|
||||
# the build then failed with `Could not parse expression with oxc: Unterminated
|
||||
# regular expression`, because MDX parses `{/_ …` as a regex literal. It works
|
||||
# in the source and dies at build, which is the worst shape a defect can take.
|
||||
#
|
||||
# The second reason is the one that would matter even if that were fixed
|
||||
# upstream: **these files are hand-audited prose.** Each of the five launch
|
||||
# articles was read line by line against `AGENTS.md` §4 and against the sourced
|
||||
# extracts in `docs/reference/`, and 76 findings were applied to them. Machine
|
||||
# reflowing audited copy means the committed bytes are no longer the bytes that
|
||||
# were audited. Prose wrapping here is checked by eye, exactly as `*.md` above.
|
||||
*.mdx
|
||||
|
||||
@@ -57,8 +57,9 @@ being restated in the prompt.
|
||||
anything live), read the governing specs, name the decisions the task touches,
|
||||
and **stop and ask on any conflict**. A blocked build is a correct build.
|
||||
2. **Implement** — following the conventions below.
|
||||
3. **Adversarial review** — invoke `adversarial-reviewer` and `claims-auditor` in
|
||||
parallel on the diff.
|
||||
3. **Adversarial review** — invoke `adversarial-reviewer` on the diff. **D20:
|
||||
`claims-auditor` does NOT run per step.** It runs once, at cutover, over the
|
||||
whole finished site.
|
||||
4. **Resolve** — fix each finding or decline it with a stated reason. Re-review
|
||||
material fixes.
|
||||
5. **Verify** — run the checks. Never report a check as passing that you did not
|
||||
@@ -67,6 +68,14 @@ being restated in the prompt.
|
||||
|
||||
`/review` runs phase 3 alone. `/wrap` runs phase 6 at session end.
|
||||
|
||||
**Agent definitions load at session start.** An edit to `.claude/agents/*.md`
|
||||
does not reach the session you made it in — the brief in force is the one that
|
||||
was on disk when the session began. After committing a change to one, **restart
|
||||
before relying on it, and say in the report which version actually ran.** Found
|
||||
2026-08-30: the gloss lens was added to `claims-auditor` and the agent then
|
||||
reconstructed it from the `AGENTS.md` Change Log rather than having it in its
|
||||
brief, which is luck, not process.
|
||||
|
||||
**Think deeply before acting.** Extended thinking is on by default for this
|
||||
project (`.claude/settings.json`), and `/build` and `/review` request it
|
||||
explicitly. The planning and review phases are where it earns its cost — a defect
|
||||
@@ -90,11 +99,33 @@ an accusation, and do not argue a reviewer down — either fix it, or record the
|
||||
reason you declined it so a later reader can see the judgement was made rather
|
||||
than missed.
|
||||
|
||||
**Two reviewers, because they catch different things.** `adversarial-reviewer`
|
||||
reads the code. `claims-auditor` reads the copy against the §4 register and knows
|
||||
nothing about whether the code is elegant. A generic reviewer consistently
|
||||
under-weights the professional-conduct check, which is the highest-stakes failure
|
||||
mode on this project — so it gets its own pass.
|
||||
**Two reviewers, because they catch different things — but they no longer run at
|
||||
the same time.** `adversarial-reviewer` reads the code. `claims-auditor` reads the
|
||||
copy against the §4 register and knows nothing about whether the code is elegant.
|
||||
A generic reviewer consistently under-weights the professional-conduct check,
|
||||
which is the highest-stakes failure mode on this project — so it keeps its own
|
||||
pass rather than being folded into the code review.
|
||||
|
||||
**D20, 2026-08-30 — the claims pass moved to cutover.** Per step it is
|
||||
`adversarial-reviewer` alone. `claims-auditor` runs **once, over the whole
|
||||
finished site**, as a blocking item on `docs/06`'s cutover checklist. Pouya's
|
||||
reasoning, and it is a calibration and not an erosion: nothing has shipped, so
|
||||
every claims finding so far has been about a page no visitor can reach — the risk
|
||||
is deferred to cutover anyway, and one pass over twenty finished pages catches
|
||||
**more** than nine passes over drafts, because it sees the site as a reader does.
|
||||
The `/med-arb/` ADRIC gloss is the proof: no individual claim was false, the
|
||||
defect was **adjacency**, and adjacency does not exist until the pages sit next to
|
||||
each other. The code reviewer stays per step because what it catches **compounds**
|
||||
— an accessibility or crawlability defect propagates into the next page built on
|
||||
it, and a claims defect does not; it sits there until someone reads it.
|
||||
|
||||
**What it costs is recorded in `AGENTS.md` D20, not summarised away here.** Read
|
||||
it before proposing any further relaxation: `claims-auditor` has caught defects
|
||||
that would have been serious on a live page, and D20 accepts that such a defect
|
||||
may now live in an unpublished draft for weeks. Two things carry that risk in the
|
||||
meantime — **`npm run check:claims`, which is unchanged and runs on every build
|
||||
and both deploy paths**, and **Pouya reading the copy as it is built**. Neither is
|
||||
optional, and neither is a substitute for the cutover pass.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -105,11 +136,44 @@ npm run build # static build to ./dist
|
||||
npm run preview # serve ./dist locally
|
||||
npm run check # astro check — type and template errors
|
||||
npm run check:claims # §4 Forbidden, enforced on dist/ — run it after a build
|
||||
npm run check:intake # the form's field table vs the Lambda's — they are two on purpose
|
||||
npm run og:proof # every og:image resolves; every card headline IS its page's <h1>
|
||||
npm run lighthouse # the performance budget. LOCAL ONLY — needs Chrome, not in CI
|
||||
npm run bio:pdf # re-renders the committed one-page PDF from /bio/. LOCAL ONLY
|
||||
npm run icons # re-derives public/favicon.ico from the brand master. REGENERATOR
|
||||
npm run lint # eslint + prettier check
|
||||
npm run format # prettier — rewrite files in place
|
||||
npm run deploy # build + deploy from this machine (see docs/06)
|
||||
```
|
||||
|
||||
**Four of those are gates and two of them cannot run in CI.** `check`,
|
||||
`check:claims`, `check:intake` and `og:proof` are pure Node and run anywhere.
|
||||
`lighthouse` and `bio:pdf` drive an installed browser, and the Gitea runner has
|
||||
none — so they are keyboard gates plus blocking items on `docs/06`'s cutover
|
||||
checklist, and **they are deliberately not wired into `npm run build` or either
|
||||
deploy path.** Do not describe either as gating a deploy: a check described as
|
||||
running where it cannot is the defect `AGENTS.md` Q22 turned out to be.
|
||||
|
||||
**`bio:pdf` and `icons` are REGENERATORS, not gates** — they rewrite committed
|
||||
artefacts (`public/pouya-lajevardi-bio.pdf`, `public/favicon.ico`) rather than
|
||||
checking anything, so they are run deliberately and their output is committed.
|
||||
⚠️ **`LOCAL ONLY` above means a different thing for each, so do not read the two
|
||||
labels as one.** `bio:pdf` **cannot** run in CI — it drives a browser. `icons`
|
||||
is pure Node and **could**; it is out of the build because a build should not
|
||||
silently rewrite an artefact a human approved, which is why it is labelled
|
||||
`REGENERATOR` rather than `LOCAL ONLY`. It regenerates the favicon **only** — the touch icon
|
||||
is hand-made and must stay opaque cream (`docs/reference/brand-assets.md`
|
||||
§The icon set).
|
||||
|
||||
**`og:proof` and `check:intake` exist because two facts in this repo are
|
||||
deliberately duplicated**, and a duplicated fact needs a mechanism rather than a
|
||||
comment. `og:proof` compares each generated OG card's headline against its page's
|
||||
own `<h1>` — **text baked into a JPEG cannot be grepped by `check:claims`**, so
|
||||
that comparison is the only thing keeping card copy inside the claim register.
|
||||
`check:intake` compares the form's field table against the Lambda's, which are
|
||||
independent because a server that validates against a list the client shipped it
|
||||
is not validating.
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
@@ -221,6 +285,35 @@ argument and the comparison never ran. Prefer `git grep`, quote or array-expand
|
||||
anything you pass as flags, and re-check any result whose shape is "uniformly
|
||||
bad".
|
||||
|
||||
**zsh does not word-split parameter expansions; a loop over `$VAR` runs ONCE —
|
||||
use command substitution or arrays, and assert the iteration count.** *Pouya's
|
||||
convention, 2026-09-01.* `for p in $PAGES` iterates one item, not twenty-two,
|
||||
and `node probe.mjs 320,360 $P` measures one page — both of which then report
|
||||
"max=0, nonzero=0" and read as a clean sweep. `$(cat file)` and `${=VAR}` do
|
||||
split; `"${(@f)VAR}"` splits on newlines. **The fix is not remembering which:
|
||||
assert the count before reading the result** — a probe that says how many rows it
|
||||
measured cannot silently measure one.
|
||||
|
||||
**And re-check "uniformly GOOD" too — that is the dangerous half.** *Added
|
||||
2026-08-30; sharpened on Pouya's instruction 2026-08-31, as "the sharpest
|
||||
instrument finding yet".* The same `set -- $pair` loop recurred while confirming
|
||||
nine restored files matched a saved copy, and this time it printed **`same` on
|
||||
all nine**: `shasum` was handed both filenames as one argument, errored, and left
|
||||
both variables empty, so `"" = ""` passed.
|
||||
|
||||
**The distinguishing property, and it is the whole rule: a broken verification
|
||||
that fails loudly is safe; one that passes uniformly is not.** `DIFFER` on every
|
||||
row announces itself — it is alarming, so it starts an investigation, and the
|
||||
investigation finds the broken loop. A uniform pass is **the result you were
|
||||
hoping for, so it ends the check** instead of starting one. The two failures come
|
||||
from the identical bug and only one of them is survivable.
|
||||
|
||||
So a comparison must **assert that both things it compares exist** before
|
||||
comparing them — that is the assertion the shell loop skipped, and it is what
|
||||
turns this class of bug back into the loud kind. Note the same hole in `git grep`:
|
||||
it silently misses untracked files, so a clean sweep across new work means
|
||||
nothing until the files are staged.
|
||||
|
||||
**A parent cannot style a child component's root element.** Astro does not pass
|
||||
a parent's scope attribute down, so `<Button class="header-cta" />` compiles the
|
||||
parent's rule to `.header-cta[data-astro-cid-<parent>]` while the rendered `<a>`
|
||||
@@ -282,7 +375,7 @@ an operator to delete the three records that authenticate outbound mail —
|
||||
under the heading "Never delete".
|
||||
|
||||
**A measurement is a claim about your instrument until you check the
|
||||
instrument.** This has now cost six times, and the shape is identical every
|
||||
instrument.** This has now cost nine times, and the shape is identical every
|
||||
time: a number that looks like a finding, from a probe nobody validated.
|
||||
|
||||
- `timeout 60 ls "$DRIVE"` — **the command never ran.** `timeout` is not
|
||||
@@ -310,11 +403,85 @@ time: a number that looks like a finding, from a probe nobody validated.
|
||||
from the list. **A `grep -o` window count is not an enumeration** — to count
|
||||
occurrences of a string, iterate every match position, or `grep -o` the bare
|
||||
string with no context window.
|
||||
- **An ink bounding box of `48x48` filling the whole 48 px favicon** — which
|
||||
reads as *the regeneration blew the mark up to the full square*. The probe
|
||||
identified ink as "differs from cream", which is **correct on a matted icon and
|
||||
meaningless on a transparent one**, because `alpha = 0` pixels still carry RGB
|
||||
`(0,0,0)`. **Nothing about the probe changed — the class of input did**, and a
|
||||
probe cannot tell you that. Composite over a known ground first, so both things
|
||||
compared are the kind of thing the instrument was built for.
|
||||
- **A contrast ratio of `14.02:1` for the mark's darkest ink** — computed from a
|
||||
raw channel value **without asking what alpha it is painted at**. Exactly one
|
||||
pixel carried that colour and its alpha was 251; a `>= 250` filter let it
|
||||
through and the ratio was then taken as if it were opaque. **It is a number
|
||||
about a colour that is never painted.** Composite against the actual ground
|
||||
before measuring contrast, and take "the ink colour" only from fully opaque
|
||||
pixels.
|
||||
- **`POST /api/intake` returning 403 — read as "the route does not exist", on a
|
||||
LIVE site.** ⚠️ **A bare POST to `/api/intake` returns 403 BY DESIGN.** The
|
||||
handler rejects a request with no `Origin` header, and **`docs/09` §7.1 says so
|
||||
in as many words** — *"403 means the `Origin` header did not arrive"* — three
|
||||
lines below the probe it prescribes. **The only valid route probe is `docs/09`
|
||||
§7.1 verbatim, `Origin` header included; a 403 without that header is not
|
||||
evidence about the route.** Run correctly it returns **303** to
|
||||
`/contact/could-not-send/`, which is the handler answering as designed. This
|
||||
fired **twice on this project in two days** — Pouya's own probe tripped it
|
||||
2026-09-02, and it then reached a Change Log entry, a `docs/06` blocker and a
|
||||
report to him as *"the intake form is live and broken"*. **The status code was
|
||||
read without reading the document that defines what that status code means on
|
||||
that route**, and the document was in the repo the whole time. Generalised:
|
||||
**before interpreting a response, check whether the endpoint documents its own
|
||||
failure modes** — an API that rejects by design looks exactly like an API that
|
||||
is missing.
|
||||
|
||||
So before acting on a number: say what it is a number *of*; confirm the command
|
||||
actually ran and read its exit status; and check it against a second method that
|
||||
cannot fail the same way — the bytes on disk, a screenshot, a hit test.
|
||||
|
||||
**Two simulations of 200% text are not equivalent: media-query `rem` resolves
|
||||
against the browser DEFAULT font size, not the root element — measure under both
|
||||
methods before declaring a reflow result.** *Pouya's convention, 2026-09-01.*
|
||||
Raising the default moves the breakpoints along with the type, so the desktop
|
||||
layout is never reached and nothing overflows; setting `documentElement.style
|
||||
.fontSize` doubles the type and leaves the breakpoints where they were, which is
|
||||
the layout the desktop nav was measured in. One of those reported **0** while the
|
||||
other reported **944 px** on the same 22 pages, and the prose generalised the
|
||||
zero.
|
||||
|
||||
⚠️ **AND THERE ARE THREE MECHANISMS, NOT TWO — the third defeats the `rem`/`em`
|
||||
FAMILY, which is not the same as defeating CSS.** Measured 2026-09-01: Chrome's
|
||||
**"Minimum font size"** *floors* computed font sizes instead of scaling them, so
|
||||
text enlarges while `rem` keeps resolving at 16 px. A media query in `rem` does
|
||||
not see it, and neither does a container query — `@container` `rem`/`em` DO track
|
||||
the root element (that is the one real difference from `@media`, and it is
|
||||
measured), but under a minimum-font-size setting they still resolve at 16 px while
|
||||
`getComputedStyle` reports 32 px.
|
||||
|
||||
⚠️ **BUT "NO CSS CONDITIONAL CAN SEE IT" IS FALSE, AND ASSERTING IT COST A
|
||||
CONFORMANCE FAILURE.** *Corrected 2026-09-01, same day, by `adversarial-reviewer`.*
|
||||
The **font-metric** units read the *used* font size and therefore double:
|
||||
**`ch`, `ex`, `cap`, `lh`, `rlh`** all respond — in property values, in `@media`
|
||||
**and** in `@container` (`ch` 10.608 → 21.216 px; `@media (min-width: 100ch)`
|
||||
flips). Only `rem`, `em`, `ic` and `px` are blind. The false generalisation was
|
||||
written into `docs/02`, `docs/06`, `global.css` and `tokens.css`, and it was then
|
||||
used as the premise for accepting a **WCAG 2.2 SC 2.4.11 (AA)** failure as
|
||||
unfixable — *"the only fix is JS"*. **The lesson is the shape, not the units: "no
|
||||
mechanism can X" is a claim about every mechanism, including the ones you did not
|
||||
enumerate.** Test the family you did not think of before writing "none", and
|
||||
prefer "every construct I measured is blind, and here is the list" — which is
|
||||
falsifiable and was what the measurement actually supported.
|
||||
|
||||
For *reflow* the conclusion is unchanged: **used-value layout — wrapping — is
|
||||
still the right mechanism**, because it needs no threshold and no fitted constant.
|
||||
That path
|
||||
was the worst of the three: on one grid of 22 pages × 16 widths, **219 of 352
|
||||
page-widths overflowed** against root-style's **175**, and it was the only one
|
||||
failing at 320 px and 1024 px. **Always state the grid with the count** — two
|
||||
sweeps in that session quoted totals of 220, 330 and 352 for the same claim, and
|
||||
side by side they read as contradictions rather than as different width lists.
|
||||
Measure all three mechanisms; treat a clean result from one as evidence about
|
||||
that one.
|
||||
|
||||
**And a grep that matches is not a finding until you read what it matched.**
|
||||
A case-insensitive sweep for `LSO` hit `I aLSO practise`; a superlative sweep for
|
||||
`leading` hit `the pLEADINGs`. Both on the same page on the same day. Print the
|
||||
@@ -339,6 +506,46 @@ sweep; instances survived all three, and one of them was inside
|
||||
`.claude/agents/claims-auditor.md` — the definition of the agent whose job is to
|
||||
catch exactly that. Recall is not evidence.
|
||||
|
||||
**And sweep the VOCABULARY, not only the subject.** *Added 2026-08-30, from the
|
||||
Q.Arb amendment.* `git grep 'Q.Arb'` is line-anchored, so it could not find **ten
|
||||
lines in `docs/03` that were entirely about Q.Arb and never named it** — an
|
||||
unstruck, imperative block still instructing the struck form, eleven lines below
|
||||
that change set's own strike notice on the same bullet. The sweep was a real
|
||||
command and its output was read honestly. It was still the wrong command.
|
||||
|
||||
So after sweeping the term, sweep the words its claims are **made of** — here,
|
||||
the stage vocabulary (`commenced`, `in progress`, `pathway`, `not yet`) with no
|
||||
mention of the designation. This is R8's sharpest edge, and it is the one that
|
||||
survives an honest reader: a sweep can pass every test in the rule above and
|
||||
still miss everything, because the anchor you chose is not the anchor the text
|
||||
uses. The same session also excluded `docs/reference/` as "sourced extracts" —
|
||||
half right. The quotations there are evidence; **the commentary around them is
|
||||
this repository's voice**, and three lines of it still asserted the struck row.
|
||||
|
||||
|
||||
**`check:claims` IS FROZEN. It is a tripwire, not a program.** *Pouya's ruling,
|
||||
2026-08-30.* Round 2 of the Q.Arb amendment found **five defects in round 1's own
|
||||
fixes to that script, two of which made it worse than before the pattern
|
||||
existed** — a dedup key that reported two breaches of the same string as one (the
|
||||
check truncating its own output), and a collapsed-text view whose window leapt
|
||||
paragraph boundaries onto approved copy while its comment claimed it could not.
|
||||
At that point it was generating defects at roughly the rate it caught them.
|
||||
|
||||
The rule, and it has no exceptions:
|
||||
|
||||
- **A pattern is added only after a real breach has reached `dist/`.** Never
|
||||
speculatively, never to close a gap you can imagine.
|
||||
- **Each addition ships with a probe** — an injected page proving it catches the
|
||||
actual breach — **and a negative fixture** proving it stays silent on the
|
||||
approved copy nearest to it.
|
||||
- **No refactors. No coverage improvements. No tidying.** If a pattern is wrong,
|
||||
change that pattern deliberately, with a Change Log entry. Do not rewrite the
|
||||
scanner around it.
|
||||
|
||||
Under D20 this script is the only per-step claims control, which is an argument
|
||||
for keeping it **correct**, not for growing it. It catches the §4 breaches that
|
||||
are greppable and makes no claim about the ones that are not.
|
||||
|
||||
**Comments record decisions, not history — D19.** *"X because D13"* stays.
|
||||
*"This was Y, then flagged, then became X"* belongs in the `AGENTS.md` Change
|
||||
Log, which is where a reader looks for how something got here. **A comment
|
||||
@@ -365,13 +572,30 @@ actually been provisioned is `AGENTS.md` Q22. It must never reach the repo.
|
||||
every page. Under 100 KB of JS on any route. LCP under 2.0 s on a simulated
|
||||
Slow 4G connection. Treat a budget breach as a failing build.
|
||||
|
||||
**Lighthouse cannot currently be run.** `@lhci/cli` was removed on 2026-08-26
|
||||
(it carried 7 high-severity advisories, `0.15.1` is `latest`, and it had no
|
||||
pages and no `lighthouserc` to work with). The budget stands; the instrument is
|
||||
missing. It is re-added at build step 7 under `AGENTS.md` R11 — with a freshly
|
||||
verified pin, not on the assumption that `0.15.1` is still the ceiling. **Say
|
||||
"not run — tool unavailable" rather than silently omitting it.** A documented
|
||||
control that no longer exists is precisely the defect Q22 turned out to be.
|
||||
**Lighthouse runs again as of 2026-08-31 — `npm run lighthouse`, and it is
|
||||
`lighthouse` rather than `@lhci/cli`.** It enumerates every `index.html` in
|
||||
`dist/`, so the page set cannot go stale; it asserts the four category scores and
|
||||
**reports** LCP and CLS without asserting them, because simulated throttling on a
|
||||
loopback server is not the Slow 4G field measurement `docs/04` describes.
|
||||
|
||||
**It is a LOCAL gate, not a CI check.** Standalone Lighthouse drives an installed
|
||||
browser and the Gitea runner has none. So it is `npm run lighthouse` at a
|
||||
keyboard plus a blocking item on `docs/06`'s cutover checklist, and it is
|
||||
deliberately not wired into `npm run build` or either deploy path. Do not
|
||||
describe it as gating a deploy.
|
||||
|
||||
**Two things about the numbers, and both have to travel with them.** The
|
||||
accessibility category is measured with `prefers-reduced-motion` **forced** —
|
||||
otherwise axe's `color-contrast` audit reads the scroll-driven reveal's
|
||||
mid-animation opacity and reports 24 false nodes (measured; `#d0cbc4` on
|
||||
`#f8f4ed`, neither of which is in this palette). And the reason it is
|
||||
`lighthouse` and not `@lhci/cli` is that `AGENTS.md` §7's advisory attribution
|
||||
was **wrong**: the carriers were `@lhci/cli`'s own `tmp` and `@puppeteer/browsers`'
|
||||
`extract-zip`, not Lighthouse, and `lighthouse@13.4.1` audits clean. The budget
|
||||
was unmeasurable for five days on a cause nobody re-derived — which is the same
|
||||
lesson from the other side: **a documented control that no longer exists is
|
||||
precisely the defect Q22 turned out to be**, and so is one recorded as impossible
|
||||
on a reason that was never re-tested.
|
||||
|
||||
## What "done" means for a page
|
||||
|
||||
@@ -379,7 +603,7 @@ control that no longer exists is precisely the defect Q22 turned out to be.
|
||||
- [ ] No `TODO(pouya)` left unlogged in §9
|
||||
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
|
||||
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
|
||||
- [ ] Lighthouse ≥ 95 mobile, all four categories — **UNAVAILABLE until step 7**
|
||||
(see the performance budget above). Report it as not run; do not tick it
|
||||
- [ ] Lighthouse ≥ 95 mobile, all four categories — `npm run lighthouse` after
|
||||
`npm run build`. Read the exit status, not the table
|
||||
- [ ] Renders correctly with JavaScript disabled
|
||||
- [ ] `AGENTS.md` Change Log entry appended
|
||||
|
||||
@@ -19,13 +19,37 @@ npm run dev # http://localhost:4321
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `npm run dev` | Development server with hot reload |
|
||||
| `npm run build` | Static build to `./dist` |
|
||||
| `npm run build` | Static build to `./dist` — 22 pages |
|
||||
| `npm run preview` | Serve the built site locally |
|
||||
| `npm run check` | `astro check` — type and template errors |
|
||||
| `npm run check:claims` | `AGENTS.md` §4 Forbidden, enforced on `dist/`. **Runs on every deploy** |
|
||||
| `npm run check:intake` | The intake form's field table against the Lambda's — two on purpose |
|
||||
| `npm run og:proof` | Every `og:image` resolves; every card headline **is** its page's `<h1>` |
|
||||
| `npm run lint` | ESLint + Prettier check |
|
||||
| `npm run format` | Prettier — rewrite files in place |
|
||||
| `npm run lighthouse` | The performance budget, all four categories. **Local only** |
|
||||
| `npm run bio:pdf` | Re-renders the committed one-page PDF from `/bio/`. **Local only** |
|
||||
| `npm run deploy` | Build and deploy from this machine — see Deployment |
|
||||
|
||||
**Two of those cannot run in CI, and that is stated rather than left to be
|
||||
discovered.** `lighthouse` and `bio:pdf` drive an installed browser; the Gitea
|
||||
runner has none. They are keyboard gates plus blocking items on `docs/06`'s
|
||||
cutover checklist, and they are deliberately **not** wired into `npm run build`
|
||||
or either deploy path — a check described as running where it cannot is the
|
||||
defect `AGENTS.md` Q22 turned out to be.
|
||||
|
||||
**`og:proof` and `check:intake` exist because two facts here are deliberately
|
||||
duplicated**, and a duplicated fact needs a mechanism rather than a comment.
|
||||
Text baked into an OG card cannot be grepped by `check:claims`, so `og:proof`
|
||||
comparing each card's headline to its page's `<h1>` is the only thing keeping
|
||||
card copy inside the claim register. And the Lambda validates against its own
|
||||
field table, because a server that validates against a list the client shipped
|
||||
it is not validating.
|
||||
|
||||
*(`npm run check:claims` was missing from this table before 2026-08-31, along
|
||||
with the four added that day. A table of the project's controls that omits a
|
||||
control is the shape those controls exist to catch.)*
|
||||
|
||||
## Before you contribute
|
||||
|
||||
Read **`AGENTS.md`** first, and maintain it as you work — it is the living
|
||||
|
||||
+27
-2
@@ -32,12 +32,37 @@ export default defineConfig({
|
||||
integrations: [
|
||||
mdx(),
|
||||
sitemap({
|
||||
// /legal/* is noindex by spec (docs/04) and nothing else is excluded.
|
||||
// NOINDEX PAGES ARE EXCLUDED, and the list is now three shapes rather
|
||||
// than one. `/legal/*` is noindex by spec (docs/04). The two added at
|
||||
// build step 8 are the intake form's POST-redirect-GET landing pages:
|
||||
// both are transactional, neither has standalone value, and a search
|
||||
// result reading "your inquiry has been received" for someone who has not
|
||||
// sent one is worse than no result at all.
|
||||
//
|
||||
// ⚠️ `/insights/` IS DELIBERATELY NOT HERE even though it emits
|
||||
// `noindex` while no article is published. This filter cannot see
|
||||
// collection data — it runs from build config, with no access to
|
||||
// `getCollection` — so the exclusion could only be a guess at the
|
||||
// collection's state, and it would then be wrong in the direction that
|
||||
// matters the moment an article publishes. The page derives its own
|
||||
// `noindex` from the collection on every build, so the mismatch is
|
||||
// temporary, self-clearing, and reported accurately by Search Console as
|
||||
// excluded-by-noindex. Recorded rather than fixed with a frontmatter
|
||||
// parser in build config.
|
||||
//
|
||||
// The `/type-scale/` half of this condition is gone with the page it
|
||||
// named: the step-1 proof sheet was deleted at step 2, as its own comment
|
||||
// and InfinityMark's both said it would be. Recoverable from git if the
|
||||
// specimen is ever wanted again; it is not a route the site ships.
|
||||
filter: (page) => !page.includes('/legal/'),
|
||||
filter: (page) =>
|
||||
!page.includes('/legal/') &&
|
||||
!page.includes('/contact/received/') &&
|
||||
!page.includes('/contact/could-not-send/') &&
|
||||
// `/bio/` is a condensed duplicate of `/about/` and `/fees/`, and it
|
||||
// exists to be rendered to a PDF (R16). Two URLs competing on the same
|
||||
// content is the thing `docs/04` is most concerned with, so it is
|
||||
// `noindex` and out of the sitemap.
|
||||
!page.includes('/bio/'),
|
||||
changefreq: 'monthly',
|
||||
// No `lastmod`. It was `new Date()`, which stamped every URL with the
|
||||
// build time — telling crawlers all 17 pages changed whenever one did.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The intake handler's OWN field table. Spec: docs/05-backend-spec.md §Form fields.
|
||||
*
|
||||
* ⚠️ THIS IS A SECOND, INDEPENDENT COPY OF THE FORM'S FIELD LIST, AND THE
|
||||
* DUPLICATION IS ARCHITECTURAL RATHER THAN AN OVERSIGHT.
|
||||
*
|
||||
* docs/05: "Client-side validation is a convenience. **The Lambda re-validates
|
||||
* everything.**" A server that validates against a list the client shipped it is
|
||||
* not validating — it is asking the caller what the rules are. And this file is
|
||||
* deployed inside the Lambda zip, which cannot import from `src/` at all.
|
||||
*
|
||||
* WHAT KEEPS THE TWO HONEST IS A CHECK, NOT A SHARED IMPORT.
|
||||
* `npm run check:intake` imports this module and `src/data/intake.ts` and
|
||||
* asserts they agree on every field name, on which are required, on every length
|
||||
* cap, and on every closed option set. A disagreement means either the form
|
||||
* offers something the handler rejects — a lost inquiry that looks like a
|
||||
* browser bug — or the handler accepts something no form ever shows.
|
||||
*
|
||||
* It lives in its own file rather than inside `handler.mjs` so the check can
|
||||
* import it. `handler.mjs` calls `requireEnv()` at module scope and throws
|
||||
* without a configured environment, so importing THAT would mean inventing
|
||||
* fixture credentials to run a check that has nothing to do with them.
|
||||
* (The first version of the check scraped this table out of the handler as text
|
||||
* and evaluated it. Its "refuse anything executable" guard then rejected the
|
||||
* table on the word `process` — which is a FIELD NAME. A guard that fires on the
|
||||
* data it exists to protect is worse than no guard, and the fix was to stop
|
||||
* scraping.)
|
||||
*
|
||||
* `select` and `radio` fields carry their option list, and a value outside it is
|
||||
* REJECTED rather than coerced — a select is a closed set, and a request that
|
||||
* sends something else is not a browser.
|
||||
*
|
||||
* ⚠️ **`label` IS HERE BECAUSE THE CONFIRMATION EMAIL PRINTED FIELD NAMES.**
|
||||
* `summaryLines` was `${f.name}: ${value}`, so the inquirer's receipt read
|
||||
* `practiceArea: Construction`, `otherParties: …`, `opposingCounsel: …`. That
|
||||
* email is the one artefact an inquirer keeps from this practice, and it is also
|
||||
* the artefact that quotes third-party names back at them, so its legibility is
|
||||
* not cosmetic. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
* `npm run check:intake` compares labels as well as names, requiredness, caps
|
||||
* and option sets — so the receipt cannot drift from the form's own wording.
|
||||
*/
|
||||
export const FIELDS = [
|
||||
{ name: 'name', label: 'Your name', required: true, max: 120 },
|
||||
{ name: 'email', label: 'Email', required: true, max: 254 },
|
||||
{ name: 'phone', label: 'Phone', required: false, max: 40 },
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Your role',
|
||||
required: true,
|
||||
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
|
||||
},
|
||||
{
|
||||
name: 'organisation',
|
||||
label: 'Firm or organisation',
|
||||
required: false,
|
||||
max: 160,
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
label: 'Process sought',
|
||||
required: true,
|
||||
options: [
|
||||
'Mediation',
|
||||
'Arbitration',
|
||||
'Med-Arb',
|
||||
'Early neutral evaluation',
|
||||
'Not sure',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'practiceArea',
|
||||
label: 'Subject matter',
|
||||
required: true,
|
||||
options: [
|
||||
'Construction',
|
||||
'Technology',
|
||||
'Energy',
|
||||
'Insurance',
|
||||
'Shareholder',
|
||||
'Cross-border',
|
||||
'Other',
|
||||
],
|
||||
},
|
||||
{ name: 'otherParties', label: 'Other parties', required: false, max: 300 },
|
||||
{
|
||||
name: 'opposingCounsel',
|
||||
label: 'Opposing counsel',
|
||||
required: false,
|
||||
max: 300,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
label: 'What the dispute is about',
|
||||
required: true,
|
||||
max: 2000,
|
||||
},
|
||||
{
|
||||
name: 'timing',
|
||||
label: 'Timing',
|
||||
required: false,
|
||||
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
|
||||
},
|
||||
{
|
||||
name: 'preferredContact',
|
||||
label: 'Preferred reply',
|
||||
required: false,
|
||||
options: ['Email', 'Phone'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The honeypot field name. NOT in `FIELDS`, and that is load-bearing: it is
|
||||
* checked before validation and a non-empty value gets the SUCCESS page, not a
|
||||
* rejection. Telling a bot it was detected is how the next version of the bot
|
||||
* stops filling the field. `check:intake` asserts it is absent from `FIELDS`.
|
||||
*/
|
||||
export const HONEYPOT = 'company_website';
|
||||
|
||||
/**
|
||||
* The SECOND honeypot — a decoy checkbox that must arrive ABSENT. Also not in
|
||||
* `FIELDS`, for the same reason, and `check:intake` asserts that too.
|
||||
*
|
||||
* ⚠️ **DIFFERENT TRAP, NOT A SECOND COPY.** `HONEYPOT` catches a bot that fills
|
||||
* every text input; this catches one that sets every control it enumerates.
|
||||
*
|
||||
* ⚠️ **IT IS PROBABLY INERT AGAINST THE 2026-09-04 PAIR, AND THE COMMENT HERE
|
||||
* SAID THE OPPOSITE FOR ONE ROUND.** They left `HONEYPOT` empty, so they skip
|
||||
* hidden fields — and a bot that skips a hidden text input skips a hidden
|
||||
* checkbox. `src/data/intake.ts` carries the full argument; this is defence in
|
||||
* depth against a different class, not a counter to the observed one.
|
||||
*
|
||||
* ⚠️ **UNCHECKED SENDS NOTHING, so absence is the pass — and so is an empty
|
||||
* value, because the handler tests for a non-empty one rather than for mere
|
||||
* presence.** See
|
||||
* `src/data/intake.ts` for the full reasoning; the two files state it separately
|
||||
* because they are separately deployed and `check:intake` is what keeps the
|
||||
* NAMES in step, not the comments.
|
||||
*/
|
||||
export const DECOY_CHECKBOX = 'updates_optin';
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* The intake handler. Spec: docs/05-backend-spec.md. Resource names, region,
|
||||
* table and SES state: AGENTS.md §7 — this file reads them from the environment
|
||||
* and does not restate them.
|
||||
*
|
||||
* ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and
|
||||
* `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built
|
||||
* `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT
|
||||
* DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on
|
||||
* this file can say: an edit made in that belief ships to a form real inquirers
|
||||
* are using. Changes here reach production on the next `docs/09` Part 5 run.
|
||||
*
|
||||
* ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check
|
||||
* below. `docs/09` §7.1 is the only valid route probe; a 403 without that header
|
||||
* is not evidence about the route. It has been misread as one twice.
|
||||
*
|
||||
* ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ─────────────────────────────
|
||||
*
|
||||
* The site ships ZERO JavaScript (AGENTS.md §7, and it is not "minimal" — none).
|
||||
* So the form is a plain HTML POST, and this handler answers with **303 See
|
||||
* Other** and a `Location` on the site. That gives, with no script anywhere:
|
||||
*
|
||||
* - a working form with JavaScript disabled, which is the failure this whole
|
||||
* project exists to fix;
|
||||
* - no JSON response rendered as a raw page, which is what a plain POST to an
|
||||
* API Gateway JSON endpoint shows the user;
|
||||
* - no double submission on refresh, because the browser lands on a GET.
|
||||
*
|
||||
* docs/05's definition of done asks that the form "degrades to a mailto:
|
||||
* fallback with JavaScript disabled". It does not need to: there is nothing to
|
||||
* degrade FROM, because the form never used script. The email address is
|
||||
* published on /contact/ regardless.
|
||||
*
|
||||
* ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ──────────────────────────────
|
||||
*
|
||||
* ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled
|
||||
* *"raise the timing floor"* after the first real spam. There is no floor to
|
||||
* raise — the check has never existed — and the reason below is unchanged by
|
||||
* the spam arriving: it is a property of a CDN-cached static page, not of how
|
||||
* hard anyone has tried. What CAN carry a per-visitor clock is named in
|
||||
* `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66.
|
||||
*
|
||||
* **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.**
|
||||
* docs/05 asks to "reject submissions completed in under 3 seconds". It cannot
|
||||
* be done here and implementing it would produce a control that does nothing:
|
||||
* the check needs to know when the form was SERVED to that visitor, and
|
||||
* /contact/ is a static file cached at the CloudFront edge. A build-time
|
||||
* timestamp is the same value for every visitor and is hours or days old, so
|
||||
* `now - served` is always large — the check would pass for a bot exactly as it
|
||||
* passes for a human. A per-visitor token needs either a dynamic origin or
|
||||
* client-side script, and the site has neither by design.
|
||||
*
|
||||
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
|
||||
* records of what a control that exists on paper and not in fact costs here. So
|
||||
* it is omitted, said out loud, and the load is carried by the TWO honeypots,
|
||||
* the Origin check, the aggregate API Gateway route throttle and the validation
|
||||
* below — plus, since 2026-09-04, a score that LABELS and never rejects.
|
||||
* (Aggregate, not per-IP — see above; the earlier wording here said "rate
|
||||
* limit" and let the reader supply the stronger meaning.)
|
||||
*
|
||||
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
|
||||
*
|
||||
* - An AGGREGATE API Gateway route throttle. NOT per source IP: API Gateway
|
||||
* throttling is per route and per stage across all callers, so docs/05's
|
||||
* "5 requests / 5 minutes per source IP" is struck — per-IP needs AWS WAF.
|
||||
* Never describe what ships as per-IP. docs/09 Part 6.3.
|
||||
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
|
||||
* - CloudWatch alarms on Lambda `Errors` and on API Gateway 5xx for this
|
||||
* route. NOT a dead-letter queue: `DeadLetterConfig` is used only for
|
||||
* ASYNCHRONOUS invocations, API Gateway invokes synchronously, so a DLQ here
|
||||
* would sit at depth 0 for ever and an alarm on it would be a permanently
|
||||
* green light. docs/05 §Notification carries the replacement.
|
||||
* - The `ses-alerts` SNS email subscription is CONFIRMED (§7) — R9 closed
|
||||
* 2026-09-01, so the bounce and complaint alarms reach someone.
|
||||
*/
|
||||
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
|
||||
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
/* The field table and BOTH honeypot names live in their own module so that
|
||||
`npm run check:intake` can import them without this file's module-scope
|
||||
`requireEnv()` calls running. See fields.mjs for why there are two tables. */
|
||||
import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs';
|
||||
/* Scoring lives in its own module so it can be unit-tested — this file throws at
|
||||
import without a configured environment, so it cannot be. `node
|
||||
backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP:
|
||||
`docs/09` Part 5.1 packages it explicitly, and a cold start would fail with
|
||||
ERR_MODULE_NOT_FOUND if it were left out. */
|
||||
import {
|
||||
isPossibleSpam,
|
||||
scoreSubmission,
|
||||
SPAM_THRESHOLD,
|
||||
} from './spam-score.mjs';
|
||||
|
||||
/* Region comes from the Lambda runtime, which sets AWS_REGION to the function's
|
||||
own region — the one §7 records. Not hardcoded: a second copy of a fact §7
|
||||
owns is the copy that goes stale. */
|
||||
const ddb = new DynamoDBClient({});
|
||||
const ses = new SESv2Client({});
|
||||
|
||||
const TABLE = requireEnv('INTAKE_TABLE');
|
||||
const SITE_ORIGIN = requireEnv('SITE_ORIGIN');
|
||||
const NOTIFY_TO = requireEnv('NOTIFY_TO');
|
||||
const MAIL_FROM = requireEnv('MAIL_FROM');
|
||||
|
||||
/** 24 months, docs/05 §Retention. It must match /legal/privacy/ exactly.
|
||||
* ⚠️ Writing this attribute is NOT the mechanism — TTL must be enabled on the
|
||||
* table, and AGENTS.md §7 records whether it is. */
|
||||
const RETENTION_MONTHS = 24;
|
||||
|
||||
/** The public commitment, §4 and Q27. It must read identically here, on
|
||||
* /contact/, and in any bio. Injected rather than typed so one edit moves all
|
||||
* three; the deploy step sets it from `CONTACT.responseTime`. */
|
||||
const RESPONSE_TIME = requireEnv('RESPONSE_TIME');
|
||||
|
||||
/**
|
||||
* ⚠️ INJECTED FOR EXACTLY THE REASON ABOVE, AND IT WAS HAND-TYPED UNTIL
|
||||
* 2026-08-31. The confirmation email spelled the no-retainer notice out in
|
||||
* prose, which made it a **fourth** hand-copy of `NO_RETAINER_NOTICE` — and the
|
||||
* copy **dropped the fourth clause the constant carries**, *"and does not itself
|
||||
* create a conflict check"*, which `docs/01` §`/contact/` requires. It also used
|
||||
* a hyphen where the constant uses an en dash.
|
||||
*
|
||||
* The reasoning three lines above applied to it identically and was not applied.
|
||||
* `npm run check:intake` compares field tables only, so a future softening of
|
||||
* 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`.
|
||||
*
|
||||
* ⚠️ 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');
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
// Fail at cold start, not per request: a function missing configuration
|
||||
// should not accept a submission it cannot store.
|
||||
throw new Error(`intake handler: ${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML before storage and before anything enters an email body (docs/05).
|
||||
*
|
||||
* NOT AN HTML SANITISER, AND IT DOES NOT NEED TO BE — every field is stored and
|
||||
* rendered as PLAIN TEXT, never as markup, so the job is to make a value
|
||||
* incapable of becoming markup later, not to allow safe markup now.
|
||||
*
|
||||
* ⚠️ AND FOR THAT REASON IT STRIPS ANGLE BRACKETS RATHER THAN ENTITY-ENCODING
|
||||
* THEM. The first version of this function escaped `&` to `&`, which is
|
||||
* correct only when the sink is HTML: both sinks here are plain text, so the
|
||||
* reader of the confirmation email would have received the five literal
|
||||
* characters `&` wherever they had typed an ampersand. Encoding for the
|
||||
* wrong sink is a defect wearing the costume of a protection.
|
||||
*/
|
||||
function toPlainText(value) {
|
||||
return (
|
||||
value
|
||||
// Control characters, including the CR/LF that would let a value forge a
|
||||
// header line in an email, and the C1 range.
|
||||
//
|
||||
// `no-control-regex` is disabled ON PURPOSE and with the reason: that rule
|
||||
// exists to catch a control character that reached a pattern by ACCIDENT,
|
||||
// usually a mis-escaped literal. Here the control range IS the thing being
|
||||
// matched, and it is the one part of this function that stops a submitted
|
||||
// value from forging an email header. Rewriting it as a charCodeAt filter
|
||||
// to satisfy the linter would make the intent less legible, not more.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
// Angle brackets removed rather than entity-encoded. The destination is
|
||||
// plain text — a DynamoDB string attribute and a text/plain email body —
|
||||
// so `&` would REACH THE READER as the five characters "&", which
|
||||
// is a defect rather than a protection. Encoding is right when the sink is
|
||||
// HTML; here the requirement is only that the value can never become
|
||||
// markup if it is later put into one, and no `<` satisfies that
|
||||
// permanently. Nothing else in the value is altered.
|
||||
.replace(/[<>]/g, '')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email validation, server side. Deliberately structural rather than clever:
|
||||
* one @, something either side, a dot in the domain, no whitespace, no angle
|
||||
* brackets, within the RFC 5321 length. A regex that tries to implement RFC 5322
|
||||
* rejects real addresses, and the confirmation email in D18 is the real check —
|
||||
* if it does not arrive, the address was wrong whatever a regex said.
|
||||
*/
|
||||
function looksLikeEmail(value) {
|
||||
return (
|
||||
value.length <= 254 &&
|
||||
/^[^\s@<>]+@[^\s@<>.]+(\.[^\s@<>.]+)+$/.test(value) &&
|
||||
!value.includes('..')
|
||||
);
|
||||
}
|
||||
|
||||
function parseBody(event) {
|
||||
const raw = event.isBase64Encoded
|
||||
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
|
||||
: (event.body ?? '');
|
||||
const type = headerOf(event, 'content-type') ?? '';
|
||||
if (type.includes('application/x-www-form-urlencoded')) {
|
||||
return Object.fromEntries(new URLSearchParams(raw));
|
||||
}
|
||||
// JSON is accepted so the endpoint stays testable with curl, and because a
|
||||
// future island could post JSON without changing this handler.
|
||||
if (type.includes('application/json')) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error('body is not an object');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
throw new Error(`unsupported content-type: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ THE UNFORGEABLE VALUE, AND NOT THE USEFUL ONE. `requestContext.http
|
||||
* .sourceIp` is the TCP peer, which behind the CloudFront behaviour that routes
|
||||
* /api/* is a CloudFront EDGE — so this records AWS rather than the inquirer.
|
||||
*
|
||||
* IT READ `x-forwarded-for` FOR ONE REVISION AND THAT WAS WORSE. CloudFront
|
||||
* APPENDS the viewer address to a client-supplied XFF rather than replacing it,
|
||||
* so the leftmost entry is whatever the client sent: a submission with
|
||||
* `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. That turns a field held for abuse
|
||||
* investigation into one that can be made to name an uninvolved third party, and
|
||||
* /legal/privacy/ promises the record holds "your IP address". A forgeable value
|
||||
* presented as an identification is worse than an honest useless one.
|
||||
*
|
||||
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which
|
||||
* CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request
|
||||
* policy on the /api/* behaviour — the managed AllViewerAndCloudFrontHeaders
|
||||
* forwards Host, which 403s every request at API Gateway.
|
||||
*
|
||||
* ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5,
|
||||
* Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL
|
||||
* DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet
|
||||
* acted on*. What the record holds is published field by field on
|
||||
* /legal/privacy/, so storing a different address is a DISCLOSURE change
|
||||
* governed by `docs/09` §7.2's decision table — an infrastructure change
|
||||
* forwards a header; only a privacy-policy change may store one. Do not "fix"
|
||||
* this from any header without that measurement and that edit.
|
||||
*/
|
||||
function viewerIp(event) {
|
||||
return event.requestContext?.http?.sourceIp ?? 'unknown';
|
||||
}
|
||||
|
||||
function headerOf(event, name) {
|
||||
const headers = event.headers ?? {};
|
||||
// API Gateway HTTP API lowercases header keys; a direct invoke or a test
|
||||
// harness may not, so this does not assume it.
|
||||
const hit = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
||||
return hit ? headers[hit] : undefined;
|
||||
}
|
||||
|
||||
const redirect = (path) => ({
|
||||
statusCode: 303,
|
||||
headers: {
|
||||
Location: `${SITE_ORIGIN}${path}`,
|
||||
// A redirect that a CDN or a browser caches would send the next visitor
|
||||
// straight to the confirmation page without submitting anything.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
body: '',
|
||||
});
|
||||
|
||||
const SUCCESS = '/contact/received/';
|
||||
const FAILURE = '/contact/could-not-send/';
|
||||
|
||||
export async function handler(event) {
|
||||
/**
|
||||
* ORIGIN CHECK, AND IT IS THE CONTROL CORS IS USUALLY MISTAKEN FOR. A form
|
||||
* POST is a top-level navigation: it is exempt from CORS preflight, so an
|
||||
* `Access-Control-Allow-Origin` setting on the endpoint does not stop another
|
||||
* site from posting a form here. Checking the header does.
|
||||
*
|
||||
* Firefox omits `Origin` on some same-origin form navigations, so `Referer` is
|
||||
* accepted as a fallback — both must MATCH the site origin when present, and
|
||||
* a request with neither is refused.
|
||||
*/
|
||||
const origin = headerOf(event, 'origin');
|
||||
const referer = headerOf(event, 'referer');
|
||||
const originOk = origin
|
||||
? origin === SITE_ORIGIN
|
||||
: referer
|
||||
? referer.startsWith(`${SITE_ORIGIN}/`)
|
||||
: false;
|
||||
if (!originOk) {
|
||||
return {
|
||||
statusCode: 403,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = parseBody(event);
|
||||
} catch {
|
||||
return redirect(FAILURE);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE HONEYPOT GETS THE SUCCESS PAGE, NOT AN ERROR. Telling a bot it was
|
||||
* detected is how the next version of the bot stops filling the field. A
|
||||
* human cannot reach this field — it is `display: none`, `tabindex="-1"` and
|
||||
* `aria-hidden` — so a non-empty value is not a mistake anyone made.
|
||||
*/
|
||||
/* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can
|
||||
arrive as `true` or `1` rather than a string — and `typeof === 'string'`
|
||||
let exactly that through both traps for one round. `String(v).trim()`
|
||||
catches every non-empty shape and still treats absence as a pass. */
|
||||
if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') {
|
||||
/* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION
|
||||
AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing
|
||||
on real visitors — a stylesheet that 404s, an autofiller, a template edit
|
||||
that unhides the wrapper — is indistinguishable from quiet weeks, and the
|
||||
only signal is inquiries that were never mentioned again. The FIELD NAME
|
||||
only: the value is whatever a bot chose and nothing about the submission
|
||||
is kept, which is what makes this safe to log at all. */
|
||||
console.warn('intake: discarded by honeypot', { field: HONEYPOT });
|
||||
return redirect(SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no
|
||||
* person can see; an unchecked box sends nothing at all, so a VALUE arrives
|
||||
* only because something ticked it. The value itself is not compared —
|
||||
* `=1`, `=yes` and `=on` are all a tick — only that there is one.
|
||||
*
|
||||
* Same silent SUCCESS as above, and for the same reason.
|
||||
*
|
||||
* ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and
|
||||
* they fail differently:
|
||||
*
|
||||
* - Requiring the field to ARRIVE would turn every dropped-field path — an
|
||||
* extension, a proxy, a template edit — into a lost inquiry reported as
|
||||
* sent.
|
||||
* - Trapping on mere PRESENCE (`!== undefined`) would catch a form
|
||||
* serialiser that emits `updates_optin=` for a hidden checkbox without
|
||||
* reading its checked state. That is rare and it is not impossible, and
|
||||
* the cost of being wrong is a real legal inquiry discarded in silence.
|
||||
*
|
||||
* So the test is the same shape as the honeypot above — a non-empty value —
|
||||
* while the BEHAVIOUR it catches is the opposite one. That is the distinction
|
||||
* that matters: filling text fields versus ticking boxes, not `undefined`
|
||||
* versus `''`.
|
||||
*/
|
||||
if (
|
||||
body[DECOY_CHECKBOX] !== undefined &&
|
||||
String(body[DECOY_CHECKBOX]).trim() !== ''
|
||||
) {
|
||||
console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX });
|
||||
return redirect(SUCCESS);
|
||||
}
|
||||
|
||||
const clean = {};
|
||||
const errors = [];
|
||||
for (const field of FIELDS) {
|
||||
const rawValue = body[field.name];
|
||||
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
||||
if (value === '') {
|
||||
if (field.required) errors.push(`${field.name} is required`);
|
||||
continue;
|
||||
}
|
||||
// REJECT over the cap rather than truncating (docs/05). A silently
|
||||
// truncated matter summary is a file read wrongly.
|
||||
if (field.max && value.length > field.max) {
|
||||
errors.push(`${field.name} exceeds ${field.max} characters`);
|
||||
continue;
|
||||
}
|
||||
if (field.options && !field.options.includes(value)) {
|
||||
errors.push(`${field.name} is not one of the offered values`);
|
||||
continue;
|
||||
}
|
||||
if (field.name === 'email' && !looksLikeEmail(value)) {
|
||||
errors.push('email is not a well-formed address');
|
||||
continue;
|
||||
}
|
||||
clean[field.name] = toPlainText(value);
|
||||
}
|
||||
|
||||
// Explicit, unchecked by default, and required (docs/05). An unchecked box
|
||||
// sends no value at all, so absence is the failure case.
|
||||
if (body.consent !== 'on' && body.consent !== 'true') {
|
||||
errors.push('consent was not given');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Logged for the operator, never returned to the caller: an error list is a
|
||||
// description of the validation rules, which is a gift to whoever is
|
||||
// probing them.
|
||||
console.warn('intake rejected', { errors });
|
||||
return redirect(FAILURE);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
/* NO `|| 0` FALLBACK (removed 2026-08-31): DynamoDB will not expire an item
|
||||
whose TTL is more than five years past, so `ttl: 0` means RETAINED FOREVER
|
||||
while /legal/privacy/ promises deletion. Let a bad value fail the write. */
|
||||
const ttl = Math.floor(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth() + RETENTION_MONTHS,
|
||||
now.getUTCDate(),
|
||||
now.getUTCHours(),
|
||||
now.getUTCMinutes(),
|
||||
now.getUTCSeconds(),
|
||||
) / 1000,
|
||||
);
|
||||
|
||||
/**
|
||||
* DYNAMODB FIRST, THEN MAIL — docs/05: "SES failure must never lose the
|
||||
* submission." The order is the whole guarantee. If SES fails after this
|
||||
* write, the record exists and a resend has something to resend; if the write
|
||||
* fails, nothing was accepted and the inquirer is told so. (This said "the DLQ
|
||||
* replay" — there is no DLQ and there cannot usefully be one on a
|
||||
* synchronously invoked function; see the note at the top of this file.)
|
||||
*/
|
||||
try {
|
||||
await ddb.send(
|
||||
new PutItemCommand({
|
||||
TableName: TABLE,
|
||||
Item: {
|
||||
/* ⚠️ `submissionId` IS THE TABLE'S PARTITION KEY AND THERE IS NO SORT
|
||||
KEY. A DynamoDB key schema cannot be altered after creation, so this
|
||||
attribute name is fixed by the table `AGENTS.md` §7 names, not
|
||||
chosen here — and an item missing it fails the whole write with
|
||||
`ValidationException`, which this function converts into the failure
|
||||
page. Verify against `describe-table` before changing either name;
|
||||
`submittedAt` is an ordinary attribute and is free. */
|
||||
submissionId: { S: id },
|
||||
submittedAt: { S: now.toISOString() },
|
||||
ttl: { N: String(ttl) },
|
||||
// Abuse investigation only (docs/05). Named so a later reader does not
|
||||
// repurpose them: they are not analytics and not part of the reply.
|
||||
/* Behind CloudFront this is the EDGE address, not the inquirer's.
|
||||
See `viewerIp()` — and read it before changing this. */
|
||||
sourceIp: { S: viewerIp(event) },
|
||||
userAgent: {
|
||||
S: (headerOf(event, 'user-agent') ?? 'unknown').slice(0, 400),
|
||||
},
|
||||
consentAt: { S: now.toISOString() },
|
||||
...Object.fromEntries(
|
||||
Object.entries(clean).map(([k, v]) => [k, { S: v }]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('intake: DynamoDB write failed', error);
|
||||
return redirect(FAILURE);
|
||||
}
|
||||
|
||||
// `f.label`, not `f.name` — see the note on `label` in fields.mjs. The
|
||||
// notification to the operator gets the same rendering: one shape, so the two
|
||||
// messages cannot describe the same submission differently.
|
||||
const summaryLines = FIELDS.filter((f) => clean[f.name] !== undefined)
|
||||
.map((f) => `${f.label}: ${clean[f.name]}`)
|
||||
.join('\n');
|
||||
|
||||
/**
|
||||
* SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04.
|
||||
*
|
||||
* ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF
|
||||
* ORDERING. Nothing below can decline a submission: by the time it runs, the
|
||||
* write has already succeeded and the only remaining question is what the
|
||||
* OPERATOR's subject line says. There is deliberately no branch here that can
|
||||
* reach `redirect(FAILURE)`.
|
||||
*
|
||||
* ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is
|
||||
* unchanged. A real inquirer wrongly scored must never be told that a machine
|
||||
* thought they were a bot.
|
||||
*/
|
||||
/* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception
|
||||
here would escape `handler`, API Gateway would answer 500, and the inquirer
|
||||
would see a failure for a submission ALREADY WRITTEN to the table — a path
|
||||
that costs an inquiry, decided by a labelling function. Pouya's constraint
|
||||
is that nothing but a honeypot may cost one, so the scorer is allowed to
|
||||
fail and the submission is not. Unlabelled is the safe default. */
|
||||
let spam = { score: 0, signals: [] };
|
||||
try {
|
||||
spam = scoreSubmission(clean);
|
||||
} catch (error) {
|
||||
console.error('intake: spam scoring failed; sending unlabelled', {
|
||||
id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
const flagged = isPossibleSpam(spam);
|
||||
const notificationBody = [
|
||||
`Received ${now.toISOString()}`,
|
||||
`submissionId ${id}`,
|
||||
...(flagged
|
||||
? [
|
||||
'',
|
||||
`Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` +
|
||||
`Signals: ${spam.signals.join('; ')}.`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
summaryLines,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* TWO EMAILS — D18, and the second one is why the form beats a mailto: link.
|
||||
* `Promise.allSettled`, not `Promise.all`: the record is already stored, so a
|
||||
* failure on either message must be logged rather than lost, and one failing
|
||||
* must not prevent the other from being attempted.
|
||||
*/
|
||||
const results = await Promise.allSettled([
|
||||
ses.send(
|
||||
new SendEmailCommand({
|
||||
FromEmailAddress: MAIL_FROM,
|
||||
Destination: { ToAddresses: [NOTIFY_TO] },
|
||||
// Replyable to the inquirer (docs/05), which is what makes the
|
||||
// notification usable without copying an address out of it.
|
||||
ReplyToAddresses: [clean.email],
|
||||
Content: {
|
||||
Simple: {
|
||||
/* The prefix is what Pouya filters on in Gmail, so it is the
|
||||
FIRST thing in the subject and it is a fixed string. Do not make
|
||||
it conditional on anything else, and do not vary its wording. */
|
||||
Subject: {
|
||||
Data:
|
||||
`${flagged ? '[Possible spam] ' : ''}` +
|
||||
`Intake — ${clean.name} (${clean.practiceArea})`,
|
||||
},
|
||||
Body: {
|
||||
// The body carries the bare submissionId, because it is the
|
||||
// partition key: that line gets pasted into the console to find
|
||||
// the record, so it must be the key and not a rendering of it.
|
||||
Text: { Data: notificationBody },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
ses.send(
|
||||
new SendEmailCommand({
|
||||
FromEmailAddress: MAIL_FROM,
|
||||
Destination: { ToAddresses: [clean.email] },
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: 'Your inquiry has been received' },
|
||||
Body: {
|
||||
Text: {
|
||||
Data: [
|
||||
`Thank you — your inquiry has been received.`,
|
||||
``,
|
||||
RESPONSE_TIME,
|
||||
``,
|
||||
NO_RETAINER_NOTICE,
|
||||
``,
|
||||
`What you sent:`,
|
||||
``,
|
||||
summaryLines,
|
||||
``,
|
||||
`How this information is handled, and how to ask for it to be`,
|
||||
`deleted: ${SITE_ORIGIN}/legal/privacy/`,
|
||||
``,
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`intake: SES send ${i === 0 ? 'notification' : 'confirmation'} failed`,
|
||||
{ id, reason: result.reason },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The submission is stored. Mail failures are an operator problem, not the
|
||||
// inquirer's, and telling them it failed would invite a second submission of
|
||||
// a record that already exists.
|
||||
return redirect(SUCCESS);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Spam SCORING for the intake handler. Pouya's ruling, 2026-09-04.
|
||||
*
|
||||
* ⚠️ **THIS MODULE NEVER REJECTS ANYTHING, AND THAT IS THE WHOLE DESIGN.** It
|
||||
* returns a score and a list of signal names. The handler stores the record and
|
||||
* sends both emails either way; above the threshold it prefixes the OPERATOR
|
||||
* notification's subject with `[Possible spam] ` and adds one line naming the
|
||||
* signals. Pouya filters in Gmail. His words: *"Nothing is dropped; a false
|
||||
* positive costs him one glance."*
|
||||
*
|
||||
* That asymmetry is why the thresholds below can be tuned aggressively. The cost
|
||||
* of a false positive is a subject-line prefix; the cost of a false negative is
|
||||
* one unlabelled email. Neither loses an inquiry — which a filter that rejected
|
||||
* would, and a legal inquiry lost silently is the one outcome this form must not
|
||||
* produce.
|
||||
*
|
||||
* ⚠️ **NOTHING HERE IS STORED.** The score and the signals do not enter the
|
||||
* DynamoDB item. `/legal/privacy/` publishes what the record holds, field by
|
||||
* field, and adding an attribute would make that list wrong — a disclosure
|
||||
* defect, not a schema change. The label lives only in the operator
|
||||
* notification. ⚠️ **THAT MAILBOX IS DELEGATED, NOT PERSONAL** — §9 Q63 and
|
||||
* `/legal/privacy/` §Who can see it both say so, and an earlier draft of this
|
||||
* comment said the label "lives in an email that only Pouya reads", which is the
|
||||
* exclusivity Q63 struck. It reaches whoever reads `info@smlcompany.ca`. If a
|
||||
* stored score is ever wanted, the page changes first.
|
||||
*
|
||||
* ⚠️ **AND THE INQUIRER NEVER SEES ANY OF THIS.** The confirmation email is
|
||||
* untouched. A person wrongly scored must not be told a machine thought they
|
||||
* were a bot.
|
||||
*
|
||||
* WHY SCORING RATHER THAN MORE REJECTION. The two submissions of 2026-09-04
|
||||
* (`docs/05` §Observed abuse) passed the honeypot. Every rule that would have
|
||||
* caught them — a foreign phone, a link in the summary, a disposable-looking
|
||||
* address — is a rule some real inquirer also trips: this practice takes
|
||||
* cross-border commercial work, so a `+44` number is a client, not a bot. A
|
||||
* rejecting rule set built from those signals would eventually discard a real
|
||||
* dispute and report success while doing it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ⚠️ **NOT MEASURED FROM A CORPUS — THERE IS NO CORPUS.** No genuine inquiry has
|
||||
* arrived through this form yet, so there is nothing to measure a normal summary
|
||||
* length against, and a number presented as measured when it is not is the
|
||||
* defect `AGENTS.md` keeps paying for.
|
||||
*
|
||||
* It is DERIVED, and the derivation is the form's own instruction: the `summary`
|
||||
* field's hint reads *"A few sentences is enough."* This floor sits **below** what
|
||||
* that invites, so it fires on a summary that does not attempt the question
|
||||
* rather than on one that answers it briefly. `[assumed 2026-09-04]`
|
||||
*
|
||||
* ⚠️ **TUNE IT DOWN WHEN IN DOUBT, NEVER UP.** An unlabelled spam costs nothing
|
||||
* that matters; a labelled real inquiry spends the reader's trust in the label.
|
||||
* *"Shareholder dispute, two directors, Ontario CBCA company."* is 57 characters
|
||||
* and is exactly what the hint asks for — a floor above that scores the form's
|
||||
* own instruction as a spam signal.
|
||||
*
|
||||
* **Pouya can replace this with a measurement whenever he likes** — the two spam
|
||||
* records of 2026-09-04 are still in the table, and their `summary` lengths are
|
||||
* the first real data this number could rest on. §9 Q65 records that.
|
||||
*/
|
||||
export const SHORT_SUMMARY_CHARS = 100;
|
||||
|
||||
/**
|
||||
* Above this, the notification is labelled. Weights below are 1 for a signal a
|
||||
* real inquirer plausibly trips and 2 for one they rarely do, so the threshold
|
||||
* of 2 means: **one strong signal, or two weak ones.**
|
||||
*
|
||||
* Worked, because a threshold nobody has worked through is a guess with a number
|
||||
* on it:
|
||||
* - Ontario counsel, local number, three-line summary → 0, clean
|
||||
* - Cross-border counsel, `+44` number, three-line summary → 1, clean
|
||||
* - Cross-border counsel, `+44` number, one-line summary → 2, LABELLED
|
||||
* - A four-part real name at gmail.com → 1, clean
|
||||
* - Anyone pasting a link to a public tender document → 2, LABELLED
|
||||
* - foreign number + a short scraped summary carrying a link → 4, LABELLED
|
||||
*
|
||||
* The third and fourth rows are the accepted false positives. Both are real
|
||||
* shapes, both cost one glance, and both were preferred to missing the fifth.
|
||||
*
|
||||
* ⚠️ **THE LAST ROW IS A SHAPE, NOT A MEASUREMENT OF THE TWO 2026-09-04
|
||||
* SUBMISSIONS. THOSE RECORDS WERE NEVER READ.** What the attested signature
|
||||
* guarantees is a non-NANP phone — **one weak signal** — and whether either is
|
||||
* labelled turns on facts only the two rows in the table hold. §9 Q65 records
|
||||
* that they are still there and are the only real data any of these numbers
|
||||
* could rest on.
|
||||
*/
|
||||
export const SPAM_THRESHOLD = 2;
|
||||
|
||||
/** `https://…` or `www.…` only. A bare `acme.com` is NOT matched: an inquirer
|
||||
* writing "the dispute concerns acme.com's supply contract" is describing a
|
||||
* party, and matching that would label ordinary commercial prose. */
|
||||
const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)\S/i;
|
||||
|
||||
/**
|
||||
* NANP: an explicit `+<cc>` settles it; otherwise ten digits, or eleven
|
||||
* beginning with 1, after the tail is dropped.
|
||||
*
|
||||
* ⚠️ **A DIGIT COUNT ALONE CANNOT DO THIS.** `416-555-0123 ext 22` is twelve
|
||||
* digits, `416-555-0123 or 416-555-0124` is twenty, and both are ordinary
|
||||
* Toronto numbers that a bare count calls foreign — a signal saying the opposite
|
||||
* of the truth. The tail is dropped at the first extension marker or
|
||||
* second-number separator, and the marker list is deliberately generous.
|
||||
*
|
||||
* **The country code is read FIRST because it is the only unambiguous thing in
|
||||
* the field.** `+44 …` and `+7 …` are settled without counting anything, which
|
||||
* is what a pure shape test cannot do: `+7 912 345 6789` is grouped 3-3-4
|
||||
* exactly like a NANP number, so matching the shape would call it Canadian.
|
||||
* Only when there is no explicit country code does the digit count run, and then
|
||||
* the tail is dropped at the first extension marker or second-number separator.
|
||||
*/
|
||||
function looksNorthAmerican(phone) {
|
||||
const trimmed = phone.trim();
|
||||
/* An explicit international prefix is decisive in both directions. */
|
||||
const cc = trimmed.match(/^\+\s*(\d{1,3})/);
|
||||
if (cc) return cc[1] === '1';
|
||||
/* Longest alternative FIRST: regex alternation is leftmost-first, so `ext`
|
||||
placed before `extension` matches the first three letters and then relies on
|
||||
backtracking. Ordering it correctly is cheaper than depending on that.
|
||||
`\bx\b` would NOT match the `x` in `x22` — the digit after it is a word
|
||||
character, so there is no boundary — which is how `(416) 555-0123 x22` scored
|
||||
foreign for one round. The marker is matched by what FOLLOWS it. */
|
||||
const digits = trimmed
|
||||
.split(/\s*(?:extension|extn|ext|x)[.:-]?\s*\d|[#,;]|\bor\b/i)[0]
|
||||
.replace(/\D/g, '');
|
||||
return digits.length === 10 || (digits.length === 11 && digits[0] === '1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The Gmail dot trick: one mailbox, unlimited distinct-looking addresses,
|
||||
* because Gmail ignores dots in the local part.
|
||||
*
|
||||
* ⚠️ **A DOT IS NOT THE SIGNAL, AND TREATING IT AS ONE WOULD LABEL MOST REAL
|
||||
* GMAIL USERS.** `first.last@gmail.com` is the single most ordinary form a Gmail
|
||||
* address takes. What distinguishes the trick is dot DENSITY: **three or more
|
||||
* dots**, and nothing else.
|
||||
*
|
||||
* ⚠️ **AND THE WEIGHT IS 1, NOT 2, WHICH MATTERS MORE THAN THE BOUNDARY DOES.**
|
||||
* `mary.jane.o.brien@gmail.com` and `maria.de.la.cruz@gmail.com` are three-dot
|
||||
* REAL names — compound surnames and middle initials are ordinary, not rare —
|
||||
* and weight 2 is defined here as what a real inquirer rarely trips. At weight 1
|
||||
* nothing can be labelled on the shape of its owner's name alone; a genuine
|
||||
* dot-trick address reaches the threshold as soon as it trips anything else,
|
||||
* which spam reliably does. **Do not raise it back.**
|
||||
*/
|
||||
function looksLikeGmailDotTrick(email) {
|
||||
const at = email.lastIndexOf('@');
|
||||
if (at < 1) return false;
|
||||
const local = email.slice(0, at);
|
||||
const domain = email.slice(at + 1).toLowerCase();
|
||||
if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false;
|
||||
const dots = local.split('.').length - 1;
|
||||
return dots >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} fields the handler's `clean` map — validated,
|
||||
* plain-texted values, keyed by field name. Absent fields are simply absent.
|
||||
* @returns {{score: number, signals: string[]}} `signals` are written for a
|
||||
* human reading one line of an email, not for a machine.
|
||||
*/
|
||||
export function scoreSubmission(fields) {
|
||||
const signals = [];
|
||||
let score = 0;
|
||||
const add = (weight, label) => {
|
||||
score += weight;
|
||||
signals.push(label);
|
||||
};
|
||||
|
||||
const summary = fields.summary ?? '';
|
||||
const phone = fields.phone ?? '';
|
||||
const email = fields.email ?? '';
|
||||
|
||||
/* Only when a summary exists. An absent one is a validation failure the
|
||||
handler has already turned into the failure page, so scoring an empty
|
||||
string here would be scoring a submission that never got this far. */
|
||||
if (summary !== '' && summary.length < SHORT_SUMMARY_CHARS) {
|
||||
add(1, `summary under ${SHORT_SUMMARY_CHARS} characters`);
|
||||
}
|
||||
/* `phone` is OPTIONAL. Not giving one is not a signal — most inquirers will
|
||||
not — so this fires only on a number that is present and not North
|
||||
American. Treating absence as suspicious would label the quiet majority. */
|
||||
if (phone !== '' && !looksNorthAmerican(phone)) {
|
||||
add(1, 'phone is not a Canadian or US number');
|
||||
}
|
||||
if (URL_IN_TEXT.test(summary)) {
|
||||
add(2, 'link in the dispute summary');
|
||||
}
|
||||
if (looksLikeGmailDotTrick(email)) {
|
||||
add(1, 'Gmail address using the dot trick');
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/** True when the operator notification should carry the label. */
|
||||
export const isPossibleSpam = ({ score }) => score >= SPAM_THRESHOLD;
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Unit test for the intake spam scorer. `node backend/intake/spam-score.test.mjs`.
|
||||
*
|
||||
* Same shape and same reasoning as `infra/cloudfront/router.test.mjs`: the real
|
||||
* check is a real submission, this one runs in a second and catches the branch
|
||||
* mistakes that a regex change makes silently.
|
||||
*
|
||||
* ⚠️ **EVERY SIGNAL SHIPS WITH A NEGATIVE FIXTURE**, which is the discipline
|
||||
* `CLAUDE.md` imposes on `check:claims` and applies here for the same reason:
|
||||
* this scorer's failure mode is not missing spam, it is labelling a real
|
||||
* inquiry. The pairs below are the nearest legitimate submission to each trap —
|
||||
* `j.k.smith@gmail.com` beside the dot trick, an extension-carrying Toronto
|
||||
* number beside a Russian one, ordinary commercial prose naming a company
|
||||
* beside a pasted link.
|
||||
*
|
||||
* ⚠️ **EACH CASE ASSERTS THE SIGNAL NAMES, NOT ONLY THE SCORE.** Asserting the
|
||||
* total alone lets two rules swap weights, or one rule fire in place of
|
||||
* another, with every case still passing — the suite would then be checking
|
||||
* arithmetic rather than behaviour. `expected` is the exact signal set.
|
||||
*/
|
||||
import {
|
||||
scoreSubmission,
|
||||
isPossibleSpam,
|
||||
SPAM_THRESHOLD,
|
||||
SHORT_SUMMARY_CHARS,
|
||||
} from './spam-score.mjs';
|
||||
|
||||
const SHORT = `summary under ${SHORT_SUMMARY_CHARS} characters`;
|
||||
const PHONE = 'phone is not a Canadian or US number';
|
||||
const LINK = 'link in the dispute summary';
|
||||
const GMAIL = 'Gmail address using the dot trick';
|
||||
|
||||
const MID =
|
||||
'A construction lien dispute over a delayed fit-out. Counsel are engaged ' +
|
||||
'on both sides and we want a mediator.';
|
||||
|
||||
const LONG =
|
||||
'The parties are in dispute over a delayed fit-out on a Toronto office ' +
|
||||
'tower. The subcontract was terminated in June and the holdback has not ' +
|
||||
'been released. Counsel are engaged on both sides and we are looking for a ' +
|
||||
'mediator with construction experience.';
|
||||
|
||||
/* [label, fields, expected signals] — score and labelled are DERIVED from the
|
||||
weights below, so a weight change fails every affected case by name rather
|
||||
than silently re-balancing the totals. */
|
||||
const WEIGHTS = { [SHORT]: 1, [PHONE]: 1, [LINK]: 2, [GMAIL]: 1 };
|
||||
|
||||
const CASES = [
|
||||
// ---- clean submissions, which is the half that matters most -------------
|
||||
[
|
||||
'ordinary Ontario inquiry',
|
||||
{ summary: LONG, phone: '416-555-0123', email: 'a.counsel@firm.ca' },
|
||||
[],
|
||||
],
|
||||
['no phone given at all', { summary: LONG, email: 'counsel@firm.ca' }, []],
|
||||
[
|
||||
'+1 with punctuation',
|
||||
{ summary: LONG, phone: '+1 (647) 555-0188', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'ten digits, no punctuation',
|
||||
{ summary: LONG, phone: '6475550188', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'Toronto number with an extension',
|
||||
{ summary: LONG, phone: '416-555-0123 ext 22', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'extension written x22',
|
||||
{ summary: LONG, phone: '(416) 555-0123 x22', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'extension written Ext:',
|
||||
{ summary: LONG, phone: '416-555-0123 Ext: 4501', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'extension spelled out',
|
||||
{ summary: LONG, phone: '416-555-0123 extension 22', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'extension hyphenated',
|
||||
{ summary: LONG, phone: '416-555-0123 ext-22', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'two numbers in one field',
|
||||
{
|
||||
summary: LONG,
|
||||
phone: '416-555-0123 or 416-555-0124',
|
||||
email: 'c@firm.ca',
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'ordinary gmail, one dot',
|
||||
{ summary: LONG, phone: '416-555-0123', email: 'first.last@gmail.com' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'gmail, single initial',
|
||||
{ summary: LONG, phone: '416-555-0123', email: 'j.smith@gmail.com' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'gmail, TWO initials and a surname',
|
||||
{ summary: LONG, email: 'j.k.smith@gmail.com' },
|
||||
[],
|
||||
],
|
||||
/* ⚠️ NON-GMAIL, THREE DOTS — this pins the DOMAIN GUARD, which nothing did.
|
||||
Deleting `if (domain !== 'gmail.com' && …) return false` left all 30 cases
|
||||
passing: the nearest legitimate submission to a three-dot trap is a
|
||||
three-dot address at a firm domain, and it was the one fixture missing. */
|
||||
[
|
||||
'law-firm address, three dots',
|
||||
{ summary: LONG, email: 'j.p.van.dam@blakes.com' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'four-part real name at gmail',
|
||||
{
|
||||
summary: LONG,
|
||||
phone: '416-555-0123',
|
||||
email: 'mary.jane.o.brien@gmail.com',
|
||||
},
|
||||
[GMAIL],
|
||||
],
|
||||
[
|
||||
'company named in prose, no link',
|
||||
{ summary: `${LONG} The respondent is acme.com Ltd.`, email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'googlemail, one dot',
|
||||
{ summary: LONG, email: 'first.last@googlemail.com' },
|
||||
[],
|
||||
],
|
||||
|
||||
// ---- one weak signal: still clean ---------------------------------------
|
||||
[
|
||||
'cross-border counsel, UK number',
|
||||
{ summary: LONG, phone: '+44 20 7946 0958', email: 'c@firm.co.uk' },
|
||||
[PHONE],
|
||||
],
|
||||
[
|
||||
'the concise summary the hint invites',
|
||||
{
|
||||
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
|
||||
phone: '416-555-0123',
|
||||
email: 'c@firm.ca',
|
||||
},
|
||||
[SHORT],
|
||||
],
|
||||
|
||||
// ---- boundaries ----------------------------------------------------------
|
||||
/* PINS THE FLOOR'S VALUE, which the two boundary cases below cannot: they
|
||||
derive their lengths from `SHORT_SUMMARY_CHARS`, so they move with it and
|
||||
a floor raised back to 140 passed them silently. This one is a literal
|
||||
109-character summary of the kind the form's hint invites, and it fails the
|
||||
moment the floor rises above it. */
|
||||
[
|
||||
'a realistic 109-character summary',
|
||||
{ summary: MID, email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'summary exactly at the floor',
|
||||
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS), email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'summary one under the floor',
|
||||
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS - 1), email: 'c@firm.ca' },
|
||||
[SHORT],
|
||||
],
|
||||
[
|
||||
'eleven digits not starting 1',
|
||||
{ summary: LONG, phone: '+7 912 345 6789', email: 'c@firm.ca' },
|
||||
[PHONE],
|
||||
],
|
||||
/* ⚠️ TEN DIGITS IN TOTAL, AND FOREIGN — Iceland writes +354 followed by seven.
|
||||
This is the ONE case that pins the country-code branch: without it the
|
||||
digit count reads 10 and calls this a NANP number. Every other foreign
|
||||
fixture here has 11+ digits, so the count agrees by accident and the
|
||||
branch could be deleted with the whole suite still green. */
|
||||
[
|
||||
'ten-digit international number',
|
||||
{ summary: LONG, phone: '+354 555 1234', email: 'c@firm.is' },
|
||||
[PHONE],
|
||||
],
|
||||
[
|
||||
'gmail, exactly two dots',
|
||||
{ summary: LONG, email: 'a.b.smith@gmail.com' },
|
||||
[],
|
||||
],
|
||||
[
|
||||
'gmail, exactly three dots',
|
||||
{ summary: LONG, email: 'a.b.c.smith@gmail.com' },
|
||||
[GMAIL],
|
||||
],
|
||||
|
||||
// ---- two weak signals: labelled ------------------------------------------
|
||||
[
|
||||
'foreign number and terse summary',
|
||||
{
|
||||
summary: 'Need a mediator.',
|
||||
phone: '+7 912 345 6789',
|
||||
email: 'c@firm.ru',
|
||||
},
|
||||
[SHORT, PHONE],
|
||||
],
|
||||
|
||||
// ---- one strong signal: labelled -----------------------------------------
|
||||
[
|
||||
'link in the summary',
|
||||
{ summary: `${LONG} See https://example.com/tender`, email: 'c@firm.ca' },
|
||||
[LINK],
|
||||
],
|
||||
[
|
||||
'www link in the summary',
|
||||
{ summary: `${LONG} See www.example.com/tender`, email: 'c@firm.ca' },
|
||||
[LINK],
|
||||
],
|
||||
[
|
||||
'dot trick, four dots',
|
||||
{ summary: LONG, email: 'j.o.h.nsmith@gmail.com' },
|
||||
[GMAIL],
|
||||
],
|
||||
/* ⚠️ WEIGHT 1, SO IT DOES NOT LABEL ALONE. That is the whole point of the
|
||||
weight change, and this is the case that fails if it goes back to 2. */
|
||||
[
|
||||
'dot trick alone does not label',
|
||||
{ summary: LONG, email: 'r.a.n.d.om@gmail.com' },
|
||||
[GMAIL],
|
||||
],
|
||||
|
||||
// ---- the shape the 2026-09-04 pair is described as ------------------------
|
||||
// NOT a measurement of those records: their `summary` values were never read.
|
||||
[
|
||||
'scraped text, foreign number, link',
|
||||
{
|
||||
summary: 'Buy now at https://spam.example/offer',
|
||||
phone: '+7 912 345 6789',
|
||||
email: 'r.a.n.d.om@gmail.com',
|
||||
},
|
||||
[SHORT, PHONE, LINK, GMAIL],
|
||||
],
|
||||
/* The module's own worked example of a legitimate concise summary, beside a
|
||||
Toronto direct line. It scored 2 and shipped `[Possible spam]` while the
|
||||
extension strip was incomplete. */
|
||||
[
|
||||
'concise summary + Toronto extension',
|
||||
{
|
||||
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
|
||||
phone: '416-555-0123 ext: 4501',
|
||||
email: 'c@firm.ca',
|
||||
},
|
||||
[SHORT],
|
||||
],
|
||||
// The attested signature ALONE — a non-NANP phone and nothing else known —
|
||||
// is one weak signal and is NOT labelled. Kept as a case so the limit of what
|
||||
// the observed evidence supports is asserted rather than described.
|
||||
[
|
||||
'attested signature alone',
|
||||
{ summary: LONG, phone: '+7 912 345 6789', email: 'random@gmail.com' },
|
||||
[PHONE],
|
||||
],
|
||||
|
||||
// ---- absent fields must not throw or score -------------------------------
|
||||
['empty object', {}, []],
|
||||
[
|
||||
'summary absent, phone local',
|
||||
{ phone: '416-555-0123', email: 'c@firm.ca' },
|
||||
[],
|
||||
],
|
||||
['email absent', { summary: LONG }, []],
|
||||
['malformed email, no @', { summary: LONG, email: 'not-an-address' }, []],
|
||||
['gmail with no local part', { summary: LONG, email: '@gmail.com' }, []],
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
const seen = new Set();
|
||||
for (const [label, fields, expected] of CASES) {
|
||||
const result = scoreSubmission(fields);
|
||||
expected.forEach((sig) => seen.add(sig));
|
||||
const wantScore = expected.reduce((n, sig) => n + WEIGHTS[sig], 0);
|
||||
const wantLabelled = wantScore >= SPAM_THRESHOLD;
|
||||
const gotSignals = [...result.signals].sort();
|
||||
const wantSignals = [...expected].sort();
|
||||
const ok =
|
||||
result.score === wantScore &&
|
||||
isPossibleSpam(result) === wantLabelled &&
|
||||
JSON.stringify(gotSignals) === JSON.stringify(wantSignals);
|
||||
if (ok) {
|
||||
pass += 1;
|
||||
} else {
|
||||
failures.push(
|
||||
` ${label}\n` +
|
||||
` expected score ${wantScore}, labelled ${wantLabelled}, signals ${JSON.stringify(wantSignals)}\n` +
|
||||
` got score ${result.score}, labelled ${isPossibleSpam(result)}, signals ${JSON.stringify(gotSignals)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* COVERAGE, ASSERTED RATHER THAN ASSUMED. A rule with no positive case is a rule
|
||||
nobody has run, and it would still show a green suite. */
|
||||
for (const sig of Object.keys(WEIGHTS)) {
|
||||
if (!seen.has(sig)) {
|
||||
failures.push(` no case exercises the "${sig}" signal — it is untested.`);
|
||||
}
|
||||
}
|
||||
|
||||
/* The threshold is part of the contract the cases above were written against.
|
||||
Changing it without re-deriving them would leave every expectation a
|
||||
statement about a threshold that no longer exists. */
|
||||
if (SPAM_THRESHOLD !== 2) {
|
||||
failures.push(
|
||||
` SPAM_THRESHOLD is ${SPAM_THRESHOLD}, not 2 — the weights and expectations ` +
|
||||
'above were written against 2. Re-derive them before changing it.',
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`spam-score: ${failures.length} FAILED of ${CASES.length}`);
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`spam-score: ${pass} of ${CASES.length} cases pass; all ${Object.keys(WEIGHTS).length} signals exercised`,
|
||||
);
|
||||
+146
-24
@@ -49,7 +49,49 @@ decision, not an aesthetic one.
|
||||
/legal/terms/ Terms of use
|
||||
```
|
||||
|
||||
Nineteen fixed URLs plus one per article.
|
||||
Nineteen fixed URLs plus one per article — **and four more, all `noindex` and all
|
||||
excluded from the sitemap in `astro.config.mjs`.** They are utilities rather than
|
||||
pages in the IA above, which is why they are listed here rather than in it:
|
||||
|
||||
```
|
||||
/contact/received/ The intake form's success landing page
|
||||
/contact/could-not-send/ Its failure landing page
|
||||
/bio/ Source of the one-page PDF (R16)
|
||||
/404/ Emitted as dist/404.html — see below
|
||||
```
|
||||
|
||||
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
|
||||
the form is a plain POST and the handler answers `303 See Other` to a real URL —
|
||||
`docs/05` §Build step 8 has the reasoning. `/bio/` exists so the PDF circulated
|
||||
with an appointment proposal is a *rendering of a reviewed page* rather than a
|
||||
document outside this project's review apparatus.
|
||||
|
||||
⚠️ **`/404/` IS THE ONE ROUTE THAT BREAKS THIS DOCUMENT'S OWN URL RULES, AND IT
|
||||
HAS TO.** The rules above are lower-case, hyphenated, trailing slash, no file
|
||||
extensions. Astro emits `src/pages/404.astro` as **`dist/404.html`** — a file at
|
||||
the root, outside `build.format: 'directory'` — because that is the object name a
|
||||
CDN custom error response can point at. `SEO.astro` still sees the path as
|
||||
`/404/`, which is why its `OG_CARDS` key is `/404/` while the URL a tool fetches
|
||||
is `/404.html`. Added 2026-09-01; `docs/04` had asked for the page since before
|
||||
build step 1 and it did not exist.
|
||||
|
||||
**So: 23 built pages plus one per published article.**
|
||||
|
||||
⚠️ **AND THIS SENTENCE USED TO END WITH A REASSURANCE THAT WAS DISPROVEN THE DAY
|
||||
THE 404 PAGE WAS ADDED.** It read: *"`npm run lighthouse` enumerates them from
|
||||
`dist/` rather than from this list, which is why this count being stale could never
|
||||
make the gate miss a page."* Both `scripts/lighthouse.mjs` and
|
||||
`scripts/og-proof.mjs` enumerated **`index.html` under `dist/`**, not every page —
|
||||
so both missed `/404/`, and `og:proof` reported it backwards, as an orphaned card
|
||||
rather than an unchecked page. The count being stale was not the failure mode; the
|
||||
**definition of "a page"** was. Both now take any `.html` at the root as well, and
|
||||
`check:claims` always did, which is why the new page's copy was inside the claim
|
||||
register from its first build.
|
||||
|
||||
**The rule that replaces the reassurance:** a route that does not live at
|
||||
`<dir>/index.html` is invisible to anything that looks for `index.html`. If a
|
||||
future page is emitted outside the directory convention, grep the three scripts
|
||||
for `index.html` before trusting any of them.
|
||||
|
||||
### URL rules
|
||||
|
||||
@@ -191,8 +233,30 @@ to an appointment. This page carries the verifiable record.
|
||||
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
||||
an asset for circulation with appointment proposals.
|
||||
|
||||
> **The PDF bio ships at BUILD STEP 9, alongside `/fees/` — deferred by Pouya
|
||||
> 2026-08-28 (Q45), tracked as `AGENTS.md` §12 **R16**.** His reasoning: it is a
|
||||
> ✅ **SHIPPED AT BUILD STEP 9, 2026-08-31. R16 / Q45 DISCHARGED.**
|
||||
> `public/pouya-lajevardi-bio.pdf` exists, is committed, and this page links it
|
||||
> between the biography and the credentials.
|
||||
>
|
||||
> **The two decisions R16 left open are both taken, and the second makes the
|
||||
> first safe.** *(a)* Neither "generated at build" nor "authored once": the bio
|
||||
> is a **page**, `src/pages/bio.astro`, so every line is reviewed by the same
|
||||
> apparatus as every other page — and `npm run bio:pdf` renders the PDF from the
|
||||
> built page through the Chrome that Lighthouse already requires, so it adds no
|
||||
> dependency. It is **not** part of `astro build`, because CI has no Chrome.
|
||||
> *(b)* It carries **nothing the site does not** — every line renders from
|
||||
> `CREDENTIALS`, `ROLE`, `BOUTIQUE`, `PRACTICE_AREAS`, `FEES` and `CONTACT`. No
|
||||
> matter list (which R16 correctly said would collide with §4 Forbidden), no
|
||||
> referees, no figure that is not on `/fees/`.
|
||||
>
|
||||
> ⚠️ **`npm run bio:pdf` asserts ONE PAGE and writes nothing if the count is
|
||||
> wrong.** And reading the rendered PDF caught a breach the source review had
|
||||
> not: its opening clause scoped **mediation** commercial, which Q56 leaves
|
||||
> unscoped deliberately. Nothing in the build regenerates the PDF — `docs/06`'s
|
||||
> cutover checklist carries the re-render.
|
||||
|
||||
> *Original deferral note, kept because its reasoning is why this is R16 rather
|
||||
> than a to-do.* **The PDF bio ships at BUILD STEP 9, alongside `/fees/` —
|
||||
> deferred by Pouya 2026-08-28 (Q45), tracked as `AGENTS.md` §12 **R16**.** His reasoning: it is a
|
||||
> derived artefact, so building it before `/about/` and `/fees/` are final means
|
||||
> building it twice, and an appointment proposal needs the fee card as much as
|
||||
> the bio. The two decisions below are **not** settled by the deferral and travel
|
||||
@@ -213,7 +277,11 @@ to an appointment. This page carries the verifiable record.
|
||||
**Job:** convert counsel who have already decided on mediation and are choosing a
|
||||
neutral.
|
||||
**Search intent:** `commercial mediator Toronto`, `ADRIC mediation rules`,
|
||||
`what happens at mediation Ontario`.
|
||||
`what happens at mediation Ontario`. **These are queries, not the page's scope —
|
||||
Q56, 2026-08-30.** The mediation offering is **not** scoped commercial (§4's row
|
||||
is unscoped; the arbitration scope is a legal gate and does not transfer). Do not
|
||||
read the first query back into the `<title>` or the copy, which is where
|
||||
*"Commercial Mediation"* came from in the first place.
|
||||
|
||||
1. What the service is; the neutral's role stated plainly.
|
||||
2. **Formats:** full-day, half-day, shuttle, remote, hybrid.
|
||||
@@ -318,11 +386,27 @@ the C.Med-Arb endpoint" until 2026-08-29; C.Med-Arb is off the site.)*
|
||||
Q.Arb, which is what med-arb requires. The page says he conducts med-arb and
|
||||
stops."* The section is **rewritten, not edited** — it earned its credibility
|
||||
from an incomplete credential and now says what med-arb demands of one
|
||||
neutral and that he holds both designations.
|
||||
neutral. ⚠️ **AND IT NO LONGER RESTATES THE DESIGNATIONS EITHER —
|
||||
2026-09-02, `claims-auditor` D20 pass, finding F.** §Why this practice held a
|
||||
bare `DESIGNATIONS_HELD_LINE` sentence sitting directly beneath §Rules'
|
||||
quotation of ADRIC requiring *"a high level of practitioner competence"*, so
|
||||
the designations read as meeting a bar ADRIC answers with the Chartered
|
||||
Med-Arb — struck from this site entirely. The section now makes **no
|
||||
credential claim at all**; `/about/` publishes them and the JSON-LD carries
|
||||
them. Do not reinstate one here.
|
||||
5. **The ADRIC Med-Arb Rules**, which this spec did not mention and which exist:
|
||||
a published rule set, presented to ADRIC's membership at its 2019 annual conference, drafted for domestic
|
||||
commercial disputes and designed to work with its Mediation and Arbitration
|
||||
Rules. Sourced: `docs/reference/adric-rules.md` Finding 2. Quote ADRIC's own
|
||||
~~a published rule set, presented to ADRIC's membership~~ — **CORRECTED
|
||||
2026-09-02, `claims-auditor` D20 pass, finding 6.** The source says a
|
||||
**discussion draft** was presented, not the rules; `adric-rules.md` Finding 2
|
||||
records no adoption and no effective date, in deliberate contrast to the
|
||||
Arbitration Rules ("effective March 1, 2025") and the Mediation Rules ("the
|
||||
existing rules remain in effect"). The page was corrected on 2026-09-01 and
|
||||
**this line still instructed the struck form for a day** — the Q.Arb lesson
|
||||
exactly: page fixed, spec still telling the next implementer to write the
|
||||
false version. Write it as **presented to the membership as a discussion
|
||||
draft at ADRIC's 2019 annual conference** — drafted for domestic commercial
|
||||
disputes and designed to work with its Mediation and Arbitration Rules.
|
||||
Sourced: `docs/reference/adric-rules.md` Finding 2. Quote ADRIC's own
|
||||
framing rather than paraphrasing it, and **keep its superlatives inside the
|
||||
quotation marks** — Finding 5 lists the two not to lift.
|
||||
|
||||
@@ -413,9 +497,17 @@ position, not a claim of existing volume.**
|
||||
> occurrences of "allocation" of any kind across the connection process. It also
|
||||
> reached `src/data/site.ts` and shipped in the six-card grid on three pages.
|
||||
>
|
||||
> **Use the terms these bodies use:** *connection assessment and approval (CAA)*
|
||||
> is the umbrella; the IESO performs a *System Impact Assessment (SIA)* and the
|
||||
> transmitter a *Customer Impact Assessment (CIA)*. **Ontario has no
|
||||
> **Use the terms these bodies use:** the IESO's own words are *"the IESO's and
|
||||
> transmitter's connection assessment and approval (CAA) process"*, within which
|
||||
> the IESO performs a *System Impact Assessment (SIA)* and the transmitter a
|
||||
> *Customer Impact Assessment (CIA)*. ⚠️ **This read "CAA is the umbrella" until
|
||||
> 2026-09-03** — which is the extract's own COMMENTARY, not the IESO's, and
|
||||
> `CLAUDE.md` is explicit that commentary around a quotation is this
|
||||
> repository's voice. The pages took the attribution from here and gave the
|
||||
> process to the IESO alone. **And it is the CONNECTION PROCESS that runs to
|
||||
> *up to* six stages, not the CAA** — the source scopes the count by connection
|
||||
> type, and CAA is stage 2 of that process rather than a name for it. Naming the
|
||||
> wrong subject here is how the conflation reaches a page. **Ontario has no
|
||||
> interconnection queue** — the IESO says so in terms and works from "committed
|
||||
> projects" instead, so "our place in the queue" describes nothing. The
|
||||
> genuinely adjacent term, the OEB's *Capacity Allocation Model* in the
|
||||
@@ -446,9 +538,10 @@ position, not a claim of existing volume.**
|
||||
> - **LAT Rule 2.4:** *"'Case Conference' has the same meaning as 'Pre-Hearing
|
||||
> Conference' as defined in the SPPA."* **"Pre-hearing" is the Tribunal's own
|
||||
> label**, and what it labels is a case conference.
|
||||
> - **Rule 14.3:** a **Member** presides and is then disqualified from the
|
||||
> hearing panel; **Rule 14.6:** parties must attend. The neutral is the
|
||||
> Tribunal's. A privately retained one is not appointed to it and cannot be.
|
||||
> - **Rule 14.3:** a **Member** presides and does not then sit on the hearing
|
||||
> panel except with the consent of the parties; **Rule 14.6:** parties must
|
||||
> attend. The neutral is the Tribunal's. A privately retained one is not
|
||||
> appointed to it and cannot be.
|
||||
> - The LAT Rules contain **zero** occurrences of `mediat` or `arbitrat` —
|
||||
> 0 in 66,593 characters. The concept is not in them.
|
||||
> - The LAT-AABS page itself, though, says: *"Before you apply to the LAT-AABS,
|
||||
@@ -534,9 +627,11 @@ commitments, the first matter that slips makes the page false."*
|
||||
**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
|
||||
in `docs/07-fees.md`; still do not invent numbers.**
|
||||
|
||||
Hourly rate; half-day and full-day mediation; preparation time policy;
|
||||
cancellation terms; administrative fee; HST treatment; who pays and how costs
|
||||
are shared between parties; payment terms. A real page with real numbers, or a
|
||||
Hourly rate; half-day and full-day mediation; **med-arb, billed by phase and
|
||||
carrying no figure of its own** (added 2026-09-03, `docs/07` §Med-arb, INTERIM
|
||||
against R5 — the page shipped it and this outline did not name it); preparation
|
||||
time policy; cancellation terms; administrative fee; HST treatment; who pays and
|
||||
how costs are shared between parties; payment terms. A real page with real numbers, or a
|
||||
`TODO(pouya)` — nothing in between.
|
||||
|
||||
### `/for-parties/`
|
||||
@@ -544,8 +639,15 @@ are shared between parties; payment terms. A real page with real numbers, or a
|
||||
**Job:** serve the self-represented tier without diluting the counsel-facing
|
||||
pages. Plain language, short sentences, no jargon.
|
||||
|
||||
What mediation is · what it is not · who the mediator is and is not (not your
|
||||
lawyer, not a judge) · what happens on the day · what it costs · what happens if
|
||||
What mediation is · what it is not · who the mediator is and is not —
|
||||
**render `NEUTRAL_ROLE_LINE`, and read `docs/03` §For parties before writing a
|
||||
word of it.** ⚠️ **THIS LINE READ *"(not your lawyer, not a judge)"* UNTIL
|
||||
2026-08-30 AND IT INSTRUCTED A FORM STRUCK ON 2026-08-28.** *"Not your lawyer"*
|
||||
**presupposes lawyer status**, which §4 Forbidden bars and D13 treats as hard as
|
||||
assertion — a negation still carries the presupposition. `docs/03` struck it
|
||||
with that reasoning two days earlier and the sweep never reached this file;
|
||||
build step 6 is the step that consumes this line, and it was found here by
|
||||
`adversarial-reviewer` while the page itself avoided the trap · what happens on the day · what it costs · what happens if
|
||||
you do not settle · how to prepare.
|
||||
|
||||
### `/insights/` and `/insights/[slug]/`
|
||||
@@ -609,8 +711,28 @@ Dependency-ordered, so nothing is blocked mid-stream:
|
||||
4. `/mediation/`, `/arbitration/`, `/med-arb/`
|
||||
5. `/practice/` and the six area pages
|
||||
6. `/process/`, `/for-parties/`
|
||||
7. `/insights/` plumbing, then the drafted articles
|
||||
8. `/contact/` and the intake backend
|
||||
9. `/fees/` — last, though no longer blocked: D14 confirmed the card
|
||||
10. `/legal/*` — written to match the backend as actually built
|
||||
11. Audit and cutover (`06-deployment.md`)
|
||||
7. ✅ `/insights/` plumbing, then the drafted articles — **built 2026-08-31.**
|
||||
Step 7a returned Lighthouse (`AGENTS.md` §7, R11); 7b built the OG card
|
||||
generator (R15) and the Insights routes; 7c drafted the five launch articles.
|
||||
**The section is not live and cannot be**: D9 and `src/content.config.ts`
|
||||
between them mean an article publishes only when Pouya sets both flags, and
|
||||
`SiteHeader` keeps Insights out of the nav until two are live
|
||||
8. ✅ `/contact/` — **the page is built and the pipe behind it is LIVE as of
|
||||
2026-09-02.** The handler is deployed and the CloudFront `/api/*` behaviour is
|
||||
in place; `AGENTS.md` §7 holds the state and this list does not restate it.
|
||||
`docs/05` §Build step 8 records three deliberate deviations from that spec, and
|
||||
§Observed abuse records the first real spam and what was added for it.
|
||||
⚠️ **This item read *"the pipe behind it is not"* until 2026-09-04** — written
|
||||
under D11, true then, and left asserting an undeployed backend for two days
|
||||
after cutover
|
||||
9. ✅ `/fees/` — **built 2026-08-31 on Q59's ruling**, which settled where the
|
||||
overtime hour starts (the session cap) and supplied the reservation point that
|
||||
answers the rate card's arithmetic anomaly. The PDF bio shipped with it (R16)
|
||||
10. ✅ `/legal/privacy/` and `/legal/terms/` — **built 2026-08-31, written to the
|
||||
backend as actually built.** Three of the privacy policy's statements are
|
||||
DERIVED — the collected-data list from `INTAKE_FIELDS`, the retention period
|
||||
from the handler's own figure, the analytics paragraph from
|
||||
`ANALYTICS.installed` — so they cannot drift from the implementation
|
||||
11. Audit and cutover (`06-deployment.md`) — **not started. Nothing is deployed.**
|
||||
`claims-auditor`'s single pass over the whole finished site (D20) is a blocking
|
||||
item there and has not run
|
||||
|
||||
+935
-23
@@ -98,7 +98,9 @@ Preload only the two faces used above the fold — Instrument Serif regular and
|
||||
Geist 400.
|
||||
|
||||
**Scale.** Fluid, `clamp()`, `1.25` ratio at the small end widening to `1.333` at
|
||||
the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
|
||||
the display end. Tokens `--text-2xs` through `--text-6xl` in `tokens.css`, plus
|
||||
`--text-eyebrow` at 14 px — the same value as `--text-sm` and deliberately not an
|
||||
alias of it; see the eyebrow rule below.
|
||||
|
||||
**Rules.**
|
||||
|
||||
@@ -107,8 +109,102 @@ the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
|
||||
- Display line-height `0.95`–`1.05`; letter-spacing `-0.02em`.
|
||||
- Body line-height `1.6`. Measure capped at `68ch` — the old site ran full-bleed
|
||||
paragraphs at 1400 px, which is unreadable.
|
||||
- Eyebrows: mono, 11–12 px, `0.18em` tracking, uppercase, always paired with a
|
||||
real heading. An eyebrow is not a heading and never carries the `<h*>`.
|
||||
- Eyebrows: mono, **14 px** (`--text-eyebrow`), `0.18em` tracking, uppercase,
|
||||
weight 500, always paired with a real heading. Pouya raised it **12 → 13 → 14 px
|
||||
on 2026-08-31**, in two passes, because uppercase mono at this tracking reads
|
||||
smaller than it measures and 13 px was still getting lost against the display
|
||||
type. There is **one definition**, `.eyebrow` in `global.css`. An eyebrow is not
|
||||
a heading and never carries the `<h*>` — but a real heading may carry the class,
|
||||
and **fourteen do, in two groups**: the footer's four column headings
|
||||
(`SiteFooter.astro`) and `/bio/`'s ten block headings.
|
||||
⚠️ **"ONE DEFINITION" HAS ONE CARVE-OUT AND IT IS NAMED RATHER THAN IMPLIED:**
|
||||
`/contact/`'s `<label>`/`<legend>` set and its direct-contact `<dt>`s repeat the
|
||||
treatment instead of taking the class, because they need `--text-secondary`
|
||||
(11.75 : 1) rather than `.eyebrow`'s `--text-meta` (5.47 : 1) — a form label is
|
||||
operative text. **Everything else about them matches, `font-weight` included**;
|
||||
without that they rendered at 400 beneath a `p.eyebrow` of the same size and
|
||||
colour, which is the divergence a carve-out is meant to bound rather than hide.
|
||||
Do not widen this to any other element.
|
||||
**14 px is the same value as `--text-sm`, and `--text-eyebrow` is deliberately
|
||||
not an alias of it.** The two move for different reasons; aliasing would mean a
|
||||
future change to body-meta type silently moved every eyebrow on the site.
|
||||
(This bullet said "13 px is not a rung on the scale — it sits between
|
||||
`--text-xs` and `--text-sm`" for one revision. At 14 px it is that rung.)
|
||||
- **THE 11 px FLOOR HAS EXACTLY ONE CONSUMER, AND THAT IS THE CLAIM THAT IS
|
||||
TRUE.** Pouya's ruling, 2026-08-31: *"form labels are operative text, and the
|
||||
site should have exactly one small-text floor."* `--text-2xs` (11 px) now has
|
||||
**one consumer in `src/`** — the header tagline (`git grep 'var(--text-2xs)' --
|
||||
src` returns one hit, `SiteHeader.astro`; 21 rendered instances, every one
|
||||
`span.eyebrow.brand-tagline`) — and nothing else may use it without a
|
||||
measurement recorded beside it.
|
||||
⚠️ **THIS BULLET SAID "THE SMALL-TEXT FLOOR IS 14 px, AND THERE IS EXACTLY ONE
|
||||
EXCEPTION" FOR ONE REVISION, AND THAT WAS FALSE** — falsified by this document
|
||||
thirty lines below, which concedes a 12 px family. The ruling asked for the
|
||||
record that the tagline is the only sub-14 px text on the site; **it is not, so
|
||||
that is not what is recorded here.** What is true is the sentence above: one
|
||||
consumer of the 11 px token. `--text-xs` (12 px) is a separate rung and is
|
||||
enumerated below.
|
||||
**The exception, and why it is deliberate rather than residual:** the tagline is
|
||||
ornamental and layout-constrained. ⚠️ **THE COST WAS RE-MEASURED ON 2026-09-01
|
||||
AND IT IS A DIFFERENT COST NOW — the masthead may wrap, so what used to overflow
|
||||
invisibly is visible instead.** At `--text-eyebrow` (14 px) the header stands at
|
||||
**144.98 px** rather than 81 px — at 1216 px with six nav items, and at **every
|
||||
width from 1216 px up** with a seventh (measured at 1216 / 1240 / 1280 / 1360 /
|
||||
1440 / 1600 / 1760 / 1920) — because the row wraps. *(This listed four widths
|
||||
ending at 1440 for one revision; that list was carried over from the superseded
|
||||
CTA-past-the-edge measurement, which really did stop at 1440.)* **Document overflow
|
||||
is 0 and the CTA sits exactly on `.header-inner`'s content edge in every one of
|
||||
those cases**, where before the fix they were 20 px of overflow at 1216 px, 4 px
|
||||
at 1280 px, and the CTA 67.8 / 43.8 / 51.8 / 51.8 px past the content edge. So
|
||||
the two costs this bullet used to cite are **gone**, replaced by one that is
|
||||
larger and easier to see: 64 px of header height on every page. *(13 px took the
|
||||
header to 83.4 px; that figure was taken before the masthead could wrap.)*
|
||||
Insights is that seventh item. The measurements are in `SiteHeader.astro`.
|
||||
**Three blocks moved to `--text-eyebrow` on 2026-08-31 and their carve-outs are
|
||||
superseded.** (a) `/contact/`'s `<label>`/`<legend>` set — *raise, do not
|
||||
ratify*, superseding the "accepted, not ratified" note this bullet carried for
|
||||
one revision. (b) `/contact/`'s direct-contact `<dl>` terms, which are label
|
||||
text on the same page and would otherwise have been left a step behind the
|
||||
labels beside them. (c) `/bio/`'s ten block headings and its strap, which were
|
||||
**copies of five of `.eyebrow`'s six declarations, at 11 px** — the same escape
|
||||
the footer's column headings turned out to be — and now carry the class. Not
|
||||
byte-for-byte: the `font-size` differed, and **the missing sixth was
|
||||
`font-weight`**, which is precisely the one the print block now has to freeze.
|
||||
⚠️ **(c)'s carve-out was justified by a reason that did not apply.** This bullet
|
||||
said the `/bio/` sheet was held at 11 px because `npm run bio:pdf` holds it to
|
||||
one page. Measured 2026-08-31: the `@media print` block sets both elements to
|
||||
**7 pt**, so the screen size never reached the PDF and the one-page constraint
|
||||
was never what kept them small. **Print does now freeze `font-weight: 400`**,
|
||||
because taking `.eyebrow`'s 500 changed the printed sheet — the PDF grows from
|
||||
**89,496 to 91,151 bytes, +1,655** — and the ruling that moved them was about
|
||||
size. *(This read "63,743 bytes" for one revision. That is the `cmp -l`
|
||||
differing-byte count, not the size delta: the content stream is Flate-compressed,
|
||||
so a one-property change scrambles most of the file. Two figures, and the wrong
|
||||
one answered a question nobody asked.)* ⚠️ **THAT FREEZE IS NOW A RULED
|
||||
CONSTRAINT, 2026-09-01, NOT A DECISION AWAITING POUYA: the circulated PDF's
|
||||
typography changes only when its CONTENT is deliberately revised, never as a
|
||||
side effect of a screen refactor.** So the print block keeps 400 whatever the
|
||||
screen does, and `.eyebrow`'s 500 stops at the `@media print` boundary. **The
|
||||
reason it is a rule and not a preference** is that the PDF is the one artefact
|
||||
this project's apparatus sees only when someone re-renders it (`AGENTS.md`
|
||||
R16): a refactor that silently re-typesets it changes a document already in a
|
||||
reader's hands, and the diff that would have shown it is a 1,655-byte
|
||||
compressed blob nobody reads. A screen change that reaches print is therefore
|
||||
a defect by construction, not a judgement call — see `bio.astro`.
|
||||
**`--text-xs` (12 px) IS A SEPARATE RUNG AND IT WAS NOT IN SCOPE ON 2026-08-31.
|
||||
Ten declaration sites, all of them:** `Pill`, `Breadcrumbs`, `CredentialRow`,
|
||||
`DefinitionGrid`, `ArticleCard`, `ProcessStep` (two), `insights/[...slug].astro`,
|
||||
and **`/bio/`'s two — `.fine` and `.sheet-contact p`**. The first eight are mono
|
||||
and uppercase or tracked at `0.06em`, a third of the eyebrow's tracking, which is
|
||||
what makes them a different treatment. **`/bio/`'s two are neither uppercase nor
|
||||
tracked**, so they are not in that family at all — they are simply 12 px text,
|
||||
and an earlier draft of this bullet defined the exception in a way that excluded
|
||||
them and therefore missed them.
|
||||
**Rendered count, and say what it is a number of:** at 1280 px / root 16 across
|
||||
all 22 pages, **130 elements carrying their own text compute to 12 px** (149 if
|
||||
elements that merely inherit the size are counted). `ArticleCard` and the article
|
||||
meta contribute **zero** today — no article is published, so neither renders.
|
||||
Every instance measures ≥ 5.01 : 1.
|
||||
- Italic display (`.it`) is the one flourish the design allows. One italic phrase
|
||||
per headline, at most.
|
||||
- Never skip a heading level. `<h1>` once per page.
|
||||
@@ -227,6 +323,12 @@ Not a polish pass. A build requirement.
|
||||
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
|
||||
of overflow at 390. Brought down in three measured steps:
|
||||
|
||||
⚠️ **THE THREE TABLES BELOW ARE A HISTORY OF HOW THE NUMBER CAME DOWN, NOT THE
|
||||
CURRENT STATE.** Every residual in their final rows — the 3 px, the 23 px and the
|
||||
63 px — went to **0 on 2026-08-31**, on all 22 pages at 320 / 360 / 390 / 414 /
|
||||
640 / 1024 px. The current state is §Reflow below, which also carries what is
|
||||
still NOT zero at 1280 px and 1920 px. Read the rows as dated steps.
|
||||
|
||||
| Fix | 390 px | 320 px |
|
||||
|---|---|---|
|
||||
| as first built | 234 px | 304 px |
|
||||
@@ -256,10 +358,13 @@ Not a polish pass. A build requirement.
|
||||
| `/med-arb/` | **0 px** | 23 px | 63 px |
|
||||
|
||||
At the **default** root size all three are **0 px at every width measured**.
|
||||
The element-level sweep at 320/root-32, predicate `right > clientWidth`,
|
||||
names **three** `PracticeCard` chips on `/mediation/` — Construction,
|
||||
Technology, Shareholder — plus the header and footer brand elements, which
|
||||
appear on all five pages. *(This read "six" for one pass. Six is the count of
|
||||
⚠️ **THE ELEMENT-LEVEL SWEEP BELOW IS ALSO HISTORICAL.** It read: *"the
|
||||
element-level sweep at 320/root-32, predicate `right > clientWidth`, names three
|
||||
`PracticeCard` chips on `/mediation/` — Construction, Technology, Shareholder —
|
||||
plus the header and footer brand elements, which appear on all five pages."*
|
||||
Re-run with that exact predicate on 2026-08-31: **0 elements**, on all five pages
|
||||
at 320 / 360 / 390 with root 32. The chips were fixed at their cause
|
||||
(`PracticeCard`'s padding) and the brand elements at theirs; see §Reflow. *(This read "six" for one pass. Six is the count of
|
||||
`article.card` with `scrollWidth > clientWidth`, a different predicate on a
|
||||
different element; `adversarial-reviewer` re-ran it and no predicate yields
|
||||
six chips. A number in this table has to be re-runnable, which is the whole
|
||||
@@ -275,27 +380,834 @@ Not a polish pass. A build requirement.
|
||||
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
|
||||
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
|
||||
min-content size. `/about/` now measures equal to or better than `/` at all
|
||||
three widths, and its 320/360 residual is the same header decision.
|
||||
three widths. *(Its 320/360 residual was recorded here as "the same header
|
||||
decision" until 2026-08-31, when that attribution turned out to be wrong and the
|
||||
residual went to 0 — see below.)*
|
||||
|
||||
Command, so the numbers are re-runnable rather than quoted: headless Chrome
|
||||
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
|
||||
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
|
||||
enumeration of every element wider than `clientWidth` to name the offender.
|
||||
enumeration of every element that either **is wider than `clientWidth`** or
|
||||
**whose own content overflows its own box** (`scrollWidth - clientWidth`), to
|
||||
name the offender. ⚠️ **Both halves of that predicate are load-bearing and the
|
||||
second was added on 2026-09-01, after the first half alone reported "no offender"
|
||||
at three of the four widths that were failing** — the footer email spilled text
|
||||
out of a box that was itself inside the viewport. A sweep that enumerates only
|
||||
over-wide *elements* will name no cause for a whole class of real overflow.
|
||||
|
||||
Two things worth keeping. **`overflow-wrap: break-word` permits a break at
|
||||
layout time but does not reduce min-content size** — `anywhere` does, and that
|
||||
distinction was the whole of one of those fixes. And 1280 px stays over, from the header's
|
||||
deliberate `flex-wrap: nowrap` above 66 rem plus `white-space: nowrap` on the
|
||||
brand name — **602 px on `/` and 944 px on every other page**, because
|
||||
`SiteHeader` gates the tagline on `!isHome`, so the masthead is wider
|
||||
everywhere except home. *(This read a flat "602 px" until 2026-08-28. That
|
||||
figure was measured on `/` and generalised; `/about/` was already 944 when it
|
||||
was added to this table, and step 4's three pages are 944 too. Found by
|
||||
`adversarial-reviewer`.)* The 320 px residual is the same header
|
||||
plus the display headline's 104 px floor. Undoing either re-opens the measured
|
||||
step-1 header decision, so they stand. All of this is beyond what this floor
|
||||
requires — page zoom is clean — so it is a robustness margin rather than a
|
||||
failure. Revisit if a real reader hits it.
|
||||
⚠️ **AND THE RECIPE ABOVE HAS A UNIFORM-PASS TRAP THAT IS NOT VISIBLE IN IT.**
|
||||
Run `document.documentElement.style.fontSize = '32px'` with
|
||||
`prefers-reduced-motion: reduce` in force — launch flag *or* CDP emulation — and
|
||||
it reports **0 of 352 with the enlargement never applied**: brand width, header
|
||||
height and document height all byte-identical to root 16. The reduced-motion
|
||||
reset emits `*,::before,::after{transition-duration:.01ms!important}` over the
|
||||
initial `transition-property: all`, so the root font-size change becomes a
|
||||
`CSSTransition` and a synchronous read returns its START value.
|
||||
`document.getAnimations()` shows `CSSTransition: font-size, running, t=0`; after
|
||||
one `requestAnimationFrame` it is 32 px. **Kill motion with an injected
|
||||
`transition:none;animation:none` sheet rather than by emulating reduced motion,
|
||||
and assert the applied root size per cell** — that assertion is the only thing
|
||||
that distinguishes this from a clean sweep. Found by `adversarial-reviewer`,
|
||||
round 2, running this section's own published recipe.
|
||||
|
||||
✅ **THE LARGE-TEXT NAV OVERFLOW IS FIXED, 2026-09-01.** Pouya reopened the
|
||||
step-1 header decision for the purpose — *"its record attributed the residual to
|
||||
the wrong cause and characterized a measured 944 px functional failure as a
|
||||
robustness margin"* — and ruled *fix, do not accept*. What follows is the
|
||||
current state. The narrow case was ruled a defect on 2026-08-31 (*"the
|
||||
152/112/82 px overflows at 320/360/390 with 32 px root text violate WCAG 1.4.10
|
||||
and are not accepted"*) and fixed then.
|
||||
|
||||
**THREE MECHANISMS ENLARGE TEXT, AND THEY ARE NOT EQUIVALENT — this is the
|
||||
measurement the whole record turned on.** Every figure in this section before
|
||||
2026-09-01 came from `documentElement.style.fontSize = '32px'`. A later sweep
|
||||
used Chrome's `Page.setFontSizes({standard: 32})`, reported 0 everywhere, and
|
||||
the prose generalised that zero. All three are re-measured below.
|
||||
|
||||
| mechanism | `rem` in a media query | `rem`/`em` in a PROPERTY | `getComputedStyle` root | sees the enlargement? |
|
||||
|---|---|---|---|---|
|
||||
| `documentElement.style.fontSize='32px'` (root style) | **16 px → `66rem` = 1056 px** | 32 px | 32 px | media query **no**, property **yes** |
|
||||
| `Page.setFontSizes({standard:32})` (the reader's *default font size*) | 32 px → `66rem` = **2112 px** | 32 px | 32 px | **yes, both** — the breakpoint moves with the type |
|
||||
| `--blink-settings=minimumFontSize=32` (the reader's *minimum font size*) | 16 px → `66rem` = **1056 px** | **16 px** | **32 px** | **NO `rem`/`em` CONSTRUCT SEES IT** — the columns of this table are all `rem`/`em`, and that is the limit of what the row measures. ⚠️ **The FONT-METRIC units DO see it** (`ch`, `ex`, `cap`, `lh`, `rlh`); see the table below |
|
||||
|
||||
*(Row 1's media-query cell read "32 px → 2112 px" for one revision, which
|
||||
contradicted its own verdict column, the paragraph below it, and measurement —
|
||||
and it deleted the one number that explains why the failure is reachable at all.
|
||||
If `66rem` really resolved to 2112 px under root scaling, the 66 rem desktop
|
||||
masthead would never be reached at 1280 px and the 944 px overflow could not
|
||||
happen. Found by `adversarial-reviewer`.)*
|
||||
|
||||
Two consequences, both measured 2026-09-01 and both load-bearing:
|
||||
|
||||
1. **In a media query `rem` resolves against the browser's DEFAULT font size,
|
||||
not the root element's.** So raising the *default* moves the breakpoints
|
||||
along with the type and the desktop masthead is never reached; setting the
|
||||
*root* doubles the type and leaves the breakpoints where they were, which is
|
||||
the layout the desktop nav was measured in. **A container query is the one
|
||||
construct that differs** — `@container` `rem`/`em` DO resolve against the
|
||||
root element, measured — which is why the sticky gate below is a property and
|
||||
not a query.
|
||||
2. ⚠️ **UNDER A MINIMUM-FONT-SIZE SETTING, `getComputedStyle(el).fontSize` AND
|
||||
THE `em` UNIT DISAGREE.** Blink floors the *reported computed* font size to
|
||||
32 px while `em` and `rem` keep resolving at 16 px — so `scroll-padding-top:
|
||||
6.0625em` computes to **97 px, not 194 px**, a `66em`-wide box measures
|
||||
**1056 px, not 2112 px**, and `min(0px, calc(100vw - 66em))` is **0px**.
|
||||
⚠️ **AND THE SENTENCE THAT USED TO SIT HERE WAS FALSE AND LOAD-BEARING: it
|
||||
said "media queries, container queries and length units are all blind to it"
|
||||
and that "only used-value layout — wrapping — responds".** It is the
|
||||
`rem`/`em` FAMILY that is blind, not CSS. The **font-metric** units read the
|
||||
*used* font size and therefore double, and they do so in all three
|
||||
constructs — measured 2026-09-01 on `/fees/` @1280, headless Chrome, one
|
||||
probe element per cell:
|
||||
|
||||
| construct | `rem`, `em`, `ic`, `px` | `ch`, `ex`, `cap`, `lh`, `rlh` |
|
||||
|---|---|---|
|
||||
| property value | blind (16 px → 16 px) | **responds** — `ch` 10.608 → 21.216 px, `ex` 8.48 → 16.96, `cap` 11.36 → 22.72, `lh` 25.59 → 51.19 |
|
||||
| `@media (min-width: N<unit>)` | blind — threshold never flips | **responds** — `100ch`, `110ex`, `80cap`, `45lh` all flip |
|
||||
| `@container (min-width: N<unit>)` | blind | **responds** — `45ex`, `32cap` flip |
|
||||
|
||||
So wrapping is still the right mechanism for *reflow* — it needs no
|
||||
threshold and no fitted constant — but **"no CSS can detect this setting" is
|
||||
not true, and the residual below is therefore not provably unfixable.**
|
||||
Found by `adversarial-reviewer` and reproduced independently.
|
||||
|
||||
**Before and after, 22 pages × 16 widths (320 → 1920 px) = 352 page-widths per
|
||||
setting, document overflow `documentElement.scrollWidth −
|
||||
documentElement.clientWidth`. Both columns measured on the SAME grid**, from a
|
||||
build of the previous commit and a build of the working tree, by the same probe:
|
||||
|
||||
| text setting | before | after |
|
||||
|---|---|---|
|
||||
| default (root 16) | 0 of 352 | **0 of 352** |
|
||||
| root style 32 px | **175 of 352** | **0 of 352** |
|
||||
| default font size 32 | 0 of 352 | **0 of 352** |
|
||||
| minimum font size 32 | **219 of 352** | **0 of 352** — the header fix left 88, all of them the footer; the footer fix below closed them, 2026-09-01 |
|
||||
|
||||
✅ **SO: ZERO DOCUMENT OVERFLOW UNDER ALL FOUR METHODS AT EVERY WIDTH
|
||||
MEASURED — AND THE SCOPE OF THAT SENTENCE IS EXACTLY THE GRID ABOVE.** 22 built
|
||||
pages × those 16 widths × four enlargement methods = **1,408 page-widths**,
|
||||
document overflow by `documentElement.scrollWidth − documentElement.clientWidth`,
|
||||
webfonts loaded, six nav items, `prefers-reduced-motion` neutralised by an
|
||||
injected sheet rather than emulated. Plus **762** further points for the sticky
|
||||
gate (roots 9–15, roots 16–32, the band seam, both thresholds, all five Chrome
|
||||
presets), 0 failing.
|
||||
|
||||
⚠️ **It is not a claim about anything outside that grid, and "no document
|
||||
overflow" is not "no accessibility defect".** Three known cases sit outside it
|
||||
deliberately. The **fallback-metrics case** below needs the webfonts blocked and
|
||||
a seventh nav item. The **focus-obscuring case** below was a **WCAG 2.2
|
||||
SC 2.4.11 (AA) failure**, now **fixed and closed** — `AGENTS.md` Q61 — and it is
|
||||
the sharpest illustration of the warning above: **document overflow could not see
|
||||
it at all**, because nothing overflowed. The header simply covered what had
|
||||
focus, on 290 of 1,455 stops, while every zero in the table above stayed a zero.
|
||||
A residual at `minimumFontSize=16` and `=20` remains, is pre-existing, and is
|
||||
likewise invisible to this grid. And a
|
||||
**footer nav label** still overruns its own column by 24 px at 640 px under
|
||||
minimum font size with 7.7 px of clearance, again with zero document overflow.
|
||||
None of the three is contradicted by the zeros above, and none of them is
|
||||
measured by them — which is the reason each is recorded in its own right rather
|
||||
than summarised into the table. Widths
|
||||
between the sampled ones are not measured either — the grid is 16 columns, not a
|
||||
continuum, and the two thresholds and the band seam were swept precisely because
|
||||
a 16-column grid can step over a 36 px band.
|
||||
|
||||
**The wide-width figures that were the defect, root style 32 px, and what they
|
||||
are now.** Every one is 0 of 22 pages after the fix, and the nav items and the
|
||||
CTA are on-screen at every width:
|
||||
|
||||
| width | before | after |
|
||||
|---|---|---|
|
||||
| 1056 | 649 px, 22 of 22 | **0** |
|
||||
| 1100 | 606 px, 22 of 22 | **0** |
|
||||
| 1216 | 928 px / 491 px on `/`, 22 of 22 | **0** |
|
||||
| 1240 | 904 px / 468 px on `/` | **0** |
|
||||
| 1280 | **944 px** / 508 px on `/` | **0** |
|
||||
| 1440 | 784 px / 351 px on `/` | **0** |
|
||||
| 1600 | 624 px / 193 px on `/` | **0** |
|
||||
| 1920 | 304 px, 21 of 22 | **0** |
|
||||
|
||||
*(Only 1280 and 1920 were in the earlier record, so it never showed that the
|
||||
failure ran the whole width of the sticky range. The same run under minimum font
|
||||
size was 414 / 370 / 854 / 830 / 830 / 750 / 670 / 510 px, of which the record
|
||||
carried only the 830.)*
|
||||
|
||||
⚠️ **AND IT WAS NOT A MARGIN — IT WAS UNREACHABLE NAVIGATION.** The furthest
|
||||
element was `div.header-cta` at **2224 px** against a 1280 px viewport; the nav
|
||||
clipped mid-word after "Arbitration", and **Practice, Fees, Contact and the
|
||||
header CTA sat entirely off-screen** behind a horizontal scrollbar — the route
|
||||
to the inquiry form among them. WCAG 1.4.4 with loss of functionality. The
|
||||
paragraph struck here called it *"a robustness margin rather than a failure.
|
||||
Revisit if a real reader hits it."*
|
||||
|
||||
**THE FIX IS TWO DECLARATIONS, AND THE SECOND ONE IS THERE BECAUSE THE FIRST
|
||||
ONE HAS A CONSEQUENCE.**
|
||||
|
||||
**(1) The masthead may wrap.** `flex-wrap: nowrap` is gone from `.header-inner`
|
||||
and from `.nav-list` above 66 rem, along with the dead `flex: none` beside it.
|
||||
|
||||
⚠️ **WHICH OF THE TWO WAS THE CAUSE IS NOW MEASURED, AND THIS PARAGRAPH FIRST
|
||||
GOT IT WRONG.** It said *"that pair, not any one declaration, was the cause"*.
|
||||
Restoring each declaration alone on the fixed build, root 32, `/about/` —
|
||||
document overflow / number of nav links and CTA off-screen:
|
||||
|
||||
| restored above 66 rem | 1056 | 1216 | 1280 | 1440 | 1920 |
|
||||
|---|---|---|---|---|---|
|
||||
| neither (shipped) | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||||
| `.nav-list { flex-wrap: nowrap }` only | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||||
| `.header-inner { flex-wrap: nowrap }` only | 0/0 | 273/1 | 209/1 | 49/1 | 0/0 |
|
||||
| both (= the state before) | 649/3 | 928/5 | 944/5 | 784/4 | 304/1 |
|
||||
|
||||
**`.header-inner`'s `nowrap` was necessary and sufficient.** And the second row
|
||||
is the one that matters for honesty: **`.nav-list`'s removal is inert** — byte-
|
||||
identical to shipped at every width, under a 200 % root size *and* under minimum
|
||||
font size, **with six nav items and with seven**. It is kept removed for two
|
||||
reasons that are not "it fixed the overflow": the `nowrap` prohibition in
|
||||
`SiteHeader.astro` would otherwise contradict a `nowrap` still sitting in the
|
||||
file, and the override only ever re-stated `nowrap` over a base rule that already
|
||||
wraps. Found by `adversarial-reviewer`; the mechanism sentence about min-content
|
||||
was true and was not the binding constraint. *(`flex` is a flex-ITEM property and `.nav`
|
||||
computes `display: block`, measured at 1056 / 1280 / 1920 px — so `flex: none`
|
||||
set `flex-shrink: 0` on something that is not a flex item and had **no effect**.
|
||||
Removing it leaves `.nav`'s and `.nav-list`'s geometry byte-identical at all
|
||||
three widths; the only trace was the computed `flex-shrink`, 0 → 1. Its comment
|
||||
claimed it was what stopped the nav being squeezed, which it never was.)*
|
||||
**It cost nothing at any normal size: 0 geometry
|
||||
differences across 22 pages × 16 widths (352 page-widths)**, header exactly **81.00 px** at every
|
||||
width from 1056 px up, brand on one line, nav on one line, CTA exactly on
|
||||
`.header-inner`'s content edge (gap 0.00 px) — **and the same with a seventh nav
|
||||
item injected**, at 1024 / 1047 / 1056 / 1071 / 1100 / 1150 / 1200 / 1216 / 1240
|
||||
/ 1280 / 1440 / 1920 px, where all seven share one line from 1056 px up. So the
|
||||
`nowrap` was not load-bearing at any width above the breakpoint; the 66 rem
|
||||
breakpoint is what keeps the row intact, and it already accounts for seven items.
|
||||
|
||||
**(2) The masthead is sticky only while it is one row**, and this half took two
|
||||
attempts — the first one shipped a regression, which is recorded because the
|
||||
reason it failed is the useful part.
|
||||
|
||||
Wrapping makes the header **taller** at enlarged text: **244.59–350.86 px** at a
|
||||
200 % root size against 81 px normally. A sticky box that size covers `#main`
|
||||
after "Skip to content", so stickiness has to go wherever the header can exceed
|
||||
`scroll-padding-top`. The gate is a **property**, not a query, because in a
|
||||
property `rem` tracks the root element.
|
||||
|
||||
⚠️ **ROUND 1 WROTE `min(0px, calc(100vw - 66rem))` AND IT WAS WRONG IN TWO WAYS
|
||||
THAT ONLY AN INTERMEDIATE ROOT SIZE EXPOSES.** Both were found by
|
||||
`adversarial-reviewer` and independently reproduced: **20 of 300**
|
||||
(page × viewport × root) points left `#main` behind the header, 10–69 px, across
|
||||
roots 18–30 — while root 16 and root 32, the only two sizes the first sweep
|
||||
measured, were both clean. **WCAG 1.4.4 is a requirement up TO 200 %, not AT
|
||||
200 %**, and a two-point sweep cannot see a diagonal band.
|
||||
|
||||
1. **It RAMPED where it needed to STEP.** One pixel inside the threshold the term
|
||||
lifts the header by one pixel — but the header has already gained a whole row,
|
||||
so it still covered `#main`. Wrapping is a step function; the response has to
|
||||
be one too. Hence the `* 100000` saturation, bounded by `-100vh`.
|
||||
2. **It used the wrong threshold in the wider band.** The tagline appears at
|
||||
76 rem, and with it the one-row masthead fits only from **1207 px = 75.4 rem**
|
||||
— the step-1 binary search already recorded that number. Gating the wide band
|
||||
on 66 rem therefore fired far too late. **`/` measured clean throughout, and
|
||||
that is what identified the driver:** `/` is the one page that suppresses the
|
||||
masthead tagline.
|
||||
|
||||
⚠️ **AND ROUND 2 FOUND THAT THE GATE WAS ONE-SIDED — it could only fire when the
|
||||
root was LARGER than the default.** Chrome's "Font size" control has **five**
|
||||
presets (Very small 9 px, Small 12, Medium 16, Large 20, Very large 24) and the
|
||||
sweeps had used 16 and 32 — 32 is not one of them. At **9 px the masthead wraps
|
||||
for the opposite reason**: `--width-content` is `80rem`, so the content column
|
||||
shrinks to 720 px while the header's `min-inline-size: 44px` / `min-block-size:
|
||||
44px` floors do not, and the row wraps at **every** viewport width. The header
|
||||
stood at **120 px against a 54.56 px `scroll-padding-top`** — **65 px of `#main`
|
||||
behind it on the 21 pages that render the tagline**, at 1056 through 1920 px.
|
||||
`/` measured 12 px, and that 12 px is **pre-existing**: the previous build is 67 px
|
||||
against the same 54.56 px offset on all 22 pages. So the wrap fix widened a
|
||||
pre-existing 12 px defect to 65 px, and the second term closes both.
|
||||
|
||||
**The shipped form is two bands, each gating on the width ITS layout requires,
|
||||
and two terms, each catching one direction:**
|
||||
|
||||
```css
|
||||
@media (min-width: 66rem) { .site-header {
|
||||
position: sticky;
|
||||
inset-block-start: clamp(-100vh,
|
||||
min(calc((100vw - 66rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||||
@media (min-width: 76rem) { .site-header {
|
||||
inset-block-start: clamp(-100vh,
|
||||
min(calc((100vw - 76rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||||
```
|
||||
|
||||
`1rem - 16px` is negative exactly when the root is below the CSS initial font
|
||||
size — the 16 px the whole rem scale is built on — and it also catches the
|
||||
root-style equivalent, where the previous form left the header sticky at 69 px
|
||||
against a 60.63 px offset at root 10. **The cost, measured and accepted:** a
|
||||
reader whose default text size is "Small" (12 px) loses the sticky header, where
|
||||
it currently works — but only just: the header is 73 px against a 72.75 px offset,
|
||||
a **0.25 px** margin. Trading a 0.25 px margin for a provable gate is the right
|
||||
way round.
|
||||
|
||||
Both thresholds are the existing layout breakpoints, and both sit just above the
|
||||
measured one-row fit width (1047 px ≈ 65.4 rem without the tagline, 1207 px ≈
|
||||
75.4 rem with it), so the gate fires **at or before** the wrap rather than after
|
||||
it. **At every normal size both evaluate to exactly `0px`** — a matched
|
||||
`min-width: 66rem` guarantees `100vw ≥ 1056 px` and a matched `min-width: 76rem`
|
||||
guarantees `100vw ≥ 1216 px`, because `100vw` counts a classic scrollbar and the
|
||||
query width does not, so the term can only be more positive.
|
||||
|
||||
**Measured after: 0.00 px of `#main` covered — 0 of 300 grid points across roots
|
||||
16–32 × viewports 1056–1920 on three pages, 0 of 176 rows at the default size and
|
||||
at a 200 % root size over all 22 pages, 0 of 144 cases sitting directly on the
|
||||
two thresholds, and 0 of 198 at the SEAM where the two bands meet** (viewports
|
||||
1180–1260 × roots 16–32). ⚠️ **`covered: 0` is also what a non-sticky header
|
||||
gives, so the seam was checked the other way round as well:** at the default size
|
||||
across 1180 / 1200 / 1210 / 1215 / 1216 / 1217 / 1220 / 1240 / 1260 / 1440 /
|
||||
1920 px the computed `inset-block-start` is `0px` and the header is still pinned
|
||||
at top 0.00 when scrolled — stickiness is preserved, not quietly lost. ⚠️ **Saturation leaves a transition band, and it is
|
||||
arithmetic rather than measured:** a finite factor means the response is only
|
||||
a near-step, so the band is about (header − `scroll-padding-top`) / factor ≈
|
||||
**0.0007 px** of viewport width. CDP takes integer viewport widths, so that band
|
||||
was not driven directly — it is bounded by the arithmetic, not by a probe.
|
||||
|
||||
*(Lightning CSS folds the factor into the units and emits
|
||||
`clamp(-100vh,10000000vw - 6600000rem,0px)`. Verified equivalent by measurement,
|
||||
not by reading: the computed `inset-block-start` is `0px` at every sticky width at
|
||||
the default size, and the header pins at top 0.00.)*
|
||||
|
||||
⚠️ **"AN OFFSET TOO SHORT CANNOT ARISE" WAS WRITTEN HERE AND IN TWO SHIPPED
|
||||
COMMENTS, AND IT IS FALSE. THE EXCEPTIONS ARE ENUMERATED RATHER THAN WAVED AT**,
|
||||
because a comment that says "cannot" is the kind that stops the next reader
|
||||
looking. The gate handles **root-relative** enlargement in both directions.
|
||||
What it cannot see, measured, header height against computed
|
||||
`scroll-padding-top`, header `top` = `0px` in every row:
|
||||
|
||||
| condition | header | offset | short by | status |
|
||||
|---|---|---|---|---|
|
||||
| `minimumFontSize=32` | 164.58–270.56 px | 97 px | **68–174 px** | 🛑 **OPEN — needs a fresh ruling.** The 2026-09-01 acceptance rested on two facts that are both false; and the real cost is **290 entirely-obscured focus stops, SC 2.4.11 AA**, not a short skip-link landing. **A pure-CSS detector DOES exist.** See below |
|
||||
| fallback font metrics + 7th nav item, **default** text size, 1056–1091 px | 141 px | 97 px | **44 px** | latent on `showInsights`. **HARD GATE: `AGENTS.md` R20 — the seventh item does not ship until this is fixed** |
|
||||
| `defaultFontSize=9` | 120 px | 54.56 px | ~~65 px~~ | **fixed** by the `1rem - 16px` term |
|
||||
| root-style 10 px | 69 px | 60.63 px | ~~8 px~~ | **fixed** by the same term |
|
||||
|
||||
**`--header-h` IS A FLOOR, NOT A CONSTANT**, and that is the sentence that
|
||||
had to change. It is the header's height at the **default** text size; above the
|
||||
default the masthead is deliberately taller, and declaration (2) is what makes
|
||||
that safe — where the gate can see the header exceed the token it is not sticky,
|
||||
so a `scroll-padding-top` that is too *generous* is harmless. ⚠️ **It does NOT
|
||||
make a short offset impossible: the two cases the gate cannot see are tabulated
|
||||
above.** The token previously asserted "one constant 81 px across
|
||||
every width where it is sticky", and that sentence is what made a 944 px
|
||||
overflow read as settled.
|
||||
|
||||
**What the narrow-width fix DID establish, and it is worth keeping separate.**
|
||||
The narrow residual had been charged to that same `flex-wrap: nowrap` — and for
|
||||
the narrow case that attribution was **wrong**. The cause there was
|
||||
`white-space: nowrap` on `.brand-name`, a different declaration in the same
|
||||
component, and removing it **changes nothing at any real size**: the brand name
|
||||
is one line at all 16 widths at root 16, the sticky header still measures exactly
|
||||
81 px at every width from 66 rem up, and the CTA still lands exactly on
|
||||
`.header-inner`'s content edge. **A residual defended by the wrong cause is
|
||||
defended forever**, because the reason given is one nobody wants to re-open —
|
||||
which is exactly what happened for four days.
|
||||
|
||||
**ELEVEN FIXES ACROSS THREE PASSES, IN TWELVE ROWS — no `overflow-x` was added
|
||||
anywhere.** Eight are narrow-width (2026-08-31), two are the large-text nav
|
||||
(2026-09-01), and the eleventh is the footer email (2026-09-01, the pass that
|
||||
closed the last recorded overflow). ⚠️ **The twelfth row is `.nav-list`, and it
|
||||
is a row without being a fix:** it is here because its removal was *measured*,
|
||||
and it is not counted because the measurement was that it changes nothing. Say
|
||||
so rather than leaving the arithmetic to a reader who counts rows — the heading
|
||||
read "TEN FIXES" over eleven rows until 2026-09-01 for exactly this reason. All
|
||||
but one of the eleven are cause-specific; the exception is marked as the
|
||||
backstop it is:
|
||||
|
||||
| Element | Was | 320 px | Cause |
|
||||
|---|---|---|---|
|
||||
| `SiteHeader .header-inner` | `flex-wrap: nowrap` removed above 66 rem | **944 px** at 1280 px, root 32 | **Necessary and sufficient, measured.** A `nowrap` flex line cannot break, so the brand / nav / CTA row could not reflow at all |
|
||||
| `SiteHeader .nav-list` | `flex-wrap: nowrap` and a dead `flex: none` removed above 66 rem | **0 px — inert** | Kept removed so the `nowrap` prohibition is not contradicted by a `nowrap` in the same file. Byte-identical to shipped at every width and every text setting, six items and seven |
|
||||
| `SiteHeader .site-header` | `inset-block-start: 0` → a saturating `clamp()` gate, in **two** bands (66 rem, and 76 rem where the tagline shows) | *(consequence of the row above)* | Wrapping makes the header 244.59–350.86 px at root 32; sticky at that height covered `#main` by up to 157 px after the skip link. **Round 1's single-band `min()` ramped instead of stepping and left 20 of 300 grid points covered by 10–69 px** |
|
||||
| `SiteHeader .brand-name` | `white-space: nowrap` removed | **63 px, all 22 pages** | Two words glued into one unbreakable box |
|
||||
| `/bio/ .rates-list` | `overflow-wrap: anywhere` | **152 px** | `https://adr.smlcompany.ca/fees/` has no break opportunity, and the single grid track it sized stretched all five rows to 424 px |
|
||||
| `/ .hero-h` | `overflow-wrap: anywhere` | 36 px | "contract," at 104 px held `.hero-copy` open |
|
||||
| `/ .feature-body` | `overflow-wrap: anywhere` | 26 px | "party-appointed" at 202 px |
|
||||
| `CredentialRow .credential-label` | `overflow-wrap: anywhere` | 38 px | "designation" needs 143 px in an 88 px track |
|
||||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: anywhere` | **14 px** | `info@smlcompany.ca` has no break opportunity and demands 310 px. Minimum-font-size only; also 38 / 30 / 19 px at 1024 / 1056 / 1100 px, by a different mechanism — see the footer block above |
|
||||
| `SiteFooter .footer-brand` | `flex-wrap: wrap` | 12 px | Flex item at `min-width: auto` cannot shrink below "Lajevardi" |
|
||||
| `PracticeCard .card` | `padding` clamped | *(the cause under `Pill`)* | The space scale is rem-based, so `--space-6` is 64 px a side at root 32 — 128 px of padding in a ~224 px box |
|
||||
| `Pill` | `overflow-wrap: anywhere` | 33 px → **1 px** | ⚠️ **SYMPTOM, NOT CAUSE.** A one-word pill cannot wrap at a space that is not there |
|
||||
|
||||
`flex-wrap: wrap` on the footer brand rather than `anywhere`, because it reflows
|
||||
without hyphenating a person's name.
|
||||
|
||||
⚠️ **`Pill`'s `anywhere` IS A BACKSTOP AND THE TRADE IS RECORDED RATHER THAN
|
||||
HIDDEN.** It removes the min-content floor of a shrink-to-fit `inline-flex` box,
|
||||
so the pill collapses to whatever its parent gives it. The real cause was
|
||||
`PracticeCard`'s padding: with it clamped, "Construction" at root 32 / 320 px
|
||||
went from **94 × 220 px broken into six two-character lines** to **158 × 85 px on
|
||||
two lines**, and root 16 is unchanged at 121 × 27 px on one line. **`anywhere` is
|
||||
still load-bearing** — removing it leaves 1 px of document overflow on `/`,
|
||||
`/mediation/` and `/practice/`, and renders the pill 240 px wide inside a 224 px
|
||||
card, escaping its own rounded border. Two lines is the better of those. The
|
||||
clamp does not change any normal size: 10vw holds 32 px from 320 px up.
|
||||
|
||||
**TEN instrument findings, and each one hid a real defect. Items 4–6 are from the
|
||||
2026-09-01 header pass and all three produced a CLEAN-LOOKING result; 7–10 are
|
||||
from the footer pass the same day. Each is recorded once — the footer block above
|
||||
points here rather than restating them.** ⚠️ **Finding 2 RECURRED in that footer
|
||||
pass**, in a probe written by the person who had written finding 2 down: it is
|
||||
described where it did its damage, beside the footer fix, and not duplicated as
|
||||
an eleventh item.
|
||||
|
||||
|
||||
4. ⚠️ **A UNIFORM PASS FROM A TAUTOLOGY.** The skip-link probe reported
|
||||
`0.0 px of #main covered` on **946 of 946** page-widths, before and after,
|
||||
under every mechanism. `global.css` sets `scroll-behavior: smooth`, which
|
||||
makes `scrollIntoView()` **asynchronous**, so a synchronous read afterwards
|
||||
measures `scrollY = 0`; and at `scrollY = 0` the target sits exactly at the
|
||||
header's bottom edge, so `covered` is 0 **by construction**. Pass
|
||||
`behavior: 'instant'` and assert that `scrollY` equals its predicted value —
|
||||
once fixed the same probe reported 10–26 px covered on 22 of 22 pages, a
|
||||
pre-existing defect the tautology had hidden.
|
||||
5. ⚠️ **A RUNNING TRANSITION MAKES A PROPERTY READ RETURN THE OLD VALUE.**
|
||||
Forcing reduced motion — by CDP emulation *or* by
|
||||
`--force-prefers-reduced-motion` — made `documentElement.style.fontSize =
|
||||
'32px'` read back as **16 px**, while the inline `style` attribute plainly
|
||||
said `font-size: 32px`. This codebase's reduced-motion block sets
|
||||
`transition-duration: 0.01ms !important` on `*`, which starts a transition on
|
||||
**every** property change, and a synchronous `getComputedStyle` then returns
|
||||
the transition's *start* value. The instrument now injects
|
||||
`transition:none;animation:none` instead. Same family as *a running
|
||||
transition outranks an `!important` author declaration*.
|
||||
6. **A DOM-INJECTED ELEMENT RECEIVES NO SCOPED STYLES.** Astro scopes component
|
||||
CSS with a `data-astro-cid-*` attribute, so the hand-built
|
||||
`<a class="nav-link">` used to test a seventh nav item got **none** of
|
||||
`.nav-link`'s rules — no `min-inline-size`, no `min-block-size`, no
|
||||
`font-size` — and measured narrower and shorter than a real item, which is
|
||||
the direction that makes a width test pass. **Clone a real node** and assert
|
||||
its computed styles match a sibling's.
|
||||
|
||||
7. ⚠️ **A CAUSE READ OFF A TRUNCATED LIST.** The header harness capped its
|
||||
offender array at `slice(0, 20)` while reporting `offenderCount` beside it,
|
||||
and **44 of the 88 failing rows exceeded the cap** — so the enumeration that
|
||||
was supposed to name the footer's cause was silently missing 34 of the 54
|
||||
boxes at 320 px. The truncation was visible only to a reader who compared the
|
||||
two numbers. Same family as the `tail -3` rule in `CLAUDE.md`, through a
|
||||
different pipe.
|
||||
8. ⚠️ **`grep -c` COUNTS LINES, NOT OCCURRENCES, AND MINIFIED CSS IS ONE LINE.**
|
||||
`grep -c 'overflow-wrap:anywhere' dist/_astro/*.css` returned `1` for a file
|
||||
that holds **three** such declarations. It was being used to confirm the new
|
||||
rule had reached `dist/`, which it could not do. Extract and print the bytes
|
||||
instead — the rule that actually settled it was reading the emitted selector,
|
||||
`.footer-contact[data-astro-cid-nns7i3if] a[data-astro-cid-nns7i3if][href^=mailto\:]`.
|
||||
|
||||
9. ⚠️ **`getClientRects().length` IS 1 FOR A `display: flex` ELEMENT HOWEVER MANY
|
||||
LINES OF TEXT IT HOLDS** — it is one block box, not a fragmented inline. The
|
||||
wrap probe therefore reported `lines=1` for the footer email while its own
|
||||
`height` said **102.38 px = 2 × 51.19**, i.e. two lines. Line counts come from
|
||||
`Range` rects over the text, which do fragment per line. **Two metrics of the
|
||||
same quantity disagreeing is the cheapest defect detector in this section** —
|
||||
it is what caught this one.
|
||||
10. ⚠️ **`Page.captureScreenshot`'s `clip` IS IN PAGE COORDINATES, NOT VIEWPORT
|
||||
COORDINATES.** Given a `getBoundingClientRect()` taken after a scroll, it
|
||||
captured a **blank cream plate** — of a footer that is ink. The screenshot
|
||||
existed to be the independent second method for a break the numbers could not
|
||||
judge, so a silently wrong one is worse than none. Add `window.scrollX/Y`, or
|
||||
pass `captureBeyondViewport: true` and page coordinates.
|
||||
|
||||
The original three:
|
||||
|
||||
1. **`overflow-wrap: break-word` permits a break at layout time but does not
|
||||
reduce min-content size** — `anywhere` does. Five of the fixes are this
|
||||
distinction. `global.css:166` sets `break-word` on **`p` AND `h1`–`h6`** —
|
||||
*(this line said "`h1`–`h6` and nothing else, so body copy inherits no
|
||||
wrapping at all", which was wrong in a way that mattered: `.feature-body`
|
||||
**is** a `<p>` and already had `break-word`, so the premise given for its fix
|
||||
was false while the fix itself was right)*. Nothing else gets one, so a
|
||||
`<span>`, `<li>`, `<dd>` or `<a>` inherits no wrapping.
|
||||
2. **`getBoundingClientRect()` reports BORDER BOXES, so an element sweep cannot
|
||||
see text spilling outside its own box.** `.credential-label`'s words ran 86 px
|
||||
past an 88 px cell while every element's rect stayed inside the viewport —
|
||||
the document was 38 px over and the offender was invisible to the predicate
|
||||
this table's own command uses. Read `scrollWidth - clientWidth` per element as
|
||||
well as per document.
|
||||
3. **Under `Emulation.setDeviceMetricsOverride({mobile: true})` the LAYOUT
|
||||
viewport expands to fit the content**, so `innerWidth` becomes 472 at a
|
||||
requested 320 and `scrollWidth - innerWidth` reads **0** while the reader is
|
||||
still scrolling sideways. The honest formula is
|
||||
`documentElement.scrollWidth - documentElement.clientWidth`, which is correct
|
||||
in both modes.
|
||||
|
||||
✅ **THE FOOTER RESIDUAL IS FIXED, 2026-09-01 — ONE DECLARATION, AND THE
|
||||
EARLIER RECORD MISDIAGNOSED HALF OF IT.** Under Chrome's minimum font size the
|
||||
document overflowed at four widths on all 22 pages — **14 px at 320, 38 px at
|
||||
1024, 30 px at 1056, 19 px at 1100** — and 0 from 1200 px up. One cause at all
|
||||
four: `info@smlcompany.ca` has no break opportunity, so its **min-content width
|
||||
is 310 px**.
|
||||
|
||||
| Element | Was | Fix | Effect |
|
||||
|---|---|---|---|
|
||||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: normal` | `overflow-wrap: anywhere` | **88 of 352 → 0 of 352** |
|
||||
|
||||
⚠️ **TWO MECHANISMS, ONE CAUSE — and this is the part the earlier record got
|
||||
wrong.** It said the 1024–1100 px half "looks like one `overflow-wrap: anywhere`
|
||||
on the footer contact links" and that "the 320 px half is a column-sizing
|
||||
question and is not the same fix". **It is the same fix.** The column sizing at
|
||||
320 px is *driven by* that same 310 px:
|
||||
|
||||
- **At 320 px** `.footer-grid`'s implicit track is `auto`, whose minimum is
|
||||
min-content, so the track — and every `.footer-col` in it — measures **310 px
|
||||
inside a 272 px content area**. The boxes themselves cross the viewport edge:
|
||||
**54 elements** did, right edge 334 px against a 320 px client width.
|
||||
- **At 1024 / 1056 / 1100 px** the contact column is `minmax(0, 1fr)` — a **0**
|
||||
minimum, so the track does not grow — and the box stays **224 / 232 / 243 px**
|
||||
while the text spills **86 / 78 / 67 px outside it**. **No element's border box
|
||||
crosses the viewport edge at all** at those widths.
|
||||
|
||||
Reducing one min-content width closes both halves.
|
||||
|
||||
⚠️ **AND IT CLOSED A THIRD CASE THAT THE FOUR-METHOD TABLE REPORTED AS CLEAN —
|
||||
"MEASURE THE ELEMENTS, NOT ONLY THE PAGE", WHICH IS ALREADY A RULE IN THIS
|
||||
SECTION.** With the fix disabled as a control, over 420 points (3 pages × 10
|
||||
widths × 6 minimum-font-size presets and 8 root sizes), **69 failed: 12 by
|
||||
document overflow and 57 by the link overrunning its OWN box while the document
|
||||
measured 0.** Those 57 sit at **root style 26 / 28 / 30 / 32 px** and at
|
||||
**`minimumFontSize` 24 and 32** — and root style 32 px is a row the table above
|
||||
reports as **0 of 352**, truthfully, because it is a document measurement.
|
||||
|
||||
**Why it never reached the viewport edge, measured rather than surmised:**
|
||||
`.wrap` carries a **96 px** right gutter at that size, and the spill is smaller
|
||||
than the gutter. At root 32 on `/about/` the link's box ends at 928 px and its
|
||||
text at **1007.3 px** against a **1024 px** client width — 79 px of spill sitting
|
||||
inside 96 px of padding. The spill shrinks as the viewport widens (79 / 71 / 60 /
|
||||
35 / 15 / 0 px at 1024 / 1056 / 1100 / 1200 / 1280 / 1440), which is why the case
|
||||
disappears at 1440 px rather than at a breakpoint.
|
||||
|
||||
**After the fix: 0 of 420, and 0 clipping at every preset and every root size.**
|
||||
The control is what makes that number mean anything — a sweep returning zero on
|
||||
every cell is the shape this section warns about twice, so it was re-run with the
|
||||
declaration forced back to `normal` and it failed 69.
|
||||
|
||||
⚠️ **AND THE ELEMENT SWEEP THAT LOOKED FOR THE CAUSE REPORTED "NONE" AT THREE
|
||||
OF THE FOUR WIDTHS** — instrument finding 2 in this section, committed again by
|
||||
the person who wrote it down. `getBoundingClientRect()` reports **border boxes**,
|
||||
so a `right > clientWidth` predicate cannot see text spilling out of a box that
|
||||
is itself inside the viewport, which is precisely the 1024–1100 px mechanism.
|
||||
The document figure was right and the offender list was empty, and those two
|
||||
facts together are the signature of this bug. **Read `scrollWidth − clientWidth`
|
||||
per element as well as per document.**
|
||||
|
||||
⚠️ **And the harness's offender list was truncated at 20 entries on 44 of the 88
|
||||
failing rows**, so it could not have named the cause either — instrument finding
|
||||
**7** below.
|
||||
|
||||
**What the fix costs, measured rather than asserted.** At the default text size,
|
||||
**nothing**: the address is one line, **44.00 px** tall — exactly the touch-target
|
||||
floor — at all 16 widths, and the normal-settings identity check is 0 differences
|
||||
on 352 page-widths. Under minimum font size it wraps to two lines and the break
|
||||
is mid-token — `info@smlcompany` / `.ca` at 320 px, `info@smlcomp` / `any.ca` at
|
||||
1024 px, `info@smlcompa` / `ny.ca` at 1100 px — read out of the DOM one character
|
||||
at a time and confirmed on a screenshot, not inferred from a width. The `href`
|
||||
is untouched (`mailto:info@smlcompany.ca`), every painted glyph is inside the
|
||||
viewport in **176 of 176** rows, and the link never clips its own box. A
|
||||
mid-token break in an address is cosmetic; a document that scrolls sideways is a
|
||||
WCAG 1.4.10 failure.
|
||||
|
||||
**`anywhere`, NOT `break-word` — and that was established with a NEGATIVE
|
||||
CONTROL rather than from the rule.** `break-word` permits a break at layout time
|
||||
without reducing min-content size, so it leaves the defect untouched: injected in
|
||||
place of `anywhere` it failed **88 of 176** rows, the same rows as the unfixed
|
||||
baseline. Both the baseline and the control had to fail for the trial to mean
|
||||
anything — **a trial in which every candidate passes is a broken trial**, and
|
||||
this section already carries five instrument findings of exactly that shape.
|
||||
|
||||
**On the link, not on the column, and not in the markup.** Three candidates all
|
||||
reach 0 of 176: `anywhere` on the email link, on `.footer-contact`, and on
|
||||
`.footer-col a`. The address is the only string in the footer that produces
|
||||
**document** overflow, so the rule sits on the address and the two broader
|
||||
selectors were declined as broader than the cause.
|
||||
|
||||
⚠️ **THE REASON FIRST GIVEN FOR THAT SCOPING WAS THE WRONG TEST, AND IT IS WORTH
|
||||
MORE THAN THE SCOPING.** It said *"the address is the only string in the footer
|
||||
with no break opportunity — the location line, the response sentence and the
|
||||
sixteen nav labels all have spaces"*. **A space is not the test. Min-content is
|
||||
set by the longest WORD**, so a label with spaces still overruns its track when
|
||||
one of its words does not fit. `adversarial-reviewer` found one that does, and it
|
||||
reproduced at a width the review had not sampled:
|
||||
|
||||
| width | element | own-box overrun | clearance to the next column | document |
|
||||
|---|---|---|---|---|
|
||||
| 640 px | `.footer-col a` "Construction & Infrastructure" | **24 px** out of a 176 px box | **7.7 px** | 0 |
|
||||
| 640 px | `.footer-col a` "Shareholder & Family Business" | 2 px | 7.7 px | 0 |
|
||||
| 700 px | `.footer-col a` "Construction & Infrastructure" | 4 px out of a 196 px box | 27.7 px | 0 |
|
||||
|
||||
`minimumFontSize=32`, all 22 pages, 12 widths from 320 to 1024 px = 264 rows;
|
||||
clean at every other width sampled, including 768 and 834 px. **"Infrastructure"
|
||||
is 14 characters and does not fit a 176 px track at 32 px** — the space in the
|
||||
label is irrelevant to that.
|
||||
|
||||
⚠️ **AND THE 12-WIDTH GRID STEPPED OVER TWO MORE**: round 2 found the same label
|
||||
at **17 px @660 and 11 px @680**, widths this grid does not sample. That is this
|
||||
section's own caveat holding — *the grid is 16 columns, not a continuum* — and it
|
||||
is the second time in one session that a between-columns width mattered.
|
||||
|
||||
**A FOURTH CASE OF THE SAME FAMILY, and it should be ruled on together with the
|
||||
footer label rather than separately.** `minimumFontSize=32`,
|
||||
`/practice/construction/` @320 px: `nav.crumbs > ol` overruns its own 272 px box
|
||||
by **7 px**, furthest text edge 302.9 px against a 320 px client width,
|
||||
`overflow-x: visible`, no clipping, **document overflow 0**. Clean at 360 / 390 /
|
||||
414 px and on the other five practice pages. **No success criterion fails**, so
|
||||
like the footer label it is recorded rather than fixed — but if
|
||||
`.footer-col a { overflow-wrap: anywhere }` is ruled in, the same call covers
|
||||
`.crumbs` and the ruling should be taken once for both. Raised by
|
||||
`adversarial-reviewer`, round 2.
|
||||
|
||||
**Not fixed here, and the reason is a scope judgement rather than a measurement.**
|
||||
It produces **no document overflow at any width**, so it fails no success
|
||||
criterion; `.footer-col a { overflow-wrap: anywhere }` is measured to close it
|
||||
and to be inert at normal settings. What it would change is how six practice-area
|
||||
names break on a marketing surface under enlargement, which is a copy-adjacent
|
||||
call. ⚠️ **The maintenance risk is the real finding: 7.7 px of clearance is one
|
||||
label rename away from two columns colliding**, and nothing in the build measures
|
||||
it. Pouya's to rule on; `docs/06` carries it. `word-break: break-all` also
|
||||
reaches 0 and was declined for the same reason: it breaks where a normal
|
||||
opportunity exists. **And NOT a `<wbr>` after the `@`**, which would give a
|
||||
prettier break: it splits a §4-registered fact across an element boundary, so
|
||||
`info@smlcompany.ca` would no longer be greppable in `dist/` — the surface
|
||||
`npm run check:claims` reads — and its longest unbreakable run (`smlcompany.ca`,
|
||||
~224 px) does not fit the 224 px column at 1024 px anyway, so it would need
|
||||
`anywhere` as a backstop regardless.
|
||||
|
||||
**Not header-side, and that survives the fix:** no header element extends the
|
||||
document at any width under any of the four mechanisms, and the nav items and CTA
|
||||
are on-screen in **1408 of 1408** measurements. *(This said "946 of 946" for one
|
||||
revision. 946 is a different sweep — the four runs made with the tautological
|
||||
skip probe in instrument finding 4 — and reusing it for the final build was the
|
||||
two-similar-totals mistake this section keeps warning about.)* *(The Practice
|
||||
dropdown panel's contents overflow their own box by 84 px under minimum font
|
||||
size and 40 px at the default, on all 22 pages — unchanged by this fix, and it
|
||||
never extends the document because the panel sits inside a **closed**
|
||||
`<details>`. Re-checked after the fix rather than carried forward.)*
|
||||
|
||||
🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL IS BACK OPEN, AND IT IS NOW A
|
||||
CONFORMANCE FAILURE RATHER THAN A CONVENIENCE LOSS. IT NEEDS A FRESH RULING —
|
||||
THE ONE OF 2026-09-01 WAS TAKEN ON TWO FACTS THAT ARE BOTH WRONG.** Pouya ruled
|
||||
it accepted on the basis *"you proved no CSS mechanism can see
|
||||
minimum-font-size; the only fix is JS and zero-JS is a founding decision."*
|
||||
`adversarial-reviewer` attacked both halves of that and both attacks reproduced.
|
||||
|
||||
**(1) THE PREMISE IS FALSE.** The proof was of `rem`/`em` and of queries written
|
||||
in them. The font-metric units `ch`, `ex`, `cap`, `lh` and `rlh` all read the
|
||||
*used* font size and double under the setting — in property values, in `@media`
|
||||
and in `@container`. The table at the top of this section carries the
|
||||
measurement. **A pure-CSS detector for this mechanism exists**, so the residual
|
||||
is not unfixable and "the only fix is JS" does not hold.
|
||||
|
||||
**(2) THE COST WAS UNDERSTATED, AND BY A CATEGORY RATHER THAN A NUMBER.** The
|
||||
record described it as the skip link landing short — 68–174 px of `#main` behind
|
||||
the header — and argued *"one of the two is a WCAG 1.4.10 failure while the other
|
||||
degrades the convenience of a skip link that still works."* What is actually
|
||||
happening is that **ordinary keyboard focus lands entirely behind an opaque
|
||||
sticky header**, which is **WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum),
|
||||
Level AA** — the same conformance level as the 1.4.10 failure it was traded
|
||||
against. **So the asymmetry that was the whole argument does not exist: both
|
||||
sides are AA failures.**
|
||||
|
||||
**Measured, and the instrument is checked in three ways.** Shift+Tab walk — the
|
||||
ordinary way a keyboard user returns to a link they passed — 6 pages × 5 widths
|
||||
≥ 1056 px, 70 steps per cell, elements inside the header excluded:
|
||||
|
||||
| build | mode | focus stops | entirely hidden | zones |
|
||||
|---|---|---|---|---|
|
||||
| working tree | default | 1,455 | **0** | — |
|
||||
| working tree | `minimumFontSize=32` | 1,455 | **290** | 254 footer, **36 `#main`** |
|
||||
| `fce89d4~1` (pre-header-fix) | `minimumFontSize=32` | 1,455 | **0** | — |
|
||||
| `fce89d4~1` (pre-header-fix) | default | 1,455 | **0** | — |
|
||||
|
||||
⚠️ **THE THIRD ROW IS THE ONE THAT MATTERS: THE HEADER FIX CREATED THIS.** Before
|
||||
it, `flex-wrap: nowrap` kept the masthead one row under this setting, so it was
|
||||
short enough that focus landed clear; the overflow went sideways instead. After
|
||||
it the masthead wraps to 164.58–270.56 px and covers the 97 px landing. This is
|
||||
a **regression introduced by the 2026-09-01 header fix**, not a condition it
|
||||
inherited — and the ruling to accept it was taken without that comparison
|
||||
existing.
|
||||
|
||||
**Instrument checks, because a focus-obscuring count is easy to fake in both
|
||||
directions.** *(a)* The default-mode run returns **0**, so the predicate is not
|
||||
tautological. *(b)* ⚠️ **Geometry alone was WRONG and said 43.** All 43 were the
|
||||
**skip link**, which is stacked deliberately *above* the header and is not
|
||||
covered by it at all — so `elementFromPoint` at the focused box's centre is
|
||||
authoritative and geometry only nominates candidates. Under the setting there
|
||||
are 333 geometric candidates and **290** survive the hit test. *(c)* Sampled
|
||||
hits agree 6 of 6, e.g. `/` @1056: focused `a` "Technology, AI & Data" at
|
||||
97 → 199.4 px inside a header at 0 → 228.6 px, `position: sticky`,
|
||||
`inset-block-start: 0px`, background `rgb(250, 247, 242)` — opaque cream.
|
||||
|
||||
✅ **FIXED AND CLOSED — Q61 RULED *fix now*, 2026-09-01. Candidate B SHIPPED.**
|
||||
Two candidates existed, and the second was better on the exact ground that had
|
||||
deferred the first. The measurements below are the pre-fix state; the acceptance
|
||||
sweep that closed it is at the end of this block.
|
||||
|
||||
*Candidate A, round 1:* a third gate term `calc((15px - 1ch) * 100000)`,
|
||||
un-sticking the header under the setting while leaving `inset-block-start: 0px`
|
||||
at the default. **Deferred:** 15 px is fitted to Geist's `ch`, so it needs
|
||||
checking under fallback metrics and Chrome's other font presets, and the
|
||||
reviewer's own injection of it was flaky.
|
||||
|
||||
*Candidate B, round 2 — recommended:* `scroll-padding-top`, the property that
|
||||
already exists for this job, rather than un-sticking anything. Two declarations
|
||||
in the existing `@media (min-width: 66rem)` block:
|
||||
|
||||
```css
|
||||
html {
|
||||
scroll-padding-top: calc(var(--header-h) + var(--space-4)); /* fallback: no `lh` */
|
||||
scroll-padding-top: max(
|
||||
calc(var(--header-h) + var(--space-4)),
|
||||
calc(10lh - 83px)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Verified independently — every value read from `getComputedStyle`, not
|
||||
reasoned:**
|
||||
|
||||
| condition | `1lh` on `<html>` | computed offset | header | verdict |
|
||||
|---|---|---|---|---|
|
||||
| default, fonts loaded | 18 px | **97 px — byte-identical to shipped** | 81 px | clears |
|
||||
| `minimumFontSize=32`, fonts loaded | 37 px | **287 px** | 270.56 px | clears |
|
||||
| default, **every `.woff2` blocked** | **18 px** | **97 px** | 81 px | clears |
|
||||
| `minimumFontSize=32`, **`.woff2` blocked** | **37 px** | **287 px** | 270.56 px | clears |
|
||||
| `minimumFontSize=32`, family Georgia | 36 px | 277 px | 270.56 px | clears |
|
||||
| `minimumFontSize=32`, family Verdana | 39 px | 307 px | 270.56 px | clears |
|
||||
|
||||
⚠️ **Rows 3 and 4 are why B beats A: `1lh` on `<html>` is immune to the webfont
|
||||
fallback.** `<html>` keeps the UA default family — `--font-sans` is set on
|
||||
`body`, and `<html>`'s computed `font-family` measures `Times` — so blocking every
|
||||
font file changes nothing, and the fallback-metrics caveat that deferred
|
||||
candidate A does not apply to B at all.
|
||||
|
||||
**What was said against B before it shipped, and what the sweep did with each.**
|
||||
`10lh - 83px` is two fitted constants — the difference from A being that a
|
||||
`max()` ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||||
stickiness the wrong way. **The first declaration is load-bearing:** an engine
|
||||
without `lh` support drops the whole `max()` as invalid and `scroll-padding-top`
|
||||
falls back to `var(--space-4)` = 16 px, which is *worse* than no fix — so it is
|
||||
written first, as a plain declaration, and must stay there. The focus probe is
|
||||
**Chrome-only**, which is still true and is a limit on the evidence rather than
|
||||
on the fix. The two remaining objections were discharged by measurement:
|
||||
|
||||
**THE ACCEPTANCE SWEEP — 777 cells over 37 settings, plus the focus walk.**
|
||||
|
||||
Per cell: navigate, kill transitions *before* any root-size change, set the
|
||||
root, `location.hash = 'main'`, then read how much of `#main` sits behind the
|
||||
header. The cell count is asserted, so a silently-truncated grid cannot pass.
|
||||
|
||||
| setting | before | after |
|
||||
|---|---|---|
|
||||
| root style 9..32 (504 cells) | 0 | 0 |
|
||||
| `defaultFontSize` 9 / 12 / 16 / 20 / 24 (105) | 0 | 0 |
|
||||
| `minimumFontSize=9`, `=12` (42) | 0 | 0 |
|
||||
| `minimumFontSize=16` (21) | 6 cells, max 52 px | **6 cells, max 52 px** |
|
||||
| `minimumFontSize=20` (21) | 12 cells, max 64 px | **6 cells, max 14 px** |
|
||||
| `minimumFontSize=24` (21) | 15 cells, max 75 px | **0** |
|
||||
| `minimumFontSize=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| fonts blocked (21) | 0 | 0 |
|
||||
| fonts blocked + `minFont=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| **total** | **63 of 777** | **12 of 777** |
|
||||
|
||||
**The focus walk, which is the criterion itself: 290 entirely-hidden stops of
|
||||
1,455 → 0.** Same grid (6 pages × 5 widths ≥ 1056 px, 30 cells, 344
|
||||
header-internal stops skipped), hit-tested with `elementFromPoint`. **The
|
||||
pre-fix tree was rebuilt in a git worktree and put through the identical probe:
|
||||
it still reports 290** — footer 254, `#main` 36, geometry candidates 333 against
|
||||
the fixed build's 43. That control is what makes the 0 a result rather than a
|
||||
hope: both modes reading 0 with identical step counts is the shape `CLAUDE.md`
|
||||
warns *ends* a check instead of starting one.
|
||||
|
||||
**Default settings unchanged: 0 differences over 352 page-widths × 17 fields =
|
||||
5,984 comparisons**, full-page geometry fingerprint included, positive control
|
||||
detecting exactly 1 injected difference.
|
||||
|
||||
⚠️ **AND THE FIRST ATTEMPT AT THAT COMPARISON REPORTED 4,224 DIFFERENCES.** It
|
||||
keyed rows on `page`/`width`; this harness names them `url`/`w`, so every row
|
||||
collapsed onto one map entry and was compared against an arbitrary single row.
|
||||
It was caught only because the result was *uniformly bad*. **The fix is not the
|
||||
corrected field names — it is that the comparison now asserts the key fields
|
||||
exist and are unique before comparing anything.**
|
||||
|
||||
⚠️ **ONE COLUMN OF THE SWEEP WAS MEASURING NOTHING, AND TWO METRICS
|
||||
DISAGREEING IS WHAT SURFACED IT.** A summary column reported
|
||||
`min(scroll-padding-top − headerH)` of **−71.89 px** on root-style rows whose
|
||||
`covered` read **0**. Cause: `insetBlockStart` is **−900 px** in those rows —
|
||||
the header is `position: sticky` and **not pinned**, because that is exactly how
|
||||
the saturating `clamp()` gate un-sticks it. Comparing the offset to the header
|
||||
height is meaningless where the header is not pinned. `covered` — real geometry
|
||||
after a real hash navigation — is the valid metric. **A `position` of `sticky`
|
||||
is not the same fact as "the header is pinned", and any future probe that
|
||||
compares an offset against a header height has to read
|
||||
`inset-block-start` too.**
|
||||
|
||||
**THE 12 REMAINING CELLS ARE PRE-EXISTING, REDUCED, AND DELIBERATELY NOT
|
||||
FIXED.** `minimumFontSize=16` leaves 52 px of `#main` behind the header on
|
||||
`/about/` and `/contact/` at 1280, 1440 and 1920 px; `=20` leaves 14 px on the
|
||||
same six. Every one is unchanged or better than before the fix and none is new.
|
||||
**Why the ramp cannot see them, which is the part worth keeping:** the setting
|
||||
*floors* computed sizes, so at `minimumFontSize=16` the root is genuinely 16 and
|
||||
`1lh` on `<html>` is 18 px — identical to the default — while the header grows to
|
||||
**149.38 px** because the **sub-16 px** type (the 11 px tagline, the 14 px
|
||||
eyebrow and nav) is floored up. **The ramp reads a quantity the setting did not
|
||||
move.** Closing it needs a different measurement — `1lh` taken from an element
|
||||
that carries the small type, or a floor on the header — which is a header change
|
||||
with site-wide reach. Pouya's instruction on this step was *"if the sweep
|
||||
surfaces anything beyond this one declaration's blast radius, stop and report —
|
||||
do not widen"*, so it is reported: `docs/06` carries it as its own item. It is
|
||||
**not** an SC 2.4.11 failure, which is about a component *entirely* hidden.
|
||||
|
||||
**`docs/06`'s item is ticked and carries the figures.** The decision was
|
||||
`AGENTS.md` **Q61**, now closed.
|
||||
|
||||
⚠️ **AND ONE MORE LATENT CASE, AT THE DEFAULT TEXT SIZE — NO READER SETTING
|
||||
INVOLVED: FALLBACK FONT METRICS PLUS THE SEVENTH NAV ITEM.** With every `.woff2`
|
||||
blocked — what a reader on the Slow 4G profile `docs/04` budgets against sees
|
||||
first, since the fonts are `font-display: swap` — and a seventh nav item cloned
|
||||
from a real node, the header measures **141 px at every width from 1056 to
|
||||
1091 px**, and 81 px from 1092 px up. With the webfonts loaded it is 81 px at all
|
||||
of them. **Two consequences, and the record first carried only the smaller one:**
|
||||
|
||||
1. **A 60 px collapse** when Geist swaps in — a CLS contribution on all 22 pages,
|
||||
against the CLS < 0.05 budget. **Larger, not new:** the previous build gives
|
||||
86.59–86.97 px in the same band, so a ~5.6 px shift already existed.
|
||||
2. ⚠️ **AND 44 px OF `#main` BEHIND THE STICKY HEADER** after the skip link,
|
||||
because 141 px exceeds the 97 px `scroll-padding-top`. **This one IS new** —
|
||||
the previous build's 86.97 px stayed under 97 px, so it covered **0**. Framing
|
||||
this case as "a CLS contribution" understated it, and a reader ruling on a
|
||||
layout shift would weigh it differently from one ruling on the skip link.
|
||||
|
||||
*(The band was recorded as "1056 / 1064 / 1072 / 1084 … 81 px from 1092 up",
|
||||
which reads as though 1086–1090 were checked and clear; they are all 141 px. It
|
||||
is a contiguous **36 px** band, 1056–1091. Both errors found by
|
||||
`adversarial-reviewer`.)*
|
||||
|
||||
**It is latent, not shipped:** with six items there is no wrap at any width, and
|
||||
`showInsights` turns the seventh on only at two published articles. **Not fixed
|
||||
here, and deliberately:** the two candidates are raising the desktop breakpoint
|
||||
past 1091 px, which changes the layout at normal settings in that band, or giving
|
||||
Geist a metric-matched `size-adjust` fallback, which is a font-stack change well
|
||||
outside "the header and its consumers".
|
||||
|
||||
⚠️ **AND IT IS NOW GATED RATHER THAN MERELY PARKED — Pouya's ruling,
|
||||
2026-09-01: NO SEVENTH NAV ITEM SHIPS UNTIL THIS IS FIXED.** Fixing it is a
|
||||
**prerequisite of publishing the second Insights article**, because publishing
|
||||
article #2 is what adds the item — `SiteHeader` computes `showInsights` from the
|
||||
collection, so nothing else stands between that editorial decision and both
|
||||
consequences above. The gate is recorded in three places on purpose: `AGENTS.md`
|
||||
R20, `docs/06`'s `/insights/` state item, and the comment on `showInsights` in
|
||||
`SiteHeader.astro` — the last of those being the only one a person editing an
|
||||
article's front matter is likely to be looking at. The fix itself is *"to be
|
||||
designed then, not now"*.
|
||||
|
||||
**What is still latent, measured and not a defect today:** `.hero-h` has **16
|
||||
scoped rules, one per page, and only 2 carry `overflow-wrap: anywhere`** (`/` and
|
||||
`/about/`). The other 14 measure 0 because their headlines' longest words are
|
||||
shorter. The single-source fix would be to change `global.css`'s `h1`–`h6` rule
|
||||
from `break-word` to `anywhere` and delete both overrides; that is a design-system
|
||||
change with a site-wide blast radius on intrinsic sizing and it has not been
|
||||
measured, so it is recorded here rather than done.
|
||||
|
||||
- **Measure the elements, not only the page.** A document-level overflow check
|
||||
passes while a flex child absorbs the deficit by being crushed — that is how
|
||||
|
||||
+127
-32
@@ -92,6 +92,48 @@ detect padding instantly and discount everything after it.
|
||||
scoped; found by `claims-auditor` on re-audit, one file over from the same
|
||||
defect fixed in `docs/04` earlier the same day.
|
||||
|
||||
⚠️ **AND THE MEDIATION HALF IS THE OPPOSITE — Q56, ruled by Pouya 2026-08-30.
|
||||
DO NOT MIRROR THIS SCOPE ONTO MEDIATION.** The scope above exists because of a
|
||||
**legal gate**: family arbitration in Ontario requires prescribed training, so
|
||||
"in commercial matters" is load-bearing there. **Mediation has no such gate,
|
||||
§4's mediation row is unscoped on purpose.** **He mediates in all six published
|
||||
practice areas** — construction, technology, energy, insurance, shareholder,
|
||||
cross-cultural — and §4's row now enumerates them (`PRACTICE_AREAS` in
|
||||
`src/data/site.ts`), each named under **Q35(c)'s subject-matter publication
|
||||
gate**. ⚠️ **The six are the PUBLISHED areas, not the authorised
|
||||
subject-matter list, and Q35(c)'s gate is not spent by having been used six
|
||||
times.** A seventh may be named where it clears that gate.
|
||||
|
||||
*(Q56's ruling had named five, which was four of the six areas plus the word
|
||||
"commercial", and this block called the list "illustrative of breadth, not a
|
||||
closed set" for one day. Pouya struck the hedge on 2026-08-31: "the register
|
||||
should match the site; 'illustrative' is a hedge covering a gap that has a
|
||||
correct value." Technology and energy are the two that were missing. The
|
||||
non-exhaustiveness clause was struck with the hedge in the same pass and
|
||||
restored the same day — his ruling supplied a correct value, it did not close
|
||||
the class.)*
|
||||
|
||||
The site-wide *"Commercial Mediation"* framing was **under-describing the
|
||||
offering**, and it was corrected rather than ratified as positioning. The
|
||||
asymmetry between the two halves is designed; a later editor tidying them into
|
||||
a matching pair would be reintroducing the defect.
|
||||
|
||||
⚠️ **AND DO NOT TREAT ANY LIST OF THE CORRECTED SURFACES AS COMPLETE.** Q56
|
||||
named four. The sweep that implemented it changed **thirteen shipped strings
|
||||
across five files**, and `adversarial-reviewer` then found **three more it had
|
||||
missed** — the worst on `/practice/`, where *"These describe the process the
|
||||
parties are choosing between, in commercial matters"* scoped mediation with
|
||||
the two words never appearing in the same element, so no proximity grep could
|
||||
reach it. The command, and its limit:
|
||||
|
||||
```
|
||||
git grep -nEi 'commercial[^.]{0,60}mediat|mediat[^.]{0,60}commercial' -- src/
|
||||
```
|
||||
|
||||
That finds the **adjacent** form only. For the split form there is no pattern —
|
||||
read every occurrence of `commercial` in `src/` and in `dist/`, and ask what
|
||||
each one is scoping.
|
||||
|
||||
⚠️ **TEN LINES WERE DELETED HERE ON 2026-08-30, AND THE DELETION IS THE
|
||||
POINT.** They read *"'Pathway', never 'designation'"*, cited the **struck**
|
||||
Forbidden row as live, instructed that *"the limit being named is the stage of
|
||||
@@ -429,41 +471,94 @@ Drafted by Claude, **every word reviewed by Pouya before publication**:
|
||||
|
||||
## Compliance checklist — before any page ships
|
||||
|
||||
⚠️ **EVERY ITEM HERE THAT BARS SOMETHING CITES ITS `AGENTS.md` §4 ROW. IT DOES
|
||||
NOT RESTATE IT — STRUCTURAL FIX, Pouya, 2026-08-31, after the third instance.**
|
||||
|
||||
His ruling: *"the checklist must CITE the §4 row, not restate it. Same
|
||||
single-source rule as §7 and operational facts."* **This file is what gets
|
||||
grepped; §4 is what is correct.** Three times a line here paraphrased a §4 row,
|
||||
dropped a qualifier, and then flagged the copy a spec **requires** — *"no dollar
|
||||
figures"* for §4's *"attached to **past matters**"*, which forbade the rate card
|
||||
D8 commits to; *"no testimonials"* for §4's bar on endorsements **of the practice
|
||||
or of Pouya**, which forbade the institutional quotations `docs/01` directs; and
|
||||
the licence-capacity item, which rejected the exact sentence the section above
|
||||
exists to bless. Each was caught by review, never by the checklist, and the row
|
||||
was never wrong.
|
||||
|
||||
**So an item below names what to look for on the page, and which row decides it.
|
||||
Not both halves of the rule.**
|
||||
|
||||
- [ ] Every factual claim appears in `AGENTS.md` §4 Verified
|
||||
- [ ] No matter counts, settlement rates, dollar figures, or time-to-award stats
|
||||
- [ ] No testimonials, endorsements, or third-party quotes **about the practice
|
||||
or about Pouya**. Scope matters: `docs/01` §`/med-arb/` **directs** the page
|
||||
to quote ADRIC's own framing of med-arb, and `/mediation/` and
|
||||
`/arbitration/` name published rule sets. Quoting an institution about
|
||||
**its own rules**, from a committed source, is not an endorsement — and an
|
||||
unscoped version of this line flags the copy the spec requires. Same shape
|
||||
as the "legal advice" item corrected on 2026-08-28, in the file that gets
|
||||
grepped. **Keep the source's superlatives inside the quotation marks**
|
||||
- [ ] No superlatives and no guarantee language
|
||||
- [ ] No claim or implication of legal licensure anywhere (D13)
|
||||
- [ ] Q.Arb described as **held** — `Q.Arb (ADRIC / ADRIO)`, no acquisition
|
||||
date, and never as "commenced", "in progress", "a pathway" or "not yet"
|
||||
(amended 2026-08-29). `npm run check:claims` enforces the **stage words**
|
||||
and a **date near `Q.Arb`** on `dist/`. It cannot catch a stage expressed
|
||||
without naming the designation — the struck h1 "Available now, and open
|
||||
about the stage" matches nothing — and that gap is `claims-auditor`'s to
|
||||
close, not this checklist's
|
||||
- [ ] **C.Med-Arb appears nowhere** — struck entirely 2026-08-29
|
||||
- [ ] Nothing implies a firm, a team, or offices that do not exist
|
||||
- [ ] **Numbers that describe the practice** — any count, rate, percentage, time
|
||||
or dollar figure about matters, hours, years, or outcomes. Decided by §4
|
||||
Forbidden's *counts of matters closed / hours mediated / years in ADR
|
||||
practice*, *settlement rates, resolution percentages, median time to
|
||||
award*, *aggregate value resolved; any dollar figure attached to past
|
||||
matters*, and *named or describable past matters*. `npm run check:claims`
|
||||
`counts-and-tenure` sweeps `dist/`. ⚠️ **The rate card is a different
|
||||
thing and no row reaches it:** D8 commits to publishing it in full, D14
|
||||
confirms the figures `[verified 2026-08-26 — Pouya]`, and `docs/01`
|
||||
requires `/for-parties/` to answer *"what it costs"*. A published **price**
|
||||
is an offer; a published **statistic about past matters** is what the old
|
||||
site fabricated
|
||||
- [ ] **Third-party voices** — decided by §4 Forbidden's *testimonials,
|
||||
endorsements, quotes from counterparties*. ⚠️ **Scope is the whole
|
||||
question:** that row reaches a third party praising the practice or Pouya.
|
||||
It does not reach an institution quoted **about its own rules**, from a
|
||||
committed source — which `docs/01` §`/med-arb/` **directs**, and which
|
||||
`/mediation/`, `/arbitration/` and `/process/` all do. **Keep the source's
|
||||
superlatives inside the quotation marks**
|
||||
- [ ] **Superlatives and guarantee language** — decided by §4 Forbidden's
|
||||
*guarantees of outcome, or superlatives*. `check:claims` `superlatives`
|
||||
sweeps `dist/`
|
||||
- [ ] **Licensure, asserted or implied** — decided by §4 Forbidden's two
|
||||
licensure rows and D13. The second of the two is the one that catches
|
||||
copy: it reaches phrasing that *implies* entitlement without saying so.
|
||||
`check:claims` `licensure-of-pouya` and `acting-for-a-party` sweep `dist/`
|
||||
- [ ] **The licence-capacity question answered in EITHER direction** — see *When
|
||||
a fact is `[unestablished]`* above, and §4's own note that its two
|
||||
licensure rows are verified **directives not to publish**, not a verified
|
||||
status. Check for *cannot*, *do not*, *am not*, *not permitted*, *not
|
||||
qualified* **attached to giving legal advice, practising law, or holding a
|
||||
licence.** ⚠️ **The objection is to answering the capacity question, not
|
||||
to the words themselves** — the approved sentence contains "do not" and
|
||||
passes: *"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."* That is **role
|
||||
conduct**, which he may state freely. `check:claims` `capacity-phrasing`
|
||||
sweeps `dist/`
|
||||
- [ ] **Q.Arb** — decided by §4's Q.Arb Verified row and its Forbidden row. The
|
||||
publishable form is `Q.Arb (ADRIC / ADRIO)`; the acquisition date is
|
||||
recorded in §4 and is not published. `check:claims` `q-arb-as-a-stage`
|
||||
enforces the stage words and a date near the designation, on `dist/`.
|
||||
⚠️ It cannot catch a stage expressed **without naming the designation** —
|
||||
the struck h1 *"Available now, and open about the stage"* matches nothing —
|
||||
and that gap belongs to the cutover claims pass, not to this checklist
|
||||
- [ ] **C.Med-Arb appears nowhere** — decided by §4's struck *C.Med-Arb as a
|
||||
long-term designation goal* row, which carries the reasoning and the
|
||||
deliberate deviation from the strategy brief. `check:claims`
|
||||
`c-med-arb-struck` sweeps `dist/`
|
||||
- [ ] **A class statement about what Ontario law does or does not gate behind an
|
||||
arbitral designation** — decided by §4 Forbidden's *"Anyone may be appointed
|
||||
an arbitrator in Ontario" / "nothing in law gates the role"* row, which
|
||||
carries the committed source and the reason the scoped replacement is
|
||||
**attributed to Pouya rather than stamped**. It bars the claim in **both**
|
||||
directions; this repository does not conclude a proposition of law.
|
||||
`check:claims` `struck-universal-q39` sweeps `dist/`. *(Added 2026-08-31,
|
||||
when Pouya ruled the §4 row into existence: this line could not exist before
|
||||
it, because an item here cites a row rather than restating a bar.)*
|
||||
- [ ] **A firm, a team, or offices that do not exist** — §4 Forbidden rows the
|
||||
specific false artefacts (*"Since 2009"*, *"sixteen years"*, the London
|
||||
and New York offices, the company number, and the fictitious founder);
|
||||
the general rule is **D16** — the boutique is never named, and the
|
||||
publishable form is *Director of Firm Operations at a Toronto litigation
|
||||
and ADR boutique*. `check:claims` `fabricated-founder` sweeps `dist/`
|
||||
- [ ] Contact page states that an inquiry creates no retainer and no
|
||||
mediator–party relationship
|
||||
- [ ] Any comparative claim is factual and verifiable
|
||||
- [ ] No sentence answers the licence-capacity question in **either** direction —
|
||||
see *When a fact is `[unestablished]`* above. Check for *cannot*, *do not*,
|
||||
*am not*, *not permitted*, *not qualified* **attached to giving legal
|
||||
advice, practising law, or holding a licence.** The objection is to
|
||||
answering the *capacity* question, not to the words themselves — the
|
||||
approved sentence contains "do not" and passes: *"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."* That is **role conduct**, which he may state freely.
|
||||
*(This item shipped unscoped for one pass and `adversarial-reviewer` showed
|
||||
it would fail the exact sentence the section exists to bless — and the
|
||||
checklist is what gets grepped.)*
|
||||
- [ ] Any comparative claim is factual and verifiable — **Q41(b), closed
|
||||
2026-08-27**, which struck a comparative claim about other neutrals from
|
||||
this file's own positioning statement. Pouya: *"comparative claims must be
|
||||
factual and verifiable… assert his capability, not the field's
|
||||
incapability."*
|
||||
- [ ] Abbreviations expanded on §11 Glossary's authority only — and expansions
|
||||
for the five ADR designations checked against
|
||||
`docs/reference/adrio-designations.md`, not from memory. "Chartered
|
||||
|
||||
+84
-23
@@ -88,16 +88,38 @@ So:
|
||||
|
||||
| Pages | Card |
|
||||
|---|---|
|
||||
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer |
|
||||
| Every other page | Generated at build with `satori` or `astro-og-canvas`, using the site's own type and palette: display headline on cream, infinity mark, designation line |
|
||||
| Each article | Per-article card from the same generator — the reason the two jobs are one build |
|
||||
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer. Resolved from `PORTRAIT_PAGES` in `src/data/og-cards.ts`, not from a per-page prop |
|
||||
| Every other page | **Generated at build by `src/pages/og/[...slug].jpg.ts`** from `satori` + `sharp`, in the site's own type and palette: display headline on cream, infinity mark, designation line |
|
||||
| Each article | Per-article card from the same endpoint — the reason the two jobs were one build |
|
||||
|
||||
**Until step 7 every page shares the portrait, and that is a RECORDED interim
|
||||
that blocks cutover, not build step 3.** It is tracked as **R15** in
|
||||
`AGENTS.md` §12 with its removal trigger, because a link preview nobody on the
|
||||
team ever sees is exactly the kind of interim that becomes permanent by
|
||||
never being raised. The dependency choice is made against R11 on the day, not
|
||||
recalled from this paragraph.
|
||||
✅ **BUILT — step 7b, 2026-08-31. R15 IS DISCHARGED.** `satori@0.33.4` was chosen
|
||||
over `astro-og-canvas@0.13.0` (both 0 vulnerabilities, verified that day): `sharp`
|
||||
is already a dependency to rasterise satori's SVG, so it adds one library rather
|
||||
than a CanvasKit wasm blob, and it renders with this site's own fonts and tokens
|
||||
rather than approximating them.
|
||||
|
||||
**Four things about the implementation are load-bearing and are not style
|
||||
choices.** Each is recorded because a later reader would otherwise "tidy" it:
|
||||
|
||||
1. **Colours are parsed out of `src/styles/tokens.css` at build time**, not
|
||||
copied into the generator. `CLAUDE.md` requires every colour to come from a
|
||||
token; the alternative was a duplicated hex table, which is the SES-DKIM shape.
|
||||
A missing token throws rather than falling back.
|
||||
2. **The fonts are `@fontsource`'s static `.woff` cuts, not `public/fonts/`.**
|
||||
satori parses TTF/OTF/WOFF and not WOFF2, and decompressing the site's own
|
||||
subset **variable** Geist to TTF *throws* inside satori's `opentype.js` fork —
|
||||
Fontsource's subsetting drops the `name` records the `fvar` table points at.
|
||||
Same typeface, same upstream version, same weight; build-time only.
|
||||
3. **Every card's headline is its page's own `<h1>`, character for character, and
|
||||
`npm run og:proof` enforces it** against the built HTML. This is a compliance
|
||||
mechanism, not a convenience: **text baked into a JPEG cannot be grepped by
|
||||
`npm run check:claims`**, which under D20 is the only per-step claims control
|
||||
there is. A card must not carry a claim its page does not already make in
|
||||
auditable HTML. The same check confirms every page's `og:image` resolves to a
|
||||
file that exists — a 404 preview is invisible from inside the repo.
|
||||
4. **A page with no card entry is a BUILD ERROR, not a fallback to the portrait.**
|
||||
R15's failure mode was never the wrong image; it was the wrong image shipping
|
||||
*invisibly* and reading as intentional. A silent fallback recreates it exactly.
|
||||
|
||||
## Structured data
|
||||
|
||||
@@ -106,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 |
|
||||
@@ -131,7 +153,20 @@ here.** This spec used to reproduce the file inline and the reproduction had
|
||||
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
|
||||
§7 rule exists to stop.
|
||||
|
||||
**It disallows nothing, and that is deliberate.** This spec previously
|
||||
**It disallows exactly one path, and everything about that exception is in the
|
||||
file.** ⚠️ **THIS READ "It disallows nothing, and that is deliberate" UNTIL
|
||||
2026-09-04.** `Disallow: /pouya-lajevardi-bio.pdf` was added that day as the
|
||||
stand-in for `X-Robots-Tag: noindex` on `*.pdf`, which needs a CloudFront
|
||||
response-headers policy the distribution's pricing plan forbids (`AGENTS.md` §7).
|
||||
|
||||
⚠️ **IT IS A SUBSTITUTE, NOT AN EQUIVALENT, AND THE RULE BELOW IS WHY.** It stops
|
||||
the PDF being *fetched* — so its contents are never indexed and the
|
||||
duplicate-of-`/bio/` problem is solved — but it does **not** de-index the URL,
|
||||
and the PDF is linked from `/bio/` and `/about/`, so a bare listing remains
|
||||
possible. That residual is accepted deliberately. **The rule below is unchanged
|
||||
and this is its exception, not its repeal.**
|
||||
|
||||
The general rule: this spec previously
|
||||
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
|
||||
cancel each other: a crawler forbidden to *fetch* a URL never reads the
|
||||
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
|
||||
@@ -167,16 +202,41 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
|
||||
| CLS | < 0.05 |
|
||||
| INP | < 150 ms |
|
||||
| JS per route | < 100 KB |
|
||||
| Lighthouse (mobile) | ≥ 95 all four categories — **not measurable until step 7, see below** |
|
||||
| Lighthouse (mobile) | ≥ 95 all four categories — **measurable again as of 2026-08-31, see below** |
|
||||
|
||||
> ⚠️ **Lighthouse verification is UNAVAILABLE until build step 7.** `@lhci/cli`
|
||||
> was removed on 2026-08-26 — it was the sole source of all 10 `npm audit`
|
||||
> findings (7 high), `0.15.1` is `latest` so there was no clean upgrade, and it
|
||||
> could not run at all with no pages and no `lighthouserc`. The budget below is
|
||||
> not suspended; the tool that measures it is absent. Re-add at step 7 under
|
||||
> `AGENTS.md` R11, checking for a patched release rather than assuming `0.15.1`
|
||||
> is still the ceiling. Until then, a run that skips this is skipping something
|
||||
> known — not something forgotten. `AGENTS.md` §7 has the state.
|
||||
> ✅ **THE INSTRUMENT IS BACK — build step 7a, 2026-08-31. `npm run lighthouse`,
|
||||
> and it is `lighthouse` rather than `@lhci/cli`.** R11's re-add trigger said to
|
||||
> put `@lhci/cli` back; this is a deliberate deviation from its literal wording
|
||||
> and `AGENTS.md` §7 records both the reason and what it costs.
|
||||
>
|
||||
> **The reason is that §7's advisory attribution was wrong, and it was the
|
||||
> attribution that made the tool look unusable.** §7 recorded the ten findings as
|
||||
> arriving *"via `lighthouse → puppeteer-core → extract-zip`"*. Measured from two
|
||||
> probe lockfiles: `@lhci/cli@0.15.1` carries 10 (7 high) and pins **lighthouse
|
||||
> 12.6.1**, and the two high carriers are `tmp@0.1.0` — *its own direct
|
||||
> dependency* — and `extract-zip@2.0.1` via `@puppeteer/browsers`.
|
||||
> `lighthouse@13.4.1` standalone is 109 packages, and both are **absent**:
|
||||
> `npm audit` returns 0. So Lighthouse was never the carrier, and the budget was
|
||||
> unmeasurable for five days on a cause nobody re-derived.
|
||||
>
|
||||
> **What it does not do: run in CI.** Standalone Lighthouse drives an installed
|
||||
> browser and the Gitea runner has none (§7, Q23). So it is a local gate plus a
|
||||
> blocking item on `docs/06`'s cutover checklist, and it is deliberately not
|
||||
> wired into `npm run build` or either deploy path — a check described as running
|
||||
> where it cannot is the defect Q22 turned out to be.
|
||||
>
|
||||
> ⚠️ **THE ACCESSIBILITY CATEGORY IS MEASURED WITH `prefers-reduced-motion`
|
||||
> FORCED, and that is a deviation that has to travel with the number.** Measured
|
||||
> twice per condition on `/process/`: motion on gives **96** with
|
||||
> `color-contrast` failing on **24 nodes**; motion off gives **100** with 0. The
|
||||
> 24 were the scroll-driven reveal caught mid-flight — axe reported foregrounds
|
||||
> like `#d0cbc4` on `#f8f4ed`, and neither is in this palette; they are the real
|
||||
> colours blended toward the background by an in-progress `opacity` keyframe. A
|
||||
> category reporting 24 known-false nodes on ten of fourteen pages cannot surface
|
||||
> the twenty-fifth real one. The reduced-motion rendering is the branch
|
||||
> `global.css` ships for a real user setting, and it is the one where every
|
||||
> element sits at its final colour. Palette ratios are computed in
|
||||
> `docs/02-design-system.md`; `scripts/lighthouse.mjs` carries the measurement.
|
||||
|
||||
How: static HTML, self-hosted preloaded subset fonts, AVIF/WebP with explicit
|
||||
dimensions, critical CSS inlined, no third-party scripts on any page except the
|
||||
@@ -200,6 +260,7 @@ nothing more.
|
||||
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
||||
- [ ] Sitemap submitted to Google Search Console and Bing
|
||||
- [ ] No page returns 200 for a URL that should 404
|
||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
|
||||
— **blocked until `@lhci/cli` is re-added at step 7.** Do not tick this box
|
||||
from a manual Chrome DevTools run and call it the same check
|
||||
- [ ] Lighthouse ≥ 95 mobile on **every built page** — `npm run lighthouse`,
|
||||
which enumerates `dist/` rather than taking a list, so the set cannot go
|
||||
stale as pages are added. Do not tick this box from a manual Chrome
|
||||
DevTools run and call it the same check
|
||||
|
||||
+351
-22
@@ -16,6 +16,108 @@ a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
|
||||
|
||||
The shape is right. This is a hardening and rework pass, not a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Build step 8, as actually built — 2026-08-31
|
||||
|
||||
**What is in the repository:** `/contact/` with the intake form, two
|
||||
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
|
||||
`backend/intake/fields.mjs` + `backend/intake/spam-score.mjs` — the handler that
|
||||
**replaced** the hand-built `adr-intake-handler` §7 records.
|
||||
|
||||
🟢 **IT IS ALL LIVE AS OF 2026-09-02, AND THIS PARAGRAPH SAID THE OPPOSITE UNTIL
|
||||
2026-09-04.** It read *"the handler is not deployed, and the CloudFront `/api/*`
|
||||
behaviour the form posts to does not exist"* — true when written under D11, false
|
||||
from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in
|
||||
the document an implementer reads before touching the handler. **Measured
|
||||
2026-09-04:** the function carries `handler.handler` with six environment
|
||||
variables and its deployed source entries match a commit **§7 names**; the API has
|
||||
exactly one route, `POST /api/intake`; §7 holds the full state and this spec does
|
||||
not restate it. ⚠️ **THIS SENTENCE NAMED THE COMMIT — `02739ad` — IN THE SAME
|
||||
BREATH AS DISCLAIMING RESTATEMENT, AND THE 2026-09-04 REDEPLOY MADE IT FALSE**
|
||||
(three source entries now, matching a later commit). The count is gone with it:
|
||||
both were facts §7 owns. `/contact/` still publishes the email address beside the form,
|
||||
which is now a courtesy rather than a fallback.
|
||||
|
||||
### The form is a plain HTML POST, and it answers 303
|
||||
|
||||
The site ships **zero** JavaScript (§7 — none, not "minimal"), so the form is a
|
||||
`<form method="post">` and the handler replies **303 See Other** to a page on the
|
||||
site. That buys three things with no script anywhere: it works with JavaScript
|
||||
disabled, which is the failure this whole project exists to fix; the visitor never
|
||||
sees a raw JSON body rendered as a page; and a refresh cannot resubmit, because
|
||||
the browser lands on a GET.
|
||||
|
||||
Two pages exist for the two outcomes — `/contact/received/` and
|
||||
`/contact/could-not-send/`. Both are `noindex` and both are excluded from the
|
||||
sitemap in `astro.config.mjs`. **The failure page names no field**, because the
|
||||
handler deliberately does not return the error list (an enumeration of the
|
||||
validation rules is a gift to whoever is probing them) and because a static page
|
||||
cannot read `?error=` without script.
|
||||
|
||||
### It posts to `/api/intake`, not to the execute-api hostname
|
||||
|
||||
Same-origin, with a CloudFront behaviour routing `/api/*` to the HTTP API origin
|
||||
§7 records. Four consequences, and the fourth is the one that matters day to day:
|
||||
`form-action 'self'` alone satisfies the CSP below; there is no cross-origin POST
|
||||
to reason about; the endpoint id stays out of the HTML and out of the repo; and
|
||||
**submitting the form from `astro dev` does nothing**, because there is no
|
||||
`/api/` route locally. Under the alternative, clicking Submit on a laptop would
|
||||
write a real DynamoDB record and send two real emails.
|
||||
|
||||
### ⚠️ Three deviations from this spec, each deliberate
|
||||
|
||||
**1. The 3-second timestamp check is NOT implemented.** It cannot be, and
|
||||
implementing it would produce a control that does nothing. The check needs to know
|
||||
when the form was *served to that visitor*; `/contact/` is a static file cached at
|
||||
the CloudFront edge, so a build-time timestamp is the same value for every visitor
|
||||
and is hours or days old. `now − served` is therefore always large, and the check
|
||||
passes for a bot exactly as it passes for a human. A per-visitor token needs a
|
||||
dynamic origin or client-side script, and the site has neither by design.
|
||||
|
||||
A control that exists on paper and not in fact is worse than a stated gap — that
|
||||
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
|
||||
said out loud, and the load is carried by the honeypot, the `Origin` check, the
|
||||
**aggregate** API Gateway route throttle and server-side validation. (Aggregate,
|
||||
not per-IP — see §Validation. "Rate limit" was the wording here and let the reader
|
||||
supply the stronger meaning.)
|
||||
|
||||
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
|
||||
is a top-level navigation: it is exempt from CORS preflight, so an
|
||||
`Access-Control-Allow-Origin` setting cannot stop another site posting a form
|
||||
here. The handler compares `Origin` (falling back to `Referer`, which Firefox
|
||||
sends where it omits `Origin`) against the site origin and refuses anything else.
|
||||
The CORS restriction in this spec is still right — it governs *scripted* calls to
|
||||
the endpoint — but it is a different control and was being relied on for this one.
|
||||
|
||||
**3. There is no `mailto:` fallback, because there is nothing to fall back FROM.**
|
||||
This spec's definition of done asks that the form "degrades to a `mailto:`
|
||||
fallback with JavaScript disabled". The form never used script, so it does not
|
||||
degrade. The email address is published on `/contact/` regardless, and the failure
|
||||
page routes to it.
|
||||
|
||||
### Two field tables, cross-checked
|
||||
|
||||
`src/data/intake.ts` builds the form. `backend/intake/fields.mjs` is what the
|
||||
handler validates against. **The duplication is architectural**, because this
|
||||
spec's own rule is that the Lambda re-validates everything: a server validating
|
||||
against a list the client shipped it is asking the caller what the rules are. And
|
||||
the Lambda is a separately deployed zip that cannot import from `src/`.
|
||||
|
||||
**`npm run check:intake` is what keeps them honest** — it imports both and asserts
|
||||
they agree on every field name, on which are required, on every length cap, and on
|
||||
every closed option set. Probed with three deliberate mismatches (a changed cap, a
|
||||
dropped field, a changed option); each was caught, exit 1.
|
||||
|
||||
### Analytics: decided, not installed
|
||||
|
||||
D15 chose Plausible. **§7 records that no script is on any page**, and
|
||||
`ANALYTICS.installed` in `src/data/site.ts` is `false`. `/legal/privacy/` renders
|
||||
its analytics paragraph from that flag, so today the policy says the site sets no
|
||||
cookies and runs no analytics — which is the fact. **Flipping the flag is a change
|
||||
to a published disclosure**, not a config edit: the policy changes on the same
|
||||
build and its last-updated date moves with it.
|
||||
|
||||
## What this data actually is
|
||||
|
||||
The form collects, in a live legal dispute: the inquirer's identity and contact
|
||||
@@ -52,7 +154,7 @@ might reasonably treat as privileged. The intake call is for that.
|
||||
|
||||
### Consent text
|
||||
|
||||
> I consent to Pouya Lajevardi storing and using the information in this form to
|
||||
> I consent to SML Company Ltd storing and using the information in this form to
|
||||
> respond to my inquiry and to run a conflicts check. I understand that
|
||||
> submitting this form does not create a retainer, does not appoint a neutral,
|
||||
> and does not itself establish a mediator–party relationship.
|
||||
@@ -64,13 +166,124 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
||||
- Required fields present; email well-formed; lengths within bounds
|
||||
- Reject any field over its cap rather than truncating silently
|
||||
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
||||
- **Timestamp check** — reject submissions completed in under 3 seconds
|
||||
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information,
|
||||
and the two controls above stop the traffic that matters
|
||||
- **Second honeypot** — a hidden CHECKBOX that must arrive **absent**, added
|
||||
2026-09-04. A different trap, not a second copy: the first catches a bot that
|
||||
fills every text input, this one catches a bot that sets every control it
|
||||
enumerates. ⚠️ **IT IS PROBABLY INERT AGAINST THE TRAFFIC THAT PROMPTED IT —
|
||||
see §Observed abuse, which retracts in full the argument this bullet made for
|
||||
one round** (*"which anything reaching validation must do, because the consent
|
||||
box is required and unchecked by default"*). The retraction was written sixty
|
||||
lines below this bullet and did not reach it. **Unchecked sends nothing, so
|
||||
absence is the pass —
|
||||
and so is an empty value**, because the handler tests for a non-empty one
|
||||
rather than for presence: no dropped-field path and no blind form serialiser
|
||||
can turn it into a lost inquiry. It carries its **own** wrapper class (not the
|
||||
first honeypot's), the `hidden` attribute as well as the CSS rule, and a label
|
||||
that tells a human not to tick it — see `src/pages/contact.astro`, where each
|
||||
of the three is a correction rather than a precaution
|
||||
- **Spam SCORING that labels and never rejects**, added 2026-09-04. See
|
||||
§Observed abuse. It changes the operator notification's subject line and
|
||||
nothing else
|
||||
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
|
||||
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
|
||||
while this line stayed an unqualified imperative** — the handler's header,
|
||||
§Three deviations above, and the definition of done below. §Three deviations
|
||||
has the reasoning: `/contact/` is a CDN-cached static file, so a build-time
|
||||
timestamp is the same value for every visitor and `now − served` is always
|
||||
large. **This is the unstruck-imperative shape `CLAUDE.md` names** — and it
|
||||
survived in the same list whose sibling bullet was struck correctly, which is
|
||||
the sweep failure exactly. Found by `adversarial-reviewer` round 2
|
||||
- ~~**Rate limit** by source IP at API Gateway: 5 requests / 5 minutes~~
|
||||
⚠️ **STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS
|
||||
ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT.** HTTP API
|
||||
throttling is **aggregate** — a rate and a burst, per route and per stage,
|
||||
across all callers. Per-IP limiting needs **AWS WAF** with a rate-based rule on
|
||||
the distribution, which is a paid service and therefore a decision rather than
|
||||
a step. What ships instead is the aggregate throttle
|
||||
(`docs/09-cutover-runbook.md` Part 6.3), and it must never be described as
|
||||
per-IP. This is deviation 1's own argument turned on this spec: *"a control that
|
||||
exists on paper and not in fact is worse than a stated gap"* — the throttle is
|
||||
real and bounds total volume; the per-IP claim was neither
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information.
|
||||
⚠️ **THIS BULLET USED TO END "and the two controls above stop the traffic that
|
||||
matters", WHICH THE FIRST REAL SPAM FALSIFIED** — see §Observed abuse. The
|
||||
reason to keep CAPTCHA out is unchanged and stands on its own; the claim that
|
||||
what ships is sufficient was an untested prediction and has been removed rather
|
||||
than reworded
|
||||
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
||||
- Strip HTML from every field before storage and before it enters an email body
|
||||
|
||||
## Observed abuse
|
||||
|
||||
**First real-world spam: 2026-09-04.** Two automated submissions, **10:51Z** and
|
||||
**12:16Z**, `submissionId` prefixes `50cda580…` and `e3e21122…`. Recorded here
|
||||
rather than in the Change Log alone because this section's controls were
|
||||
specified against an imagined attacker and this is the first measured one.
|
||||
|
||||
**Both passed the honeypot**, and neither was stopped by anything else that
|
||||
ships: the aggregate route throttle is 1 request/second with a burst of 5
|
||||
(`AGENTS.md` §7), and two submissions ninety minutes apart are nowhere near it.
|
||||
|
||||
**The signature, as Pouya recorded it:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Names | random |
|
||||
| Email | random Gmail addresses — **one using the dot trick** |
|
||||
| Phone | Russian format |
|
||||
| Organisation | big-brand names |
|
||||
| Dispute summary | scraped text |
|
||||
|
||||
⚠️ **THE HONEYPOT WAS NOT DEFEATED BY CLEVERNESS — IT WAS NOT ENGAGED.** A bot
|
||||
that submits only the fields it recognises never touches a decoy text input.
|
||||
|
||||
🛑 **AND THAT CUTS BOTH WAYS. THE SECOND HONEYPOT IS PROBABLY INERT AGAINST THIS
|
||||
PAIR, AND THIS SECTION CLAIMED THE OPPOSITE FOR ONE ROUND.** It said the checkbox
|
||||
*"is aimed at a behaviour the traffic must have"*, reasoning that the consent box
|
||||
is required so anything that validated must have been ticking checkboxes.
|
||||
**Sending `consent=on` shows only that it knows one field name.** A bot selective
|
||||
enough to skip a hidden text input is selective enough to skip a hidden checkbox,
|
||||
and the same evidence that explains the first honeypot's silence predicts the
|
||||
second's. It is **defence in depth against a different and common class** — the
|
||||
bot that enumerates controls and sets all of them — which is worth adding and is
|
||||
not a counter to what was observed. Nothing in this repository has yet caught a
|
||||
bot with it.
|
||||
|
||||
**What was added, and the ordering rule Pouya set:** *"Nothing is dropped; a
|
||||
false positive costs him one glance."*
|
||||
|
||||
1. A second honeypot — above.
|
||||
2. **Scoring that labels.** `backend/intake/spam-score.mjs`, unit-tested at
|
||||
`spam-score.test.mjs`. Signals and weights: summary under a floor **(1)**,
|
||||
phone present and not North American **(1)**, a link in the summary **(2)**,
|
||||
a Gmail address with dot-trick density **(2)**; **threshold 2**. Above it the
|
||||
record is still stored, both emails are still sent, and only the operator
|
||||
notification changes — subject prefixed `[Possible spam] `, plus one line
|
||||
naming the signals. **The confirmation to the inquirer is untouched.**
|
||||
3. **Nothing is stored.** The score and signals do not enter the DynamoDB item,
|
||||
because §Storage's attribute list is published on `/legal/privacy/` and adding
|
||||
one would make that page wrong.
|
||||
|
||||
⚠️ **THE TIMING FLOOR WAS RULED AND COULD NOT BE BUILT — §9 Q66.** Pouya's ruling
|
||||
of 2026-09-04 asked to *"raise the timing floor"*. **There is no floor to raise:**
|
||||
the timestamp check is struck above and has never existed, for a reason unchanged
|
||||
by the spam arriving — `/contact/` is a CDN-cached static file, so no per-visitor
|
||||
"served at" value exists to subtract from. Nothing inside *"handler + form only,
|
||||
zero-JS preserved"* can produce one, and the three mechanisms that could each
|
||||
break one of his constraints:
|
||||
|
||||
| mechanism | what it costs |
|
||||
|---|---|
|
||||
| Client-side script timing the fill | **Breaks zero JavaScript** (§7, and it is *none*, not *minimal*) |
|
||||
| A CloudFront Function on viewer-response setting a signed short-lived cookie, read by the handler | Outside *"handler + form only"*, and it puts a **cookie** on a site whose privacy policy turns on there being none — a `/legal/privacy/` change and a consent question this repository must not answer for itself |
|
||||
| A dynamic origin for `/contact/` | Reverses D1's `output: 'static'` |
|
||||
|
||||
**A fourth is worse than doing nothing:** shipping a build-time timestamp and
|
||||
calling it a timing check. `now − served` would be hours or days for every
|
||||
caller, so it would pass for a bot exactly as it passes for a human — the control
|
||||
that exists on paper and not in fact, which is what deviation 1 and `AGENTS.md`
|
||||
Q22 are both records of.
|
||||
|
||||
## Storage
|
||||
|
||||
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||||
@@ -79,23 +292,64 @@ handing over sensitive material, and where it comes to rest is a fair question
|
||||
for them to ask. Confirm the existing table's region and migrate if it is
|
||||
elsewhere — §7 has the table name and region.
|
||||
|
||||
⚠️ **THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND
|
||||
THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION.** This table specified
|
||||
`pk: INTAKE#<uuid>` and `sk: <timestamp>`, and `handler.mjs` was written to it.
|
||||
The table `AGENTS.md` §7 names has a single partition key **`submissionId` (S)`
|
||||
and no sort key** `[verified 2026-09-01 — aws dynamodb describe-table]`. A
|
||||
`PutItem` missing the key attribute fails the whole write with
|
||||
`ValidationException`, the handler catches it and answers the failure page — so
|
||||
the form would have looked broken to every inquirer while the record went
|
||||
nowhere, from the moment `/api/*` was wired. **A DynamoDB key schema cannot be
|
||||
altered after creation**, so the handler was changed to the table rather than the
|
||||
reverse; the alternative, a new table matching the old shape, was declined
|
||||
because it would re-open the §7-verified TTL and PITR state on a fresh resource
|
||||
at cutover to buy a sort key nothing queries. Verify with `describe-table`, not
|
||||
against this row.
|
||||
|
||||
| Attribute | |
|
||||
|---|---|
|
||||
| `pk` | `INTAKE#<uuid>` |
|
||||
| `sk` | `<ISO-8601 timestamp>` |
|
||||
| `submissionId` | `<uuid>` — **the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
|
||||
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
|
||||
| fields | as above |
|
||||
| `sourceIp`, `userAgent` | abuse investigation only |
|
||||
| `ttl` | epoch seconds — **automatic deletion** |
|
||||
| `sourceIp`, `userAgent` | ~~abuse investigation only~~ — ⚠️ **AMENDED 2026-09-02: that purpose holds for `userAgent` and `submittedAt`, and NOT for `sourceIp`.** Behind the `/api/*` behaviour `requestContext.http.sourceIp` is a CloudFront edge, so it identifies the network rather than the sender and cannot serve an abuse investigation. `/legal/privacy/` now states the two purposes separately — timestamp and user-agent for abuse, the address as something that simply arrives with the request. **A spec and a page disagreeing about WHY data is held is the disclosure PIPEDA actually turns on**, and this row said one thing while the page said another for a day. `docs/09` Part 7.2 measures the field; if it holds the reader's own address, this row and that paragraph both change. `adversarial-reviewer`, round 2 |
|
||||
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
|
||||
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
|
||||
|
||||
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
|
||||
on.** Table access limited to the Lambda role and one named administrative
|
||||
principal.
|
||||
on.** ⚠️ **THE THIRD LINE HERE WAS *"table access limited to the Lambda role and
|
||||
one named administrative principal"*, AND IT WAS THE Q62 FALSEHOOD — struck
|
||||
2026-09-02.** It is false on both halves: `adr-intake-lambda-role` holds
|
||||
`PutItem` **only** and cannot read the table at all, and access is not one
|
||||
principal. **`AGENTS.md` §7's `Intake table — who can read it` row is the answer
|
||||
and this spec does not restate it** — a duplicated fact is one that goes wrong in
|
||||
the copy nobody re-reads, which is what happened here: the Q62 sweep ran over
|
||||
`src/` and never reached a spec, and `check:claims` carries this exact sentence
|
||||
as a string that reached `dist/`. It survived the sweep this file's own
|
||||
definition-of-done claims to have completed (`adversarial-reviewer`, round 2).
|
||||
|
||||
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
|
||||
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
|
||||
PointInTimeRecoveryStatus: ENABLED, 35-day window]`. Encryption at rest is on
|
||||
with the **AWS-owned key, not a customer-managed KMS key** `[verified
|
||||
2026-09-01 — describe-table returns no SSEDescription]`. That gap is
|
||||
deliberately not a cutover blocker: `/legal/privacy/` says "encrypted at rest",
|
||||
which is unconditionally true of every DynamoDB table, and it does not claim a
|
||||
customer-managed key — so nothing published depends on it. It stays on
|
||||
`docs/06`'s checklist as the improvement it is.
|
||||
|
||||
### Retention
|
||||
|
||||
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
||||
mechanism that runs whether anyone remembers or not.
|
||||
|
||||
⚠️ **WRITING THE ATTRIBUTE IS NOT THE MECHANISM.** The handler supplies `ttl`;
|
||||
TTL must also be **enabled on the table**, and **`AGENTS.md` §7 records whether
|
||||
it is — this section deliberately does not.** So the paragraph above is a
|
||||
statement about the design and not about the running system until the cutover
|
||||
item below is ticked on **both** halves: `ENABLED` by command, and a test record
|
||||
observed to disappear.
|
||||
|
||||
Rationale: long enough to serve conflicts screening across a normal matter
|
||||
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
|
||||
personal information only as long as necessary. Whatever number ships must match
|
||||
@@ -187,11 +441,45 @@ no visibility.
|
||||
— silently, months later.
|
||||
|
||||
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
||||
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.
|
||||
first, then send. ~~A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.~~
|
||||
|
||||
⚠️ **THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT
|
||||
RECEIVED NOTHING.** Lambda's `DeadLetterConfig` is used **only for asynchronous
|
||||
invocations** (and event-source failures). API Gateway invokes this function
|
||||
**synchronously** and the error is returned to the caller, so a DLQ configured on
|
||||
`adr-intake-handler` would sit at depth 0 for ever and an alarm on it would be a
|
||||
green light that means nothing — the third instance of this project's most
|
||||
expensive shape, after `AGENTS.md` Q22 and the Lighthouse row.
|
||||
|
||||
What actually protects a submission is already built and is not a queue: the
|
||||
handler **writes to DynamoDB before sending mail**, so a mail failure cannot lose
|
||||
a record, and a write failure returns the visitor to `/contact/could-not-send/`
|
||||
rather than telling them an inquiry was received. What is missing is **detection**,
|
||||
and the replacement is two CloudWatch alarms rather than one:
|
||||
|
||||
- **Lambda `Errors` ≥ 1** on `adr-intake-handler` — this is what a DLQ alarm was
|
||||
reaching for and it fires on a synchronous failure, which a DLQ cannot see.
|
||||
- **API Gateway `5xx` ≥ 1** on the `POST /api/intake` route — it catches the one
|
||||
failure the Lambda cannot report, a permission or integration fault where the
|
||||
function is never entered at all (`docs/09-cutover-runbook.md` Part 6.1 is the
|
||||
step whose omission causes exactly that).
|
||||
|
||||
Both notify the `ses-alerts` topic, whose email subscription is **confirmed** as
|
||||
of `AGENTS.md` §7 — so unlike the DLQ alarm, these reach someone.
|
||||
|
||||
## Booking
|
||||
|
||||
**PARKED — R6, and `/contact/` ships without it.** Pouya parked the booking tool
|
||||
on 2026-08-26; build step 8 shipped the form and no embed. The "reserved slot"
|
||||
`docs/01` asks for is `CONTACT.bookingUrl` being `null`: nothing renders, and a
|
||||
URL there brings the block back without a rebuild of the page.
|
||||
|
||||
**Nothing on `/contact/` mentions booking**, deliberately — a page that says
|
||||
"book a call" with no way to book one is worse than a page that says to email.
|
||||
D10 committed to booking because it removes the back-and-forth that loses
|
||||
appointments, so the form alone is a partial answer and R6 stays live.
|
||||
|
||||
An embedded scheduler for the 30–45 minute confidential intake call
|
||||
(**Q5** — tool not yet chosen).
|
||||
|
||||
@@ -231,6 +519,13 @@ Content-Security-Policy: default-src 'self'; img-src 'self' data:;
|
||||
base-uri 'self'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
⚠️ **`form-action` IS NOW `'self'` ALONE, and that is tighter than the line
|
||||
above.** Build step 8 posts the intake form to the same-origin path `/api/intake`
|
||||
rather than to the execute-api hostname, so no third-party origin needs to appear
|
||||
in the policy. Drop `<api-endpoint>` from `form-action` when the policy is
|
||||
written. `frame-src <booking-provider>` is also unnecessary while R6 keeps the
|
||||
embed parked — add it with the embed, not before.
|
||||
|
||||
Tighten CSP once the booking provider is chosen. `unsafe-inline` on styles is
|
||||
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
|
||||
nonce for the reveal script.
|
||||
@@ -253,13 +548,47 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] Server-side validation independent of the client
|
||||
- [ ] Honeypot and timing checks live; rate limit configured
|
||||
- [ ] CORS restricted to the production origin
|
||||
- [ ] TTL set and verified by test record
|
||||
- [ ] KMS encryption and PITR enabled
|
||||
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
|
||||
- [x] **The first honeypot is live** — the hidden text input that must arrive
|
||||
empty. Deployed since cutover. ⚠️ **The timing check is NOT implemented** —
|
||||
see deviation 1 above and §Observed abuse; it is unimplementable on a
|
||||
CDN-cached static page and would be a control that does nothing.
|
||||
**Re-ruled and re-blocked 2026-09-04, §9 Q66**
|
||||
- [ ] 🛑 **THE SECOND HONEYPOT AND THE SPAM SCORING ARE WRITTEN AND NOT
|
||||
DEPLOYED** — 2026-09-04. Both live in `backend/intake/`, and **a site
|
||||
deploy does not carry `backend/`**: `scripts/deploy-local.sh` is an S3 sync
|
||||
and an invalidation, nothing more. They need `docs/09` Part 5 (and Part 5.5,
|
||||
which is the path in production). ⚠️ **THIS LINE READ `[x]` … "live" FOR ONE
|
||||
ROUND, ON AN UNCOMMITTED WORKING TREE**, while §7's own row recorded the
|
||||
running function as last modified 2026-09-02 with source digests matching
|
||||
`HEAD` — the spec asserting a control that its neighbour proved absent.
|
||||
`node backend/intake/spam-score.test.mjs` returns **39 of 39** and all four
|
||||
signals are exercised `[verified 2026-09-04]`; that is a statement about
|
||||
the repository, not about production. ⚠️ **The only paths that discard a
|
||||
submission are the two honeypots**, and both answer with the success page
|
||||
rather than an error. Validation failures redirect to
|
||||
`/contact/could-not-send/`, which is a told failure, not a silent one
|
||||
- [x] ✅ **Throttle configured — `POST /api/intake` at rate 1.0 req/s, burst 5, detailed metrics on** `[verified 2026-09-04 — get-stage]`. ⚠️ **IT IS A `RouteSettings` ENTRY, NOT THE STAGE DEFAULT**, and a query projecting `DefaultRouteSettings` alone returns only `DetailedMetricsEnabled` and reads as *no throttle configured* — which is how §7 came to say so. Read `RouteSettings` before concluding it is absent. An **aggregate** route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
|
||||
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||||
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
|
||||
- [x] **PITR enabled** — `ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
|
||||
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
|
||||
- [x] ✅ **Table access matches what `/legal/privacy/` says about it — 2026-09-02.** **The access is unchanged; the page now states it.** Pouya ruled *state the truth* rather than *remove the access* (§9 Q62), so the page states the truth about access rather than a false exclusivity. ⚠️ **WHAT IT STATES CHANGED TWICE MORE THAT DAY AND THIS LINE IS WRITTEN AGAINST THE SHIPPED BYTES, NOT AGAINST THE RULING.** §9 Q63 took the human headcount off (a simulation counts identities and the page was reading them as people), and a second ruling then cut §Who can see it to **four plain statements**. The page now says: *"The record in the table: me, and the small number of people who administer the account it sits in with me"*; that the receiving system *"can only add a record — it cannot read back what is stored"*; where the notification goes and who reads it; and that the confirmation sits with the reader's own provider. **§Where it is stored carries the shared-account disclosure** — *"an Amazon Web Services account that also runs systems unrelated to this practice"*. ⚠️ **`adr-sml-deploy` is `implicitDeny` on all seven read AND write actions — MEASURED, TRUE, AND NO LONGER ON THE PAGE**; it went with the mechanics cut and it is §7's claim now, not the policy's. Do not tick this item against a page that states it. Evidence and commands: `docs/reference/intake-table-access-verification.md`, whose enumeration was **extended on 2026-09-02** — the original screened roles by `list-attached-role-policies` alone, missing that 23 of 26 non-service-linked roles carry inline policies and that the two CDK `lookup` roles can read the table. Four roles can, not two; every one of them is reachable only by those administrators. ⚠️ **Do not restate that as a count of PEOPLE** — this line said *"all four terminate at the same two people"* until 2026-09-02, which is the inference §9 Q63 struck. ⚠️ **THIS LINE SAID "IT DOES NOT" FOR A DAY AFTER THE PAGE WAS CORRECTED, AND IT IS A DEFINITION-OF-DONE LIST SOMEONE FOLLOWS AT CUTOVER** — the Q62 sweep was run over `src/` only, so it could not reach a spec. `adversarial-reviewer`, round 1. The sweep across `docs/` is in the Change Log entry
|
||||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||||
- [ ] DLQ and CloudWatch alarm configured
|
||||
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
|
||||
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
|
||||
- [ ] Privacy policy matches the implementation line for line
|
||||
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
|
||||
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
|
||||
- [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`
|
||||
> ✅ **THE THREE ITEMS BELOW WERE COMMANDS AND ALL THREE HAVE RUN — cutover,
|
||||
> 2026-09-02**, verified against the live account 2026-09-04. They are ticked
|
||||
> below and the reasoning is kept because it is what made them non-obvious.
|
||||
> The commands are in `docs/09-cutover-runbook.md` — Parts 5, 6 and 3
|
||||
> respectively, each with its verification and the output to expect. Two things that spec found by reading the
|
||||
> running system rather than the specs, and both would have lost every
|
||||
> submission: the API route needs its **own** Lambda invoke permission, because
|
||||
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
|
||||
> handler's item shape had to change, because the table's partition key is
|
||||
> `submissionId` and a key schema cannot be altered after creation (§Storage).
|
||||
|
||||
- [x] ✅ **CloudFront `/api/*` behaviour created** `[verified 2026-09-04 — get-distribution-config: 1 cache behaviour, 2 origins, 1 function association, 1 custom error response]`, routing to the HTTP API origin §7 records. The form does not work without it. **And two other distribution changes are prerequisites of the site working at all**, neither of which is intake: a viewer-request function for `trailingSlash: 'always'`, without which 22 of 23 pages return S3's `AccessDenied`, and the 404 mapping `docs/04` requires
|
||||
- [x] ✅ **Handler deployed 2026-09-02**, replacing the hand-built `adr-intake-handler` `[verified 2026-09-04 — get-function-configuration: `handler.handler`, 15 s, 512 MB, six variables; and the deployed zip downloaded and read]`. ⚠️ **Ticking it does NOT mean the current working tree is deployed** — the running artefact matches `HEAD`, and `backend/` changes reach production only through Part 5. 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
|
||||
|
||||
+1188
-32
File diff suppressed because it is too large
Load Diff
+207
-4
@@ -97,11 +97,128 @@ All figures **plus HST**.
|
||||
|
||||
| Item | Fee |
|
||||
|---|---|
|
||||
| Half day — up to 3.5 h, including 2 h preparation | **$2,000** |
|
||||
| Full day — up to 7 h, including 3 h preparation | **$4,000** |
|
||||
| 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** |
|
||||
| Each party beyond two | **$500** |
|
||||
| Overtime, per hour | **$500** |
|
||||
|
||||
⚠️ **THE TWO ROWS ABOVE WERE AMBIGUOUS UNTIL 2026-08-31, AND THE AMBIGUITY WAS
|
||||
IN THIS DOCUMENT RATHER THAN IN ANY COPY — Q58, ruled by Pouya.** They read
|
||||
*"Half day — up to 3.5 h, including 2 h preparation"* and *"Full day — up to
|
||||
7 h, including 3 h preparation"*. Read literally that makes 3.5 the **whole
|
||||
billed envelope** and the time in the room **1.5 h**. Pouya's ruling: *"3.5 was
|
||||
meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours
|
||||
in the room… The intended reading is the market's, and my wording obscured
|
||||
it."*
|
||||
|
||||
**What found it was arithmetic, not reading:** 3.5 and 7 are exactly 2×, which
|
||||
they would not be if preparation sat inside them, **because preparation does not
|
||||
scale with session length**. Under the literal reading the room time was 1.5 h
|
||||
and 4 h, which is not 2× anything.
|
||||
|
||||
**The corrected numbers are corroborated by this file's own research table**, one
|
||||
section up — which is the reason they are 3 and 6 rather than a round guess:
|
||||
|
||||
- **Patey** publishes **3 h** half-day and **6 h** full-day in *both* tiers.
|
||||
- **Zuber** publishes **3 h** video half-day and **6 h** full day.
|
||||
- **ADR Chambers**' roster rate covers *"one half hour of preparation time per
|
||||
party **and** up to three hours of mediation"* — preparation counted
|
||||
**separately from** a three-hour session, which is the shape this card now
|
||||
has.
|
||||
|
||||
Pouya: *"Selling 1.5 hours of room time as a half day would be an outlier nobody
|
||||
would recognise."* ⚠️ **One provenance note, because this file is the authority
|
||||
on money:** he recalled Patey and Zuber as publishing *"all or part of 3 hours"*.
|
||||
The extract above records their **hours** but not that phrase, so **the hours are
|
||||
what this file relies on** — do not quote the phrase as theirs.
|
||||
|
||||
**THE PREPARATION ALLOWANCE IS CAPPED, AND MUST BE PUBLISHED AS CAPPED.**
|
||||
*"Including **up to** 2 hours of preparation."* Never *"including 2 hours"*,
|
||||
which reads as a flat entitlement, and never *"preparation included"*, which
|
||||
sells an uncapped one. See §All parameters confirmed below.
|
||||
|
||||
**Consequences, applied 2026-08-31:** `FEES.mediation.*.hours` in
|
||||
`src/data/site.ts` means the **session** and is corrected 3.5 → 3 and 7 → 6, and
|
||||
**`/fees/` is unblocked for build step 9** on the question Q58 asked.
|
||||
|
||||
✅ **WHERE OVERTIME BEGINS — RULED. Q59, Pouya, 2026-08-31. IT RUNS FROM THE
|
||||
SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the
|
||||
billed envelope. The two candidates were the session cap (3 h / 6 h) and the
|
||||
envelope (5 h / 9 h), and this file could not choose between them — a fee term is
|
||||
a fact we do not have, not an inference. A first pass at this paragraph asserted
|
||||
the session cap as applied fact and `adversarial-reviewer` struck it in the same
|
||||
change set that wrote it; the strike was right, and the ruling has now supplied
|
||||
the value the strike was waiting for.
|
||||
|
||||
⚠️ **AND THE RULING'S SECOND HALF IS THE PART THAT MATTERS MOST, BECAUSE IT
|
||||
ANSWERS THE ARITHMETIC ANOMALY BELOW RATHER THAN RESTATING IT.** 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.** That is what a reader
|
||||
doing the arithmetic in the table below is missing: `2000 + 500 × 3 = 3500`
|
||||
against `4000` looks like a $500 penalty for booking properly, and it is not —
|
||||
the two are different products. Half-day overtime depends on the time after the
|
||||
session still being free, and on a booked day it is not.
|
||||
|
||||
**So the reservation sentence is published ADJACENT TO THE OVERTIME ROW on
|
||||
`/fees/`, not in a footnote**, and it is rendered from
|
||||
`FEES.mediation.reservation` rather than retyped. Structurally the same rule as
|
||||
`PROCESS_FRAMING` beside the five timings under Q43: a reader who takes the
|
||||
number and skips the framing has read a different offer.
|
||||
|
||||
⚠️ **THE ANOMALY IS NOT CLOSED BY THIS.** The gap is still in D14's own figures —
|
||||
the half-to-full step is $2,000 and three hours of overtime is $1,500 — and the
|
||||
reservation point explains what the gap buys without removing it. It stays on
|
||||
**§12 R5**'s 12-month review, and §Recorded dissent below carries the table for
|
||||
that review to test against.
|
||||
|
||||
**And the reason it cannot be quietly chosen is that the choice is visible in the
|
||||
arithmetic.** Take the trigger as the session cap. The half-day route costs
|
||||
`2000 + 500 × max(0, n − 3)`; the full-day route is **flat $4,000 until hour 6**
|
||||
and `4000 + 500 × (n − 6)` after it:
|
||||
|
||||
| Session run to | Book a half day + overtime | Book a full day | Gap |
|
||||
|---|---|---|---|
|
||||
| 3 h | **$2,000** | **$4,000** | **$2,000** |
|
||||
| 4 h | $2,000 + 1 × $500 = **$2,500** | **$4,000** | **$1,500** |
|
||||
| 5 h | $2,000 + 2 × $500 = **$3,000** | **$4,000** | **$1,000** |
|
||||
| 6 h | $2,000 + 3 × $500 = **$3,500** | **$4,000** | **$500** |
|
||||
| 7 h | $2,000 + 4 × $500 = **$4,000** | $4,000 + 1 × $500 = **$4,500** | **$500** |
|
||||
|
||||
**Booking a half day and paying overtime is cheaper at every length — by $2,000
|
||||
at three hours, narrowing to $500 from six hours on — and the full-day rate is
|
||||
never the cheaper choice.** The gap is in D14's figures rather than in the
|
||||
trigger: the half-to-full step is $2,000 and three hours of overtime is $1,500.
|
||||
|
||||
⚠️ **A FIRST PASS AT THIS PARAGRAPH GOT THE ARITHMETIC WRONG, AND WRONG IN THE
|
||||
DIRECTION THAT MATTERED.** It generalised the full-day route as `500n + 1000` for
|
||||
all `n ≥ 3`, which is **only valid from hour 6** — the route is flat until then —
|
||||
and concluded *"$500 cheaper at every length"*. The real spread is **up to four
|
||||
times larger and is largest at three to five hours, which is the band a half-day
|
||||
booking actually overruns into.** The document's own table contradicted the
|
||||
formula three lines above it. Found by `adversarial-reviewer` on round 2, in the
|
||||
change set that wrote it. *A measurement is a claim about your instrument, and a
|
||||
formula is an instrument.*
|
||||
|
||||
**Moving the trigger out to the 5 h / 9 h billed envelope does not fix it and is
|
||||
not uniformly better either** — the gap stays at $2,000 through five hours and is
|
||||
$1,500 at six, worse than the session-cap trigger there, but it closes to **zero**
|
||||
from nine hours on, where the session-cap trigger holds a permanent $500. So the
|
||||
two triggers trade one band against another and neither removes the anomaly. **It
|
||||
was never a defect this file could fix by picking a trigger** — which is why the
|
||||
trigger went to Pouya and the step went to R5.
|
||||
|
||||
**Both halves came back. He ruled the session cap AND supplied the reservation
|
||||
point**, which is the answer the arithmetic alone cannot give: the table compares
|
||||
prices for two things that are not the same product. Read the table as a price
|
||||
comparison and the full-day rate looks strictly worse; read it knowing a full day
|
||||
reserves the day and half-day overtime is subject to availability, and the
|
||||
$2,000-to-$500 spread is the price of certainty rather than a mistake. The
|
||||
anomaly stays on R5 because the *size* of that spread is still a judgement about
|
||||
D14's figures, and it is largest at three to five hours — the band a half-day
|
||||
booking actually overruns into.
|
||||
|
||||
### Arbitration
|
||||
|
||||
Sole, party-appointed and co-arbitration appointments **in commercial matters**
|
||||
@@ -132,6 +249,46 @@ paragraph this one used to point at.
|
||||
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
|
||||
offer tribunal-secretary work on the site.
|
||||
|
||||
### Med-arb — billed by phase
|
||||
|
||||
⚠️ **INTERIM. Set by Pouya 2026-09-03; reviewed at the twelve-month fee review,
|
||||
`AGENTS.md` §12 R5.** It is stamped interim because it is the only rule on this
|
||||
page set after the card was published rather than with it, and because it prices
|
||||
an offering by reference to two other rows — if either moves at R5, this moves
|
||||
with them and nobody will be reminded by a figure changing.
|
||||
|
||||
**The rule, and it carries no figure of its own:**
|
||||
|
||||
- Med-arb is billed **by phase**. The mediation phase is charged at the
|
||||
**mediation** rates above. If the matter proceeds to arbitration, that phase is
|
||||
charged at the **arbitration** rates above.
|
||||
- **There is no separate med-arb fee.**
|
||||
- The additional-party and cancellation terms apply to each phase **as they
|
||||
apply to that process on its own**.
|
||||
|
||||
**Why this rule exists at all, because a fee page does not usually need one.**
|
||||
`/fees/` opens *"Every figure is on this page"*, and `AGENTS.md` §4 Offerings
|
||||
carries a **Med-Arb** row that this document priced nowhere. The promise was
|
||||
therefore wider than the card — the D20 cutover claims pass, finding 10. Pouya
|
||||
closed it by **pricing the offering rather than narrowing the promise**, which is
|
||||
the more expensive of the two fixes and the one that leaves the page saying the
|
||||
stronger thing.
|
||||
|
||||
⚠️ **DO NOT GIVE MED-ARB A RATE ROW.** A med-arb figure would be a fourth price
|
||||
for a process that is already priced twice, and the first thing it would do is
|
||||
disagree with one of them. The rule is expressed as a pointer to the two cards
|
||||
above **on purpose**; that is what keeps the count of published figures the same
|
||||
as the count of published rates.
|
||||
|
||||
⚠️ **"AS THEY APPLY TO THAT PROCESS ON ITS OWN" IS NOT "TO BOTH PHASES".** The
|
||||
additional-party fee is a **mediation** row; the arbitration card has no
|
||||
equivalent. The wording above invents nothing. *"The additional-party term
|
||||
applies throughout"* would invent an additional-party charge in the arbitral
|
||||
phase, which no ruling has set.
|
||||
|
||||
`FEES.medArb` in `src/data/site.ts` holds the three sentences and `/fees/`
|
||||
renders them, so the rule is not retyped into the template.
|
||||
|
||||
### Other services — hourly
|
||||
|
||||
Early neutral evaluation, dispute-system design, and pre-dispute technical
|
||||
@@ -182,14 +339,29 @@ page is an offer.
|
||||
### All parameters confirmed
|
||||
|
||||
Q15, Q16, and Q17 were closed on 2026-08-26. **Preparation time is bundled and
|
||||
must be stated on the page** — "including 2 hours of preparation", "including
|
||||
3 hours of preparation". Do not quietly fold it into the hours figure. At these
|
||||
must be stated on the page, IN HOURS AND AS A CAP** — "including **up to** 2
|
||||
hours of preparation", "including **up to** 3 hours of preparation". Do not
|
||||
quietly fold it into the session figure, and do not drop the "up to": the
|
||||
allowance is capped, so the unqualified form sells an uncapped one. At these
|
||||
rates, saying preparation is included is the selling point, not a footnote.
|
||||
|
||||
*(The "up to" was added 2026-08-31 with Q58's ruling. This paragraph previously
|
||||
prescribed the flat form, and `/for-parties/` shipped it — the one page written
|
||||
for a reader with no counsel to catch it.)*
|
||||
|
||||
---
|
||||
|
||||
## Recorded dissent — for the 12-month review (R5)
|
||||
|
||||
⚠️ **SECOND ITEM FOR R5, ADDED 2026-09-04 — MED-ARB, AND IT IS NOT A DISSENT.**
|
||||
It is here because **R5 names this section as where its items live**, and the
|
||||
med-arb rule was stamped INTERIM against R5 in §Med-arb above and written into no
|
||||
list the review actually reads. **The rule is derived** — each phase at the rates
|
||||
for that process, no figure of its own — so **moving any mediation or arbitration
|
||||
number at R5 moves the med-arb price with it, silently, with no diff on the
|
||||
med-arb rule.** Nothing else on this page has that property. Check it against
|
||||
whatever the review does to the two cards above.
|
||||
|
||||
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
|
||||
recorded here so the 12-month review has something to test against, not to
|
||||
re-open a settled decision.
|
||||
@@ -227,6 +399,37 @@ unrecoverable, and it is far easier to add a lower tier later than to raise a
|
||||
headline rate. Setting the ceiling first and discounting privately preserves
|
||||
more optionality than the reverse.
|
||||
|
||||
**And a fourth item, added 2026-08-31: the half-day-plus-overtime route is
|
||||
strictly cheaper than the full-day rate, at every session length.** This is the
|
||||
one item in this section that is arithmetic rather than judgement, so it is the
|
||||
one the review can settle without new market data.
|
||||
|
||||
| Session run to | Half day + overtime | Full day | Gap |
|
||||
|---|---|---|---|
|
||||
| 3 h | $2,000 | $4,000 | **$2,000** |
|
||||
| 4 h | $2,500 | $4,000 | **$1,500** |
|
||||
| 5 h | $3,000 | $4,000 | **$1,000** |
|
||||
| 6 h | $3,500 | $4,000 | **$500** |
|
||||
| 7 h | $4,000 | $4,500 | **$500** |
|
||||
|
||||
*(Session-cap trigger; the trigger itself is `AGENTS.md` **Q59**, ruled and
|
||||
closed 2026-08-31 — this line said "open" for a day after line 144 of this same
|
||||
file recorded the ruling.)* The
|
||||
cause is the relationship between two of D14's own numbers rather than anything
|
||||
about the trigger: **the half-to-full step is $2,000 and three hours of overtime
|
||||
is $1,500.** Any trigger leaves a gap; the envelope trigger closes it only from
|
||||
nine hours on.
|
||||
|
||||
**What the review has to decide is whether that is a choice.** It is defensible
|
||||
as one — a full-day booking buys certainty and a reserved diary, and a client who
|
||||
knows they need six hours may prefer to pay $500 for not having to watch the
|
||||
clock. It is also exactly the kind of thing counsel comparing published cards
|
||||
finds in under a minute, and this practice's whole pricing argument is that the
|
||||
card is published in full and means what it says. **The lever, if it is not a
|
||||
choice:** either raise the overtime rate so the routes converge, or narrow the
|
||||
half-to-full step. Both are rate moves, which is why they belong here and not in
|
||||
a footnote to the card.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
@@ -14,9 +14,9 @@ it does not need restating in every prompt.
|
||||
|
||||
That single command runs: plan → implement → adversarial review → resolve →
|
||||
verify → record. It requests deep reasoning, reads `AGENTS.md` and the specs,
|
||||
stops if the task conflicts with a locked decision, invokes two independent
|
||||
reviewers on the finished diff, resolves what they find, runs the checks, and
|
||||
appends the Change Log entry.
|
||||
stops if the task conflicts with a locked decision, invokes `adversarial-reviewer`
|
||||
on the finished diff, resolves what it finds, runs the checks, and appends the
|
||||
Change Log entry.
|
||||
|
||||
You do not need to ask for thinking, for review, or for the record to be updated.
|
||||
Those are the agreement, not the request.
|
||||
@@ -26,29 +26,53 @@ Those are the agreement, not the request.
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/build <task>` | The full loop. Use for every substantive change |
|
||||
| `/review [scope]` | The review pass alone, on the working tree or a named scope. Reports; fixes nothing without your say-so |
|
||||
| `/review [scope]` | The **code** review alone, on the working tree or a named scope. Reports; fixes nothing without your say-so. Under D20 it does not run the claims pass |
|
||||
| `/wrap` | End of session — updates `AGENTS.md` under its constitution and leaves the tree clean |
|
||||
|
||||
## The two reviewers
|
||||
## The two reviewers, and when each of them runs — D20
|
||||
|
||||
Both are defined in `.claude/agents/` and run in parallel on the diff.
|
||||
Both are defined in `.claude/agents/`. **They no longer run together.**
|
||||
|
||||
**`adversarial-reviewer`** reads the code: correctness and edge cases,
|
||||
accessibility, crawlability, performance budgets, security, and whether a
|
||||
materially simpler correct version exists.
|
||||
**`adversarial-reviewer` runs on every build step.** It reads the code:
|
||||
correctness and edge cases, accessibility, crawlability, performance budgets,
|
||||
security, and whether a materially simpler correct version exists.
|
||||
|
||||
**`claims-auditor`** reads the copy against `AGENTS.md` §4 and nothing else. It
|
||||
extracts every factual assertion — credentials, roles, numbers, languages,
|
||||
locations, capabilities, and the JSON-LD — and traces each to the Verified table.
|
||||
Anything untraceable is reported and does not ship.
|
||||
**`claims-auditor` runs once, at cutover, over the whole finished site.** It reads
|
||||
the copy against `AGENTS.md` §4 and nothing else — extracting every factual
|
||||
assertion (credentials, roles, numbers, languages, locations, capabilities, and
|
||||
the JSON-LD) and tracing each to the Verified table. Anything untraceable is
|
||||
reported and does not ship. `docs/06`'s cutover checklist carries it as a blocking
|
||||
item, alongside your own read of every page against §4.
|
||||
|
||||
It is a separate agent on purpose. A general-purpose reviewer will happily
|
||||
approve elegant code containing a claim that should never have been published,
|
||||
because professional-conduct compliance is not what it is looking at. On this
|
||||
project that is the highest-stakes failure mode, so it gets its own pass.
|
||||
project that is the highest-stakes failure mode, so it keeps its own pass.
|
||||
|
||||
**Verifying they are loaded.** `.claude/agents/` is the correct location. To
|
||||
confirm the agents are live, invoke one directly:
|
||||
### Why the claims pass moved, and what it costs
|
||||
|
||||
Your ruling, 2026-08-30. `AGENTS.md` D20 carries it in full; the short form:
|
||||
|
||||
- **Nothing has shipped.** Every claims finding to date has been about a page no
|
||||
visitor can reach. The risk is deferred to cutover whether or not the audit is.
|
||||
- **One pass over twenty finished pages catches more than nine over drafts**,
|
||||
because it reads the site as a visitor does. The `/med-arb/` ADRIC gloss is the
|
||||
proof: no individual claim was false, the defect was **adjacency**, and
|
||||
adjacency does not exist until the pages sit next to each other.
|
||||
- **The code reviewer stays per step because what it catches compounds.** An
|
||||
accessibility or crawlability defect propagates into the next page built on it.
|
||||
A claims defect does not compound; it sits there until someone reads it.
|
||||
|
||||
**What it costs, stated rather than glossed:** a claims defect can now live in an
|
||||
unpublished draft for weeks. Two things carry that risk in the meantime, and
|
||||
neither is a substitute for the cutover pass — **`npm run check:claims`**, which
|
||||
is unchanged and runs on every build and both deploy paths, and **you reading the
|
||||
copy as it is built.**
|
||||
|
||||
## Verifying the agents are loaded
|
||||
|
||||
**`.claude/agents/` is the correct location.** To confirm the agents are live,
|
||||
invoke one directly:
|
||||
|
||||
```
|
||||
Use the claims-auditor agent to audit README.md against AGENTS.md §4.
|
||||
@@ -57,20 +81,29 @@ Use the claims-auditor agent to audit README.md against AGENTS.md §4.
|
||||
A verdict table back means both are wired. "No such agent" means the frontmatter
|
||||
needs looking at.
|
||||
|
||||
**Agent definitions load at session start.** An edit to `.claude/agents/*.md`
|
||||
does not reach a running session — the version in force is the one that was on
|
||||
disk when the session began. So after changing a brief, restart before relying on
|
||||
it, and **say in the report which version actually ran.** This is not theoretical:
|
||||
the gloss lens was added to `claims-auditor` on 2026-08-30 and the agent
|
||||
reconstructed it from the Change Log rather than having it in its brief, because
|
||||
the edit and the run were in the same session.
|
||||
|
||||
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
|
||||
wrong. That is the intended trade: explaining why a finding is mistaken costs
|
||||
minutes, and a missed defect on this project's public marketing pages
|
||||
costs a great deal more.
|
||||
costs a great deal more. **This weighs heavier on the cutover pass, not lighter**
|
||||
— there is nothing behind it, so D19's two-round cap does not apply there.
|
||||
|
||||
## The rule that makes it work
|
||||
|
||||
**The reviewers are given the diff and the specs — never the implementer's
|
||||
**A reviewer is given the diff and the specs — never the implementer's
|
||||
explanation of why the work is correct.**
|
||||
|
||||
A rationale anchors the reviewer. Told why something is right, a reviewer looks
|
||||
for confirmation and finds it; given only the artefact, it forms an independent
|
||||
view. That independence is the entire mechanism. Every other detail of this
|
||||
protocol is adjustable. This one is not.
|
||||
protocol is adjustable — D20 just adjusted one of them. This one is not.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,3 +168,7 @@ see that judgement was exercised rather than the finding missed.
|
||||
**A check reported as passing that was not run.** "Should pass" is not a result.
|
||||
If a build, a Lighthouse run, or a JavaScript-disabled render was not actually
|
||||
executed, it must say so.
|
||||
|
||||
**A report that says "reviewed" without naming which agent ran.** Under D20 a
|
||||
build step gets `adversarial-reviewer` and not the claims pass; "reviewed" reads
|
||||
as both. The report must name the one that ran.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,18 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||
> date and effective date below is as at the retrieval date and nothing more.
|
||||
> Re-check before cutover, and before any republish that turns on one of them.
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||
> are still the original retrieval and were not re-fetched** — what was
|
||||
> re-checked is whether the *facts* they support have moved.
|
||||
>
|
||||
> - **(g) ADRIC's Code of Ethics, quoted verbatim on `/process/` — NOT
|
||||
> RE-RETRIEVED.** Held unchanged on a cadence judgement `[assumed
|
||||
> 2026-09-01 — Pouya]`. It is the slowest-moving of the seven; the live link
|
||||
> on `/process/` is what makes a stale quotation visible to a reader.
|
||||
|
||||
**Topic as researched:** The Canadian ADR institutions this practice names, and the exact form of their names — ADRIC / ADRIO rule sets, designations, codes; ADR Chambers; early neutral evaluation and dispute-system design
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,19 @@ construction. This is the same fetch-before-writing that caught
|
||||
|
||||
**It caught one immediately. See Finding 1.**
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||
> are still the original retrieval and were not re-fetched** — what was
|
||||
> re-checked is whether the *facts* they support have moved.
|
||||
>
|
||||
> - **(f) the ADRIC National Mediation Rules, under review by ADRIC's own
|
||||
> committee — NOT RE-RETRIEVED.** Held unchanged on a cadence judgement
|
||||
> rather than a fresh fetch `[assumed 2026-09-01 — Pouya]`. The digests in
|
||||
> the Provenance table below are the 2026-08-28 bytes and were not
|
||||
> recomputed, so this stamp says nothing about whether the page changed.
|
||||
|
||||
## Provenance
|
||||
|
||||
| | |
|
||||
@@ -113,7 +126,10 @@ Administer the Arbitration, Request for the appointment of an arbitrator,
|
||||
Application for Urgent Interim Measures, Application to Challenge an Arbitrator,
|
||||
Notice of Appeal.
|
||||
|
||||
**ADRIC Med-Arb Rules.** A discussion draft was presented to the membership at
|
||||
**ADRIC Med-Arb Rules.** Developed by a **Task Force** — *"a Task Force was
|
||||
formed with a dedicated working group of med-arb professionals. The Task Force
|
||||
completed an initial draft of the Rules, which were then referred to the Rules
|
||||
Committee"* — and a discussion draft was presented to the membership at
|
||||
ADRIC's Annual Conference in **November 2019**. Two sentences are directly
|
||||
useful to `/med-arb/`, both verbatim:
|
||||
|
||||
|
||||
@@ -5,9 +5,22 @@ is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the
|
||||
infinity mark was reconstructed wrongly and **two adversarial review passes could
|
||||
not catch it**, since the real artwork was not in the repo to compare against.
|
||||
|
||||
**Every measurement below is `[verified 2026-08-26]`** — computed with `sharp`
|
||||
against the files in this repository, and re-derivable by anyone from the
|
||||
commands given. Nothing here is quoted from an external source.
|
||||
**The measurements here are computed with `sharp` against the files in this
|
||||
repository**, and are re-derivable by anyone from the commands given.
|
||||
|
||||
**Unless a section says otherwise, every figure below is `[verified
|
||||
2026-08-26]`. Where a section carries its own stamp, that stamp wins** — the
|
||||
render ladders are `[measured 2026-08-27]` and §The icon set is `[verified
|
||||
2026-09-02]`. ⚠️ *This line has been wrong in both directions in one week: first
|
||||
as a blanket 2026-08-26 that was already stale for the ladders, then as
|
||||
"each section carries its own stamp" when five sections carry none. It is a floor
|
||||
plus overrides because that is the only shape that covers every section without
|
||||
mis-dating one* (`adversarial-reviewer`, rounds 1 and 2).
|
||||
|
||||
⚠️ **Two things in §The icon set are NOT read off this repository and are
|
||||
labelled where they appear:** the tab-strip colours used in the contrast figures,
|
||||
and iOS's handling of a transparent touch icon. Neither is derivable from
|
||||
anything committed, so neither is stamped as if it were.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -103,6 +116,138 @@ Passing an explicit `width` is load-bearing: without it Astro emits the
|
||||
untouched 2668 px master as the `<img src>` fallback — **1,146,406 bytes** —
|
||||
which any client without AVIF or WebP support would actually download.
|
||||
|
||||
## The icon set
|
||||
|
||||
`[verified 2026-09-02 — every figure below read off the icon files in this
|
||||
repository, EXCEPT the two external constants flagged inline]`
|
||||
|
||||
| File | What it is | Ground |
|
||||
|---|---|---|
|
||||
| `public/favicon.ico` | 16, 32 and 48 px frames, each a PNG-encoded 32-bit RGBA image inside the ICO container | **Transparent** |
|
||||
| `public/apple-touch-icon.png` | 180 × 180 | **Opaque cream `#faf7f2`, and that is deliberate — see below** |
|
||||
|
||||
**The two grounds differ on purpose, and this is the line that stops someone
|
||||
"fixing" it.** ⚠️ *The reason is an external platform behaviour, not a repository
|
||||
fact, and it is recorded here as what it is:* **iOS does not honour transparency
|
||||
in a touch icon — it composites it onto black**, so a transparent touch icon
|
||||
ships a black tile on the home screen. Stated by Pouya in his ruling of
|
||||
**2026-09-02** and not independently re-tested here; **it has not been checked on
|
||||
a handset in this repository, and the place that does check it is the unticked
|
||||
`docs/06` item "Tested on iOS Safari…".** The tab favicon has the opposite
|
||||
requirement: a tab strip is dark for many readers, and an opaque ground shows
|
||||
there as a visible rectangle around the mark. So the favicon is transparent, the
|
||||
touch icon is matted, and **neither should be changed to match the other.**
|
||||
|
||||
### Composition
|
||||
|
||||
The mark spans **7/8 of the canvas width**, centred on both axes — 14/16, 28/32,
|
||||
42/48, and 158/180 on the touch icon. That was measured off the icons as they
|
||||
already shipped, so a regeneration reproduces the composition rather than
|
||||
restyling the mark.
|
||||
|
||||
### Regenerating
|
||||
|
||||
```
|
||||
npm run icons
|
||||
```
|
||||
|
||||
`scripts/icons.mjs` resizes `src/assets/brand/sml-infinity-mark.png` onto a
|
||||
transparent square canvas and assembles the ICO container itself, because
|
||||
`sharp` does not write `.ico`. **It writes the favicon only** and never touches
|
||||
the touch icon. It refuses to run if the render source has no alpha channel, and
|
||||
it asserts that the source is still byte-identical to the documented crop of
|
||||
`sml-infinity-mark-master.png` — R14, so the icon stays traceable to committed
|
||||
artwork. It verifies a **candidate** file and renames it into place only on
|
||||
success, so a rejected build cannot replace a good favicon.
|
||||
|
||||
**It also carries the halo test**, because the check that matters was living
|
||||
only in prose here while the generator could not run it
|
||||
(`adversarial-reviewer`, round 2). For each frame it takes every painted pixel
|
||||
touching a fully transparent one and measures how many sit within 20 of cream:
|
||||
|
||||
| | boundary px | near cream | share |
|
||||
|---|---|---|---|
|
||||
| correct, 16 / 32 / 48 px | 61 / 146 / 258 | 1 / 2 / 5 | **1.6 / 1.4 / 1.9 %** |
|
||||
| haloed fixture, 16 px | 56 | 11 | **19.6 %** |
|
||||
|
||||
The gate is **10 %**, roughly 5× clear of both. ⚠️ **The first version of this
|
||||
guard inspected only partial-alpha pixels and MISSED the fixture completely** —
|
||||
a knockout sets alpha per pixel and leaves **no partial alpha at all**, so there
|
||||
was nothing for it to look at. The fixture is built by matting the mark on cream
|
||||
and then knocking the ground out by alpha, which is the defect exactly; it fires
|
||||
at 19.6 % and leaves `public/favicon.ico` untouched. **A guard that cannot see
|
||||
the defect it is named for is worse than none**, and this one passed the real
|
||||
icon while blind, which is the shape that ends a check instead of starting one.
|
||||
|
||||
### Why the favicon was regenerated, 2026-09-02
|
||||
|
||||
It had **no transparency at all**: all three frames declared a 32-bit alpha
|
||||
channel and then carried `alpha = 255` on every one of their 256 / 1,024 / 2,304
|
||||
pixels, with the ground opaque cream `rgba(250,247,242,255)`. Found by Pouya on a
|
||||
read-through, confirmed by parsing the container directly.
|
||||
|
||||
Three measurements stand behind the replacement.
|
||||
|
||||
**1. The composition did not change.** Composited back onto cream, the new icon
|
||||
reproduces the old matted one to within **1 of 255 on every channel of every
|
||||
pixel** at all three sizes (mean delta 0.01–0.02, 0 pixels over a delta of 8).
|
||||
So the regeneration did not restyle, rescale or reposition the mark.
|
||||
|
||||
⚠️ **That is NOT a test for a halo, and an earlier draft of this file filed it as
|
||||
one.** Composite-onto-cream returns ~0 whether the edge is correct *or* is a
|
||||
cream-matted edge that has merely had its background knocked out — the second
|
||||
case composites straight back to what it came from. Both branches pass, so the
|
||||
test cannot discriminate. Found by `adversarial-reviewer`, 2026-09-02.
|
||||
|
||||
**2. No halo — the measurement that does discriminate.** Read the RGB the
|
||||
partial-alpha pixels actually carry. A cream halo means that RGB is near cream; a
|
||||
correct export means it is the ribbon's own colour. Of the partial-alpha pixels,
|
||||
**0 of 94 / 300 / 603** are within 12 of `rgb(250,247,242)`; the nearest is 13
|
||||
away, the mean distance is **174.0 / 178.3 / 177.0**, and the mean colour per
|
||||
frame is about `rgb(118,86,74)` — maroon-brown, not cream and not black.
|
||||
|
||||
**3. Legible on dark and on light, and NOT at the same cost.** ⚠️ *The ground
|
||||
colours below are external constants, not repository facts: `#202124` is
|
||||
Chrome's dark tab strip and `#f1f3f4` its light one, both `[observed
|
||||
2026-09-02]`.* All ratios are computed on **composited** pixels, and the ink
|
||||
colours are **fully opaque** pixels only — a ratio taken from a raw channel value
|
||||
is a claim about a colour that is never painted, which is how an earlier draft
|
||||
came to quote `14.02:1` for a pixel whose alpha is 251.
|
||||
|
||||
| | darkest opaque ink | lightest opaque ink | vs `#202124` | vs `#f1f3f4` |
|
||||
|---|---|---|---|---|
|
||||
| 16 px | `rgb(70,33,33)` | `rgb(216,188,143)` | champagne **8.82:1** | maroon **12.58:1** |
|
||||
| 32 px | `rgb(60,29,29)` | `rgb(234,214,172)` | champagne **11.28:1** | maroon **13.62:1** |
|
||||
| 48 px | `rgb(61,26,26)` | `rgb(238,219,175)` | champagne **11.80:1** | maroon **13.86:1** |
|
||||
|
||||
Whichever ground it sits on, one end of the ribbon carries the silhouette. **But
|
||||
the other end does not merely dim — on dark it goes.** The maroon lobe
|
||||
composites to **1.04–1.15:1**, and counting pixels that reach 3:1 against each
|
||||
ground gives **23 / 87 / 189** on dark against **40 / 168 / 378** on light, at
|
||||
16 / 32 / 48 px. **So about half as many pixels reach 3:1 on dark as on light.**
|
||||
⚠️ *Those pixels are not absent — they are painted and fall below 3:1, and the
|
||||
mark's full outline is still there, dim. An earlier draft said "roughly half the
|
||||
mark's visible pixels are absent", which is neither what was counted (pixels at
|
||||
`alpha > 0` are 109 / 388 / 805) nor what a render shows* — `adversarial-reviewer`,
|
||||
round 2. The previous opaque-cream icon was still more legible on dark.
|
||||
|
||||
**That is the price of the change and it was accepted, not overlooked.** What was
|
||||
bought is the removal of a cream rectangle from every dark tab strip and from
|
||||
white. Raised by `adversarial-reviewer`, 2026-09-02, against a draft that
|
||||
recorded the change as costless.
|
||||
|
||||
**The obvious alternative is an icon pair keyed on `prefers-color-scheme`, and it
|
||||
was NOT ruled out on measurement.** ⚠️ *An earlier draft dismissed it on a
|
||||
mechanism that is wrong: it said the query keys off the page's colour scheme. It
|
||||
does not — it reports the user's system or browser preference, and this site
|
||||
declares `color-scheme` nowhere (`git grep -n color-scheme -- src`, exit 1), so
|
||||
it would resolve to the same preference that makes the tab strip dark.* The real
|
||||
objections are **untested here** and are recorded as such: `media` on
|
||||
`<link rel="icon">` is unevenly supported for raster icons, a browser theme can
|
||||
be set independently of the system preference, and it doubles the artefact
|
||||
`npm run icons` has to keep in sync. **If this cost is ever revisited, that is
|
||||
the option to test** — do not re-dismiss it on the reason struck above.
|
||||
|
||||
## The colours are the artwork's, not the palette's
|
||||
|
||||
`tokens.css` is not involved. The ribbon carries its own gradient and it is
|
||||
|
||||
@@ -21,6 +21,19 @@ not published.
|
||||
> at second reading on the retrieval date and could be law, or dead, by the time
|
||||
> anyone reads this. **Re-check before cutover.**
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||
> are still the original retrieval and were not re-fetched** — what was
|
||||
> re-checked is whether the *facts* they support have moved.
|
||||
>
|
||||
> - **(a) Bill C-36 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 — Pouya,
|
||||
> <https://www.parl.ca/legisinfo/en/bill/45-1/c-36>]`. Still at second
|
||||
> reading in the House of Commons; latest completed stage is first reading,
|
||||
> 2026-06-15; no advance since. `/practice/technology/`'s sentence stands as
|
||||
> written.
|
||||
|
||||
**Topic as researched:** Canadian technology / data / AI dispute context — privacy legislation status as at 2026-08-29, Ontario public-sector and health privacy statutes, data residency law, and Canadian arbitral-institution rules for technology/AI disputes
|
||||
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
# Who can read `adr-intake-submissions` — verification extract
|
||||
|
||||
**Why this file exists.** `/legal/privacy/` makes a statement to the public about
|
||||
who can see the contents of the intake table. `AGENTS.md` §4 admits no factual
|
||||
claim that cannot be traced, and `CLAUDE.md`'s R14 rule is that anything a spec
|
||||
claims about must be reachable from the repository — *"if the artefact lives only
|
||||
in a console, no reviewer can compare the claim against it and the claim is
|
||||
unverifiable by construction."* Until this file existed, that sentence was the
|
||||
one claim on the site whose subject was entirely outside the repo.
|
||||
|
||||
Raised by `claims-auditor` in the D20 cutover audit, 2026-09-01, finding 8.
|
||||
|
||||
**Provenance.** Figures in the ORIGINAL sections were read from AWS on
|
||||
**2026-09-01**; everything in the **2026-09-02 addendum** at the foot of this file
|
||||
was read on 2026-09-02, and it supersedes the original role screen. Both were run
|
||||
read-only as `arn:aws:iam::327082975128:user/pouya` with the commands listed at
|
||||
the end.
|
||||
No command in this file creates or changes anything. Re-run them rather than
|
||||
trusting this file; it is dated for that reason.
|
||||
|
||||
---
|
||||
|
||||
## ✅ RULED AND APPLIED — 2026-09-02
|
||||
|
||||
**Pouya ruled `state the truth`, not `remove the access`** (§9 Q62, ruled
|
||||
2026-09-01, applied 2026-09-02). Option 2 below is the one taken; option 1 was
|
||||
declined. `lars`'s membership of `admins` is **unchanged**.
|
||||
|
||||
⚠️ **AND THEN RULED AGAIN THE SAME DAY — §9 Q63, and the second ruling is the
|
||||
one this file most needs to carry, because THIS FILE SUPPLIED THE ERROR.** The
|
||||
first shipped sentence was *"Two people can"*, and it was read straight off the
|
||||
enumeration below. **Pouya's attestation, 2026-09-02:** *"two people is an
|
||||
exaggeration… a handful is accurate — the simulation counts identities, not
|
||||
humans, and the two are not the same claim."*
|
||||
|
||||
**The enumeration is exhaustive and the inference off it was not.** Every read
|
||||
path terminates at `user/pouya` or `user/lars`; how many **people** can reach
|
||||
those two credentials is not something `simulate-principal-policy` can see, so
|
||||
the identity count is a **lower bound on people** and the page published it as an
|
||||
exact count. **No numeric human headcount may ship.** The identity counts in this
|
||||
file are unaffected and stay exactly as measured — this is a correction to what
|
||||
may be *concluded* from them, not to any of them.
|
||||
|
||||
⚠️ **AND RULED A THIRD TIME, LATER THE SAME DAY: THE PAGE STATES WHO, AND THIS
|
||||
FILE HOLDS THE METHOD.** Pouya, 2026-09-02: *"the page stays generic. It
|
||||
over-explains technical mechanics that belong in the evidence file, not in front
|
||||
of an inquirer."* §Who can see it is now **four short statements**. **Deleted from
|
||||
the page:** the measurement paragraph, the root-credential sentence, the single-sign-on and federated-login enumeration, the resource-policy clause, the *"company that runs a database"* aside, the deploy-credential sentence and the three-copies summary. ⚠️ **THE SHARED-ACCOUNT CLAUSE WAS CUT WITH THEM AND THEN RESTORED — to §Where it is stored, where it belongs.** It is a storage disclosure rather than mechanics, the ruling did not name it, and without it no page told a reader their intake sits in an account that also runs unrelated systems (`adversarial-reviewer`, round 1). **These lists must stay identical — there were four of them and they named four different sets.** None of that was retracted and none of it is lost — it
|
||||
is all still below, unchanged, and **that is now this file's job rather than a
|
||||
supporting role.** ⚠️ **THE PAGE NO LONGER CITES THIS FILE'S CONTENT, SO THE
|
||||
COMPARISON BELOW IS THE ONLY THING TYING THE TWO TOGETHER. Keep it in sync, and
|
||||
do not restore a deleted sentence to the page on the strength of finding it
|
||||
here** — the section comment in `src/pages/legal/privacy.astro` carries the same
|
||||
bar.
|
||||
|
||||
**The shipped sentences as at 2026-09-02**, so this file can be compared against
|
||||
the live page rather than against a struck one. All four, in order, complete:
|
||||
|
||||
> The record in the table: me, and the small number of people who administer the
|
||||
> account it sits in with me.
|
||||
|
||||
> The system that receives what you send **can only add a record — it cannot read
|
||||
> back what is stored.**
|
||||
|
||||
> The notification goes to the practice's mailbox, which is read by me and by
|
||||
> administrative staff and is hosted on Google Workspace — so Google holds a copy
|
||||
> of whatever you send me.
|
||||
|
||||
> The confirmation that went to you sits with whoever runs your email. That copy
|
||||
> is in your hands rather than mine.
|
||||
|
||||
**What each rests on, because that mapping is the reason this file exists.**
|
||||
Sentence 1: the enumeration below, plus Pouya's *"a handful"* attestation for the
|
||||
human quantifier — **the measurement gives administrators, the attestation gives
|
||||
the number, and neither gives the other.** Sentence 2: `adr-intake-lambda-role`
|
||||
holds `PutItem` only, implicitDeny on all six read and modify actions.
|
||||
Sentence 3: **an attestation, not a measurement** — `AGENTS.md` §7's
|
||||
`info@smlcompany.ca` row; nothing in this repository or in AWS can check it.
|
||||
Sentence 4: the handler's second `SendEmailCommand`.
|
||||
|
||||
**Three things the page deliberately does NOT say, and each was deleted by
|
||||
ruling rather than being unsupported.** The **root credential** (§7 records it as
|
||||
held by Pouya with no access key and MFA on — ⚠️ *held*, not *held only*, which
|
||||
is why publishing it needed a question and why §9 Q64 is closed as **moot** and
|
||||
not as answered). The **single-sign-on and resource-policy findings**. And the
|
||||
**`33`** — deliberately withheld even while the paragraph stood, because a role
|
||||
total moves when AWS creates a service-linked role by itself.
|
||||
|
||||
**The wording approval Pouya reserved is discharged by the read-through** — his
|
||||
ruling, 2026-09-02: *"do not hold anything open waiting on a separate wording
|
||||
approval; the read-through is the approval."* Q63(a) had approved a version, and
|
||||
the version changed twice after it.
|
||||
|
||||
⚠️ **AND THE ENUMERATION BELOW WAS NOT ENOUGH TO SUPPORT THE COMPLETENESS CLAIM
|
||||
— *"every user and every role in the account was simulated against this table"*,
|
||||
which §7 still asserts and the page no longer carries. See the addendum at the
|
||||
foot of this file**, which is what that claim actually rests on. Read it before
|
||||
citing the five-row table. *(This pointer said "the second of those sentences"
|
||||
until 2026-09-02: it indexed the quote block by POSITION, and the block changed
|
||||
length under it. Name the claim, not its ordinal.)*
|
||||
|
||||
---
|
||||
|
||||
## The claim being checked — ⚠️ STRUCK 2026-09-01, QUOTED HERE AS THE DEFECT
|
||||
|
||||
This is **no longer on the page.** It is kept because it is the string
|
||||
`check-claims.mjs`'s `sole-administrator-q62` pattern permanently bars, and a
|
||||
tripwire whose target is not recorded anywhere becomes unmaintainable.
|
||||
|
||||
`src/pages/legal/privacy.astro`, §Who can see it, **as it stood at `bd282aa`**:
|
||||
|
||||
> The table is reachable by the function that writes to it and by one
|
||||
> administrative account, which is mine — nobody else has access to the table.
|
||||
> There is no team, no assistant and no external administrator.
|
||||
|
||||
## The finding: it is false
|
||||
|
||||
The account has an IAM group **`admins`** carrying the AWS managed policy
|
||||
**`AdministratorAccess`**, and it has **two members: `pouya` and `lars`.**
|
||||
|
||||
`iam simulate-principal-policy` for `dynamodb:GetItem`, `dynamodb:Query` and
|
||||
`dynamodb:Scan` against
|
||||
`arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions`, across
|
||||
all five IAM users in the account:
|
||||
|
||||
| principal | GetItem | Query | Scan |
|
||||
|---|---|---|---|
|
||||
| `user/pouya` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/lars` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/adr-sml-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
|
||||
`lars` holds exactly the access `pouya` holds, by the same route: membership of
|
||||
`admins`. The user's own attachments are only `IAMUserChangePassword`, so the
|
||||
group is the whole of it.
|
||||
|
||||
So the published sentence is wrong on both of its halves — a second account has
|
||||
access, and it belongs to a second administrator of a shared account.
|
||||
|
||||
## The rest of the surface, recorded so the check is complete rather than partial
|
||||
|
||||
- **5 IAM users**: `adr-sml-deploy`, `gitea-deploy-meshkinilaw`, `lars`,
|
||||
`meshkini-backend-deploy`, `pouya`. The three deploy users are all
|
||||
`implicitDeny` above. `adr-sml-deploy`'s scope is S3 + CloudFront and touches
|
||||
no table (`docs/reference/deploy-credential-verification.md`).
|
||||
- **1 IAM group**: `admins` — `AdministratorAccess` and `Billing`, two members.
|
||||
- **33 IAM roles**, 26 of them not service-linked. Two carry
|
||||
`AdministratorAccess`:
|
||||
`cdk-hnb659fds-cfn-exec-role-327082975128-ca-central-1` and
|
||||
`…-us-east-1`. These are **AWS CDK bootstrap CloudFormation execution roles**,
|
||||
assumable by CloudFormation for stack deployment. They are a real path to the
|
||||
table for anyone who can deploy a CDK stack in this account — which is the two
|
||||
administrators above — rather than a third party.
|
||||
- **`adr-intake-lambda-role`** is the writing principal: `dynamodb:PutItem` on
|
||||
this table, `ses:SendEmail`/`SendRawEmail`, plus
|
||||
`AWSLambdaBasicExecutionRole`. **`PutItem` only — it cannot read the table**,
|
||||
which is worth stating because it is a stronger fact than the page currently
|
||||
claims and it is the part of the sentence that is true.
|
||||
- **No resource-based policy on the table.** DynamoDB supports one; this table
|
||||
has none, so access is governed entirely by identity policies.
|
||||
- The account is **not single-project** (`AGENTS.md` §10). `lars` and the two
|
||||
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
|
||||
in the S3 bucket listing §10 describes.
|
||||
|
||||
## What had to happen before `/legal/privacy/` went public — ✅ RESOLVED BY OPTION 2
|
||||
|
||||
Tracked as `AGENTS.md` §9 **Q62**, **closed 2026-09-02 on option 2.** Kept
|
||||
unstruck because the reasoning is what makes the ruling re-readable, and because
|
||||
option 1 remains live in one direction: if that access is ever actually removed,
|
||||
the page and the tripwire both have to change, and `check-claims.mjs`'s `rule:`
|
||||
line carries that instruction.
|
||||
|
||||
1. **Remove the access** — take `lars` out of `admins`, or replace that
|
||||
membership with a policy that denies DynamoDB on this table — and then this
|
||||
sentence becomes true. Note the likely collision: `AGENTS.md` Q23 records the
|
||||
Gitea instance as *jointly administered* and blocked on *"its second
|
||||
administrator"*, so this account is probably not the only thing that access
|
||||
is for.
|
||||
2. **Correct the sentence** to what is true. It is a privacy policy, so the
|
||||
honest version is short and specific — the number of people with
|
||||
administrative access, and that the function that writes cannot read.
|
||||
|
||||
**Do not resolve it by softening.** "Access is limited to authorised
|
||||
administrators" is the shape §4 exists to bar: defensible, uninformative, and it
|
||||
would replace a false specific with a true vacancy on the one page where a reader
|
||||
is entitled to the specific.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
Run as `user/pouya`, `ca-central-1`, all read-only. Exit status read on each; no
|
||||
stderr suppressed anywhere.
|
||||
|
||||
```bash
|
||||
aws iam list-users --query 'Users[].UserName'
|
||||
aws iam list-groups --query 'Groups[].GroupName'
|
||||
aws iam get-group --group-name admins --query 'Users[].UserName'
|
||||
aws iam list-attached-group-policies --group-name admins --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-group-policies --group-name admins --query 'PolicyNames'
|
||||
aws iam list-groups-for-user --user-name lars --query 'Groups[].GroupName'
|
||||
aws iam list-attached-user-policies --user-name lars --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-user-policies --user-name lars --query 'PolicyNames'
|
||||
|
||||
TARN="arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions"
|
||||
for U in pouya lars adr-sml-deploy gitea-deploy-meshkinilaw meshkini-backend-deploy; do
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:user/${U}" \
|
||||
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||
--resource-arns "$TARN" \
|
||||
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||
done
|
||||
|
||||
# Roles: 33 total, 26 non-service-linked; screened for broad policies.
|
||||
for R in $(aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||
| tr '\t' '\n' | grep -v '^AWSServiceRole'); do
|
||||
aws iam list-attached-role-policies --role-name "$R" \
|
||||
--query 'AttachedPolicies[].PolicyName' --output text
|
||||
done
|
||||
|
||||
aws iam get-role-policy --role-name adr-intake-lambda-role \
|
||||
--policy-name adr-intake-lambda-inline --query PolicyDocument
|
||||
aws iam list-attached-role-policies --role-name adr-intake-lambda-role
|
||||
```
|
||||
|
||||
⚠️ **`simulate-principal-policy` was the tool that produced a false negative on
|
||||
this project once already** — `AGENTS.md` Q22, where eight checks returned empty
|
||||
because `2>/dev/null` was hiding an `InvalidInput` error caused by a zsh
|
||||
parameter-expansion bug (`$ACCT:user/` parses `:u` as a history modifier). The
|
||||
loop above brace-quotes `${U}` for that reason, prints one line per principal so
|
||||
a silently-skipped iteration is visible as a missing row, and suppresses nothing.
|
||||
**Five rows, or the run did not happen.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ADDENDUM 2026-09-02 — THE ENUMERATION ABOVE WAS INCOMPLETE IN THREE WAYS, AND ITS CONCLUSION SURVIVES ANYWAY
|
||||
|
||||
**Why this addendum exists.** `/legal/privacy/` published a completeness claim
|
||||
about who can read this table — *"every user and every role in the account was
|
||||
simulated against this table"* — and, written as it stood, this file did not
|
||||
support it. ⚠️ **THAT SENTENCE IS NO LONGER ON THE PAGE**: Pouya ruled later the
|
||||
same day that the section states who and not how, and the measurement paragraph
|
||||
was deleted (see **The shipped sentences** at the top of this file, which is now
|
||||
four statements and does not include it). **This addendum is not thereby
|
||||
obsolete — it is now load-bearing in a different place.** `AGENTS.md` §7's
|
||||
`Intake table — who can read it` row still asserts the completeness, `docs/06`
|
||||
and §12 **R21** still instruct an operator to re-run it before cutover, and the
|
||||
page's first sentence still rests on its conclusion even though it no longer
|
||||
recites the method. **A claim moved off a public page into a register is still a
|
||||
claim.**
|
||||
|
||||
*(This paragraph quoted a different sentence until 2026-09-02 — round 1's
|
||||
**pre-fix** wording, *"every account and role in this infrastructure…"*, which
|
||||
the audit then struck. So this file briefly quoted two different sentences as the
|
||||
live one, in the two halves of the same document, which defeats the comparison
|
||||
R14 exists for. Fixed by pointing at the block above rather than re-quoting it:
|
||||
one copy of a fact, in one place. `adversarial-reviewer`, round 2.)* Under R14 the artefact is what a reviewer compares the
|
||||
claim against, so a claim stronger than its artefact is unverifiable by
|
||||
construction even when it happens to be true.
|
||||
|
||||
**What was missing.**
|
||||
|
||||
1. **`aws iam list-role-policies` was never run.** The command block above lists
|
||||
only `list-attached-role-policies`, which returns *managed* policies. **23 of
|
||||
the 26 non-service-linked roles carry inline policies**, and none of them had
|
||||
been read. The screen for "broad policies" could not have seen an inline grant.
|
||||
2. **No role was ever simulated against the table.** Access was inferred from
|
||||
policy *names* (`AdministratorAccess`) rather than measured as a decision.
|
||||
3. **The five users were simulated for reads only** — `GetItem`, `Query`, `Scan`.
|
||||
So the page's *(then-shipped; deleted by the mechanics ruling of 2026-09-02 and now §7's alone)* *"the credential that publishes this website has no access to the
|
||||
table at all"* covered three read actions and said "at all".
|
||||
|
||||
**What the measurement found, and it changes the role count.** Simulating all 26
|
||||
non-service-linked roles across **seven** actions — `GetItem`, `Query`, `Scan`,
|
||||
`BatchGetItem`, `PutItem`, `UpdateItem`, `DeleteItem`:
|
||||
|
||||
| role | decision on the table | trust |
|
||||
|---|---|---|
|
||||
| `cdk-hnb659fds-cfn-exec-role-…-ca-central-1` | **allowed on all 7** | `cloudformation.amazonaws.com` only |
|
||||
| `cdk-hnb659fds-cfn-exec-role-…-us-east-1` | **allowed on all 7** | `cloudformation.amazonaws.com` only |
|
||||
| `cdk-hnb659fds-lookup-role-…-ca-central-1` | **allowed on the 4 READS**, denied on writes | `arn:aws:iam::327082975128:root` |
|
||||
| `cdk-hnb659fds-lookup-role-…-us-east-1` | **allowed on the 4 READS**, denied on writes | `arn:aws:iam::327082975128:root` |
|
||||
| `adr-intake-lambda-role` | `PutItem` **only**; implicitDeny on the other six | `lambda.amazonaws.com` |
|
||||
| the other 21 | implicitDeny on all 7 | — |
|
||||
|
||||
**So FOUR roles can read the table, not the two this file recorded.** The two
|
||||
`lookup` roles were missed by exactly the gap above: their grant is the inline
|
||||
`LookupRolePolicy`, and `list-attached-role-policies` returns nothing for them.
|
||||
|
||||
**Why the published sentence is nevertheless correct.** The question is not how
|
||||
many roles exist but which *people* they lead back to.
|
||||
|
||||
- The two **cfn-exec** roles trust `cloudformation.amazonaws.com` and nothing
|
||||
else. No human can assume them; they are reachable only by deploying a
|
||||
CloudFormation/CDK stack, which requires a principal who can deploy one.
|
||||
- The two **lookup** roles trust the account root, which delegates the decision
|
||||
to the caller's own identity policy. Simulated for `sts:AssumeRole` against
|
||||
both role ARNs, for all five users, reading `ResourceSpecificResults` (ten
|
||||
per-resource decisions, not five — `EvaluationResults` is one entry per
|
||||
**action**, and an earlier pass here asserted the wrong count):
|
||||
|
||||
| principal | assume `lookup-…-ca-central-1` | assume `lookup-…-us-east-1` |
|
||||
|---|---|---|
|
||||
| `user/pouya` | **allowed** | **allowed** |
|
||||
| `user/lars` | **allowed** | **allowed** |
|
||||
| `user/adr-sml-deploy` | implicitDeny | implicitDeny |
|
||||
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny |
|
||||
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny |
|
||||
|
||||
- The **CloudFormation escalation path this file named and left untested** —
|
||||
*"a real path to the table for anyone who can deploy a CDK stack"* — is now
|
||||
measured. Simulated for `cloudformation:CreateStack`, `UpdateStack`,
|
||||
`CreateChangeSet`, `ExecuteChangeSet`, `iam:PassRole` and `sts:AssumeRole`,
|
||||
five users × six actions = **30 decisions**, count asserted:
|
||||
|
||||
| principal | the CDK / CloudFormation path |
|
||||
|---|---|
|
||||
| `user/pouya` | **allowed** on all six |
|
||||
| `user/lars` | **allowed** on all six |
|
||||
| `user/adr-sml-deploy` | implicitDeny on all six |
|
||||
| `user/gitea-deploy-meshkinilaw` | implicitDeny on all six |
|
||||
| `user/meshkini-backend-deploy` | implicitDeny on all six |
|
||||
|
||||
This is the finding that mattered most, because `meshkini-backend-deploy` is by
|
||||
its name another project's backend-deploy credential and
|
||||
`gitea-deploy-meshkinilaw` is held on a **jointly administered** Gitea instance
|
||||
(Q23). Either one, had it been able to drive CloudFormation, would have read
|
||||
the table without appearing in the five-row table above — and *"Two people
|
||||
can"* would have been wrong. Neither can.
|
||||
|
||||
- **And the seven roles every sweep here had excluded BY CONSTRUCTION are now
|
||||
measured too.** Every role loop in this file filters `grep -v
|
||||
'^AWSServiceRole'`, and the assertion was written as *"twenty-six role rows"* —
|
||||
so a service-linked role was outside the claim rather than inside it, which
|
||||
matters because a service-linked role for a backup or migration service can
|
||||
read table contents. All **7** (`APIGateway`, `CloudFrontLogger`,
|
||||
`InternetMonitor`, `RDS`, `ResourceExplorer`, `Support`, `TrustedAdvisor`) are
|
||||
**implicitDeny on all seven actions** — 49 decisions, count asserted
|
||||
`[verified 2026-09-02]`. **So the enumeration is 33 of 33 roles, not 26 of
|
||||
33**, and the page's *"every user and every role in the account"* is now
|
||||
literally true. Raised by `adversarial-reviewer`, round 2.
|
||||
|
||||
- **There is no federated identity surface at all**: `list-saml-providers` **0**,
|
||||
`list-open-id-connect-providers` **0**, `sso-admin list-instances` **0**
|
||||
`[verified 2026-09-02]`. So "every user and every role" is not leaving out a
|
||||
federated principal, because there is none to leave out.
|
||||
|
||||
- ⚠️ **AND THE `sso-admin` ZERO NEEDED A SECOND COMMAND TO MEAN ANYTHING, added
|
||||
2026-09-02 (round 2).** `list-instances` answers about the account it is called
|
||||
in, so a **member of an AWS Organization returns 0 while Identity Center runs in
|
||||
the management account** — the zero would have been true and the conclusion
|
||||
false. `aws organizations describe-organization` returns
|
||||
**`AWSOrganizationsNotInUseException`: "Your account is not a member of an
|
||||
organization"** `[verified 2026-09-02]`, so there is no management account above
|
||||
this one and the zero is conclusive. Command 8.
|
||||
|
||||
- **The table carries NO RESOURCE-BASED POLICY OF ITS OWN**, and this is now a
|
||||
command rather than an assertion. `aws dynamodb get-resource-policy` returns
|
||||
**`PolicyNotFoundException`** `[verified 2026-09-02]` — exit **254**, the error
|
||||
on stderr being the result. ⚠️ **A DynamoDB resource policy is invisible to
|
||||
`describe-table`**, so no earlier command in this file could have seen one, and
|
||||
`/legal/privacy/` publishes the claim (*"the table carries no policy of its own
|
||||
granting access to anyone"*). It is the identity-policy enumeration's blind
|
||||
spot: every simulation here asks what a **principal** may do, and a resource
|
||||
policy grants from the other side. Found unbacked by `adversarial-reviewer`
|
||||
round 2, on §10's own precedent — the client-backup bucket, where
|
||||
`get-bucket-policy` returning `NoSuchBucketPolicy` was recorded because *"a
|
||||
policy read alone could not have established the second half."* Command 7.
|
||||
|
||||
Every read path therefore terminates at `pouya` or `lars`. ⚠️ **AND THAT IS
|
||||
WHERE THIS FILE WENT WRONG, SO THE CORRECTION SITS AT THE SENTENCE THAT CAUSED
|
||||
IT.** This read *"The count of people is two, and it is now the result of an
|
||||
enumeration rather than of a policy name"* until 2026-09-02, and `/legal/privacy/`
|
||||
published that count. **It is a count of IDENTITIES.** Two credentials is a lower
|
||||
bound on the number of people who can use them, and Pouya's attestation is that
|
||||
*"a handful"* is the true figure (§9 Q63). The enumeration stands exactly as
|
||||
measured; **an enumeration of principals is not a census.**
|
||||
|
||||
**The account root user, recorded because an enumeration that quietly omits it is
|
||||
not an enumeration.** Root is not an IAM user and does not appear in
|
||||
`list-users`, so it cannot be simulated and no policy constrains it — root can
|
||||
always read the table. Two facts bound it: `get-account-summary` reports
|
||||
`AccountAccessKeysPresent: 0`, so **there is no programmatic root credential**,
|
||||
and `AccountMFAEnabled: 1`. Root access therefore requires the root password and
|
||||
its MFA device.
|
||||
|
||||
⚠️ **THE PAGE SAYS NOTHING ABOUT ROOT, AND THIS PASSAGE HAS NOW BEEN THE REASON
|
||||
FOR THAT TWICE ON OPPOSITE GROUNDS.** It first read *"The page does not mention
|
||||
root and should not"*, reasoned from its own last clause — *"who holds the root
|
||||
credentials is not established in this repository."* **Pouya then established it
|
||||
(§9 Q63(c), 2026-09-02): he holds it** `[verified 2026-09-02 — Pouya]`, the page
|
||||
published *"has no programmatic key, and I hold it"*, and the gap that opened
|
||||
immediately was that *held by* is not *held only by* — a reader takes the
|
||||
possessive as sole custody, which nothing measured or attested supports (§9
|
||||
**Q64**). **His second ruling that day deleted the sentence** along with the rest
|
||||
of the mechanics, so **Q64 is closed as MOOT rather than answered and the
|
||||
underlying fact is exactly as unestablished as it was.**
|
||||
|
||||
**The consequence to carry, because it is not "nothing happened":** root custody
|
||||
is now recorded in `AGENTS.md` §7 and nowhere public. ⚠️ **Nothing about it may
|
||||
be published without asking him again**, and the question to ask is not *who
|
||||
holds root* — that is answered — but *whether anyone else does*. The original
|
||||
reasoning still holds and is why the page's first sentence is scoped as it is: an
|
||||
account owner's own credential is inherent to every cloud account and is not a
|
||||
third party who has been *granted* access, which is why the measured claim was
|
||||
always scoped to *"every user and every role"* rather than to a bare "nobody else
|
||||
can".
|
||||
|
||||
**Two claims are supported that were not before. One of them still ships; the
|
||||
other was deleted from the page by ruling on 2026-09-02 and is kept here because
|
||||
it remains true and remains §7's.**
|
||||
|
||||
- **SHIPS** — *"The system that receives what you send can only add a record — it
|
||||
cannot read back what is stored"* — `adr-intake-lambda-role` returns `allowed`
|
||||
for `PutItem` and `implicitDeny` for `GetItem`, `Query`, `Scan`,
|
||||
`BatchGetItem`, `UpdateItem` and `DeleteItem`. Previously this rested on
|
||||
reading the policy document; it is now the simulator's decision.
|
||||
- **NO LONGER ON THE PAGE** — *"the credential that publishes this website has no
|
||||
access to the table at all"* — `adr-sml-deploy` is `implicitDeny` on all
|
||||
**seven**, so "at all" covers writes and deletes as well as reads. It went with
|
||||
the mechanics cut, not because anything about it changed.
|
||||
|
||||
**And it corroborates §10 from the IAM surface.** Of the 26 non-service-linked
|
||||
roles, **9 belong to CDK bootstrap** and **14 to four unrelated production
|
||||
systems** in the same account. *(`/legal/privacy/` tells a reader this in as many words —
|
||||
*"The table sits in an Amazon Web Services account that also runs systems
|
||||
unrelated to this practice"* — in **§Where it is stored**, which is where the
|
||||
sentence now lives: the mechanics cut removed it and it was restored there, as a
|
||||
storage disclosure rather than a method. §10 is unaffected either way; it never
|
||||
depended on the page saying so.)* *(Their role names were listed here until 2026-09-02 and
|
||||
are not any more: this is a committed file, they are another project's IAM
|
||||
surface, and the count carries the whole of the argument. `adversarial-reviewer`,
|
||||
round 2.)*
|
||||
|
||||
⚠️ **THE INSTRUMENT FAILED FIRST, UNIFORMLY, AND IN THE DIRECTION THAT READS AS
|
||||
CLEAN.** The role sweep was first run as `--action-names $ACTS` with the seven
|
||||
actions in a shell variable. **zsh does not word-split parameter expansions**, so
|
||||
`simulate-principal-policy` received **one** action name — the whole string — and
|
||||
answered it: `implicitDeny` for 22 roles, and `allowed` for the four with a `*`
|
||||
grant, because `*` matches a bogus action too. Twenty-two clean rows and a
|
||||
plausible four. The tell was the shape of the output, not the verdict: one
|
||||
decision per role where there should have been seven. **The fix is the assertion,
|
||||
not the memory** — the loop now counts `EvaluationResults` per call and refuses a
|
||||
row that does not carry exactly seven, and the users' assume check counts
|
||||
`ResourceSpecificResults` and refuses a row that does not carry exactly two.
|
||||
`CLAUDE.md` records this class five times over; this is the sixth, and it is the
|
||||
"uniformly good" half.
|
||||
|
||||
### Commands — the ones this addendum rests on
|
||||
|
||||
Read-only, run as `user/pouya` in `ca-central-1`. No stderr suppressed, exit
|
||||
status read on every call, and note the **literal** action lists: they are not in
|
||||
a variable, which is the whole point above.
|
||||
|
||||
```bash
|
||||
# 1. Inline policies — the command the original block never ran.
|
||||
# ⚠️ THE `grep -v` IS THE ORIGINAL DEFECT AND IS KEPT ONLY TO SHOW IT. It
|
||||
# excluded the 7 service-linked roles from a claim written as "every role", and a
|
||||
# service-linked role for a backup or migration service CAN read table contents.
|
||||
# For a full run, DELETE the grep -v — all 33 must be screened, which is what the
|
||||
# 2026-09-02 addendum measured and what the page's "every user and every role"
|
||||
# now rests on.
|
||||
aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||
| tr '\t' '\n' \
|
||||
| while IFS= read -r R; do
|
||||
aws iam list-role-policies --role-name "$R" --query 'PolicyNames' --output text
|
||||
done
|
||||
|
||||
# 2. Every non-service-linked role, seven actions, decision asserted per row.
|
||||
# The count check is what makes a broken call loud instead of clean.
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:role/<ROLE>" \
|
||||
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||
dynamodb:BatchGetItem dynamodb:PutItem dynamodb:UpdateItem \
|
||||
dynamodb:DeleteItem \
|
||||
--resource-arns "arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions" \
|
||||
--query 'length(EvaluationResults)' --output text # must print 7
|
||||
|
||||
# 3. Trust policies of the four roles that can read.
|
||||
aws iam get-role --role-name <ROLE> --query 'Role.AssumeRolePolicyDocument'
|
||||
|
||||
# 4. Who can assume the two lookup roles — per RESOURCE, not per action.
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
|
||||
--action-names sts:AssumeRole \
|
||||
--resource-arns "arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-ca-central-1" \
|
||||
"arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-us-east-1" \
|
||||
--query 'EvaluationResults[].ResourceSpecificResults[].{R:EvalResourceName,D:EvalResourceDecision}' \
|
||||
--output text # must print 2 rows
|
||||
```
|
||||
|
||||
```bash
|
||||
# 5. The CloudFormation / CDK escalation path, per user. Six actions, literal.
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
|
||||
--action-names cloudformation:CreateStack cloudformation:UpdateStack \
|
||||
cloudformation:CreateChangeSet cloudformation:ExecuteChangeSet \
|
||||
iam:PassRole sts:AssumeRole \
|
||||
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||
|
||||
# 6. Federated identity surfaces, and root.
|
||||
aws iam list-saml-providers --query 'length(SAMLProviderList)'
|
||||
aws iam list-open-id-connect-providers --query 'length(OpenIDConnectProviderList)'
|
||||
aws sso-admin list-instances --query 'length(Instances)'
|
||||
aws iam get-account-summary \
|
||||
--query 'SummaryMap.{AccessKeysPresentRoot:AccountAccessKeysPresent,MFA:AccountMFAEnabled}'
|
||||
|
||||
# 7. The table's OWN policy. ⚠️ NOT VISIBLE IN describe-table — a DynamoDB
|
||||
# resource policy needs its own call, and the page publishes a claim about it.
|
||||
# A "no policy" answer arrives as a NON-ZERO EXIT with PolicyNotFoundException
|
||||
# on stderr, so do not suppress stderr and do not read exit 0 as the result.
|
||||
aws dynamodb get-resource-policy \
|
||||
--resource-arn 'arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions'
|
||||
|
||||
# 8. Is the account in an AWS Organization? ⚠️ THIS IS WHAT MAKES COMMAND 6's
|
||||
# sso-admin ZERO CONCLUSIVE. `list-instances` answers about THIS account, so a
|
||||
# member account returns 0 while Identity Center runs in the management
|
||||
# account. Not-in-an-org means there is no such management account.
|
||||
aws organizations describe-organization
|
||||
```
|
||||
|
||||
**Five user rows and THIRTY-THREE role rows, or the run did not happen.** ⚠️
|
||||
**This said "twenty-six" until 2026-09-02, which made an INCOMPLETE sweep pass
|
||||
its own acceptance test** — the number matched command 1's `grep -v
|
||||
'^AWSServiceRole'`, so an operator following §12 R21's instruction to re-run this
|
||||
file would have reproduced the exclusion and got the pass line for it. The page's
|
||||
*"every user and every role"* rests on 33 of 33. `adversarial-reviewer`, round 2.
|
||||
And for every simulation: **seven decisions per role call, six per CDK-path call,
|
||||
two per-resource decisions per assume call** — the counts are the assertion,
|
||||
because a call that silently received one bogus action name answers
|
||||
`implicitDeny` and reads exactly like a clean row. **Commands 7 and 8 are read by
|
||||
their ERROR, not their output**: `PolicyNotFoundException` and
|
||||
`AWSOrganizationsNotInUseException` are each the clean result, arriving on stderr
|
||||
with a non-zero exit.
|
||||
@@ -106,7 +106,15 @@ the dispute."*
|
||||
|
||||
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
|
||||
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
|
||||
mandatory, and the Member is disqualified from the subsequent hearing panel. A
|
||||
mandatory, and the Member does not sit on the subsequent hearing panel **except
|
||||
with the consent of the parties** (Rule 14.3, quoted verbatim above). ⚠️ **This
|
||||
line read "the Member is disqualified from the subsequent hearing panel" until
|
||||
2026-09-01** — an absolute, thirty lines below the quotation that qualifies it,
|
||||
in this repository's own voice rather than the Tribunal's. `/practice/insurance/`
|
||||
took the absolute from here and published it. Corrected in both places on the
|
||||
same day; the page was corrected first and this file is where the defect would
|
||||
otherwise have re-seeded, which is `CLAUDE.md`'s point about commentary around a
|
||||
quotation being this repository speaking. A
|
||||
privately retained neutral is not appointed to it and cannot be.
|
||||
|
||||
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
|
||||
|
||||
@@ -13,6 +13,31 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||
> date and effective date below is as at the retrieval date and nothing more.
|
||||
> Re-check before cutover, and before any republish that turns on one of them.
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
|
||||
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
|
||||
> Five of seven extracts were stamped and two were not, so a reader could
|
||||
> not tell whether this one was considered and found non-volatile or simply
|
||||
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
|
||||
> control-fires-over-part-of-its-scope defect as R18 having no checklist
|
||||
> item, one notch smaller. **Nothing here was re-retrieved.**
|
||||
>
|
||||
> - **No R18 limb, but ONE CANDIDATE LIMB, and it is flagged rather than
|
||||
> silently adopted.** `/practice/construction/` publishes: *"Ontario Power
|
||||
> Generation … applied in March 2026 for a licence to operate it."* That is a
|
||||
> **pending application**, so it moves the way limb (a) moves. It is not
|
||||
> false today — the application was made, and a completed past act stays
|
||||
> true — which is why this is a note and not a blocker. But a reader takes it
|
||||
> as current status, and unlike limb (a) the sentence is **not time-anchored**
|
||||
> ("when this page was written"). **For Pouya at the next re-check: adopt it
|
||||
> as R18 limb (h), or time-anchor the sentence and drop it.**
|
||||
> - The rest is stable: Part II.1 in force since 2019-10-01, and ODACC's own
|
||||
> statement that it is the Authorized Nominating Authority. The ODACC 2025
|
||||
> Annual Report is cited for institutional facts rather than for figures, so
|
||||
> it does not carry limb (d)'s exposure `[verified 2026-09-02 — swept
|
||||
> `dist/practice/construction/index.html`]`.
|
||||
|
||||
**Topic as researched:** Ontario construction dispute resolution — the statutory machinery (Construction Act, R.S.O. 1990, c. C.30); plus verification of the OPG Darlington New Nuclear Project and Bruce Power's Bruce C Project
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,20 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||
> date and effective date below is as at the retrieval date and nothing more.
|
||||
> Re-check before cutover, and before any republish that turns on one of them.
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||
> are still the original retrieval and were not re-fetched** — what was
|
||||
> re-checked is whether the *facts* they support have moved.
|
||||
>
|
||||
> - **(c) ERO 026-0853 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 —
|
||||
> Pouya]`. The comment period to 2026-09-12 is still open.
|
||||
> - **(b) the regulation under `Electricity Act` s. 28.1 — NOT RE-RETRIEVED.**
|
||||
> Held unchanged on a cadence judgement rather than a fresh retrieval
|
||||
> `[assumed 2026-09-01 — Pouya: unchanged by its nature at this cadence]`.
|
||||
> That is a weaker stamp than (c) and is written weaker on purpose.
|
||||
|
||||
**Topic as researched:** Ontario electricity/energy regulatory processes that generate disputes: OEB leave to construct (OEB Act, 1998 ss. 90/92/95/96), the IESO and market participation, the IESO connection assessment process (SIA/CIA), large-load and data-centre grid connection (Electricity Act, 1998 s. 28.1), and Ontario Bill 40
|
||||
|
||||
---
|
||||
@@ -412,11 +426,11 @@ supports is a defect in this file, not a fact.
|
||||
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||
- MARKET PARTICIPATION — operationally, per the IESO: "To participate in the IESO-controlled grid, IESO-administered markets or programs, you must register your organization with the IESO to authorize it as a market or program participant." Registration runs through Online IESO, requires an OEB licence, prudential support for real-time market participation, and a market registration application fee of $1,130; it ends with the IESO issuing a "registration approval notification (RAN)".
|
||||
*Source:* <https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation>
|
||||
- CONNECTION PROCESS — the IESO runs a six-stage connection process: (1) Prepare application; (2) Obtain conditional approval to connect; (3) Design and build; (4) Authorize market and program participation; (5) Register equipment; (6) Commission equipment and validate performance. "New or modified connections to a transmitter's system are generally subject to all six stages, while new or modified connections to a distributor's system may only be subject to the first three."
|
||||
- CONNECTION PROCESS — the IESO's published connection process runs to **up to six** stages (⚠️ this read *"the IESO runs a six-stage connection process"* until 2026-09-03 — it gave the process to the IESO alone and stated the count unscoped, which are the two things the note below corrects; the quotation it rests on is the Overview's *"involves up to six stages"*): (1) Prepare application; (2) Obtain conditional approval to connect; (3) Design and build; (4) Authorize market and program participation; (5) Register equipment; (6) Commission equipment and validate performance. "New or modified connections to a transmitter's system are generally subject to all six stages, while new or modified connections to a distributor's system may only be subject to the first three."
|
||||
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
|
||||
- "System Impact Assessment" IS the IESO's real term, confirmed on multiple IESO pages. The IESO: "New connections or modifications to facilities connected to a transmitter's system are subject to the IESO's system impact assessment (SIA) and the transmitter's customer impact assessment (CIA)." The IESO conducts the SIA; the transmitter conducts the CIA.
|
||||
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
|
||||
- The umbrella name for the process is the "connection assessment and approval (CAA)" process. On application the IESO "will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID". The SIA agreement is prepared "in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules". The IESO then "will assess the impact of your proposed new or modified connection on the reliability of the integrated power system" and issues a draft, then final, SIA report accompanied by either a "Notification of conditional approval (NoCA)" or a "Notification of disapproval with reasons (NoDR)".
|
||||
- ⚠️ **CORRECTED 2026-09-03 — THIS LINE IS COMMENTARY AND IT MISATTRIBUTED THE PROCESS.** It read *"The umbrella name for the process is the 'connection assessment and approval (CAA)' process"*, and the pages took that from here: `/practice/energy/` published *"The IESO operates a six-stage connection process and calls it connection assessment and approval"* and `docs/01` directed *"CAA is the umbrella"*. **The IESO's own words, quoted above at the Stage 2 heading, are "the IESO's **and transmitter's** connection assessment and approval (CAA) process"**, and the Overview says the process *"involves **up to** six stages"*, scoped by connection type. Both were corrected on the pages the same day. This is `CLAUDE.md`'s point exactly — the quotations here are evidence, the prose around them is this repository's voice, and it is where a corrected page re-seeds if the commentary is left standing. The original line follows. The umbrella name for the process is the "connection assessment and approval (CAA)" process. On application the IESO "will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID". The SIA agreement is prepared "in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules". The IESO then "will assess the impact of your proposed new or modified connection on the reliability of the integrated power system" and issues a draft, then final, SIA report accompanied by either a "Notification of conditional approval (NoCA)" or a "Notification of disapproval with reasons (NoDR)".
|
||||
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
|
||||
- The transmitter "generally initiates the customer impact assessment (CIA) after the draft SIA report from the IESO", and a CIA agreement between the connection applicant and the transmitter is required as part of the transmitter's CIA process.
|
||||
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
|
||||
|
||||
@@ -13,6 +13,22 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||
> date and effective date below is as at the retrieval date and nothing more.
|
||||
> Re-check before cutover, and before any republish that turns on one of them.
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||
> are still the original retrieval and were not re-fetched** — what was
|
||||
> re-checked is whether the *facts* they support have moved.
|
||||
>
|
||||
> - **(d) the Tribunals Ontario annual report — RE-VERIFIED UNMOVED**
|
||||
> `[re-checked 2026-09-01 — Pouya,
|
||||
> <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`.
|
||||
> **No 2025-26 report is published; FY2024-25 remains current**, so the
|
||||
> figures `/practice/insurance/` publishes are still the latest. This closes
|
||||
> the open item at the foot of this file, which asked exactly that question.
|
||||
> - **(e) the SABS, amended with effect 2026-07-01 — NOT RE-RETRIEVED.** Held
|
||||
> unchanged on a cadence judgement `[assumed 2026-09-01 — Pouya]`.
|
||||
|
||||
**Topic as researched:** Ontario accident benefits (SABS) disputes — regulation, Minor Injury Guideline, the LAT-AABS dispute route, tribunal caseload volume, mediation references in tribunal materials, and FSRA's role
|
||||
|
||||
---
|
||||
@@ -341,6 +357,7 @@ honest, and on this project it is the half that has twice been skipped.
|
||||
- **Whether a more recent annual report than 2024-25 exists (i.e. a 2025-26 report covering the year ending March 31, 2026).**
|
||||
- *Searched:* WebSearch for Tribunals Ontario annual report LAT AABS caseload; retrieved the 2024-25 report, which is dated June 30, 2025 and is the most recent surfaced.
|
||||
- *Outcome:* NOT CONFIRMED either way. The 2024-25 report (fiscal year ending March 31, 2025) is the latest located as of 2026-08-29, but no search was run specifically to rule out a 2025-26 edition. Given today's date, one may well have been published. Re-check before publishing any 'most recent' or 'latest available' framing around these figures.
|
||||
- *Outcome, R18 re-check:* **CONFIRMED — no 2025-26 edition is published, and FY2024-25 remains current** `[re-checked 2026-09-01 — Pouya, <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`. This closes the item as originally posed. The caution in the line above is retained rather than struck, because it is about a FRAMING and not about this fact: nothing on `/practice/insurance/` calls these figures the most recent or the latest available, and nothing should start to — a 2025-26 edition will appear eventually and a bare year label goes stale gracefully where a superlative does not.
|
||||
- **What the SABS amendments effective July 1, 2026 actually changed.**
|
||||
- *Searched:* Noted the consolidation banner on the e-Laws SABS page ('From July 1, 2026') and the tribunal's warning that 'Changes to the Statutory Accident Benefits Schedule effective July 1, 2026 may impact your insurance benefits'. Did not fetch O. Reg. 383/24 or the amending instruments.
|
||||
- *Outcome:* NOT ESTABLISHED. Search-result snippets suggested that certain benefits became optional, but no primary amending regulation was fetched, so nothing about the substance of the July 2026 changes is asserted here. The s. 3(1) definitions and the s. 18(1) $3,500 limit quoted above ARE from the post-July-2026 consolidation and are current as retrieved.
|
||||
|
||||
@@ -13,6 +13,22 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||
> date and effective date below is as at the retrieval date and nothing more.
|
||||
> Re-check before cutover, and before any republish that turns on one of them.
|
||||
|
||||
> ### R18 re-check — cutover pass, 2026-09-01
|
||||
>
|
||||
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
|
||||
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
|
||||
> Five of seven extracts were stamped and two were not, so a reader could
|
||||
> not tell whether this one was considered and found non-volatile or simply
|
||||
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
|
||||
> control-fires-over-part-of-its-scope defect as R18 having no checklist
|
||||
> item, one notch smaller. **Nothing here was re-retrieved.**
|
||||
>
|
||||
> - **No volatile limb at this cutover.** The facts this file supports on
|
||||
> `/practice/shareholder/` are OBCA sections and case law, which do not move
|
||||
> at this cadence, and a sweep of the built page for dates, statuses and
|
||||
> "as of" framings returned nothing time-anchored `[verified 2026-09-02 —
|
||||
> swept `dist/practice/shareholder/index.html`]`.
|
||||
|
||||
**Topic as researched:** Ontario/Canada shareholder, partnership and closely-held business disputes — statutory remedies (oppression, dissent/appraisal, winding up), the Partnerships Act, arbitration references in the corporations statutes; plus an independent re-check of Ontario family arbitration training requirements.
|
||||
|
||||
---
|
||||
|
||||
@@ -80,4 +80,37 @@ export default [
|
||||
files: ['scripts/**/*.{js,mjs}'],
|
||||
rules: { 'no-console': 'off' },
|
||||
},
|
||||
|
||||
/* `infra/cloudfront/` IS NOT A NODE MODULE AND NOT A BROWSER SCRIPT. A
|
||||
CloudFront Function's entry point is a bare `function handler(event)` that
|
||||
the runtime calls **by name** — it has no `export` (the runtime rejects
|
||||
module syntax) and nothing in the file references it, so
|
||||
`no-unused-vars` fires on the one declaration that is the whole point of
|
||||
the file. `argsIgnorePattern` cannot reach a function declaration, so the
|
||||
rule is scoped off here rather than silenced with a comment at the
|
||||
declaration, which would read as though the name were incidental.
|
||||
The test beside it is a CLI tool and prints, exactly as `scripts/` does.
|
||||
|
||||
⚠️ LIKE THE BLOCK ABOVE, THIS MUST STAY LAST. Flat config applies matching
|
||||
blocks in order and the last one wins. */
|
||||
{
|
||||
files: ['infra/cloudfront/**/*.{js,mjs}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/* THE BACKEND TEST FILE ONLY — NOT `backend/intake/**`. `handler.mjs` runs in
|
||||
Lambda, where `console.log` is a line in CloudWatch that nobody reads and
|
||||
`console.warn`/`console.error` are the two that signal, so the rule stays on
|
||||
for it deliberately. The test beside it is a CLI tool and prints its verdict,
|
||||
exactly as `scripts/` and the router test do.
|
||||
|
||||
⚠️ LAST, LIKE THE TWO ABOVE. Flat config applies matching blocks in order
|
||||
and the last one wins. */
|
||||
{
|
||||
files: ['backend/**/*.test.mjs'],
|
||||
rules: { 'no-console': 'off' },
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Shape helpers for the CloudFront policy configs `configure.mjs` builds.
|
||||
*
|
||||
* ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.**
|
||||
* `get-response-headers-policy` returns `{}` for a member the source does not
|
||||
* define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy`
|
||||
* — and sending that back fails `create-response-headers-policy` on
|
||||
* ParamValidation before the call leaves the machine. `docs/09` Part 3 carries
|
||||
* the incident and the exact error.
|
||||
*
|
||||
* **Dropping an empty member is safe at every depth, and that is a measurement
|
||||
* rather than a hope.** Of the 16 structures reachable from
|
||||
* `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at
|
||||
* least one required field** — so `{}` is not a legal value there and can only
|
||||
* be the placeholder. The single exception is `SecurityHeadersConfig` itself,
|
||||
* and `configure.mjs` skips before it can build one of those empty, because a
|
||||
* PDF policy cloning no security headers is the thing that section exists to
|
||||
* avoid.
|
||||
*
|
||||
* They live in their own module so they can be tested: `configure.mjs` reads
|
||||
* argv and calls AWS at import time, so importing THAT to reach two pure
|
||||
* functions is not possible. Same reason `fields.mjs` sits beside
|
||||
* `handler.mjs`. See `policy-shapes.test.mjs`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every empty-object member removed, at every depth, bottom-up — so a member
|
||||
* left empty by stripping its own children is removed in turn.
|
||||
*
|
||||
* Arrays are recursed into but never have elements removed: an element index is
|
||||
* load-bearing against its `Quantity` sibling, and an empty object inside one
|
||||
* would be this script's own construction rather than an AWS placeholder. That
|
||||
* case is left for `emptyObjectPaths` to report.
|
||||
*/
|
||||
export const withoutEmptyMembers = (value) => {
|
||||
if (Array.isArray(value)) return value.map(withoutEmptyMembers);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const cleaned = withoutEmptyMembers(v);
|
||||
const isEmptyObject =
|
||||
cleaned &&
|
||||
typeof cleaned === 'object' &&
|
||||
!Array.isArray(cleaned) &&
|
||||
Object.keys(cleaned).length === 0;
|
||||
if (!isEmptyObject) out[k] = cleaned;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** True for `{}` — the value AWS accepts nowhere in these configs. */
|
||||
export const isEmptyObject = (v) =>
|
||||
Boolean(v) &&
|
||||
typeof v === 'object' &&
|
||||
!Array.isArray(v) &&
|
||||
Object.keys(v).length === 0;
|
||||
|
||||
/**
|
||||
* The dotted path of every empty object left in a config. A post-condition on
|
||||
* the strip above, not a filter: if this returns anything, the strip did not do
|
||||
* what this module claims it does.
|
||||
*
|
||||
* Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and
|
||||
* common, while an empty object is valid nowhere.
|
||||
*/
|
||||
export function emptyObjectPaths(value, path = '') {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
if (Object.keys(value).length === 0) return [path || '(root)'];
|
||||
return Object.entries(value).flatMap(([k, v]) =>
|
||||
emptyObjectPaths(v, path ? `${path}.${k}` : k),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **NO VALIDATOR I COULD READ ENFORCES ANY OF THESE, WHICH IS WHY THIS TABLE
|
||||
* EXISTS.** Measured 2026-09-04 against **`aws-cli/2.34.53`'s bundled
|
||||
* `botocore/validate.py`**: it checks **neither `max` nor `pattern`** —
|
||||
* `range_check()` reads only `min`, and the word `pattern` does not appear in
|
||||
* the file. And the caps that matter are not modelled as constraints anyway: on
|
||||
* both policy configs `Comment` is a bare `string`, and the 128 lives in the
|
||||
* shape's **`documentation` prose**.
|
||||
*
|
||||
* ⚠️ **THAT IS ONE INSTRUMENT, NAMED, NOT A CLAIM ABOUT EVERY MECHANISM.** This
|
||||
* machine also carries `aws-cli/2.11.15`, whose install is PyInstaller-frozen
|
||||
* and whose `validate.py` cannot be read, so it is **unchecked rather than
|
||||
* confirmed**. `CLAUDE.md`: *"no mechanism can X" is a claim about every
|
||||
* mechanism, including the ones you did not enumerate* — so the honest form is
|
||||
* this one. What is **direct evidence** either way: the 182-character `Comment`
|
||||
* reached the API and came back `InvalidArgument`, so nothing stopped it on the
|
||||
* CLI that actually ran. `docs/09` Part 3 carries both attempts.
|
||||
*
|
||||
* Every entry below **with a cited AWS source** is therefore enforced by the
|
||||
* service and by nothing local. The two `[assumed]` entries are not known to be
|
||||
* enforced at all.
|
||||
*
|
||||
* ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES, NOT LITERALS THIS
|
||||
* FILE AUTHORS.** A literal we write is reviewed when it is written; a value
|
||||
* copied out of the default behaviour's policy changes without anyone here
|
||||
* touching it, and `docs/05` already specifies a Content-Security-Policy that
|
||||
* would land there. `CreateResponseHeadersPolicy` declares a dedicated error
|
||||
* for exactly that — `TooLongCSPInResponseHeadersPolicy`.
|
||||
*
|
||||
* ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED: `RemoveHeadersConfig` is cloned
|
||||
* too and its count cap is not published.** The operation declares
|
||||
* `TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists; the quotas
|
||||
* page states no number and inventing one would be worse than the gap. A breach
|
||||
* there surfaces as that error at the write, not as a pre-flight skip.
|
||||
*
|
||||
* ⚠️ **ABSENCE FROM A SOURCE IS NOT ABSENCE OF A LIMIT.** Entries marked
|
||||
* `[assumed]` have no AWS source at all; they are kept because they cost nothing
|
||||
* and constrain nothing this script sends.
|
||||
*/
|
||||
export const PAYLOAD_LIMITS = {
|
||||
'response-headers-policy': [
|
||||
{
|
||||
path: 'Name',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'[assumed] — no AWS source states a policy name length; the documented Name rule is uniqueness. Pouya, 2026-09-04',
|
||||
},
|
||||
{
|
||||
path: 'Comment',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'service model, ResponseHeadersPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters"',
|
||||
},
|
||||
{
|
||||
/* CLONED, not authored here — see the header. */
|
||||
path: 'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||
rule: 'maxLength',
|
||||
limit: 1783,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Maximum length of the Content-Security-Policy header value | 1,783 characters"; error shape TooLongCSPInResponseHeadersPolicy',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[].Header',
|
||||
rule: 'maxLength',
|
||||
limit: 256,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header name | 256 characters"',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[].Value',
|
||||
rule: 'maxLength',
|
||||
limit: 1783,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header value | 1,783 characters"',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[]',
|
||||
rule: 'maxCount',
|
||||
limit: 10,
|
||||
source:
|
||||
'CloudFront quotas: "maximum number of custom headers that you can add to a response headers policy | 10" (adjustable); error shape TooManyCustomHeadersInResponseHeadersPolicy',
|
||||
},
|
||||
{
|
||||
paths: [
|
||||
'CustomHeadersConfig.Items[].Header',
|
||||
'CustomHeadersConfig.Items[].Value',
|
||||
],
|
||||
rule: 'maxCombinedLength',
|
||||
limit: 10240,
|
||||
source:
|
||||
'CloudFront quotas: "Custom headers: maximum length of all header values and names combined | 10,240 characters"',
|
||||
},
|
||||
],
|
||||
'origin-request-policy': [
|
||||
{
|
||||
path: 'Name',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source: '[assumed] — see the response-headers-policy Name entry',
|
||||
},
|
||||
{
|
||||
path: 'Comment',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'service model, OriginRequestPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters". This is the one that failed on 2026-09-04 at 182',
|
||||
},
|
||||
{
|
||||
path: 'HeadersConfig.Headers.Items[]',
|
||||
rule: 'maxCount',
|
||||
limit: 10,
|
||||
source:
|
||||
'CloudFront quotas: "Headers per origin request policy | 10" (adjustable); error shape TooManyHeadersInOriginRequestPolicy. We send 5',
|
||||
},
|
||||
{
|
||||
paths: ['HeadersConfig.Headers.Items[]'],
|
||||
rule: 'maxCombinedLength',
|
||||
limit: 1024,
|
||||
source:
|
||||
'CloudFront quotas: "Total combined length of all query string, header, and cookie names in an origin request policy | 1024". We contribute header names only',
|
||||
},
|
||||
],
|
||||
/* Checked as a flag before any AWS call, because by the time a distribution
|
||||
payload exists sections 4 and 5 may already have created policies.
|
||||
|
||||
⚠️ NOT DECORATION. `aws cloudfront list-functions --output text` returns the
|
||||
ARN twice, tab-joined, because the function exists in a DEVELOPMENT and a
|
||||
LIVE stage — 113 characters, and it fails the pattern too. Staging that
|
||||
replaces a working `router.js` association with a value CloudFront will not
|
||||
accept, and `router.js` keeps 22 of 23 pages off S3's AccessDenied.
|
||||
`docs/09` Part 2 derives it correctly with `describe-function --stage LIVE`. */
|
||||
'function-association': [
|
||||
{
|
||||
path: 'FunctionARN',
|
||||
rule: 'maxLength',
|
||||
limit: 108,
|
||||
source: "service model, shape FunctionARN: {'max': 108}",
|
||||
},
|
||||
{
|
||||
path: 'FunctionARN',
|
||||
rule: 'pattern',
|
||||
limit: 'arn:aws:cloudfront::[0-9]{12}:function\\/[a-zA-Z0-9-_]{1,64}',
|
||||
source: 'service model, shape FunctionARN: pattern',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a dotted path, where `[]` means "every element of this array". Always
|
||||
* returns `{path, value}` pairs with the index substituted, so a violation
|
||||
* names the element rather than the collection.
|
||||
*/
|
||||
function resolvePath(root, path) {
|
||||
let frontier = [{ path: '', value: root }];
|
||||
for (const segment of path.split('.')) {
|
||||
const next = [];
|
||||
const isArray = segment.endsWith('[]');
|
||||
const key = isArray ? segment.slice(0, -2) : segment;
|
||||
for (const { path: p, value } of frontier) {
|
||||
const child = value?.[key];
|
||||
const here = p ? `${p}.${key}` : key;
|
||||
if (child === undefined || child === null) continue;
|
||||
if (isArray) {
|
||||
if (!Array.isArray(child)) continue;
|
||||
child.forEach((v, i) => next.push({ path: `${here}[${i}]`, value: v }));
|
||||
} else {
|
||||
next.push({ path: here, value: child });
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return frontier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every limit the given payload breaches. Empty means it is safe to send as far
|
||||
* as this table knows — which is a claim about the table, not about AWS.
|
||||
*/
|
||||
export function limitViolations(kind, payload) {
|
||||
const rules = PAYLOAD_LIMITS[kind];
|
||||
if (!rules) throw new Error(`no limit table for payload kind '${kind}'`);
|
||||
const out = [];
|
||||
const add = (v) => out.push(v);
|
||||
|
||||
for (const rule of rules) {
|
||||
/* `paths` (plural) is for the aggregate rules, where AWS caps a total
|
||||
across more than one field — header names AND values combined. */
|
||||
const paths = rule.paths ?? [rule.path];
|
||||
const label = paths.join(' + ');
|
||||
const resolved = paths.flatMap((one) => resolvePath(payload, one));
|
||||
|
||||
if (rule.rule === 'maxCount') {
|
||||
if (resolved.length > rule.limit) {
|
||||
add({
|
||||
path: label,
|
||||
rule: 'maxCount',
|
||||
actual: resolved.length,
|
||||
limit: rule.limit,
|
||||
message: `${label} has ${resolved.length} entries; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.rule === 'maxCombinedLength') {
|
||||
const total = resolved.reduce(
|
||||
(n, { value }) => n + (typeof value === 'string' ? value.length : 0),
|
||||
0,
|
||||
);
|
||||
if (total > rule.limit) {
|
||||
add({
|
||||
path: label,
|
||||
rule: 'maxCombinedLength',
|
||||
actual: total,
|
||||
limit: rule.limit,
|
||||
message: `${label} totals ${total} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { path, value } of resolved) {
|
||||
if (typeof value !== 'string') continue;
|
||||
if (rule.rule === 'maxLength' && value.length > rule.limit) {
|
||||
add({
|
||||
path,
|
||||
rule: 'maxLength',
|
||||
actual: value.length,
|
||||
limit: rule.limit,
|
||||
message: `${path} is ${value.length} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.rule === 'pattern' &&
|
||||
!new RegExp(`^(?:${rule.limit})$`).test(value)
|
||||
) {
|
||||
add({
|
||||
path,
|
||||
rule: 'pattern',
|
||||
actual: JSON.stringify(value),
|
||||
limit: rule.limit,
|
||||
message: `${path} does not match ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04
|
||||
* `--apply` failure recorded in `docs/09` Part 3.
|
||||
*
|
||||
* The first case is that failure verbatim: the `SecurityHeadersConfig` the live
|
||||
* `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and
|
||||
* all, which is what `create-response-headers-policy` rejected.
|
||||
*
|
||||
* node infra/cloudfront/policy-shapes.test.mjs
|
||||
*/
|
||||
import {
|
||||
withoutEmptyMembers,
|
||||
emptyObjectPaths,
|
||||
isEmptyObject,
|
||||
limitViolations,
|
||||
PAYLOAD_LIMITS,
|
||||
} from './policy-shapes.mjs';
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const t = (name, got, want) => {
|
||||
if (eq(got, want)) pass += 1;
|
||||
else
|
||||
failures.push(
|
||||
`${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`,
|
||||
);
|
||||
};
|
||||
|
||||
/* The live source policy, copied from `get-response-headers-policy` on
|
||||
67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */
|
||||
const LIVE_SECURITY_HEADERS = {
|
||||
XSSProtection: { Override: false, Protection: true, ModeBlock: true },
|
||||
FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' },
|
||||
ReferrerPolicy: {
|
||||
Override: false,
|
||||
ReferrerPolicy: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
ContentSecurityPolicy: {},
|
||||
ContentTypeOptions: { Override: true },
|
||||
StrictTransportSecurity: {
|
||||
Override: false,
|
||||
AccessControlMaxAgeSec: 31536000,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---- the incident itself ------------------------------------------------ */
|
||||
const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS);
|
||||
t(
|
||||
'the 2026-09-04 breach: ContentSecurityPolicy is dropped',
|
||||
Object.keys(stripped).sort(),
|
||||
[
|
||||
'ContentTypeOptions',
|
||||
'FrameOptions',
|
||||
'ReferrerPolicy',
|
||||
'StrictTransportSecurity',
|
||||
'XSSProtection',
|
||||
],
|
||||
);
|
||||
t(
|
||||
'and five survive — the count docs/09 Part 3 tells the operator to read',
|
||||
Object.keys(stripped).length,
|
||||
5,
|
||||
);
|
||||
t(
|
||||
'the surviving members are untouched',
|
||||
stripped.StrictTransportSecurity,
|
||||
LIVE_SECURITY_HEADERS.StrictTransportSecurity,
|
||||
);
|
||||
t('nothing empty is left behind', emptyObjectPaths(stripped), []);
|
||||
|
||||
/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip
|
||||
turned into a hard abort (adversarial-reviewer, round 1) ------------- */
|
||||
t(
|
||||
'a top-level policy-config member is dropped',
|
||||
withoutEmptyMembers({
|
||||
Name: 'p',
|
||||
CorsConfig: {},
|
||||
SecurityHeadersConfig: stripped,
|
||||
}),
|
||||
{ Name: 'p', SecurityHeadersConfig: stripped },
|
||||
);
|
||||
|
||||
/* ---- and the one BELOW that, which the first repair still aborted on
|
||||
(adversarial-reviewer, round 2) -------------------------------------- */
|
||||
t(
|
||||
'a CorsConfig member is dropped, and the emptied CorsConfig with it',
|
||||
withoutEmptyMembers({
|
||||
Name: 'p',
|
||||
CorsConfig: { AccessControlExposeHeaders: {} },
|
||||
}),
|
||||
{ Name: 'p' },
|
||||
);
|
||||
t(
|
||||
'but a CorsConfig that still has content survives',
|
||||
withoutEmptyMembers({
|
||||
CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false },
|
||||
}),
|
||||
{ CorsConfig: { OriginOverride: false } },
|
||||
);
|
||||
|
||||
/* ---- things that must NOT be discarded ---------------------------------- */
|
||||
t(
|
||||
'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common',
|
||||
withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }),
|
||||
{ RemoveHeadersConfig: { Quantity: 0, Items: [] } },
|
||||
);
|
||||
t(
|
||||
'false, 0, null and empty string are kept',
|
||||
withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }),
|
||||
{ a: false, b: 0, c: null, d: '' },
|
||||
);
|
||||
t(
|
||||
'array elements are recursed into but never removed',
|
||||
withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }),
|
||||
{ Items: [{ Header: 'X' }, {}] },
|
||||
);
|
||||
t(
|
||||
'the custom-headers list the script builds is untouched',
|
||||
withoutEmptyMembers({
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
t('stripping is idempotent', withoutEmptyMembers(stripped), stripped);
|
||||
|
||||
/* ---- the drift comparison: {} and absent must normalise alike ------------
|
||||
Round 1's repair stripped children but left `norm({})` as "{}" against
|
||||
`norm(undefined)` as "null", which reported permanent, unrepairable drift on
|
||||
the intake form's own path. */
|
||||
const norm = (o) => {
|
||||
const v = withoutEmptyMembers(o);
|
||||
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
|
||||
};
|
||||
t('norm({}) equals norm(undefined)', norm({}), norm(undefined));
|
||||
t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({}));
|
||||
t(
|
||||
'but a real difference still differs',
|
||||
norm({ a: 1 }) === norm({ a: 2 }),
|
||||
false,
|
||||
);
|
||||
|
||||
/* ---- emptyObjectPaths, the post-condition ------------------------------- */
|
||||
t(
|
||||
'reports the incident path',
|
||||
emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }),
|
||||
['SecurityHeadersConfig.ContentSecurityPolicy'],
|
||||
);
|
||||
t(
|
||||
'reports round 2s deeper path',
|
||||
emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }),
|
||||
['CorsConfig.AccessControlExposeHeaders'],
|
||||
);
|
||||
t(
|
||||
'reports an empty object inside an array, with its index',
|
||||
emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }),
|
||||
['Items[1]'],
|
||||
);
|
||||
t(
|
||||
'reports every one, not just the first',
|
||||
emptyObjectPaths({ a: {}, b: { c: {} } }),
|
||||
['a', 'b.c'],
|
||||
);
|
||||
t('silent on an empty array', emptyObjectPaths({ a: [] }), []);
|
||||
t(
|
||||
'silent on null, undefined and primitives',
|
||||
emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }),
|
||||
[],
|
||||
);
|
||||
t('names the root when the whole config is empty', emptyObjectPaths({}), [
|
||||
'(root)',
|
||||
]);
|
||||
|
||||
/* ---- the invariant the two functions exist to hold together ------------- */
|
||||
t(
|
||||
'THE INVARIANT: nothing survives the strip that the assertion would report',
|
||||
emptyObjectPaths(
|
||||
withoutEmptyMembers({
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
SecurityHeadersConfig: LIVE_SECURITY_HEADERS,
|
||||
CorsConfig: { AccessControlExposeHeaders: {} },
|
||||
ServerTimingHeadersConfig: {},
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/* ---- PAYLOAD_LIMITS: the 2026-09-04 second failure -----------------------
|
||||
InvalidArgument, "The parameter Comment is too big", from
|
||||
create-origin-request-policy. The model types Comment as a bare `string`, so
|
||||
ParamValidation could not see it and the dry run was the only place it could
|
||||
have been caught. */
|
||||
const ORP = (comment) => ({
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
Comment: comment,
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: 1, Items: ['Origin'] },
|
||||
},
|
||||
});
|
||||
t(
|
||||
'the 182-character Comment that failed is reported',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(182))).map((v) => [
|
||||
v.path,
|
||||
v.actual,
|
||||
v.limit,
|
||||
]),
|
||||
[['Comment', 182, 128]],
|
||||
);
|
||||
t(
|
||||
'the shipped Comment passes',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
ORP(
|
||||
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.',
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'128 exactly is allowed — the cap is inclusive',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(128))),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'129 is not',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(129))).length,
|
||||
1,
|
||||
);
|
||||
t(
|
||||
'the 118-character Comment AWS accepted on 2026-09-04 passes',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/* Section 4's payload needs its OWN over-cap cases: asserting only that the
|
||||
accepted 118 passes leaves the cap free to be wrong in the loose direction,
|
||||
which a mutation raising it to 1280 proved by surviving. */
|
||||
t(
|
||||
'a 129-character response-headers Comment is caught',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'x'.repeat(129),
|
||||
}).map((v) => [v.path, v.actual, v.limit]),
|
||||
[['Comment', 129, 128]],
|
||||
);
|
||||
t(
|
||||
'and 128 exactly is allowed',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'x'.repeat(128),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an over-long policy Name is caught on both policy kinds',
|
||||
[
|
||||
limitViolations('response-headers-policy', { Name: 'n'.repeat(129) })
|
||||
.length,
|
||||
limitViolations('origin-request-policy', { Name: 'n'.repeat(129) }).length,
|
||||
],
|
||||
[1, 1],
|
||||
);
|
||||
t(
|
||||
'and both shipped names pass',
|
||||
[
|
||||
limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex' })
|
||||
.length,
|
||||
limitViolations('origin-request-policy', {
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
}).length,
|
||||
],
|
||||
[0, 0],
|
||||
);
|
||||
|
||||
/* ---- the function ARN, the one limit the service model does give us ------ */
|
||||
const GOOD_ARN = 'arn:aws:cloudfront::327082975128:function/adr-sml-router';
|
||||
t(
|
||||
'a correctly derived function ARN passes',
|
||||
limitViolations('function-association', { FunctionARN: GOOD_ARN }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'the tab-doubled ARN that `list-functions --output text` returns breaks both rules',
|
||||
limitViolations('function-association', {
|
||||
FunctionARN: `${GOOD_ARN}\t${GOOD_ARN}`,
|
||||
})
|
||||
.map((v) => v.rule)
|
||||
.sort(),
|
||||
['maxLength', 'pattern'],
|
||||
);
|
||||
t(
|
||||
'a Lambda@Edge ARN is not a CloudFront function ARN',
|
||||
limitViolations('function-association', {
|
||||
FunctionARN: 'arn:aws:lambda:us-east-1:327082975128:function:edge',
|
||||
}).some((v) => v.rule === 'pattern'),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the aggregate rules, both documented on the CloudFront quotas page -- */
|
||||
const HDRS = (items) => ({
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
Comment: 'c',
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: items.length, Items: items },
|
||||
},
|
||||
});
|
||||
const SHIPPED_HEADERS = [
|
||||
'CloudFront-Viewer-Address',
|
||||
'Content-Type',
|
||||
'Origin',
|
||||
'Referer',
|
||||
'User-Agent',
|
||||
];
|
||||
t(
|
||||
'the five headers we actually whitelist pass every rule',
|
||||
limitViolations('origin-request-policy', HDRS(SHIPPED_HEADERS)),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an 11th whitelisted header breaches "Headers per origin request policy | 10"',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 11 }, (_, i) => `X-H${i}`)),
|
||||
).map((v) => [v.rule, v.actual, v.limit]),
|
||||
[['maxCount', 11, 10]],
|
||||
);
|
||||
t(
|
||||
'ten is allowed',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 10 }, (_, i) => `X-H${i}`)),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'header names totalling over 1024 breach the combined-length quota',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 9 }, () => 'X'.repeat(120))),
|
||||
)
|
||||
.map((v) => v.rule)
|
||||
.sort(),
|
||||
['maxCombinedLength'],
|
||||
);
|
||||
|
||||
t(
|
||||
'an 11th custom response header breaches its own count quota',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 11,
|
||||
Items: Array.from({ length: 11 }, (_, i) => ({
|
||||
Header: `X-${i}`,
|
||||
Value: 'v',
|
||||
})),
|
||||
},
|
||||
}).map((v) => v.rule),
|
||||
['maxCount'],
|
||||
);
|
||||
t(
|
||||
'the single X-Robots-Tag header we add passes',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'names and values combined over 10,240 are caught',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 8,
|
||||
Items: Array.from({ length: 8 }, (_, i) => ({
|
||||
Header: `X-${i}`,
|
||||
Value: 'v'.repeat(1500),
|
||||
})),
|
||||
},
|
||||
}).some((v) => v.rule === 'maxCombinedLength'),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the walker -------------------------------------------------------- */
|
||||
t(
|
||||
'[] resolves every element and the violation names the index',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 2,
|
||||
Items: [
|
||||
{ Header: 'X-Robots-Tag', Value: 'noindex' },
|
||||
{ Header: 'X'.repeat(300), Value: 'v' },
|
||||
],
|
||||
},
|
||||
}).map((v) => v.path),
|
||||
['CustomHeadersConfig.Items[1].Header'],
|
||||
);
|
||||
t(
|
||||
'an absent field is not a violation',
|
||||
limitViolations('response-headers-policy', { Name: 'n' }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a non-string value is skipped rather than crashing',
|
||||
limitViolations('response-headers-policy', { Name: 'n', Comment: 12345 }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a null along the path is skipped',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an unknown payload kind throws rather than passing silently',
|
||||
(() => {
|
||||
try {
|
||||
limitViolations('nope', {});
|
||||
return 'no throw';
|
||||
} catch (e) {
|
||||
return e.message.includes('nope');
|
||||
}
|
||||
})(),
|
||||
true,
|
||||
);
|
||||
t(
|
||||
'every limit entry carries a source',
|
||||
Object.values(PAYLOAD_LIMITS)
|
||||
.flat()
|
||||
.every((l) => typeof l.source === 'string' && l.source.length > 0),
|
||||
true,
|
||||
);
|
||||
t(
|
||||
'every entry addresses exactly one of path / paths',
|
||||
Object.values(PAYLOAD_LIMITS)
|
||||
.flat()
|
||||
.every((l) => (l.path === undefined) !== (l.paths === undefined)),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the CLONED values, which change without this file being touched.
|
||||
`docs/05` specifies a Content-Security-Policy that would land on the default
|
||||
behaviour's policy and be copied straight into ours; the API declares
|
||||
TooLongCSPInResponseHeadersPolicy for exactly that. */
|
||||
const withCsp = (csp) => ({
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'c',
|
||||
SecurityHeadersConfig: {
|
||||
ContentTypeOptions: { Override: true },
|
||||
ContentSecurityPolicy: { Override: false, ContentSecurityPolicy: csp },
|
||||
},
|
||||
});
|
||||
t(
|
||||
'a cloned CSP over 1,783 characters is caught before the create',
|
||||
limitViolations('response-headers-policy', withCsp('x'.repeat(1784))).map(
|
||||
(v) => [v.path, v.actual, v.limit],
|
||||
),
|
||||
[
|
||||
[
|
||||
'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||
1784,
|
||||
1783,
|
||||
],
|
||||
],
|
||||
);
|
||||
t(
|
||||
'1,783 exactly is allowed',
|
||||
limitViolations('response-headers-policy', withCsp('x'.repeat(1783))),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a realistic CSP passes',
|
||||
limitViolations(
|
||||
'response-headers-policy',
|
||||
withCsp("default-src 'self'; img-src 'self' data:; style-src 'self'"),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'the live source policy, which defines no CSP at all, passes whole',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||
SecurityHeadersConfig: stripped,
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(
|
||||
`policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`policy-shapes: ${pass} of ${pass} cases pass`);
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* CloudFront Function, VIEWER REQUEST, on the DEFAULT behaviour and on `*.pdf`.
|
||||
* Not on `/api/*` — see the rule below, which is the one that matters.
|
||||
*
|
||||
* `*.pdf` has it because this function is NOT a no-op on file paths: it
|
||||
* normalises `\` to `/` and collapses a leading `//` run BEFORE the extension
|
||||
* test, and 301s when that changed anything. Measured live 2026-09-03:
|
||||
* `//pouya-lajevardi-bio.pdf` returns 301. Dropping the association there hands
|
||||
* S3 the doubled key and returns 404 instead.
|
||||
*
|
||||
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
|
||||
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
|
||||
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
|
||||
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
|
||||
* distribution 2026-09-01, before this function existed: `/about/` and
|
||||
* `/definitely-not-a-page/` both returned **403 with an 111-byte
|
||||
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
|
||||
* `/` worked, via the distribution's default root object. That is 22 of the 23
|
||||
* pages.
|
||||
*
|
||||
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
|
||||
* `/api/intake` has no extension and no trailing slash, so the redirect branch
|
||||
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
|
||||
* which would lose the submission body silently. The association is per
|
||||
* behaviour and `/api/*` gets none.
|
||||
*
|
||||
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
|
||||
*
|
||||
* /about/ -> rewrite to /about/index.html (the origin has that key)
|
||||
* /about -> 301 to /about/ (one canonical URL per page)
|
||||
*
|
||||
* Anything with a file extension in its last segment is left alone —
|
||||
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
|
||||
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
|
||||
*
|
||||
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
|
||||
* arrow functions, no `String.prototype.endsWith`, no template literals. The
|
||||
* runtime supports more than this; a viewer-request function runs on every
|
||||
* request to the site and is the wrong place to be clever.
|
||||
*/
|
||||
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
|
||||
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
|
||||
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
|
||||
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
|
||||
raw and `|` is routine in tracking values. Strip rather than encode — these
|
||||
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
|
||||
function safe(part) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
|
||||
}
|
||||
|
||||
function handler(event) {
|
||||
var request = event.request;
|
||||
|
||||
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
|
||||
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
|
||||
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
|
||||
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
|
||||
the same, because the URL Standard maps it to `/` in special schemes.
|
||||
Redirect rather than rewrite, or `//about/` serves the About page at a second
|
||||
URL with a 200. Only the leading run: an interior `//` is a key that does not
|
||||
exist. */
|
||||
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
|
||||
var normalised = uri !== request.uri;
|
||||
var lastSlash = uri.lastIndexOf('/');
|
||||
var lastSegment = uri.substring(lastSlash + 1);
|
||||
|
||||
// A file, not a route.
|
||||
if (lastSegment.indexOf('.') !== -1) {
|
||||
if (normalised) return moved(uri, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// A directory-style route: hand the origin the key it actually holds.
|
||||
if (lastSegment === '') {
|
||||
if (normalised) return moved(uri, request);
|
||||
request.uri = uri + 'index.html';
|
||||
return request;
|
||||
}
|
||||
|
||||
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
|
||||
page has ONE address: serving it at both would put two indexable URLs on the
|
||||
same content, which `docs/04` treats as its primary concern. */
|
||||
return moved(uri + '/', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 301 to a path on this origin, carrying the query string. `location` is always
|
||||
* built from an already-normalised path, which is what keeps it same-origin.
|
||||
*/
|
||||
function moved(path, request) {
|
||||
var qs = '';
|
||||
var names = Object.keys(request.querystring);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var name = names[i];
|
||||
var value = request.querystring[name];
|
||||
if (value.multiValue) {
|
||||
for (var j = 0; j < value.multiValue.length; j++) {
|
||||
qs +=
|
||||
(qs === '' ? '' : '&') +
|
||||
safe(name) +
|
||||
'=' +
|
||||
safe(value.multiValue[j].value);
|
||||
}
|
||||
} else {
|
||||
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
|
||||
than the function guessing which the viewer meant. */
|
||||
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
statusCode: 301,
|
||||
statusDescription: 'Moved Permanently',
|
||||
headers: {
|
||||
location: { value: path + (qs === '' ? '' : '?' + qs) },
|
||||
'cache-control': { value: 'public, max-age=0, must-revalidate' },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit test for the viewer-request router. `node infra/cloudfront/router.test.mjs`.
|
||||
*
|
||||
* The function file cannot use module syntax — CloudFront's runtime has no
|
||||
* `export` — so it is read and evaluated rather than imported. `aws cloudfront
|
||||
* test-function` is the authoritative check because it runs the real runtime;
|
||||
* this one runs in a second, catches the branch mistakes, and costs nothing.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, 'router.js'), 'utf8');
|
||||
const handler = new Function(`${src}; return handler;`)();
|
||||
|
||||
const req = (uri, querystring = {}) => ({ request: { uri, querystring } });
|
||||
|
||||
const CASES = [
|
||||
// [uri, querystring, expected] — expected is {uri} for a rewrite/passthrough
|
||||
// or {status, location} for a redirect.
|
||||
['/', {}, { uri: '/index.html' }],
|
||||
['/about/', {}, { uri: '/about/index.html' }],
|
||||
['/practice/construction/', {}, { uri: '/practice/construction/index.html' }],
|
||||
['/contact/received/', {}, { uri: '/contact/received/index.html' }],
|
||||
['/about', {}, { status: 301, location: '/about/' }],
|
||||
['/practice/energy', {}, { status: 301, location: '/practice/energy/' }],
|
||||
// Files are untouched — every one of these is a real object in dist/.
|
||||
['/robots.txt', {}, { uri: '/robots.txt' }],
|
||||
['/sitemap-index.xml', {}, { uri: '/sitemap-index.xml' }],
|
||||
['/404.html', {}, { uri: '/404.html' }],
|
||||
['/favicon.ico', {}, { uri: '/favicon.ico' }],
|
||||
['/pouya-lajevardi-bio.pdf', {}, { uri: '/pouya-lajevardi-bio.pdf' }],
|
||||
['/_astro/schema.Cm5su60K.css', {}, { uri: '/_astro/schema.Cm5su60K.css' }],
|
||||
['/og/mediation.jpg', {}, { uri: '/og/mediation.jpg' }],
|
||||
// The query string survives the redirect, normalised to `name=value`.
|
||||
[
|
||||
'/fees',
|
||||
{ utm_source: { value: 'linkedin' }, ref: { value: '' } },
|
||||
{ status: 301, location: '/fees/?utm_source=linkedin&ref=' },
|
||||
],
|
||||
/* ⚠️ THE OPEN-REDIRECT CASES. CloudFront forwards duplicate leading slashes
|
||||
verbatim (it collapses dot-segments but not `//`), so without normalisation
|
||||
`//evil.example.com/x` produced `Location: //evil.example.com/x/` — a
|
||||
network-path reference that sends the viewer to another host from this
|
||||
domain's own URL. The backslash form defeats a `startsWith('//')` guard,
|
||||
because the URL Standard maps `\` to `/` in special schemes. Both must stay
|
||||
same-origin, and both must keep a SINGLE leading slash. */
|
||||
[
|
||||
'//evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
[
|
||||
'///evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
[
|
||||
'/\\evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
/* ⚠️ A NORMALISED PATH IS REDIRECTED, NOT REWRITTEN — this asserted a 200 for
|
||||
one revision, which closed the redirect and opened an unbounded family of
|
||||
duplicate URLs for every page on the site. */
|
||||
[
|
||||
'//evil.example.com/x/',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
['//about/', {}, { status: 301, location: '/about/' }],
|
||||
['///about/', {}, { status: 301, location: '/about/' }],
|
||||
['/\\about/', {}, { status: 301, location: '/about/' }],
|
||||
/* A file is normalised too. This branch returned `request` untouched for one
|
||||
revision, so `//robots.txt` reached S3 with the doubled slash and 404'd. */
|
||||
['//robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||
['/\\robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||
/* An interior `//` is left alone on purpose: it is a key that does not exist,
|
||||
so it resolves to the 404 page. Only the leading run is a security question. */
|
||||
['/a//b/', {}, { uri: '/a//b/index.html' }],
|
||||
/* Header-injection surface: CR, LF, space and the delimiters browsers disagree
|
||||
about are stripped rather than re-encoded — an already-encoded value must not
|
||||
be encoded twice. `%20` therefore passes through untouched. */
|
||||
[
|
||||
'/fees',
|
||||
{ q: { value: 'a b"><x' }, utm: { value: 'a%20b' } },
|
||||
{ status: 301, location: '/fees/?q=abx&utm=a%20b' },
|
||||
],
|
||||
[
|
||||
'/fees',
|
||||
{ evil: { value: 'x\r\nSet-Cookie: a=b' } },
|
||||
{ status: 301, location: '/fees/?evil=xSet-Cookie:a=b' },
|
||||
],
|
||||
/* `#` changes the STRUCTURE of the Location — without stripping it, `&b=y`
|
||||
lands in a fragment and the parameter is silently lost. */
|
||||
[
|
||||
'/fees',
|
||||
{ a: { value: 'x#&b=y' } },
|
||||
{ status: 301, location: '/fees/?a=x&b=y' },
|
||||
],
|
||||
/* ⚠️ AND THESE MUST SURVIVE. `| ^ ` { }` are not in WHATWG's query
|
||||
percent-encode set, so a browser sends them raw — and `|` is routine in
|
||||
ad-platform tracking values. One revision of `safe()` stripped all of them,
|
||||
silently corrupting exactly the campaign links the 301 exists to preserve. */
|
||||
[
|
||||
'/fees',
|
||||
{ utm_content: { value: 'banner|top' }, k: { value: 'a{b}c^d`e' } },
|
||||
{ status: 301, location: '/fees/?utm_content=banner|top&k=a{b}c^d`e' },
|
||||
],
|
||||
// multiValue, which no case exercised before.
|
||||
[
|
||||
'/fees',
|
||||
{ tag: { value: 'a', multiValue: [{ value: 'a' }, { value: 'b' }] } },
|
||||
{ status: 301, location: '/fees/?tag=a&tag=b' },
|
||||
],
|
||||
/* /api/intake must NEVER be redirected — a 301 turns a POST into a GET and
|
||||
the submission body is gone. This function is not associated with the
|
||||
/api/* behaviour, so this case documents WHY the association matters: if it
|
||||
ever were associated, this is the damage. */
|
||||
['/api/intake', {}, { status: 301, location: '/api/intake/' }],
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
for (const [uri, qs, expected] of CASES) {
|
||||
const out = handler(req(uri, qs));
|
||||
let actual;
|
||||
if (out.statusCode) {
|
||||
actual = { status: out.statusCode, location: out.headers.location.value };
|
||||
} else {
|
||||
actual = { uri: out.uri };
|
||||
}
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) pass += 1;
|
||||
else
|
||||
failures.push(
|
||||
`${uri} -> ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pass + failures.length !== CASES.length) {
|
||||
throw new Error(`case count ${pass + failures.length} != ${CASES.length}`);
|
||||
}
|
||||
console.log(`router: ${pass} of ${CASES.length} cases pass`);
|
||||
for (const f of failures) console.error(' FAIL ' + f);
|
||||
if (failures.length > 0) process.exit(1);
|
||||
Generated
+1352
-15
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -2,7 +2,7 @@
|
||||
"name": "adr-smlcompany-ca",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
||||
"description": "The dispute resolution practice of Pouya Lajevardi \u2014 Toronto",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24",
|
||||
@@ -16,7 +16,12 @@
|
||||
"check:claims": "node scripts/check-claims.mjs",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"deploy": "bash scripts/deploy-local.sh"
|
||||
"deploy": "bash scripts/deploy-local.sh",
|
||||
"lighthouse": "node scripts/lighthouse.mjs",
|
||||
"og:proof": "node scripts/og-proof.mjs",
|
||||
"check:intake": "node scripts/check-intake.mjs",
|
||||
"bio:pdf": "node scripts/bio-pdf.mjs",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^7.0.8",
|
||||
@@ -27,12 +32,17 @@
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource/geist": "^5.3.0",
|
||||
"@fontsource/instrument-serif": "^5.3.0",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-astro": "^3.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"globals": "^17.11.0",
|
||||
"lighthouse": "^13.4.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"satori": "^0.33.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.68.0"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
+22
-10
@@ -2,18 +2,30 @@
|
||||
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
|
||||
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
|
||||
|
||||
# NOTHING IS DISALLOWED, DELIBERATELY.
|
||||
# /legal/* is kept out of the index by
|
||||
# `<meta name="robots" content="noindex,follow">`, which is the directive that
|
||||
# actually de-indexes. Disallowing them as well would defeat it: a crawler that
|
||||
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are
|
||||
# linked from the footer of every page, so Google discovers them regardless and
|
||||
# would have listed the bare URLs as "no information available" — the opposite
|
||||
# of the intent — with the noindex sitting unread behind the wall.
|
||||
# Add a Disallow only for something that must not be FETCHED. Use noindex for
|
||||
# something that must not be LISTED. They are different problems.
|
||||
# EXACTLY ONE THING IS DISALLOWED, AND IT IS A SUBSTITUTE FOR A MECHANISM THIS
|
||||
# DISTRIBUTION IS NOT ALLOWED TO HAVE.
|
||||
# The bio PDF duplicates /bio/. The intended fix was `X-Robots-Tag: noindex` on
|
||||
# *.pdf via a CloudFront response-headers policy, which the distribution's
|
||||
# pricing plan forbids — AGENTS.md §7, and docs/09 Part 3 for the three failed
|
||||
# attempts. Disallow is the remaining lever and it is NOT the same instrument:
|
||||
# - it stops the PDF being FETCHED, so its contents are never indexed and the
|
||||
# duplicate-content problem it was raised for is solved;
|
||||
# - it does NOT de-index the URL. The PDF is linked from /bio/ and /about/, so
|
||||
# a search engine can still list the bare URL with no snippet.
|
||||
# That residual is accepted deliberately: a bare link to a bio PDF is not the
|
||||
# harm the noindex was for. Revisit if the pricing plan ever changes.
|
||||
#
|
||||
# /legal/* is still NOT disallowed, and that reasoning is unchanged — it is the
|
||||
# general rule this one path is the exception to. A crawler forbidden to FETCH a
|
||||
# URL never reads the `noindex` on it, so the two cancel; the legal pages are
|
||||
# linked from every footer, so they are discovered regardless, and the pair would
|
||||
# have produced bare "no information available" listings with the directive that
|
||||
# would have suppressed them sitting unread behind the wall.
|
||||
# `noindex` is what de-indexes; `Disallow` is what prevents fetching. Use the one
|
||||
# that matches the problem, and never both on the same path.
|
||||
|
||||
User-agent: *
|
||||
Disallow: /pouya-lajevardi-bio.pdf
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Renders `/bio/` to `public/pouya-lajevardi-bio.pdf`. `npm run bio:pdf`,
|
||||
* after `npm run build`. Discharges `AGENTS.md` R16 / Q45.
|
||||
*
|
||||
* WHY A LOCAL SCRIPT AND NOT A BUILD STEP. It drives Chrome, and the Gitea
|
||||
* runner has none (`AGENTS.md` §7, Q23) — the same reason `npm run lighthouse`
|
||||
* is a local gate. A build step that cannot run in CI is a control that exists
|
||||
* on paper, which is the shape Q22 turned out to be. So the PDF is **committed**:
|
||||
* the artefact is in the repository, which is also what R14 asks for.
|
||||
*
|
||||
* ⚠️ IT IS NOT BYTE-REPRODUCIBLE, AND AN EARLIER VERSION OF THIS COMMENT SAID
|
||||
* "deterministically". Two consecutive runs produced 89,496 bytes both times and
|
||||
* DIFFERENT SHA-256 digests — Chrome stamps a `/CreationDate` into the document.
|
||||
* Measured by `adversarial-reviewer`, 2026-08-31.
|
||||
*
|
||||
* The consequence is not cosmetic: the "regenerate and re-commit the PDF" item on
|
||||
* `docs/06`'s cutover checklist therefore always produces a binary diff, so a
|
||||
* reviewer cannot tell a real content change from a no-op re-render. Do not
|
||||
* re-commit it out of habit — re-commit it when `/bio/`, §4, the rate card or the
|
||||
* print styles actually changed, and say which in the commit message.
|
||||
*
|
||||
* WHY THE PDF IS A RENDERING OF A PAGE RATHER THAN A DESIGNED DOCUMENT. R16's
|
||||
* worry was never tooling: *"a PDF circulated with an appointment proposal is
|
||||
* read once, by the reader who matters most, and never seen by a reviewer
|
||||
* again."* Rendering it from `/bio/` puts it back inside this project's review
|
||||
* apparatus — `astro check`, `check:claims` on the built HTML, the adversarial
|
||||
* review and the cutover claims pass all see every word of it, because every
|
||||
* word of it is on a page. (That is what caught `/bio/` opening with a clause
|
||||
* that scoped mediation commercial, which Q56 forbids.)
|
||||
*
|
||||
* ⚠️ IT ASSERTS ONE PAGE. A one-page bio that silently becomes two is the defect
|
||||
* this script exists to catch, and it is invisible from the source: it depends on
|
||||
* the print stylesheet, the paper size, and how much §4 has grown since anyone
|
||||
* looked. `printBackground: false` matches Chrome's own default print dialog,
|
||||
* where "Background graphics" is unchecked — `global.css` records what that did
|
||||
* to `/about/`'s inverse band when nobody checked.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { join, extname } from 'node:path';
|
||||
import * as chromeLauncher from 'chrome-launcher';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DIST = join(ROOT, 'dist');
|
||||
const OUT = join(ROOT, 'public', 'pouya-lajevardi-bio.pdf');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.avif': 'image/avif',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ico': 'image/x-icon',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
try {
|
||||
await stat(join(DIST, 'bio', 'index.html'));
|
||||
} catch {
|
||||
console.error(
|
||||
'dist/bio/index.html is missing. Run `npm run build` first — this renders ' +
|
||||
'the BUILT page, not the dev server, so what ships is what is measured.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
const file = pathname.endsWith('/')
|
||||
? join(DIST, pathname, 'index.html')
|
||||
: join(DIST, pathname);
|
||||
const stream = createReadStream(file);
|
||||
stream.on('error', () => {
|
||||
res.writeHead(404);
|
||||
res.end('404');
|
||||
});
|
||||
stream.once('open', () => {
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
const chrome = await chromeLauncher.launch({
|
||||
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'],
|
||||
});
|
||||
|
||||
/** Minimal CDP client over the DevTools WebSocket. `chrome-launcher` starts the
|
||||
* browser and does not speak the protocol; adding a client library for four
|
||||
* calls would be a dependency for nothing. */
|
||||
async function cdp(port, fn) {
|
||||
const list = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
const target = list.find((t) => t.type === 'page');
|
||||
if (!target) throw new Error('no page target in Chrome');
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true });
|
||||
ws.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const events = new Map();
|
||||
ws.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) reject(new Error(JSON.stringify(message.error)));
|
||||
else resolve(message.result);
|
||||
} else if (message.method && events.has(message.method)) {
|
||||
events.get(message.method)();
|
||||
}
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const once = (method) =>
|
||||
new Promise((resolve) => events.set(method, resolve));
|
||||
try {
|
||||
return await fn({ send, once });
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pdfBase64;
|
||||
try {
|
||||
pdfBase64 = await cdp(chrome.port, async ({ send, once }) => {
|
||||
await send('Page.enable');
|
||||
const loaded = once('Page.loadEventFired');
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:${port}/bio/` });
|
||||
await loaded;
|
||||
// The page self-hosts its fonts and `document.fonts.ready` is the only
|
||||
// reliable signal that they are laid out — a PDF printed before the serif
|
||||
// arrives is set in the fallback and looks nothing like the site.
|
||||
await send('Runtime.evaluate', {
|
||||
expression: 'document.fonts.ready',
|
||||
awaitPromise: true,
|
||||
});
|
||||
const result = await send('Page.printToPDF', {
|
||||
// Letter, because this circulates in Canada with Canadian counsel.
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.55,
|
||||
marginBottom: 0.55,
|
||||
marginLeft: 0.6,
|
||||
marginRight: 0.6,
|
||||
printBackground: false,
|
||||
preferCSSPageSize: false,
|
||||
});
|
||||
return result.data;
|
||||
});
|
||||
} finally {
|
||||
chrome.kill();
|
||||
server.close();
|
||||
}
|
||||
|
||||
const pdf = Buffer.from(pdfBase64, 'base64');
|
||||
|
||||
/**
|
||||
* PAGE COUNT, ASSERTED. Counted from the PDF's own page objects rather than
|
||||
* trusting the layout — this is the whole reason the script exists rather than a
|
||||
* note telling someone to check. A one-page bio that quietly becomes two pages
|
||||
* is exactly the class of defect nobody looks for again.
|
||||
*/
|
||||
const text = pdf.toString('latin1');
|
||||
const pageCount =
|
||||
(text.match(/\/Type\s*\/Page[^s]/g) ?? []).length ||
|
||||
Number((/\/Count\s+(\d+)/.exec(text) ?? [])[1] ?? 0);
|
||||
|
||||
console.log(
|
||||
`bio:pdf — ${pdf.length.toLocaleString()} bytes, ${pageCount} page(s), Letter.`,
|
||||
);
|
||||
|
||||
if (pageCount !== 1) {
|
||||
console.error(
|
||||
`\nTHE BIO IS ${pageCount} PAGES AND MUST BE ONE.\n` +
|
||||
' It is specified as a one-page bio (docs/01 §/about/ item 7, R16), and a\n' +
|
||||
' second sheet carrying three lines is worse than a denser first one.\n' +
|
||||
' Tighten the @media print block in src/pages/bio.astro — do not widen\n' +
|
||||
' the margins here, which changes the document rather than the layout.\n' +
|
||||
' Nothing was written.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await writeFile(OUT, pdf);
|
||||
console.log(`wrote public/pouya-lajevardi-bio.pdf`);
|
||||
console.log(
|
||||
'It is COMMITTED. Regenerate and re-commit it whenever /bio/, §4, the rate ' +
|
||||
'card or the print styles change — nothing in the build does this for you.',
|
||||
);
|
||||
@@ -257,6 +257,55 @@ 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 — the sole-administrative-access claim on /legal/privacy/ was FALSE ' +
|
||||
'and is permanently barred from returning. The page does NOT make it now; ' +
|
||||
'if this fired, something reintroduced the shape. ' +
|
||||
"⚠️ IF THE SECOND ADMINISTRATOR'S ACCESS IS EVER ACTUALLY REMOVED, this " +
|
||||
'pattern starts failing the build on TRUE copy, and the answer is neither ' +
|
||||
'to delete it nor to work around it: re-run the verification in ' +
|
||||
'`docs/reference/intake-table-access-verification.md`, rewrite the page to ' +
|
||||
'the new measured truth, and narrow THIS pattern deliberately with a ' +
|
||||
'Change Log entry. Q23 records the Gitea instance as jointly administered, ' +
|
||||
'so that removal is live rather than hypothetical.',
|
||||
incident:
|
||||
'The page shipped "nobody else has access to the table. There is no team, ' +
|
||||
'no assistant and no external administrator." while TWO principals could ' +
|
||||
'read it. It was in dist/, `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. The gate was human ' +
|
||||
'memory. Pouya ruled the pattern PERMANENT on 2026-09-01: it no longer ' +
|
||||
'describes live copy, it bars the shape. Do not delete it, and do not ' +
|
||||
'loosen it into a rule about a class. Full history: AGENTS.md entry (an).',
|
||||
/* THREE CONSTRAINTS, AND THEY ARE WHY THIS IS SHAPED AS IT IS.
|
||||
|
||||
1. `\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/`.
|
||||
|
||||
2. EACH ALTERNATIVE IS ONE STRING THAT REACHED `dist/`, NEVER A FAMILY.
|
||||
The first draft of alternative 3 was `(?:or|and)\s+(?:outside|external)`
|
||||
— four phrasings where one was published, so three had no probe and no
|
||||
negative fixture, which is the freeze's stated condition.
|
||||
|
||||
3. ALTERNATIVE 3 IS A DELIBERATE OVER-BAR AND THIS IS ITS COST. The
|
||||
truthful receipt-scoped sentence CONTAINS the published string, so any
|
||||
pattern catching one catches the other — adding that truthful form as a
|
||||
negative fixture failed the self-test, which is the instrument check
|
||||
working. So: the clause "no assistant or outside administrator" cannot
|
||||
be published here in ANY scoping, true or false, without failing this
|
||||
build. That is the right trade for a phrase that has already put a
|
||||
false statement on a privacy policy, and `rule:` says what to do. A
|
||||
lookahead tuned to one guessed phrasing would be the speculative
|
||||
pattern the freeze bars.
|
||||
|
||||
The window in alternative 5 is `[^\w<]{0,8}`, not `\W{0,8}`: `\W` matches
|
||||
`<` and the block sentinel, so it would have been bounded by neither. */
|
||||
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|no\s+assistant\s+or\s+outside\s+administrator|one\s+administrative\s+account,\s+which\s+is\s+mine|honest\s+answer\s+to\s+[^\w<]{0,8}who\s+can\s+see\s+this[^\w<]{0,8}\s*is:\s+me/gi,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -341,13 +390,61 @@ const FIXTURES = {
|
||||
'C.Med-Arbitrators',
|
||||
'C.Med-Arbitration',
|
||||
],
|
||||
'sole-administrator-q62': [
|
||||
/* The FIVE published clauses from dist/legal/privacy/ as it stood at
|
||||
`bd282aa`, before the Q62 correction. The first form of the pattern
|
||||
caught only the first two: clauses 3 and 4 are the same falsehood in
|
||||
different words in a different section, and clause 5 is the summary
|
||||
that would have re-asserted the struck number. */
|
||||
'nobody else has access to the table',
|
||||
'There is no team, no assistant and no external administrator.',
|
||||
'no analytics on the submission, and no assistant or outside administrator.',
|
||||
'The table is reachable by the function that writes to it and by one administrative account, which is mine',
|
||||
/* The third surface — the summary that would have re-asserted the struck
|
||||
number four lines below the corrected paragraph. */
|
||||
'So the honest answer to "who can see this" is: me, and Google as the company that runs my mail.',
|
||||
],
|
||||
'struck-universal-q39': [
|
||||
'Anyone may be appointed an arbitrator in Ontario',
|
||||
'nothing in law gates the role',
|
||||
],
|
||||
},
|
||||
/* Every one of these is real published or spec-approved copy on this site. */
|
||||
/* Mostly real published or spec-approved copy on this site. A few are
|
||||
deliberate NEAR MISSES — truthful sentences about the same subject that
|
||||
were never published — because a pattern also has to be proven silent on
|
||||
the wording a correction is likely to reach for. Where a fixture is one of
|
||||
those, the comment beside it says so. */
|
||||
mustNotMatch: [
|
||||
/* NEGATIVE FIXTURES FOR `sole-administrator-q62`. The first FIVE are LIVE
|
||||
PAGE COPY, verbatim from the corrected `/legal/privacy/` — which is the
|
||||
fixture that matters, because Q62's ruling required this pattern to be
|
||||
proven silent on the true sentence as well as loud on the false one.
|
||||
RE-SYNC THEM WHENEVER THAT COPY CHANGES. ⚠️ **A SENTENCE THAT LEAVES THE
|
||||
PAGE LEAVES THIS LIST — it is not kept as a near miss.** Struck copy in a
|
||||
list captioned "what this site legitimately publishes" is an invitation to
|
||||
restore it. The rest below ARE near misses on the same subject: the
|
||||
pattern is anchored on five strings that reached `dist/`, not on the ideas
|
||||
in them, so a truthful sentence about administrative access must pass.
|
||||
Rendered as text, without the `<strong>` wrappers — what is proven is that
|
||||
the PATTERN is silent on the words. */
|
||||
'The record in the table: me, and the small number of people who administer the account it sits in with me.',
|
||||
'The system that receives what you send can only add a record — it cannot read back what is stored.',
|
||||
"The notification goes to the practice's mailbox, which is read by me and by administrative staff and is hosted on Google Workspace — so Google holds a copy of whatever you send me.",
|
||||
'The confirmation that went to you sits with whoever runs your email. That copy is in your hands rather than mine.',
|
||||
'No one else is sent it. There is no CRM, no mailing list and no analytics on the submission.',
|
||||
'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.',
|
||||
/* ⚠️ TWO FIXTURES WERE REMOVED FROM HERE ON 2026-09-02 AND THE REASON
|
||||
MATTERS MORE THAN THE STRINGS: `'Nobody else has access to my mailbox.'`
|
||||
and `'There is no team. Every inquiry is read by me.'` They were added as
|
||||
harmless near-misses, and §7's `info@smlcompany.ca` row then established
|
||||
that the mailbox is DELEGATED — Pouya and administrative staff — which
|
||||
makes both FALSE. The second is also a paraphrase of a `mustMatch` breach
|
||||
string. This list is documented as the copy the site legitimately
|
||||
publishes, so a maintainer reaching for a tidier answer would have found
|
||||
one here, which is how this page acquired its false sentence the first
|
||||
time. Removing a false fixture keeps the list true; it is not a coverage
|
||||
change and the freeze does not reach it. `adversarial-reviewer`, round 1. */
|
||||
'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.',
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cross-checks the intake form's two field tables. `npm run check:intake`.
|
||||
*
|
||||
* WHY THERE ARE TWO TABLES AT ALL, because the obvious reaction to this script
|
||||
* is to delete one of them and share an import. `docs/05-backend-spec.md`:
|
||||
* *"Client-side validation is a convenience. **The Lambda re-validates
|
||||
* everything.**"* A server that validates against a list the client shipped it
|
||||
* is not validating — it is asking the caller what the rules are. And the Lambda
|
||||
* is a separately deployed zip that cannot import from `src/` anyway.
|
||||
*
|
||||
* So the duplication is architectural, and what makes it safe is this check
|
||||
* rather than a shared module: the two tables must agree on every field NAME, on
|
||||
* which fields are REQUIRED, on every length CAP, and on every closed OPTION
|
||||
* SET. If they disagree, the form offers something the handler rejects, or the
|
||||
* handler accepts something the form never shows — and the first is a lost
|
||||
* inquiry that looks like a bug in the browser.
|
||||
*
|
||||
* This is the one place in the repo where a duplicated fact is deliberate, and
|
||||
* `AGENTS.md`'s standing rule about duplicated facts is why it needs a mechanism
|
||||
* on top of a comment.
|
||||
*
|
||||
* Both files are read directly — Node strips the types out of the `.ts` — so
|
||||
* this script holds no third copy of the list.
|
||||
*/
|
||||
import {
|
||||
INTAKE_FIELDS,
|
||||
HONEYPOT_FIELD,
|
||||
DECOY_CHECKBOX_FIELD,
|
||||
} from '../src/data/intake.ts';
|
||||
import {
|
||||
FIELDS as SERVER_FIELDS,
|
||||
HONEYPOT,
|
||||
DECOY_CHECKBOX,
|
||||
} from '../backend/intake/fields.mjs';
|
||||
|
||||
/**
|
||||
* BOTH TABLES ARE IMPORTED, NOT PARSED. The first version of this script read
|
||||
* `handler.mjs` as text, sliced out the `const FIELDS = [ … ]` literal, munged
|
||||
* quotes and commas into JSON, and guarded the result with a regex meant to
|
||||
* refuse anything executable.
|
||||
*
|
||||
* **That guard rejected the table on the word `process`, which is a FIELD NAME.**
|
||||
* A guard that fires on the data it exists to protect is worse than no guard,
|
||||
* and the munging underneath it would have broken on the first apostrophe or
|
||||
* URL in a label. The fix was not a better regex: the server's table moved into
|
||||
* `backend/intake/fields.mjs`, which has no module-scope side effects and can
|
||||
* simply be imported. The independence that matters is that the SERVER's table
|
||||
* lives with the server and the handler trusts nothing from `src/` — not that a
|
||||
* check script refuses to load it.
|
||||
*/
|
||||
|
||||
const problems = [];
|
||||
const server = SERVER_FIELDS;
|
||||
|
||||
const clientNames = INTAKE_FIELDS.map((f) => f.name);
|
||||
const serverNames = server.map((f) => f.name);
|
||||
|
||||
for (const name of clientNames) {
|
||||
if (!serverNames.includes(name)) {
|
||||
problems.push(
|
||||
`"${name}" is on the form but the handler does not accept it — the ` +
|
||||
'inquirer would fill it and it would be silently dropped.',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const name of serverNames) {
|
||||
if (!clientNames.includes(name)) {
|
||||
problems.push(
|
||||
`"${name}" is validated by the handler but is not on the form.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (HONEYPOT !== HONEYPOT_FIELD) {
|
||||
problems.push(
|
||||
`honeypot name differs: form "${HONEYPOT_FIELD}", handler "${HONEYPOT}". ` +
|
||||
'A bot fills the field the form renders; the handler checks the one it ' +
|
||||
'knows about, so a mismatch disables the honeypot silently.',
|
||||
);
|
||||
}
|
||||
if (serverNames.includes(HONEYPOT_FIELD)) {
|
||||
problems.push(
|
||||
`the honeypot "${HONEYPOT_FIELD}" is in the handler's FIELDS table; it must ` +
|
||||
'be checked separately, or a bot filling it would just fail validation ' +
|
||||
'instead of being sent to the success page.',
|
||||
);
|
||||
}
|
||||
|
||||
/* THE SECOND HONEYPOT GETS THE SAME THREE CHECKS, and it needs a fourth.
|
||||
Added 2026-09-04 with the decoy checkbox. Every failure mode below is silent
|
||||
in production: a mismatched name disables the trap, a name inside `FIELDS`
|
||||
turns it into ordinary validation, and two traps sharing one name is one
|
||||
trap with a comment claiming there are two. */
|
||||
if (DECOY_CHECKBOX !== DECOY_CHECKBOX_FIELD) {
|
||||
problems.push(
|
||||
`decoy checkbox name differs: form "${DECOY_CHECKBOX_FIELD}", handler ` +
|
||||
`"${DECOY_CHECKBOX}". The form renders one name and the handler checks ` +
|
||||
'another, so the trap is disabled and nothing fails.',
|
||||
);
|
||||
}
|
||||
if (serverNames.includes(DECOY_CHECKBOX_FIELD)) {
|
||||
problems.push(
|
||||
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the handler's FIELDS ` +
|
||||
'table; it must be checked separately, or ticking it would fail ' +
|
||||
'validation instead of sending the bot to the success page.',
|
||||
);
|
||||
}
|
||||
if (clientNames.includes(DECOY_CHECKBOX_FIELD)) {
|
||||
problems.push(
|
||||
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the form's ` +
|
||||
'INTAKE_FIELDS table; it would render as a real, visible field.',
|
||||
);
|
||||
}
|
||||
/* `consent` is submitted by the form and read by the handler, and it is in
|
||||
NEITHER field table — so the two checks above cannot see a collision with it.
|
||||
A honeypot named `consent` would discard every valid submission behind the
|
||||
success page, which is the worst failure this file can fail to catch. */
|
||||
for (const [what, name] of [
|
||||
['honeypot', HONEYPOT_FIELD],
|
||||
['decoy checkbox', DECOY_CHECKBOX_FIELD],
|
||||
]) {
|
||||
if (name === 'consent') {
|
||||
problems.push(
|
||||
`the ${what} is named "consent", which the form submits and the handler ` +
|
||||
'requires — every valid submission would be discarded behind the ' +
|
||||
'success page.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (DECOY_CHECKBOX_FIELD === HONEYPOT_FIELD) {
|
||||
problems.push(
|
||||
'the two honeypots share the name ' +
|
||||
`"${HONEYPOT_FIELD}" — that is one trap, not two, and the second ` +
|
||||
'mechanism (a checkbox that must arrive absent) would not exist.',
|
||||
);
|
||||
}
|
||||
/* And the first honeypot must not appear on the form's own table either — the
|
||||
mirror of the check above it, which existed only for the handler's side. */
|
||||
if (clientNames.includes(HONEYPOT_FIELD)) {
|
||||
problems.push(
|
||||
`the honeypot "${HONEYPOT_FIELD}" is in the form's INTAKE_FIELDS table; ` +
|
||||
'it would render as a real, visible field.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const clientField of INTAKE_FIELDS) {
|
||||
const serverField = server.find((f) => f.name === clientField.name);
|
||||
if (!serverField) continue;
|
||||
|
||||
if (Boolean(clientField.required) !== Boolean(serverField.required)) {
|
||||
problems.push(
|
||||
`"${clientField.name}": form required=${Boolean(clientField.required)}, ` +
|
||||
`handler required=${Boolean(serverField.required)}. A field the form ` +
|
||||
'marks optional and the handler requires is a rejection the inquirer ' +
|
||||
'cannot see the reason for.',
|
||||
);
|
||||
}
|
||||
|
||||
/* LABELS TOO, since 2026-08-31. The handler now renders `f.label` into the
|
||||
confirmation email the inquirer keeps, so a label that drifts from the
|
||||
form's own wording means the receipt describes fields by names the form
|
||||
never showed. One more comparison; the duplication stays mechanical. */
|
||||
if (clientField.label !== serverField.label) {
|
||||
problems.push(
|
||||
`"${clientField.name}": labels differ.\n` +
|
||||
` form: ${JSON.stringify(clientField.label)}\n` +
|
||||
` handler: ${JSON.stringify(serverField.label ?? null)}\n` +
|
||||
' The handler renders its label into the confirmation email.',
|
||||
);
|
||||
}
|
||||
|
||||
if ((clientField.max ?? null) !== (serverField.max ?? null)) {
|
||||
problems.push(
|
||||
`"${clientField.name}": form max=${clientField.max ?? 'none'}, ` +
|
||||
`handler max=${serverField.max ?? 'none'}. The form's maxlength stops ` +
|
||||
'typing; a lower cap in the handler rejects a submission that looked fine.',
|
||||
);
|
||||
}
|
||||
|
||||
const clientOptions = clientField.options ? [...clientField.options] : null;
|
||||
const serverOptions = serverField.options ? [...serverField.options] : null;
|
||||
if (JSON.stringify(clientOptions) !== JSON.stringify(serverOptions)) {
|
||||
problems.push(
|
||||
`"${clientField.name}": option sets differ.\n` +
|
||||
` form: ${JSON.stringify(clientOptions)}\n` +
|
||||
` handler: ${JSON.stringify(serverOptions)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`check:intake — ${clientNames.length} form fields, ${serverNames.length} ` +
|
||||
'handler fields, compared on name, label, requiredness, cap and option ' +
|
||||
`set; 2 honeypots ("${HONEYPOT_FIELD}", "${DECOY_CHECKBOX_FIELD}") ` +
|
||||
'compared on name and checked out of both tables.',
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`);
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
console.error(
|
||||
'\ndocs/05: the handler re-validates everything. The two tables are ' +
|
||||
'independent on purpose; they still have to agree.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('OK — the form and the handler agree.');
|
||||
+107
-10
@@ -20,26 +20,42 @@
|
||||
# Required environment (values are in AGENTS.md §7 — deliberately not restated
|
||||
# here; §7 is the single source of truth for operational facts):
|
||||
#
|
||||
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID INTAKE_ENDPOINT
|
||||
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID
|
||||
#
|
||||
# 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.
|
||||
# ⚠️ INTAKE_ENDPOINT IS NO LONGER ONE OF THEM, AND THE GUARD THAT DEMANDED IT
|
||||
# WAS BLOCKING A DEPLOY ON A VALUE NOTHING READ. Build step 8 moved the intake
|
||||
# form to the same-origin path /api/intake (see src/data/intake.ts for the four
|
||||
# reasons). After that, `git grep PUBLIC_INTAKE_ENDPOINT -- src/` returned
|
||||
# nothing — the value exported into the build below was consumed by no page —
|
||||
# and the guard's own message was false in both directions: the form posts to
|
||||
# /api/intake whatever that variable holds, and the thing that actually decides
|
||||
# whether it works, the CloudFront /api/* behaviour, was guarded nowhere.
|
||||
#
|
||||
# So the guard now checks the thing that matters, after the deploy, at the
|
||||
# bottom of this script. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
# 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, `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
|
||||
|
||||
# Same six values the workflow guards. Emptiness only — no value is echoed.
|
||||
# Same five values the workflow guards. Emptiness only — no value is echoed.
|
||||
missing=''
|
||||
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
|
||||
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
|
||||
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
|
||||
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
|
||||
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || missing="$missing AWS_ACCESS_KEY_ID"
|
||||
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || missing="$missing AWS_SECRET_ACCESS_KEY"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "Not set:$missing" >&2
|
||||
echo >&2
|
||||
echo "Values are in AGENTS.md §7. An empty INTAKE_ENDPOINT does not fail the" >&2
|
||||
echo "build — it ships a live contact form posting to nothing." >&2
|
||||
echo "Values are in AGENTS.md §7." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -53,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
|
||||
@@ -62,9 +78,10 @@ echo "==> Type and template check"
|
||||
npm run check
|
||||
|
||||
echo "==> Build"
|
||||
# Only PUBLIC_SITE_URL, because it is the only one astro.config.mjs reads.
|
||||
# PUBLIC_INTAKE_ENDPOINT and PUBLIC_BOOKING_URL were exported here and consumed
|
||||
# by nothing — see the header.
|
||||
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
|
||||
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
|
||||
PUBLIC_BOOKING_URL="${BOOKING_URL:-}" \
|
||||
npm run build
|
||||
|
||||
# AFTER the build and BEFORE anything is uploaded. AGENTS.md §4 Forbidden,
|
||||
@@ -101,4 +118,84 @@ aws cloudfront create-invalidation \
|
||||
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||
--paths "/*" >/dev/null
|
||||
|
||||
# THE CHECK THAT REPLACES THE INTAKE_ENDPOINT GUARD, and it runs AFTER the
|
||||
# deploy because it tests the deployed thing rather than a variable.
|
||||
#
|
||||
# The intake 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 that behaviour exists, and a
|
||||
# deploy that succeeds while the form posts into a 404 is the failure the old
|
||||
# guard was reaching for and could not see.
|
||||
#
|
||||
# ⚠️ 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")
|
||||
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" || 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
|
||||
# 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
|
||||
# THE ORIGIN REQUEST POLICY ON /api/* IS NO LONGER A CONSTANT. Since
|
||||
# 2026-09-04 the behaviour may carry the custom `adr-sml-api-viewer-address`
|
||||
# whitelist (docs/09 Part 3, change 8) instead of the managed policy, so this
|
||||
# text no longer names one and tells the operator to read it. Naming the old
|
||||
# one would send them to "restore" what was deliberately replaced.
|
||||
echo "403 means CloudFront rejected the method, or the handler refused the" >&2
|
||||
echo "Origin. Read which origin request policy /api/* carries — since" >&2
|
||||
echo "2026-09-04 it may be the custom whitelist adr-sml-api-viewer-address" >&2
|
||||
echo "rather than Managed-AllViewerExceptHostHeader — because a policy that" >&2
|
||||
echo "drops or fails to forward Origin turns every real submission into a" >&2
|
||||
echo "403. Rollback id: b689b0a8-53d0-40ab-baf2-68738e2966ac." >&2
|
||||
echo "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,278 @@
|
||||
/**
|
||||
* Regenerates `public/favicon.ico` from the committed brand master.
|
||||
*
|
||||
* LOCAL ONLY, like `bio:pdf`. Not wired into `npm run build` or either deploy
|
||||
* path — the icons are committed artefacts and this is what re-derives them.
|
||||
*
|
||||
* Writes the favicon ONLY. `apple-touch-icon.png` is deliberately not touched
|
||||
* and must stay opaque — `docs/reference/brand-assets.md` §The icon set.
|
||||
*/
|
||||
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const MARK = `${root}src/assets/brand/sml-infinity-mark.png`;
|
||||
const MASTER = `${root}src/assets/brand/sml-infinity-mark-master.png`;
|
||||
const OUT = `${root}public/favicon.ico`;
|
||||
|
||||
/** Sizes carried in the container, ascending — the order BaseLayout declares. */
|
||||
const SIZES = [16, 32, 48];
|
||||
|
||||
/**
|
||||
* The mark spans 7/8 of the canvas and is centred on both axes. Not a taste
|
||||
* decision at this point: it is the composition already shipping, measured off
|
||||
* the previous icon at all three sizes (14/16, 28/32, 42/48) and off the touch
|
||||
* icon (158/180). Regenerating for alpha must not also restyle the mark.
|
||||
*/
|
||||
const INK_FRACTION = 7 / 8;
|
||||
|
||||
/** `--cream` — the ground the previous icon was matted against. */
|
||||
const CREAM = [250, 247, 242];
|
||||
|
||||
const die = (msg) => {
|
||||
rmSync(`${OUT}.candidate`, { force: true });
|
||||
console.error(`icons: ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* R14 — the icon must be traceable to the artwork in this repository, not to a
|
||||
* file on someone's disk. The render source is a tight crop of the master, so
|
||||
* assert it still IS that crop before deriving anything from it.
|
||||
*/
|
||||
async function assertProvenance() {
|
||||
const mark = await sharp(MARK).metadata();
|
||||
const crop = { left: 159, top: 646, width: 2668, height: 1704 };
|
||||
if (mark.width !== crop.width || mark.height !== crop.height) {
|
||||
die(
|
||||
`render source is ${mark.width}x${mark.height}, expected ${crop.width}x${crop.height}`,
|
||||
);
|
||||
}
|
||||
const [a, b] = await Promise.all([
|
||||
sharp(MASTER).extract(crop).raw().toBuffer(),
|
||||
sharp(MARK).raw().toBuffer(),
|
||||
]);
|
||||
if (!a.equals(b))
|
||||
die('render source is no longer the documented crop of the master');
|
||||
console.log(
|
||||
`provenance: ${crop.width}x${crop.height} at (${crop.left},${crop.top}) of the master — identical`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the regeneration. A source without alpha would mean
|
||||
* deriving a mask from the cream ground, which is a different and lossier job —
|
||||
* so fail rather than silently ship a matted icon again.
|
||||
*/
|
||||
async function loadMark() {
|
||||
const meta = await sharp(MARK).metadata();
|
||||
if (!meta.hasAlpha)
|
||||
die(
|
||||
`${MARK} has no alpha channel — cannot export a transparent icon from it`,
|
||||
);
|
||||
const { data, info } = await sharp(MARK)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let transparent = 0;
|
||||
for (let p = 3; p < data.length; p += 4) if (data[p] === 0) transparent++;
|
||||
if (transparent === 0)
|
||||
die(`${MARK} declares alpha but every pixel is opaque`);
|
||||
console.log(
|
||||
`source: ${info.width}x${info.height} alpha, ${transparent} fully transparent px`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize onto a TRANSPARENT canvas. sharp premultiplies around the resample, so
|
||||
* the ribbon's anti-aliased edge blends toward its own colour rather than
|
||||
* toward the RGB sitting under alpha 0 — that is the cream halo this change
|
||||
* exists to remove, and it would come straight back with a matted background.
|
||||
*/
|
||||
async function frame(size) {
|
||||
const w = Math.round(size * INK_FRACTION);
|
||||
const png = await sharp(MARK)
|
||||
.resize({
|
||||
width: w,
|
||||
kernel: 'lanczos3',
|
||||
fit: 'inside',
|
||||
withoutEnlargement: false,
|
||||
})
|
||||
.toBuffer();
|
||||
const { height: h } = await sharp(png).metadata();
|
||||
if (h > size) die(`size ${size}: mark is ${w}x${h}, taller than the canvas`);
|
||||
const left = Math.round((size - w) / 2);
|
||||
const top = Math.round((size - h) / 2);
|
||||
const out = await sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite([{ input: png, left, top }])
|
||||
.png({ compressionLevel: 9, effort: 10, palette: false })
|
||||
.toBuffer();
|
||||
console.log(
|
||||
` ${size}x${size}: mark ${w}x${h} at (${left},${top}), ${out.length} B`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ICO container: 6-byte header, one 16-byte directory entry per frame, then the PNG payloads. */
|
||||
function buildIco(frames) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type 1 = icon
|
||||
header.writeUInt16LE(frames.length, 4);
|
||||
|
||||
const dir = Buffer.alloc(16 * frames.length);
|
||||
let offset = header.length + dir.length;
|
||||
frames.forEach(({ size, png }, i) => {
|
||||
const e = i * 16;
|
||||
dir[e] = size; // width — a byte; 0 would mean 256, which SIZES never is
|
||||
dir[e + 1] = size; // height
|
||||
dir[e + 2] = 0; // palette size — 0 for truecolour
|
||||
dir[e + 3] = 0; // reserved
|
||||
dir.writeUInt16LE(1, e + 4); // colour planes
|
||||
dir.writeUInt16LE(32, e + 6); // bits per pixel
|
||||
dir.writeUInt32LE(png.length, e + 8);
|
||||
dir.writeUInt32LE(offset, e + 12);
|
||||
offset += png.length;
|
||||
});
|
||||
|
||||
return Buffer.concat([header, dir, ...frames.map((f) => f.png)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the container FROM DISK and decode each frame, rather than inspecting
|
||||
* the buffers we just built — a check that reads its own inputs proves nothing.
|
||||
* (It is still `sharp` decoding `sharp`'s output, so it is not a second
|
||||
* instrument. The independent reads are in `docs/reference/brand-assets.md`.)
|
||||
*/
|
||||
async function verify(path) {
|
||||
const buf = readFileSync(path);
|
||||
const count = buf.readUInt16LE(4);
|
||||
if (count !== SIZES.length)
|
||||
die(`container declares ${count} frames, expected ${SIZES.length}`);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const e = 6 + i * 16;
|
||||
const size = buf[e];
|
||||
const len = buf.readUInt32LE(e + 8);
|
||||
const off = buf.readUInt32LE(e + 12);
|
||||
if (off + len > buf.length)
|
||||
die(`frame ${i}: range ${off}+${len} exceeds ${buf.length} B`);
|
||||
const { data, info } = await sharp(buf.subarray(off, off + len))
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (info.width !== size || info.height !== size)
|
||||
die(`frame ${i}: decoded ${info.width}x${info.height}, dir says ${size}`);
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[size - 1, 0],
|
||||
[0, size - 1],
|
||||
[size - 1, size - 1],
|
||||
];
|
||||
for (const [x, y] of corners) {
|
||||
const a = data[(y * size + x) * 4 + 3];
|
||||
if (a !== 0)
|
||||
die(`frame ${size}: corner (${x},${y}) has alpha ${a}, expected 0`);
|
||||
}
|
||||
/*
|
||||
* THE CORNER AND TRANSPARENCY CHECKS CANNOT SEE A CREAM HALO. A frame whose
|
||||
* edge was matted against cream and then had its background knocked out has
|
||||
* clear corners, transparent pixels and opaque pixels, and passes every one
|
||||
* of them. What distinguishes it is the colour the edge blends TOWARD.
|
||||
*
|
||||
* ⚠️ AND IT IS THE BOUNDARY, NOT THE PARTIAL-ALPHA PIXELS. A first version
|
||||
* of this guard inspected only pixels at 0 < alpha < 255 and MISSED a
|
||||
* purpose-built haloed fixture entirely, because a knockout sets alpha per
|
||||
* pixel and leaves NO partial alpha at all — 0 such pixels in the fixture.
|
||||
* A guard that cannot see the defect it is named for is worse than none.
|
||||
*
|
||||
* So: take every painted pixel that touches a fully transparent one, and
|
||||
* measure how many sit near cream. Measured on this artwork — correct
|
||||
* frames 1 / 2 / 5 of 61 / 146 / 258 boundary pixels (1.4-1.9%); the haloed
|
||||
* fixture 33 of 115 (28.7%). The gate is 10%, roughly 5x clear of both.
|
||||
*/
|
||||
const NEAR_CREAM = 20;
|
||||
const HALO_SHARE = 0.1;
|
||||
const alphaAt = (x, y) =>
|
||||
x < 0 || y < 0 || x >= size || y >= size
|
||||
? 0
|
||||
: data[(y * size + x) * 4 + 3];
|
||||
let clear = 0;
|
||||
let ink = 0;
|
||||
let boundary = 0;
|
||||
let boundaryNearCream = 0;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
const a = data[i + 3];
|
||||
if (a === 0) {
|
||||
clear++;
|
||||
continue;
|
||||
}
|
||||
if (a === 255) ink++;
|
||||
const onEdge =
|
||||
alphaAt(x - 1, y) === 0 ||
|
||||
alphaAt(x + 1, y) === 0 ||
|
||||
alphaAt(x, y - 1) === 0 ||
|
||||
alphaAt(x, y + 1) === 0;
|
||||
if (!onEdge) continue;
|
||||
boundary++;
|
||||
const d = Math.max(
|
||||
Math.abs(data[i] - CREAM[0]),
|
||||
Math.abs(data[i + 1] - CREAM[1]),
|
||||
Math.abs(data[i + 2] - CREAM[2]),
|
||||
);
|
||||
if (d <= NEAR_CREAM) boundaryNearCream++;
|
||||
}
|
||||
}
|
||||
const share = boundary === 0 ? 0 : boundaryNearCream / boundary;
|
||||
if (clear === 0)
|
||||
die(`frame ${size}: no transparent pixels — the ground is fully matted`);
|
||||
if (ink === 0)
|
||||
die(`frame ${size}: no opaque pixels — the mark did not render`);
|
||||
if (boundary === 0)
|
||||
die(`frame ${size}: no boundary pixels — cannot test the edge colour`);
|
||||
if (share > HALO_SHARE)
|
||||
die(
|
||||
`frame ${size}: ${boundaryNearCream} of ${boundary} boundary pixels ` +
|
||||
`(${(share * 100).toFixed(1)}%) sit within ${NEAR_CREAM} of cream — ` +
|
||||
`the edge was matted against the ground before the ground was removed`,
|
||||
);
|
||||
console.log(
|
||||
` ${size}x${size}: ${clear} transparent, ${ink} opaque, corners clear, ` +
|
||||
`edge ${(share * 100).toFixed(1)}% near cream`,
|
||||
);
|
||||
}
|
||||
console.log(`verified ${path} (${buf.length} B)`);
|
||||
}
|
||||
|
||||
await assertProvenance();
|
||||
await loadMark();
|
||||
console.log('frames:');
|
||||
const frames = [];
|
||||
for (const size of SIZES) frames.push({ size, png: await frame(size) });
|
||||
|
||||
/*
|
||||
* Verify a CANDIDATE file, then move it into place. Writing OUT first and
|
||||
* verifying after would mean a failing check exits 1 having already replaced a
|
||||
* good favicon with the one it just rejected — and nothing downstream re-checks,
|
||||
* because this script is deliberately outside the build and both deploy paths.
|
||||
*/
|
||||
const candidate = `${OUT}.candidate`;
|
||||
writeFileSync(candidate, buildIco(frames));
|
||||
console.log('verify:');
|
||||
try {
|
||||
await verify(candidate);
|
||||
} catch (err) {
|
||||
rmSync(candidate, { force: true });
|
||||
throw err;
|
||||
}
|
||||
renameSync(candidate, OUT);
|
||||
console.log(`wrote ${OUT}`);
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The performance gate. Budget: docs/04-seo-spec.md §Performance —
|
||||
* Lighthouse >= 95 on all four categories, on mobile, for every page.
|
||||
*
|
||||
* WHY THIS IS `lighthouse` AND NOT `@lhci/cli`, WHICH IS WHAT R11 SAID TO PUT
|
||||
* BACK. Measured 2026-08-31 from two probe lockfiles, not recalled:
|
||||
*
|
||||
* @lhci/cli@0.15.1 10 vulnerabilities (7 high) pins lighthouse 12.6.1
|
||||
* high: tmp@0.1.0 <- a DIRECT dependency of @lhci/cli itself
|
||||
* high: extract-zip@2.0.1 <- via @puppeteer/browsers
|
||||
* lighthouse@13.4.1 0 vulnerabilities 109 packages
|
||||
* tmp ABSENT, extract-zip ABSENT
|
||||
*
|
||||
* So the carrier was never Lighthouse. AGENTS.md §7 recorded the advisories as
|
||||
* arriving "via lighthouse -> puppeteer-core -> extract-zip", and on that
|
||||
* attribution the tool looked unusable for as long as the advisories stood.
|
||||
* Standalone `lighthouse` measures the same budget with nothing outstanding.
|
||||
* What is given up is real and is recorded in §7: `lhci autorun`'s assertion
|
||||
* config, its server, and its CI upload.
|
||||
*
|
||||
* THIS IS A LOCAL GATE, NOT A CI CHECK, and the reason is Chrome. Standalone
|
||||
* Lighthouse drives an installed browser; the Gitea runner has none (§7 — the
|
||||
* runner is not registered at all yet, Q23). So this runs from a keyboard and
|
||||
* as a blocking item on docs/06's cutover checklist. It is not wired into
|
||||
* `npm run build` or either deploy path, and saying so is the point: a check
|
||||
* described as running where it cannot is the defect Q22 turned out to be.
|
||||
*
|
||||
* PAGES ARE ENUMERATED FROM `dist/`, NEVER LISTED HERE. A hand-written list
|
||||
* silently stops covering the site the first time a page is added — which is
|
||||
* this project's most expensive recurring shape. Every `index.html` under
|
||||
* `dist/` is a page, so the set cannot go stale.
|
||||
*
|
||||
* Usage: npm run build && npm run lighthouse
|
||||
* npm run lighthouse -- /fees/ /insights/ # a subset, by pathname
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join, extname, relative, sep } from 'node:path';
|
||||
import lighthouse from 'lighthouse';
|
||||
import * as chromeLauncher from 'chrome-launcher';
|
||||
|
||||
const DIST = new URL('../dist/', import.meta.url).pathname;
|
||||
const THRESHOLD = 95;
|
||||
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'];
|
||||
|
||||
/** docs/04's own budgets, reported alongside the scores rather than asserted
|
||||
* separately — LCP is the one the spec states in seconds. */
|
||||
const LCP_BUDGET_MS = 2000;
|
||||
const CLS_BUDGET = 0.05;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.avif': 'image/avif',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
* `trailingSlash: 'always'` + `build.format: 'directory'` (astro.config.mjs),
|
||||
* so `/mediation/` is `dist/mediation/index.html` and an extensionless path
|
||||
* without the slash is a 404 here exactly as it is on CloudFront. Serving it
|
||||
* anyway would measure a URL the site does not have.
|
||||
*/
|
||||
function resolveFile(pathname) {
|
||||
if (pathname.endsWith('/')) return join(DIST, pathname, 'index.html');
|
||||
if (extname(pathname)) return join(DIST, pathname);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function collectPages(dir = DIST) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await collectPages(full)));
|
||||
else if (entry.name === 'index.html') {
|
||||
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();
|
||||
}
|
||||
|
||||
function serveDist() {
|
||||
const server = createServer((req, res) => {
|
||||
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
const file = resolveFile(pathname);
|
||||
if (!file) {
|
||||
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||
res.end('404');
|
||||
return;
|
||||
}
|
||||
const stream = createReadStream(file);
|
||||
stream.on('error', () => {
|
||||
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||
res.end('404');
|
||||
});
|
||||
stream.once('open', () => {
|
||||
// NO `cache-control` HEADER, AND THAT IS DELIBERATE — measured
|
||||
// 2026-08-31. `cache-control: no-store` was set here to force a cold
|
||||
// cache, which it did not need to do (Lighthouse resets storage between
|
||||
// runs by default) and which cost the `bf-cache` audit outright:
|
||||
// "Pages whose main resource has cache-control:no-store cannot enter
|
||||
// back/forward cache." The audit failed on every page, in a report whose
|
||||
// whole job is to find defects on the site. Verified by toggling the one
|
||||
// header: bf-cache 0 with it, 1 without, twice each.
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () =>
|
||||
resolve({ server, port: server.address().port }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
const scoreOf = (lhr, id) => Math.round((lhr.categories[id]?.score ?? 0) * 100);
|
||||
|
||||
/**
|
||||
* ⚠️ AN INTENTIONALLY `noindex` PAGE CANNOT SCORE 95 ON LIGHTHOUSE'S SEO
|
||||
* CATEGORY, AND THE BUDGET AS WRITTEN DID NOT KNOW THAT.
|
||||
*
|
||||
* Measured 2026-08-31, first full run over 22 pages: five pages scored SEO
|
||||
* **69**, and on every one the ONLY failing audit was `is-crawlable` — *"Page is
|
||||
* blocked from indexing"* — firing on `<meta name="robots" content="noindex,
|
||||
* follow">`. That meta tag is what `docs/04` REQUIRES on `/legal/*`, and it is
|
||||
* deliberate on `/bio/`, `/contact/received/` and `/contact/could-not-send/`.
|
||||
* So the category is measuring the page doing exactly what it was built to do.
|
||||
*
|
||||
* The wrong fix is to drop the SEO threshold, or to except these pages, or to
|
||||
* stop measuring them: each of those hides every OTHER SEO defect on the pages
|
||||
* where a defect is hardest to notice. What is asserted instead is stricter than
|
||||
* a number:
|
||||
*
|
||||
* indexable page -> SEO category >= 95, as before
|
||||
* noindex page -> EVERY SEO audit must pass EXCEPT `is-crawlable`
|
||||
*
|
||||
* A missing canonical, a missing title, an unreadable font size or a bad link on
|
||||
* a noindex page still fails the gate. Only the one audit that is measuring the
|
||||
* intent is set aside, and the page is marked in the table so the number is
|
||||
* never read as unqualified.
|
||||
*
|
||||
* `noindex` is read from the BUILT HTML rather than from a list of paths here —
|
||||
* a list would stop covering the site the first time a page is added.
|
||||
*/
|
||||
const EXPECTED_NOINDEX_FAILURE = 'is-crawlable';
|
||||
|
||||
async function isNoindex(page) {
|
||||
const file = resolveFile(page);
|
||||
const html = await readFile(file, 'utf8');
|
||||
return /<meta[^>]+name="robots"[^>]+content="[^"]*noindex/i.test(html);
|
||||
}
|
||||
|
||||
function failingAudits(lhr, category) {
|
||||
return (lhr.categories[category]?.auditRefs ?? [])
|
||||
.map((ref) => lhr.audits[ref.id])
|
||||
.filter((audit) => audit && audit.score !== null && audit.score < 1)
|
||||
.map((audit) => audit.id);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await stat(join(DIST, 'index.html'));
|
||||
} catch {
|
||||
console.error(
|
||||
'dist/index.html is missing. Run `npm run build` first — this gate ' +
|
||||
'measures the bytes that would ship, not the dev server.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const requested = process.argv.slice(2).filter((a) => a.startsWith('/'));
|
||||
const all = await collectPages();
|
||||
const pages = requested.length ? requested : all;
|
||||
const unknown = requested.filter((p) => !all.includes(p));
|
||||
if (unknown.length) {
|
||||
console.error(`Not built: ${unknown.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { server, port } = await serveDist();
|
||||
const baseFlags = ['--headless', '--no-sandbox', '--disable-gpu'];
|
||||
const chrome = await chromeLauncher.launch({ chromeFlags: baseFlags });
|
||||
/**
|
||||
* ⚠️ A SECOND BROWSER, AND THE ACCESSIBILITY CATEGORY IS MEASURED IN IT.
|
||||
*
|
||||
* `--force-prefers-reduced-motion`. This is a deliberate deviation from a
|
||||
* single default run and it must be stated wherever the number is, which is
|
||||
* why the table below labels the column. Measured 2026-08-31, twice per
|
||||
* condition, on `/process/`:
|
||||
*
|
||||
* motion on a11y = 96 color-contrast FAILED, 24 nodes
|
||||
* motion off a11y = 100 color-contrast passed, 0 nodes
|
||||
*
|
||||
* The 24 nodes were the scroll-driven reveal (`animation-timeline: view()`,
|
||||
* global.css) caught mid-flight: axe reported foregrounds like `#d0cbc4` on
|
||||
* `#f8f4ed`, and NEITHER is in this site's palette — they are the real colours
|
||||
* blended toward the background by an in-progress `opacity` keyframe. So the
|
||||
* audit was measuring animation state, not contrast.
|
||||
*
|
||||
* WHY THIS IS THE HONEST RUN RATHER THAN THE CONVENIENT ONE. A category that
|
||||
* reports 24 known-false nodes on ten of fourteen pages cannot surface the
|
||||
* twenty-fifth, real one — it is a control that has stopped controlling, which
|
||||
* is the shape `AGENTS.md` Q22 and the Lighthouse removal both took. The
|
||||
* reduced-motion rendering is not a synthetic one: it is the branch
|
||||
* `global.css` ships for `prefers-reduced-motion: reduce`, a real user setting,
|
||||
* and it is the branch in which every element sits at its FINAL colour, which
|
||||
* is what a contrast audit is asking about. Contrast ratios for the palette
|
||||
* itself are computed and recorded in `docs/02-design-system.md`.
|
||||
*
|
||||
* Performance is NOT measured here — reduced motion would suppress work the
|
||||
* site really does on a default profile.
|
||||
*/
|
||||
const chromeA11y = await chromeLauncher.launch({
|
||||
chromeFlags: [...baseFlags, '--force-prefers-reduced-motion'],
|
||||
});
|
||||
|
||||
const PERF_CATEGORIES = CATEGORIES.filter((id) => id !== 'accessibility');
|
||||
const rows = [];
|
||||
const breaches = [];
|
||||
try {
|
||||
for (const page of pages) {
|
||||
const url = `http://127.0.0.1:${port}${page}`;
|
||||
// Default config otherwise: Lighthouse's mobile preset — mobile form
|
||||
// factor, mobile screen emulation, simulated Slow 4G. That is the
|
||||
// budget's own wording in docs/04, so none of it is overridden.
|
||||
const run = async (chromeInstance, onlyCategories) => {
|
||||
const result = await lighthouse(url, {
|
||||
logLevel: 'error',
|
||||
output: 'json',
|
||||
port: chromeInstance.port,
|
||||
onlyCategories,
|
||||
});
|
||||
if (!result?.lhr) {
|
||||
throw new Error(`Lighthouse returned nothing for ${page}`);
|
||||
}
|
||||
if (result.lhr.runtimeError?.code) {
|
||||
throw new Error(`${page}: ${result.lhr.runtimeError.message}`);
|
||||
}
|
||||
return result.lhr;
|
||||
};
|
||||
|
||||
const lhr = await run(chrome, PERF_CATEGORIES);
|
||||
const lhrA11y = await run(chromeA11y, ['accessibility']);
|
||||
|
||||
const scores = Object.fromEntries([
|
||||
...PERF_CATEGORIES.map((id) => [id, scoreOf(lhr, id)]),
|
||||
['accessibility', scoreOf(lhrA11y, 'accessibility')],
|
||||
]);
|
||||
const lcp = lhr.audits['largest-contentful-paint']?.numericValue ?? NaN;
|
||||
const cls = lhr.audits['cumulative-layout-shift']?.numericValue ?? NaN;
|
||||
const noindex = await isNoindex(page);
|
||||
rows.push({ page, scores, lcp, cls, noindex });
|
||||
|
||||
for (const id of CATEGORIES) {
|
||||
// The SEO category on a noindex page is asserted audit by audit
|
||||
// instead — see the comment on EXPECTED_NOINDEX_FAILURE.
|
||||
if (id === 'seo' && noindex) continue;
|
||||
if (scores[id] < THRESHOLD) {
|
||||
breaches.push(`${page} ${id} = ${scores[id]} (< ${THRESHOLD})`);
|
||||
}
|
||||
}
|
||||
if (noindex) {
|
||||
const unexpected = failingAudits(lhr, 'seo').filter(
|
||||
(id) => id !== EXPECTED_NOINDEX_FAILURE,
|
||||
);
|
||||
if (unexpected.length) {
|
||||
breaches.push(
|
||||
`${page} seo — noindex page, so only \`${EXPECTED_NOINDEX_FAILURE}\` ` +
|
||||
`may fail; these also failed: ${unexpected.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// `kill()` is synchronous in chrome-launcher 1.x — `await` on it draws
|
||||
// ts(80007) from `astro check`, which this repo keeps at zero.
|
||||
chrome.kill();
|
||||
chromeA11y.kill();
|
||||
server.close();
|
||||
}
|
||||
|
||||
const w = Math.max(28, ...rows.map((r) => r.page.length + 2));
|
||||
console.log(`\n${pad('page', w)} perf a11y* bestp seo LCP CLS`);
|
||||
console.log('-'.repeat(w + 44));
|
||||
for (const r of rows) {
|
||||
const cells = CATEGORIES.map((id) =>
|
||||
pad(id === 'seo' && r.noindex ? `${r.scores[id]}n` : r.scores[id], 6),
|
||||
).join(' ');
|
||||
const lcpCell = pad(`${(r.lcp / 1000).toFixed(2)}s`, 8);
|
||||
console.log(`${pad(r.page, w)} ${cells} ${lcpCell} ${r.cls.toFixed(3)}`);
|
||||
}
|
||||
|
||||
// The worst-of row excludes noindex pages from the SEO column, because
|
||||
// including them would report 69 as the site's worst SEO score forever and
|
||||
// train a reader to ignore the column — which is how a real regression there
|
||||
// would go unnoticed.
|
||||
const worst = (id) => {
|
||||
const relevant = id === 'seo' ? rows.filter((r) => !r.noindex) : rows;
|
||||
return relevant.length
|
||||
? Math.min(...relevant.map((r) => r.scores[id]))
|
||||
: 100;
|
||||
};
|
||||
console.log('-'.repeat(w + 44));
|
||||
console.log(
|
||||
`${pad(`worst of ${rows.length}`, w)} ` +
|
||||
CATEGORIES.map((id) => pad(worst(id), 6)).join(' ') +
|
||||
` ${pad(`${(Math.max(...rows.map((r) => r.lcp)) / 1000).toFixed(2)}s`, 8)} ` +
|
||||
Math.max(...rows.map((r) => r.cls)).toFixed(3),
|
||||
);
|
||||
console.log(
|
||||
`\nbudgets: all four categories >= ${THRESHOLD} (mobile) · ` +
|
||||
`LCP < ${LCP_BUDGET_MS / 1000}s · CLS < ${CLS_BUDGET} — docs/04-seo-spec.md`,
|
||||
);
|
||||
const noindexCount = rows.filter((r) => r.noindex).length;
|
||||
if (noindexCount) {
|
||||
console.log(
|
||||
`n = deliberately noindex (${noindexCount} page(s)). Lighthouse's SEO\n` +
|
||||
' category cannot exceed ~69 on such a page: `is-crawlable` fails on the\n' +
|
||||
' `noindex` the page is supposed to carry. Those pages are asserted audit\n' +
|
||||
' by audit instead — every SEO audit must pass except that one — and are\n' +
|
||||
' excluded from the SEO worst-of above.',
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
'* a11y is measured with prefers-reduced-motion forced. The scroll-driven\n' +
|
||||
" reveal otherwise puts axe's colour-contrast audit on mid-animation\n" +
|
||||
' opacity rather than on the palette — 24 false nodes, measured. See the\n' +
|
||||
' comment on chromeA11y in this script.',
|
||||
);
|
||||
|
||||
// Reported, not asserted. docs/04 states LCP and CLS as budgets; Lighthouse's
|
||||
// simulated throttling on a loopback server is not the Slow 4G field
|
||||
// measurement they describe, so a hard failure here would be a claim about
|
||||
// the instrument. The category scores ARE the gate.
|
||||
const lcpOver = rows.filter((r) => r.lcp >= LCP_BUDGET_MS);
|
||||
const clsOver = rows.filter((r) => r.cls >= CLS_BUDGET);
|
||||
if (lcpOver.length) {
|
||||
console.log(
|
||||
`note: LCP at or over budget on ${lcpOver.length} page(s): ` +
|
||||
lcpOver.map((r) => r.page).join(', '),
|
||||
);
|
||||
}
|
||||
if (clsOver.length) {
|
||||
console.log(
|
||||
`note: CLS at or over budget on ${clsOver.length} page(s): ` +
|
||||
clsOver.map((r) => r.page).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
if (breaches.length) {
|
||||
console.error(`\nBUDGET BREACH — ${breaches.length}:`);
|
||||
for (const b of breaches) console.error(` - ${b}`);
|
||||
console.error('\nCLAUDE.md: treat a budget breach as a failing build.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nOK — ${rows.length} page(s), no category below ${THRESHOLD}.`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Proves the Open Graph cards, two ways. `npm run og:proof`, after a build.
|
||||
*
|
||||
* WHY THIS EXISTS AT ALL. `AGENTS.md` R15: *"Nobody on this project will ever
|
||||
* see the defect. A link preview is rendered by LinkedIn, Slack and Teams for a
|
||||
* reader who is not us."* Generating the cards does not fix that — it moves the
|
||||
* invisible thing from "wrong image" to "wrong image, generated". So the two
|
||||
* failures that would stay invisible are checked mechanically:
|
||||
*
|
||||
* 1. **Every page's `og:image` resolves to a file that exists in `dist/`.** A
|
||||
* 404 preview image renders as a blank card, and nothing else in this repo
|
||||
* would notice. Checked by reading the built HTML, not the source.
|
||||
*
|
||||
* 2. **Every card's headline and eyebrow are its page's own `<h1>` and first
|
||||
* `.eyebrow`, character for character.** This is the compliance half. Text
|
||||
* baked into a JPEG is text `npm run check:claims` cannot grep, and under D20
|
||||
* that script is the only per-step claims control there is — so a card must
|
||||
* never carry a claim its page does not already make in auditable HTML. The
|
||||
* check enforces that structurally rather than trusting an author to
|
||||
* remember it, and it fails in both directions: editing the page without the
|
||||
* registry, or the registry without the page.
|
||||
*
|
||||
* It also catches the quiet one: a straight apostrophe in the registry
|
||||
* against the typographic apostrophe the page renders. Found exactly that on
|
||||
* the first run, on `/practice/insurance/`.
|
||||
*
|
||||
* It reads `src/data/og-cards.ts` DIRECTLY — Node strips the types — so there is
|
||||
* no second list of cards to keep in step with the first.
|
||||
*
|
||||
* Optional: `npm run og:proof -- --sheet` writes a contact sheet of every card
|
||||
* to `dist/og-proof.jpg` so the set can be looked at in one go. Not part of the
|
||||
* check; a human still has to look.
|
||||
*/
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
OG_CARDS,
|
||||
PORTRAIT_PAGES,
|
||||
articleCard,
|
||||
ogCardPath,
|
||||
} from '../src/data/og-cards.ts';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DIST = join(ROOT, 'dist');
|
||||
const SITE = 'https://adr.smlcompany.ca';
|
||||
|
||||
const strip = (html) =>
|
||||
html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
async function pages(dir = DIST) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await pages(full)));
|
||||
else if (entry.name === 'index.html') {
|
||||
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));
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
const fail = (msg) => problems.push(msg);
|
||||
|
||||
try {
|
||||
await stat(join(DIST, 'index.html'));
|
||||
} catch {
|
||||
console.error('dist/ is missing or empty. Run `npm run build` first.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const built = await pages();
|
||||
const seenCards = new Set();
|
||||
let checkedHeadlines = 0;
|
||||
let checkedArticles = 0;
|
||||
|
||||
/**
|
||||
* An article's expected card, from the SAME `articleCard()` the endpoint calls,
|
||||
* given the title in that article's own frontmatter. Read from the `.mdx` rather
|
||||
* than from the built page, so the comparison has two independent sides: what
|
||||
* the article says its title is, and what the route rendered as the `<h1>`.
|
||||
*/
|
||||
async function expectedArticleCard(path) {
|
||||
const slug = path.replace(/^\/insights\/|\/$/g, '');
|
||||
for (const ext of ['mdx', 'md']) {
|
||||
try {
|
||||
const src = await readFile(
|
||||
join(ROOT, 'src', 'content', 'insights', `${slug}.${ext}`),
|
||||
'utf8',
|
||||
);
|
||||
const m = /^title:\s*(.*)$/m.exec(src);
|
||||
if (!m) break;
|
||||
let title = m[1].trim();
|
||||
// YAML scalar: strip one layer of quoting and unescape a doubled single
|
||||
// quote, which is how YAML writes a literal apostrophe inside '…'.
|
||||
if (
|
||||
(title.startsWith("'") && title.endsWith("'")) ||
|
||||
(title.startsWith('"') && title.endsWith('"'))
|
||||
) {
|
||||
title = title.slice(1, -1);
|
||||
}
|
||||
title = title.replace(/''/g, "'");
|
||||
return articleCard(title);
|
||||
} catch {
|
||||
/* try the next extension */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const { path, file } of built) {
|
||||
const html = await readFile(file, 'utf8');
|
||||
|
||||
// ---- 1. og:image exists -------------------------------------------------
|
||||
const og = /<meta property="og:image" content="([^"]+)"/.exec(html);
|
||||
if (!og) {
|
||||
fail(`${path}: no og:image meta tag at all`);
|
||||
continue;
|
||||
}
|
||||
const url = og[1];
|
||||
if (!url.startsWith(SITE + '/')) {
|
||||
fail(`${path}: og:image is not an absolute URL on ${SITE} — ${url}`);
|
||||
continue;
|
||||
}
|
||||
const assetPath = url.slice(SITE.length);
|
||||
try {
|
||||
await stat(join(DIST, assetPath));
|
||||
} catch {
|
||||
fail(`${path}: og:image points at ${assetPath}, which is not in dist/`);
|
||||
continue;
|
||||
}
|
||||
seenCards.add(assetPath);
|
||||
|
||||
// ---- 2. card copy is the page's own copy --------------------------------
|
||||
const isPortrait = PORTRAIT_PAGES.includes(path);
|
||||
const card = OG_CARDS[path];
|
||||
|
||||
if (isPortrait) {
|
||||
if (card) fail(`${path}: in PORTRAIT_PAGES and in OG_CARDS — pick one`);
|
||||
if (assetPath.startsWith('/og/')) {
|
||||
fail(`${path}: is a portrait page but its og:image is a generated card`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const expected = ogCardPath(path);
|
||||
if (assetPath !== expected) {
|
||||
fail(`${path}: og:image is ${assetPath}, expected ${expected}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ AN ARTICLE IS CHECKED THE SAME WAY AS A REGISTRY PAGE, AND UNTIL
|
||||
* 2026-08-31 IT WAS NOT CHECKED AT ALL.
|
||||
*
|
||||
* The first version of this script matched an article's card FILENAME and then
|
||||
* `continue`d — skipping the headline and eyebrow comparisons entirely. So the
|
||||
* one surface `check:claims` cannot reach was also the one surface this script
|
||||
* did not compare, which is the opposite of what its own header claims and what
|
||||
* `docs/04` says it enforces.
|
||||
*
|
||||
* `adversarial-reviewer` proved it rather than arguing it: with
|
||||
* `headline: 'DELIBERATELY WRONG CARD TEXT — probe'` set in the endpoint and one
|
||||
* article published, the card rendered that sentence in 68px Instrument Serif
|
||||
* and this script printed `OK — every og:image resolves, and no card asserts
|
||||
* anything its page does not`, exit 0. **`checkedHeadlines` stayed pinned at the
|
||||
* registry size** no matter how many articles published — a coverage number
|
||||
* that reads like completeness and falls further behind as the site grows,
|
||||
* which is exactly the uniform-pass shape `CLAUDE.md` warns is the dangerous
|
||||
* half.
|
||||
*
|
||||
* An article has no registry entry by design — its card comes from the
|
||||
* collection — so the expectation comes from the page instead: the endpoint
|
||||
* sets an article card's headline to `entry.data.title`, which is also the
|
||||
* page's `<h1>`. Comparing the card's source of truth against the rendered
|
||||
* `<h1>` is therefore the same check, and the eyebrow is the literal the
|
||||
* endpoint sets.
|
||||
*/
|
||||
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
|
||||
if (!card && !isArticle) {
|
||||
fail(`${path}: built, not a portrait page, and has no OG_CARDS entry`);
|
||||
continue;
|
||||
}
|
||||
let expected_card = card;
|
||||
if (!expected_card) {
|
||||
expected_card = await expectedArticleCard(path);
|
||||
if (!expected_card) {
|
||||
fail(
|
||||
`${path}: could not read a \`title:\` from this article's own .mdx, so ` +
|
||||
'its card cannot be compared against anything. That is a failure, not ' +
|
||||
'a skip — an unchecked card is the one surface check:claims cannot see.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const expectedEyebrow = expected_card.eyebrow;
|
||||
|
||||
const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/.exec(html);
|
||||
if (!h1) {
|
||||
fail(`${path}: no <h1> to compare the card headline against`);
|
||||
} else {
|
||||
const text = strip(h1[1]);
|
||||
const expectedHeadline = expected_card.headline;
|
||||
if (text !== expectedHeadline) {
|
||||
fail(
|
||||
`${path}: card headline is not the page's <h1>.\n` +
|
||||
` <h1>: ${JSON.stringify(text)}\n` +
|
||||
` card: ${JSON.stringify(expectedHeadline)}`,
|
||||
);
|
||||
} else {
|
||||
checkedHeadlines += 1;
|
||||
if (isArticle) checkedArticles += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const eyebrow = /<p class="eyebrow"[^>]*>([\s\S]*?)<\/p>/.exec(html);
|
||||
if (!eyebrow) {
|
||||
fail(`${path}: no .eyebrow to compare the card eyebrow against`);
|
||||
} else {
|
||||
const text = strip(eyebrow[1]);
|
||||
if (text !== expectedEyebrow) {
|
||||
fail(
|
||||
`${path}: card eyebrow is not the page's first .eyebrow.\n` +
|
||||
` page: ${JSON.stringify(text)}\n` +
|
||||
` card: ${JSON.stringify(expectedEyebrow)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ AND THE COVERAGE IS ASSERTED, NOT REPORTED. Printing "20 headlines matched"
|
||||
* beside a growing site is how the gap above stayed invisible: the number went up
|
||||
* and never went up ENOUGH, and nothing said so. Every built page except the
|
||||
* portrait pages carries a generated card, so the count must equal that or a page
|
||||
* was silently skipped.
|
||||
*/
|
||||
const shouldCheck = built.filter(
|
||||
({ path }) => !PORTRAIT_PAGES.includes(path),
|
||||
).length;
|
||||
if (checkedHeadlines !== shouldCheck) {
|
||||
fail(
|
||||
`only ${checkedHeadlines} of ${shouldCheck} non-portrait pages had their ` +
|
||||
'card headline compared against their <h1>. A page was skipped, which is ' +
|
||||
'the failure this assertion exists to make loud.',
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 3. no card generated for a page that does not exist -------------------
|
||||
// A stray card is not a shipped defect, but it is the signature of a page that
|
||||
// was renamed or removed and a registry entry that was not — which the next
|
||||
// person reads as "the card exists, so the page must".
|
||||
let strays = [];
|
||||
try {
|
||||
const files = await readdir(join(DIST, 'og'));
|
||||
strays = files
|
||||
.filter((f) => f.endsWith('.jpg'))
|
||||
.map((f) => `/og/${f}`)
|
||||
.filter((p) => !seenCards.has(p));
|
||||
} catch {
|
||||
fail('dist/og/ does not exist — no cards were generated');
|
||||
}
|
||||
for (const s of strays) {
|
||||
fail(`${s}: generated, but no built page references it`);
|
||||
}
|
||||
|
||||
// ---- optional contact sheet ----------------------------------------------
|
||||
if (process.argv.includes('--sheet')) {
|
||||
const files = (await readdir(join(DIST, 'og')))
|
||||
.filter((f) => f.endsWith('.jpg'))
|
||||
.sort();
|
||||
const COLS = 3;
|
||||
const W = 400;
|
||||
const H = 210;
|
||||
const rows = Math.ceil(files.length / COLS);
|
||||
const tiles = await Promise.all(
|
||||
files.map(async (f, i) => ({
|
||||
input: await sharp(join(DIST, 'og', f))
|
||||
.resize(W, H)
|
||||
.toBuffer(),
|
||||
left: (i % COLS) * W,
|
||||
top: Math.floor(i / COLS) * H,
|
||||
})),
|
||||
);
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: COLS * W,
|
||||
height: rows * H,
|
||||
channels: 3,
|
||||
background: '#ffffff',
|
||||
},
|
||||
})
|
||||
.composite(tiles)
|
||||
.jpeg({ quality: 82 })
|
||||
.toBuffer();
|
||||
await writeFile(join(DIST, 'og-proof.jpg'), sheet);
|
||||
console.log(
|
||||
`contact sheet: dist/og-proof.jpg — ${files.length} cards, ${COLS}x${rows}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`og:proof — ${built.length} built pages, ${seenCards.size} distinct og:image ` +
|
||||
`targets, ${checkedHeadlines} card headlines matched their page <h1> ` +
|
||||
`(${checkedArticles} of them articles).`,
|
||||
);
|
||||
if (problems.length) {
|
||||
console.error(`\nOG CARD PROBLEMS — ${problems.length}:`);
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
'OK — every og:image resolves, and no card asserts anything its page does not.',
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Title, description, date, topic pills, reading time."
|
||||
*
|
||||
* ONE LINK, AND THE WHOLE CARD IS ITS HIT AREA — the `PracticeCard` pattern,
|
||||
* for the same measured reason: the link wraps only the headline, so its
|
||||
* accessible name is the headline rather than the card's four elements, and a
|
||||
* `::after` stretched over the positioned card carries the click. Three of these
|
||||
* on `/` would otherwise be three links each announcing a date, two pills, a
|
||||
* reading time and a 150-character description.
|
||||
*
|
||||
* THE PARENT MUST NOT TRY TO STYLE THIS ROOT. Astro does not pass a parent's
|
||||
* scope attribute to a child's root element, so a grid's `.card { block-size:
|
||||
* 100% }` compiles against the parent's cid and never matches — `CLAUDE.md`
|
||||
* records this costing twice, and names `ArticleCard` as one of the next places
|
||||
* it would happen. The card sizes itself below; a parent supplies only
|
||||
* `display: grid` and `gap` on its own element.
|
||||
*
|
||||
* `readingTime` IS RENDERED WITH ITS UNIT AND IS NOT A CLAIM ABOUT THE PRACTICE.
|
||||
* §4 Forbidden bars counts of matters, hours mediated and years in practice —
|
||||
* a number describing how long an article takes to read is not in that family,
|
||||
* and `check:claims`'s `counts-and-tenure` pattern is scoped to the practice.
|
||||
* Do not reach for a matter count, a settlement rate, or a case figure here.
|
||||
*/
|
||||
import Pill from './Pill.astro';
|
||||
import { TOPIC_LABELS, formatArticleDate, isoDate } from '../data/insights';
|
||||
import type { InsightTopic } from '../data/insights';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: Date;
|
||||
topics: readonly InsightTopic[];
|
||||
/** Minutes. */
|
||||
readingTime: number;
|
||||
/** Explicit: docs/02 forbids skipped heading levels. */
|
||||
level: 2 | 3;
|
||||
}
|
||||
const { href, title, description, date, topics, readingTime, level } =
|
||||
Astro.props;
|
||||
const H = `h${level}` as 'h2' | 'h3';
|
||||
---
|
||||
|
||||
<article class="acard">
|
||||
<div class="acard-meta">
|
||||
<time datetime={isoDate(date)}>{formatArticleDate(date)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
|
||||
<H class="acard-title">
|
||||
<a class="acard-link" href={href}>{title}</a>
|
||||
</H>
|
||||
|
||||
<p class="acard-desc">{description}</p>
|
||||
|
||||
{
|
||||
topics.length > 0 && (
|
||||
<ul class="acard-topics" role="list">
|
||||
{topics.map((topic) => (
|
||||
<li>
|
||||
<Pill>{TOPIC_LABELS[topic]}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.acard {
|
||||
position: relative;
|
||||
/* Sizes itself to its cell — see the note on why the grid cannot. */
|
||||
block-size: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--dur-hover) var(--ease),
|
||||
box-shadow var(--dur-hover) var(--ease);
|
||||
}
|
||||
.acard:hover {
|
||||
border-color: var(--rule);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.acard-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.acard-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.acard-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The card-wide hit area. `inset: 0` on the positioned card, so the click
|
||||
target is the card and the accessible name stays the headline. */
|
||||
.acard-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
/* The ring has to be on the CARD, not on the inline text, or focus draws a
|
||||
box around two words in the middle of a clickable panel. */
|
||||
.acard-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.acard-link:focus-visible::after {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
|
||||
.acard-desc {
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.acard-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
/* `margin-block-start: auto` pins the pills to the bottom of the card so a
|
||||
row of cards with different description lengths still aligns on them. */
|
||||
margin-block-start: auto;
|
||||
padding-block-start: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -66,18 +66,37 @@ const classes = ['btn', `btn-${variant}`, className];
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ⚠️ 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.
|
||||
|
||||
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.
|
||||
|
||||
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(--border);
|
||||
color: var(--text);
|
||||
border-color: var(--btn-ghost-border, var(--border));
|
||||
color: var(--btn-ghost-fg, var(--text));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--btn-ghost-border-hover, var(--accent));
|
||||
color: var(--btn-ghost-fg-hover, var(--accent));
|
||||
}
|
||||
|
||||
/* `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);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.btn-gold:hover {
|
||||
|
||||
@@ -57,10 +57,12 @@ const { slots } = Astro.props;
|
||||
measured-sounding comment that was false is this project's own named
|
||||
failure mode.
|
||||
|
||||
`minmax(0, 1fr)` cannot overflow at any width or any root font size,
|
||||
which also retires the `min(11rem, 100%)` guard this line briefly
|
||||
`minmax(0, 1fr)` means the TRACK cannot overflow at any width or any root
|
||||
font size, which retires the `min(11rem, 100%)` guard this line briefly
|
||||
carried — that guard was fixing the overflow symptom of a floor that
|
||||
should not have been there. */
|
||||
should not have been there. ⚠️ **THE TRACK IS NOT THE CONTENT:** a label's
|
||||
own words can overflow the track, and at a 200% default font size they
|
||||
did. That is why `.credential-label` below carries `overflow-wrap`. */
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6) var(--space-5);
|
||||
margin: 0;
|
||||
@@ -102,7 +104,10 @@ const { slots } = Astro.props;
|
||||
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
|
||||
and must not be prevented from doing so. Do not add `white-space: nowrap`
|
||||
here, and do not shorten the label to make the row tidier: the asymmetry
|
||||
is the honest part. */
|
||||
is the honest part. ⚠️ `overflow-wrap: anywhere` is LOAD-BEARING here, not
|
||||
cosmetic — `anywhere`, not `break-word`: see `docs/02` §Reflow, instrument
|
||||
finding 1. */
|
||||
text-wrap: pretty;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,12 @@ void _props;
|
||||
200% default font size (root 32px) overflowed the document by 111px at
|
||||
320px — WCAG 1.4.10 Reflow. Measured 2026-08-28; no pill on the site is
|
||||
that long today, which is exactly why this is easy to delete and must not
|
||||
be. `normal` costs nothing at default size. */
|
||||
be. `normal` costs nothing at default size.
|
||||
⚠️ `normal` ALONE IS NOT ENOUGH — a one-word pill cannot wrap at a space
|
||||
that is not there. `anywhere`, not `break-word`, and this is the BACKSTOP
|
||||
rather than the cause: `docs/02` §Reflow, instrument finding 1 and the
|
||||
`Pill` row. */
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,7 +48,13 @@ const H = `h${level}` as 'h2' | 'h3';
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
/* CLAMPED, not a flat --space-6, for the reason `.feature` on `/` carries:
|
||||
the space scale is rem-based, so `2rem` is 64 px a side at a 200% default
|
||||
font size — 128 px of padding inside a ~224 px content box, which left
|
||||
~96 px for the whole card column and was the real cause of a `Pill`
|
||||
overflowing. The 10vw term holds it at 32 px on every viewport from 320 px
|
||||
up and lets it collapse only when the rem is doubled. */
|
||||
padding: clamp(var(--space-4), 10vw, var(--space-6));
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Long-form wrapper. Owns all typographic defaults for MDX."
|
||||
*
|
||||
* WHY IT HAS TO OWN THEM. `global.css`'s reset sets `* { margin: 0 }` and the
|
||||
* base type rules deliberately do not style `<h2>`, `<ul>`, `<blockquote>` or
|
||||
* `<code>` in body flow — every page so far has written its own section markup,
|
||||
* so nothing on the site has ever needed defaults for a document. An MDX article
|
||||
* is the first content this repo does not hand-mark up, and without a wrapper it
|
||||
* would render as one undifferentiated block. `global.css` already records that
|
||||
* exact failure for `.prose` itself: two `<p>` children with a 0.0 px gap,
|
||||
* shipped, because nothing supplied paragraph spacing.
|
||||
*
|
||||
* `:where()` ON EVERY SELECTOR, so specificity stays at zero and a page or a
|
||||
* component can override any of it without `!important` — the same device
|
||||
* `global.css` uses for `:where(.prose) > p + p`, and for the same reason.
|
||||
*
|
||||
* SCOPED STYLES NEED `:global()` HERE, and this is the one component where that
|
||||
* is correct rather than a smell: the elements being styled come from MDX at
|
||||
* build time and carry no `data-astro-cid` of this component's, so a scoped
|
||||
* descendant selector would match nothing. Astro's own `is:global` guidance.
|
||||
* The wrapper element itself is ours, so everything stays inside `.prose-body`.
|
||||
*
|
||||
* NO `max-inline-size` OF ITS OWN — it composes with `global.css`'s `.prose`,
|
||||
* which caps the reading measure at `--width-prose`. A second cap here would be
|
||||
* a second number to keep true.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="prose prose-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* --- Rhythm ---------------------------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(p, ul, ol, blockquote, figure, hr, table)) {
|
||||
margin-block-start: var(--space-5);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.prose-body :global(:where(p, li)) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Headings -------------------------------------------------------- */
|
||||
|
||||
/* An article's own `<h1>` is the page's, rendered by the route. MDX bodies
|
||||
start at `##`, so these are h2/h3/h4. A skipped level is a docs/02 breach
|
||||
and is caught by review, not by CSS. */
|
||||
.prose-body :global(:where(h2)) {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(h3)) {
|
||||
margin-block-start: var(--space-7);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(h4)) {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Nothing may collapse against the top of the article. */
|
||||
.prose-body :global(:where(:first-child)) {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
/* --- Lists ------------------------------------------------------------ */
|
||||
|
||||
/* `global.css` strips list styling from `ul[role='list']` only, so an MDX
|
||||
list keeps the UA marker and needs indenting rather than resetting. */
|
||||
.prose-body :global(:where(ul, ol)) {
|
||||
padding-inline-start: var(--space-6);
|
||||
}
|
||||
.prose-body :global(:where(li + li)) {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
.prose-body :global(:where(li)) {
|
||||
padding-inline-start: var(--space-1);
|
||||
}
|
||||
.prose-body :global(:where(li::marker)) {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
/* --- Emphasis, links, quotes ----------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(strong)) {
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(em)) {
|
||||
font-style: italic;
|
||||
}
|
||||
/* Links keep `global.css`'s colour and underline; only the offset is set, so
|
||||
a descender does not sit on the rule at body size. */
|
||||
.prose-body :global(:where(a)) {
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.prose-body :global(:where(blockquote)) {
|
||||
padding-inline-start: var(--space-5);
|
||||
border-inline-start: 2px solid var(--rule);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(blockquote p)) {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-body :global(:where(hr)) {
|
||||
margin-block: var(--space-8);
|
||||
border: none;
|
||||
border-block-start: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* --- Code ------------------------------------------------------------- */
|
||||
|
||||
/* Inline code only. A statute reference or a header name, not a code block:
|
||||
nothing in docs/03's content territories calls for one, and `<pre>` would
|
||||
need horizontal overflow handling this component has no call site for. Add
|
||||
it with the first article that needs it, and give it `overflow-x: auto`. */
|
||||
.prose-body :global(:where(code)) {
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* --- Figures and tables ---------------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(img)) {
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.prose-body :global(:where(figcaption)) {
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
/* ⚠️ 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>
|
||||
+83
-14
@@ -12,6 +12,7 @@
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { SITE, PORTRAIT } from '../data/site';
|
||||
import { OG_CARDS, PORTRAIT_PAGES, ogCardPath } from '../data/og-cards';
|
||||
|
||||
export interface Props {
|
||||
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 50–60. */
|
||||
@@ -21,8 +22,15 @@ export interface Props {
|
||||
/** Overrides the canonical path. Defaults to this page's own URL. */
|
||||
canonical?: string;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
/** 1200×630 source. Defaults to the portrait crop in src/assets.
|
||||
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
|
||||
/**
|
||||
* AN EXPLICIT PER-PAGE OVERRIDE, AND ALMOST NOTHING SHOULD PASS IT. Which
|
||||
* pages take the portrait is decided by `PORTRAIT_PAGES` and everything else
|
||||
* takes its generated card — both resolved below from the pathname, so the
|
||||
* decision lives in `src/data/og-cards.ts` rather than in nineteen call sites.
|
||||
* This exists for an article that sets its own `image` in frontmatter. Passing
|
||||
* it to get the portrait onto a third page would reinstate the interim R15
|
||||
* exists to end. `ImageMetadata` is an Astro ambient global — nothing to import.
|
||||
*/
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
|
||||
@@ -78,16 +86,77 @@ if (!Astro.site) {
|
||||
}
|
||||
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
|
||||
|
||||
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
|
||||
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
|
||||
// content types, and several still do not decode WebP at all.
|
||||
const ogImage = await getImage({
|
||||
src: image ?? ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const ogImageUrl = new URL(ogImage.src, Astro.site);
|
||||
/**
|
||||
* THE OG IMAGE, AND THIS IS WHERE R15 IS DISCHARGED — build step 7b.
|
||||
*
|
||||
* Two kinds of card, per Q40 and docs/04, both resolved from the pathname: the
|
||||
* pages in `PORTRAIT_PAGES` get the portrait crop, and every other page gets the
|
||||
* card generated for it by `src/pages/og/[...slug].jpg.ts`.
|
||||
*
|
||||
* ⚠️ A MISSING REGISTRY ENTRY THROWS RATHER THAN FALLING BACK TO THE PORTRAIT.
|
||||
* That is the whole mechanism. R15's failure mode is not that the wrong image
|
||||
* ships — it is that the wrong image ships *invisibly*, because no one on this
|
||||
* project ever sees a link preview. A silent fallback reproduces exactly that,
|
||||
* and reads as intentional. Both sides derive the path from `ogCardPath()`, so a
|
||||
* page with an entry cannot point at a card the endpoint did not generate.
|
||||
*
|
||||
* Articles are exempt from the registry check: their cards come from the same
|
||||
* `getCollection('insights', not draft)` the article route pages come from, so
|
||||
* a built article always has one and a draft has neither.
|
||||
*/
|
||||
const path = Astro.url.pathname;
|
||||
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
|
||||
const usesPortrait = (PORTRAIT_PAGES as readonly string[]).includes(path);
|
||||
const hasCard = isArticle || path in OG_CARDS;
|
||||
|
||||
if (!image && !usesPortrait && !hasCard) {
|
||||
throw new Error(
|
||||
`No Open Graph card for ${path}.\n` +
|
||||
' Add an entry to OG_CARDS in src/data/og-cards.ts whose `headline` is ' +
|
||||
"this page's own <h1>, verbatim — `npm run og:proof` compares the two.\n" +
|
||||
' Only the pages in PORTRAIT_PAGES use the portrait (AGENTS.md Q40, R15).',
|
||||
);
|
||||
}
|
||||
|
||||
// JPEG on purpose, for both kinds. Page images are AVIF/WebP with a fallback
|
||||
// (CLAUDE.md), but link-preview crawlers are not browsers — LinkedIn and Slack
|
||||
// do not negotiate content types, and several still do not decode WebP at all.
|
||||
// The generated card is already a 1200×630 JPEG, so it takes no `getImage` pass;
|
||||
// running one would re-encode a finished image for nothing.
|
||||
const portraitSource = image ?? ogDefault;
|
||||
const ogImageUrl =
|
||||
image || usesPortrait
|
||||
? new URL(
|
||||
(
|
||||
await getImage({
|
||||
src: portraitSource,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
})
|
||||
).src,
|
||||
Astro.site,
|
||||
)
|
||||
: new URL(ogCardPath(path), Astro.site);
|
||||
|
||||
/**
|
||||
* ⚠️ 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 : (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
|
||||
@@ -114,13 +183,13 @@ const jsonLdText =
|
||||
<meta property="og:image" content={ogImageUrl.href} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
<meta property="og:image:alt" content={resolvedImageAlt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl.href} />
|
||||
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
<meta name="twitter:image:alt" content={resolvedImageAlt} />
|
||||
|
||||
{
|
||||
jsonLdText && (
|
||||
|
||||
@@ -71,7 +71,7 @@ const aboutLinks = [
|
||||
<div class="footer-grid">
|
||||
<nav class="footer-nav" aria-label="Footer">
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Practice areas</h2>
|
||||
<h2 class="eyebrow footer-heading">Practice areas</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
@@ -85,7 +85,7 @@ const aboutLinks = [
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Process</h2>
|
||||
<h2 class="eyebrow footer-heading">Process</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
processLinks.map((link) => (
|
||||
@@ -98,7 +98,7 @@ const aboutLinks = [
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">About</h2>
|
||||
<h2 class="eyebrow footer-heading">About</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
aboutLinks.map((link) => (
|
||||
@@ -112,7 +112,7 @@ const aboutLinks = [
|
||||
</nav>
|
||||
|
||||
<div class="footer-col footer-contact">
|
||||
<h2 class="footer-heading">Contact</h2>
|
||||
<h2 class="eyebrow footer-heading">Contact</h2>
|
||||
<ul role="list">
|
||||
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
|
||||
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
|
||||
@@ -171,8 +171,14 @@ const aboutLinks = [
|
||||
border-block-end: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* `flex-wrap: wrap` is the reflow fix, chosen OVER `overflow-wrap: anywhere` on
|
||||
the name: the name is a flex item at `min-width: auto` and cannot shrink
|
||||
below "Lajevardi", and wrapping the flex line breaks no word where
|
||||
`anywhere` would have hyphenated a person's name. No effect at any normal
|
||||
size. `docs/02` §Reflow carries the measurement. */
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-block-size: 44px;
|
||||
@@ -207,12 +213,10 @@ const aboutLinks = [
|
||||
gap: var(--space-7) var(--space-6);
|
||||
}
|
||||
|
||||
/* Type comes from the global `.eyebrow` class on the element; only what differs
|
||||
is here. The colour is load-bearing, not decorative: gold-l on ink is
|
||||
11.09:1 and `.eyebrow`'s own `--text-meta` on ink is 3.07:1, which fails. */
|
||||
.footer-heading {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-inverse-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
@@ -240,6 +244,12 @@ const aboutLinks = [
|
||||
color: var(--text-inverse-2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* `anywhere`, NOT `break-word` — only `anywhere` reduces min-content size, which
|
||||
is the defect: the address has no break opportunity. `docs/02` §Reflow. */
|
||||
.footer-contact a[href^='mailto:'] {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Not a link, so no target floor — but it shares a column with links and
|
||||
should sit on the same rhythm. */
|
||||
.footer-meta {
|
||||
|
||||
+100
-24
@@ -23,7 +23,24 @@ import InfinityMark from './InfinityMark.astro';
|
||||
import Button from './Button.astro';
|
||||
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
/* ⚠️ GATED BY A BUILD FAILURE, NOT BY THIS COMMENT — `AGENTS.md` R20 and Q61.
|
||||
The seventh nav item arms two measured header defects under fallback font
|
||||
metrics. Prose cross-references did not gate it: every check passed while it
|
||||
fired. Delete the throw as part of the fix, not before it. */
|
||||
const showInsights = published.length >= 2;
|
||||
if (showInsights) {
|
||||
throw new Error(
|
||||
'AGENTS.md R20 — the seventh nav item is gated and this build would ship it.\n' +
|
||||
`A second Insights article is published (${published.length} live), which puts ` +
|
||||
'Insights in the primary nav. With seven items under FALLBACK font metrics — ' +
|
||||
'the font-display: swap window, at the default text size, no reader setting ' +
|
||||
'involved — the masthead measures 141px across 1056-1091px instead of 81px: a ' +
|
||||
'60px shift on every page against the CLS < 0.05 budget, and 44px of #main ' +
|
||||
'behind the sticky header after "Skip to content".\n' +
|
||||
'Fix that first (docs/02 §Reflow lists the two candidate fixes), then delete ' +
|
||||
'this guard. Do not work around it by unpublishing the article.',
|
||||
);
|
||||
}
|
||||
|
||||
const items = PRIMARY_NAV.filter(
|
||||
(item) => item.href !== '/insights/' || showInsights,
|
||||
@@ -158,10 +175,15 @@ const isHome = path === '/';
|
||||
Pouya's name read as a held capability, and §4 then recorded Q.Arb as
|
||||
merely commenced. Q33 answered that the same day, and the premise was
|
||||
wrong twice over — §4 now records Q.Arb as HELD, and even before it did:
|
||||
ADR designations are voluntary credentials, not licences, and COMMERCIAL
|
||||
arbitral appointment in Ontario is not gated behind a designation — so the
|
||||
constraint was always positional, never legal, and Pouya accepts sole,
|
||||
party-appointed and co-arbitration work today. See §4 Offerings.
|
||||
ADR designations are voluntary credentials rather than licences, and on
|
||||
**Pouya's stated position, which §4 Offerings records attributed to him and
|
||||
deliberately unstamped**, commercial arbitral appointment in Ontario is not
|
||||
gated behind a designation — so the constraint was always positional rather
|
||||
than legal, and he accepts sole, party-appointed and co-arbitration work
|
||||
today. ⚠️ **STATED AS HIS POSITION, NOT AS FACT, AND THAT IS REQUIRED:** §4
|
||||
Forbidden bars the class claim about arbitral gating **in both directions**,
|
||||
and this repository does not conclude a proposition of law. See §4 Offerings
|
||||
and the `struck-universal-q39` row.
|
||||
|
||||
SCOPED 2026-08-27 (Q39). This comment said "Anyone may be appointed an
|
||||
arbitrator in Ontario", which Pouya checked and found FALSE as a universal:
|
||||
@@ -176,9 +198,11 @@ const isHome = path === '/';
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
/* 48px, not 44. Still clears the touch floor, and it reserves the height the
|
||||
two-line brand takes at >=76rem so the sticky header is one constant 81px
|
||||
across every width where it is sticky — which is what --header-h and
|
||||
scroll-padding-top are keyed to. One number instead of two bands. */
|
||||
two-line brand takes at >=76rem so the sticky header is 81px at every width
|
||||
where it is sticky — AT THE DEFAULT TEXT SIZE, which is what `--header-h`
|
||||
and `scroll-padding-top` are keyed to. One number instead of two bands.
|
||||
Above the default the masthead wraps and is taller on purpose, and the
|
||||
sticky gate at the 66rem block is what keeps that safe. */
|
||||
min-block-size: 48px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
@@ -190,22 +214,35 @@ const isHome = path === '/';
|
||||
gap: var(--space-05);
|
||||
}
|
||||
/* The tagline appears only where there is room for it — see the 76rem block.
|
||||
Measured: at 11px with 0.18em tracking the string is ~285px wide, and
|
||||
restoring it under the name pushed the one-row header past its content box
|
||||
by 18px at 1024 with six items and 84px with seven. The brand name carries
|
||||
Measured: at 11px with 0.18em tracking the string is 283.1px wide. (Two
|
||||
figures for 1024px were struck 2026-08-31: below 66rem `.nav` takes
|
||||
`flex-basis: 100%` and `.header-cta` is `display: none`, so there is no
|
||||
one-row header there and no CTA box to be past — neither number could be
|
||||
re-derived. See the rule at the 66rem block below.) The brand name carries
|
||||
the identity on its own; the tagline is a flourish, and `/` opens with the
|
||||
same words as the hero eyebrow (docs/01). */
|
||||
.brand-tagline {
|
||||
display: none;
|
||||
font-size: var(--text-2xs); /* 11px — the eyebrow floor in docs/02 */
|
||||
/* HELD AT 11px, BELOW THE `.eyebrow` THIS ELEMENT CARRIES. At 14px the header
|
||||
WRAPS and stands at 144.98px instead of 81.00px — at 1216 with six nav items,
|
||||
and at EVERY width from 1216 up with a seventh. Nothing overflows and the CTA
|
||||
stays on the content edge, so the cost is now 64px of header height on every
|
||||
page: larger than the pre-wrap cost it replaces, and visible rather than
|
||||
invisible. `docs/02` §Reflow carries the superseded figures. Insights is that
|
||||
seventh item; `showInsights` turns it on at two published articles. */
|
||||
font-size: var(--text-2xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ⚠️ NEVER `white-space: nowrap` HERE. It was, until 2026-08-31, and because the
|
||||
masthead is on all 22 pages that one declaration was the site's binding
|
||||
reflow defect at a 200% default font size. The name is two words and takes
|
||||
two lines when it has to; at every normal size it never wraps. WCAG 1.4.4 /
|
||||
1.4.10; `docs/02` §Reflow carries the measurement. */
|
||||
.brand-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.brand:hover .brand-name {
|
||||
@@ -364,14 +401,30 @@ const isHome = path === '/';
|
||||
|
||||
Sticky only from here up, too. Below this the nav takes a second row and
|
||||
the header stands at 137px, which is more of a small viewport than a
|
||||
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there. */
|
||||
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there.
|
||||
That same reasoning is what the sticky gate below extends to text size: a
|
||||
tall header is not worth sticking whether the height comes from a narrow
|
||||
viewport or from large type. */
|
||||
@media (min-width: 66rem) {
|
||||
.site-header {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
.header-inner {
|
||||
flex-wrap: nowrap;
|
||||
/* ⚠️ STICKY ONLY WHILE THE MASTHEAD IS ONE ROW. A media query cannot say so:
|
||||
its `rem` resolves against the browser's DEFAULT font size, a property's
|
||||
against the root element. TWO terms, both load-bearing —
|
||||
`100vw - 66rem` catches a root ABOVE the default (the row wraps and the
|
||||
header stands 244–351px); `1rem - 16px` catches a root BELOW it, where the
|
||||
80rem content cap shrinks faster than the header's px minimums and the row
|
||||
wraps at EVERY viewport width — 65px of `#main` sat behind the header at
|
||||
Chrome's "Very small" (9px) with only the first term. `* 100000` saturates
|
||||
because wrapping is a step and a ramp left 10–69px covered across roots
|
||||
18–30; `-100vh` bounds the result. Both terms are >= 0 at the default size,
|
||||
so this is exactly `0px`. `docs/02` §Reflow carries the measurements and
|
||||
the cases where the offset can still be short. */
|
||||
inset-block-start: clamp(
|
||||
-100vh,
|
||||
min(calc((100vw - 66rem) * 100000), calc((1rem - 16px) * 100000)),
|
||||
0px
|
||||
);
|
||||
}
|
||||
.brand {
|
||||
margin-inline-end: var(--space-5);
|
||||
@@ -379,14 +432,23 @@ const isHome = path === '/';
|
||||
.nav {
|
||||
flex-basis: auto;
|
||||
}
|
||||
/* nowrap, and flex:none so the nav is never squeezed below its content
|
||||
width. Measured before this: at 960-1250px the seven-item nav broke to
|
||||
two rows and the header stood at 141px instead of 81px. */
|
||||
/* ⚠️ NOTHING HERE MAY SAY `flex-wrap: nowrap`, ON `.header-inner` OR ON
|
||||
`.nav-list`. `.header-inner`'s was the binding one, measured necessary AND
|
||||
sufficient: a `nowrap` line cannot break, so at a 200% text size the row ran
|
||||
944px past a 1280px viewport with Practice, Fees, Contact and the CTA
|
||||
off-screen. `.nav-list`'s is INERT — identical at every width and text
|
||||
setting, six items and seven — and stays removed only so this prohibition is
|
||||
not contradicted by a `nowrap` in the same file. Wrapping is the only
|
||||
mechanism that reflows under all THREE ways a reader enlarges text, because
|
||||
it is driven by used sizes rather than by a query.
|
||||
⚠️ "It never wraps above this breakpoint" holds only with the WEBFONTS
|
||||
LOADED: under fallback metrics with a seventh nav item the header is 141px
|
||||
across 1056–1091, which is both a 60px swap-in shift and 44px of `#main`
|
||||
behind the sticky header. Latent — six items never wrap. `docs/02` §Reflow. */
|
||||
.nav-list {
|
||||
flex-wrap: nowrap;
|
||||
flex: none;
|
||||
/* 16px from 66rem, widening to 24px at 76rem where there is room for it.
|
||||
Measured with seven items at every width from 1024px up. */
|
||||
/* 16px from 66rem, widening to 24px at 80rem — see that block; the 76rem
|
||||
block deliberately does NOT widen it. Measured with seven items at every
|
||||
width from 1024px up. */
|
||||
column-gap: var(--space-4);
|
||||
}
|
||||
.header-cta {
|
||||
@@ -440,6 +502,20 @@ const isHome = path === '/';
|
||||
.brand-tagline {
|
||||
display: block;
|
||||
}
|
||||
/* THE STICKY GATE MOVES WITH THE TAGLINE, and this is the same threshold the
|
||||
binary search above produced: with the tagline the one-row masthead fits
|
||||
from 1207px = 75.4rem, so 66rem is no longer the width it needs. Gating the
|
||||
wider band on 66rem left the header sticky and wrapped from root 18 up —
|
||||
46px of `#main` behind it at 1216/1280, 51px at 1440, 69px at 1920 — while
|
||||
`/` measured clean throughout, because `/` is the one page that suppresses
|
||||
the tagline. Each band gates on the width ITS layout requires. */
|
||||
.site-header {
|
||||
inset-block-start: clamp(
|
||||
-100vh,
|
||||
min(calc((100vw - 76rem) * 100000), calc((1rem - 16px) * 100000)),
|
||||
0px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
|
||||
+16
-16
@@ -2,6 +2,7 @@ import { defineCollection } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
import { PRACTICE_SLUGS } from './data/site';
|
||||
import { INSIGHT_TOPICS } from './data/insights';
|
||||
|
||||
/**
|
||||
* `<title>` length, from docs/04-seo-spec.md.
|
||||
@@ -97,16 +98,12 @@ const insights = defineCollection({
|
||||
* regulatory and industry commentary.
|
||||
*/
|
||||
topics: z
|
||||
.array(
|
||||
z.enum([
|
||||
'process-explainer',
|
||||
'regulatory-commentary',
|
||||
'industry-commentary',
|
||||
'reflection',
|
||||
'technical-explainer',
|
||||
'credentialing',
|
||||
]),
|
||||
)
|
||||
/* The tuple lives in `src/data/insights.ts`, imported rather than
|
||||
written out here — build step 7b. It was inline until then, which
|
||||
made the display labels a second copy of the same list, and the
|
||||
copy that drifts is the one nobody re-reads. `TOPIC_LABELS` is
|
||||
keyed off it, so an unlabelled topic is a type error. */
|
||||
.array(z.enum(INSIGHT_TOPICS))
|
||||
.min(1)
|
||||
.refine((t) => new Set(t).size === t.length, 'No duplicate topics.'),
|
||||
practiceAreas: z
|
||||
@@ -121,12 +118,15 @@ const insights = defineCollection({
|
||||
image: image().optional(),
|
||||
imageAlt: z.string().trim().min(1).optional(),
|
||||
/**
|
||||
* INTENT, not yet enforced — there is no /insights/ route to enforce it
|
||||
* in. The mechanism, when step 7 builds that route: filter drafts out of
|
||||
* `getCollection('insights', ...)` so no page is generated, which keeps
|
||||
* them out of the build, the index, and the sitemap in one move. The
|
||||
* sitemap filter in astro.config.mjs cannot see collection data and is
|
||||
* not the right place for it. See docs/04-seo-spec.md.
|
||||
* ENFORCED SINCE BUILD STEP 7b, and by ONE predicate rather than four.
|
||||
* `!data.draft` is the filter passed to every `getCollection('insights')`
|
||||
* call on the site — the article route, the index, the home page's latest
|
||||
* strip, the `SiteHeader` nav gate, and the OG card endpoint. A draft
|
||||
* therefore produces no page, so it is absent from the build, the index,
|
||||
* the sitemap and the card set as a consequence of not existing, not
|
||||
* because four places each remembered to exclude it. The sitemap filter
|
||||
* in astro.config.mjs cannot see collection data and is deliberately not
|
||||
* where this lives. See docs/04-seo-spec.md.
|
||||
*/
|
||||
draft: z.boolean().default(true),
|
||||
/** Every article is reviewed by Pouya before publication — D9. */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: 'Bill 40 and grid connection: a dispute-resolution read'
|
||||
description: 'Ontario Bill 40 of the 44th Parliament, 1st Session widened what the OEB may weigh on leave to construct and gated grid connection for large loads.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['regulatory-commentary']
|
||||
practiceAreas: ['energy']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { NEUTRAL_ROLE_LINE } from '../../data/site';
|
||||
|
||||
## Which Bill 40
|
||||
|
||||
Bill numbers are reused every parliament. Bill 40 of the 43rd Parliament, 1st Session is the Moving Ontarians Safely Act, 2023, amending the Highway Traffic Act. Bill 40 of the 42nd Parliament, 2nd Session is the Support for Adults in Need of Assistance Act, 2021. Neither touches electricity.
|
||||
|
||||
The energy one is Bill 40 of the 44th Parliament, 1st Session: the Protect Ontario by Securing Affordable Energy for Generations Act, 2025, sponsored by the Minister of Energy and Mines. The Legislative Assembly's status page for the Bill records First Reading on 3 June 2025 and Royal Assent on 11 December 2025. It is now chapter 22 of the Statutes of Ontario, 2025. Both dates do work below.
|
||||
|
||||
Cite it with the parliament and the session, because a reference carrying neither points at three unrelated statutes across three different parliaments.
|
||||
|
||||
Its long title is accurate about the method: "An Act to amend various statutes with respect to energy, the electrical sector and public utilities". Its three schedules amend the Electricity Act, 1998, the Municipal Franchises Act, and the Ontario Energy Board Act, 1998. What interests me is not what it created. It is which existing negotiations it moved.
|
||||
|
||||
## Section 96 grew a second branch
|
||||
|
||||
Leave to construct is section 92 of the Ontario Energy Board Act, 1998. No person may construct, expand or reinforce an electricity transmission or distribution line, or make an interconnection, without an order of the Board granting leave. Section 89 draws the line at voltage. Above 50 kilovolts is transmission; 50 kilovolts or less is distribution.
|
||||
|
||||
The thresholds people actually argue about are not in the section. They are exemptions in O. Reg. 161/99, which carves distribution lines out of section 92 outright and exempts a transmission line of two kilometres or less.
|
||||
|
||||
Section 96(1) supplies the test. If the Board is of the opinion that the work is in the public interest, it shall make an order granting leave. On a section 92 application, section 96(2) confines what the public interest may mean: the Board "shall only consider" the matters enumerated there. Bill 40's Schedule 3 lengthened that list. It now runs to the interests of consumers with respect to prices and the reliability and quality of electricity service, and to supporting economic growth consistent with Government of Ontario policy. A new section 96(3) requires the Board, on such an application, to consider such reports, documents or other information as may be prescribed by regulation. Both came into force on 11 December 2025.
|
||||
|
||||
That changes the shape of the record rather than the outcome of any application. A proponent's economic case now sits inside the statutory test instead of behind it, and part of the evidentiary burden can be set by regulation after the project's commercial arrangements are signed. Two allocations follow, and section 96(2) excludes both from the Board's public-interest inquiry: who pays to produce that material, and who carries the delay if it comes back thin. Both belong in the parties' commercial agreements, not the Board's record.
|
||||
|
||||
## A priority project settles need, not route
|
||||
|
||||
Section 96.1(1) lets the Lieutenant Governor in Council declare the construction, expansion or reinforcement of a specified transmission line to be needed as a priority project. The OEB's own page on leave-to-construct applications for priority transmission projects is direct about the consequence: approval under section 92 is still required, but "in these cases the OEB must accept that the project is needed when forming its opinion under section 96 of the Act."
|
||||
|
||||
A declaration removes one argument and leaves the others standing. Need is settled. Route, land, conditions and cost responsibility are not. Section 94 shows where the friction lives. The applicant files a map showing the municipalities, highways, railways, utility lines and navigable waters the proposed work passes through, under, over, upon or across. Each of those is a counterparty, an approval, or both.
|
||||
|
||||
As at the end of August 2026 that same OEB page recorded that no leave-to-construct application for a declared priority project was before the Board. That will change, and the first one will run alongside private disputes about access and cost, neither of which is among the two matters section 96(2) lets the Board weigh.
|
||||
|
||||
## The gate for large loads, and the date that sorts the pipeline
|
||||
|
||||
Schedule 1 added section 28.1 to the Electricity Act, 1998, in force 11 December 2025. It is a hard gate. Unless a transmitter or distributor is satisfied that the specified connection requirements have been complied with, it shall not connect a specified load facility to its system, or reconnect one that was disconnected for breach of those requirements.
|
||||
|
||||
"Specified load facility" is defined two ways. A facility that is a data centre and meets any criteria set out in the regulations. Or a facility that withdraws electricity from the IESO-controlled grid or from a distributor's system, whose connection demand exceeds a prescribed amount, and which meets any other prescribed criteria. Both limbs point outward. Bill 40 amended the regulation-making authority in section 114 to match, adding the power to define "data centre" for the purposes of section 28.1 and to prescribe criteria by geographic area, volume withdrawn, or connection demand.
|
||||
|
||||
The enabling section has been in force since 11 December 2025. The Ministry of Energy and Mines' Environmental Registry of Ontario posting of 13 August 2026, ERO 026-0853, described the connection-approval regulation as something "the province is considering drafting". The same posting proposes a Data Centre Playbook assessed against economic development, data security and digital sovereignty, and community trust, and separately proposes a new rate class under O. Reg. 429/04 for data centres above a demand threshold that would not be eligible for the Industrial Conservation Initiative. That comment period closes on 12 September 2026, so anything in it may move.
|
||||
|
||||
The provision that does not move is the transition rule. Section 28.1(6) says the section does not apply to a specified load facility whose connection request was submitted to a transmitter or distributor, in accordance with the Transmission System Code or the Distribution System Code, before 3 June 2025. That is the day Bill 40 had First Reading.
|
||||
|
||||
So one date sorts a pipeline into two regimes, and the requirements the later one turns on were still described by the Ministry in August 2026 as something the province is considering drafting. I expect arguments about which side a given project falls on: what was submitted, to whom, on what date, and whether it was a connection request in accordance with the applicable code at all. Those are questions about documents, which a neutral can work through with the parties rather than around them. The [energy disputes](/practice/energy/) I am built for start there rather than at the Board.
|
||||
|
||||
## The connection process is where the schedule lives
|
||||
|
||||
The mechanics of getting connected sit outside Bill 40, and they are what a supply agreement or a construction programme is quietly dated against.
|
||||
|
||||
The IESO's own description of the connection process sets out up to six stages, beginning with preparing the application and ending after the equipment is registered and tested. A transmitter's connections are generally subject to all six; a distributor's may be subject only to the first three. The IESO decides whether an application qualifies for a system impact assessment or an expedited one, and the transmitter generally runs its own customer impact assessment after the IESO's draft report, under a separate agreement. The final report goes out with either a notification of conditional approval or a notification of disapproval with reasons.
|
||||
|
||||
There is no queue. The IESO states in terms that it is not using an interconnection queue, and works instead from the concept of committed projects defined in its Market Manual 1.4. An argument built on a project's place in line is an argument about nothing.
|
||||
|
||||
The published timings are long. The IESO puts conditional approval at typically one year, and the process as a whole at anywhere from a few months for a small modification to more than three years for a new facility. A supply agreement, a site lease or a construction programme written against an earlier assumption is the dispute, and a dispute in that shape is a [construction claim](/practice/construction/) as much as an energy one.
|
||||
|
||||
## Schedule 2 takes the electors out of a municipal by-law
|
||||
|
||||
Schedule 2 amended the Municipal Franchises Act. The Bill's own summary of that schedule records that section 3 was re-enacted to remove the requirement for the municipal electors to assent to the by-law, and instead to require that a municipality pass a by-law setting out the terms and conditions.
|
||||
|
||||
That is a small amendment and it moves a step. Where that amendment is in force, the pressure moves to the drafting: assent to a by-law is one yes-or-no step, and terms and conditions in a by-law are not. Terms that have to be settled between a municipality and the party a by-law concerns are the kind of thing a facilitated process can move.
|
||||
|
||||
## What this changes about choosing a process
|
||||
|
||||
Each of these amendments puts a commercial argument next to a regulatory process that keeps its own timetable. That timetable is not something the parties can agree to move. Almost everything around it is: cost responsibility, schedule risk, the consequences of a condition attached to a conditional approval, and the allocation of a delay that nobody caused.
|
||||
|
||||
Two points follow for counsel. The first is timing. A session booked before the final system impact assessment report exists is one at which the conditional approval, and the conditions attached to it, do not exist yet, so the regulatory sequence belongs in the scheduling conversation. [The shape of an engagement](/process/) sets out where I put it. The second is the record. Where the parties want a decision instead of a settlement, a [commercial arbitration](/arbitration/) keeps the study, the conditions and the technical argument in front of one decision-maker rather than split across a hearing and a negotiation.
|
||||
|
||||
The disputes Bill 40 will generate have mostly not been had yet, because the regulation the large-load gate depends on was still under consideration as at August 2026. That is the honest description of this area, and it is why I describe energy as a position I am building into rather than a volume of work I have already done. Everything above is as at the end of August 2026 and should be checked before it is relied on. Nothing here is applied to a particular matter. {NEUTRAL_ROLE_LINE}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: 'Choosing a neutral: what counsel should actually ask'
|
||||
description: 'What counsel should ask before appointing a mediator or a commercial arbitrator: what a designation records, whose rules apply, and who reads the record.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['process-explainer', 'credentialing']
|
||||
practiceAreas: ['construction', 'technology']
|
||||
readingTime: 7
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { CONDUCT_UNDERTAKINGS } from '../../data/site';
|
||||
|
||||
## What a designation records, and what it does not
|
||||
|
||||
Counsel choosing a neutral usually has a short list of names, a rate for each, and a signature block full of abbreviations. The abbreviations are the part most often skipped. They are also the part that can be checked in a few minutes.
|
||||
|
||||
The ADR Institute of Ontario publishes its own expansions on its professional designations page. Q.Med is Qualified Mediator. Q.Arb is Qualified Arbitrator. C.Med is Chartered Mediator. C.Arb is Chartered Arbitrator. The long forms are worth taking from the conferring body's own page rather than from recall, because the abbreviations sit close together and a wrong expansion is easy to write.
|
||||
|
||||
What those designations record is training. ADRIO's page for the Qualified designations describes them as recognising practitioners who have completed sufficient mediation or arbitration training, and related dispute resolution training. The same page notes that Q.Med criteria vary across affiliates, and points an applicant to the checklist on the application form for the criteria specific to Ontario.
|
||||
|
||||
What the page does not describe is what any activity requires. ADRIO sets out what its own designations recognise, and it says nothing about permission. A designation should not be read as though it did. So the question a designation answers is narrow: which body conferred it, against which criteria, and is it current.
|
||||
|
||||
Currency is the half that gets assumed. ADRIO's pages for C.Med and C.Arb each state that there is an annual fee to maintain the designation, payable to the ADR Institute of Canada, Inc., and that the holder must remain a member in good standing with the ADR Institute of Ontario to retain it. The Qualified page addresses neither fees nor retention. That is a fact about the page rather than an answer, particularly since ADRIO records on the same page that the criteria vary across affiliates. A signature block cannot settle currency. That is a question for the neutral, or for the conferring body.
|
||||
|
||||
## Whose rules the process will run under
|
||||
|
||||
The second question is whose rules the process runs under, and it is cheaper to ask before an appointment than to discover at the first call.
|
||||
|
||||
For mediation, the ADR Institute of Canada publishes the ADRIC National Mediation Rules. ADRIC's own description is that the rules provide for initiating mediations, including the appointment of a mediator should the parties be unable to come to an agreement. The document carries more than the rules themselves: a code of conduct, a standard form agreement to mediate at Schedule B, ADRIC's administration fees at Schedule A, and a model dispute resolution clause for contracts.
|
||||
|
||||
One currency note on the same rules. ADRIC's page states, as of 2025, that its Mediation Committee is reviewing the Mediation Rules, and that the existing rules remain in effect and should continue to be used until any updates are formally adopted. The sensible course is to check it at the point of appointment rather than to date the rules in a submission.
|
||||
|
||||
For arbitration, ADRIC adopted new Arbitration Rules and a new Arbitrator Appointment Protocol effective 1 March 2025, published as the ADRIC Arbitration Rules – Effective 2025. It publishes named forms alongside them: Notice to Arbitrate, Request to Administer the Arbitration, Request for the appointment of an arbitrator, Application for Urgent Interim Measures, Application to Challenge an Arbitrator, and Notice of Appeal.
|
||||
|
||||
None of that is a statement of what the rules require. The rules are published documents, and where an appointment will run under them the document is the thing to read rather than a summary of it, this one included. What can be settled in advance is which rule set applies, what governs where the contract is silent, and what the tribunal is left to decide. Where it is silent, the protocol is settled in writing before the session. I set out the rule sets I work under on [mediation](/mediation/) and [commercial arbitration](/arbitration/).
|
||||
|
||||
## What the neutral does with what is said in caucus
|
||||
|
||||
Third, and this is the question that discriminates most: what happens to caucus material.
|
||||
|
||||
In mediation the answer should be stated rather than assumed. Mine is published, and it is this. "{CONDUCT_UNDERTAKINGS.mediationCaucus}"
|
||||
|
||||
In med-arb the question is harder, because the neutral who hears the caucus may later decide the matter. ADRIC publishes ADRIC Med-Arb Rules, presented to its membership as a discussion draft at its 2019 annual conference and designed, in ADRIC's words, to "work in tandem with ADRIC's existing Mediation Rules and Arbitration Rules." Nothing here is a claim about what that draft provides, or about its status. Where a med-arb appointment names a rule set, the document is the thing to read.
|
||||
|
||||
Two things are worth asking of any med-arb appointment, and both are answerable in writing before it starts. The first is when and how the switch from mediation to arbitration happens, and what has to be agreed for it to happen at all. The second is what becomes of something said in confidence that turns out to matter to the decision. I accept med-arb appointments in commercial matters, and both answers are set out on [med-arb](/med-arb/). The second is the harder one. "{CONDUCT_UNDERTAKINGS.medArbStepOut}"
|
||||
|
||||
## Dates, and whether they are real
|
||||
|
||||
Fourth: availability. Three questions get at it. Which dates are actually held. How long a date is held without a signed agreement to mediate. Whether a second day is booked at the outset or looked for after the first one runs out.
|
||||
|
||||
Where the parties cannot agree on a name, ADRIC's National Mediation Rules cover the appointment of a mediator. That is a route rather than a date.
|
||||
|
||||
In a commercial arbitration the date that matters most is the award. Mine is published, and it is this. "{CONDUCT_UNDERTAKINGS.arbitrationAwardDate}"
|
||||
|
||||
No turnaround figure is published here. A time to award quoted before anyone has seen the record is a guess, whoever quotes it. A date in the first procedural order is a different thing: it is fixed once the shape of the record is known, and both parties can see it.
|
||||
|
||||
## Fees, and what happens when the day runs long
|
||||
|
||||
Fifth. The rate is the easy part of the fee question. What a day means and when it ends, whether preparation is charged separately and how it is estimated, the cancellation schedule and the notice period it turns on, who is billed and in what shares — those are the terms that decide what a process actually costs.
|
||||
|
||||
The overrun question is specific enough to be worth its own sentence. A session is booked to five o'clock, and at seven the parties are close. The possible answers are all defensible: the day converts to hourly, a day is a day whatever it runs to, the neutral stops. What is not defensible is finding out which one applies at half past six.
|
||||
|
||||
ADRIC's mediation rules publish the institute's administration fees at Schedule A of the same document. Whether they apply to a given appointment is a question for the institute, and it is not the neutral's own fee.
|
||||
|
||||
## Whether the neutral can read the record the dispute turns on
|
||||
|
||||
Sixth, and last. Some disputes turn on a document rather than on a submission — a critical-path analysis is one, a model card is another.
|
||||
|
||||
I work as a machine-learning and infrastructure engineer. On a [construction](/practice/construction/) file that means the baseline programme, the as-built, the change-order log and the delay analysis are documents I read, rather than take on trust from whichever expert explains them most confidently. On a [technology](/practice/technology/) file it means an API trace, a set of monitoring dashboards, a model card, an evaluation harness and a data-processing addendum.
|
||||
|
||||
The question that gets at this with any neutral is which primary documents will be read before the session, and which will be taken on an expert's account of them. An answer that names documents can be checked against the productions. An answer that names an industry cannot.
|
||||
|
||||
## Asked before the appointment, and answered in writing
|
||||
|
||||
None of this requires a long call. All of it is easier to raise before an appointment than after, because before the appointment an answer is a term and after it is a complaint.
|
||||
|
||||
[The shape of an engagement](/process/) sets out when conflicts are run: on the intake call, before anything is agreed. Where a party has no counsel, [what happens at a mediation](/for-parties/) is the more useful page. Everything else above is a question, and the answers are what counsel is actually choosing between.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 'What the Ontario data-centre build-out means for dispute resolution'
|
||||
seoTitle: 'Ontario''s data-centre build-out and dispute resolution'
|
||||
description: 'A large-load grid connection, a construction contract and a technology contract meet on one date. Why litigating first and mediating late fits that badly.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['industry-commentary', 'technical-explainer']
|
||||
practiceAreas: ['technology', 'construction', 'energy']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
## Three sets of rules over one connection date
|
||||
|
||||
A large data centre in Ontario is three projects wearing one name.
|
||||
|
||||
There is a connection: an assessment run by the Independent Electricity System Operator and by the transmitter, ending in an approval that arrives on the date the financial model assumed, or does not. There is a build: a construction contract, subcontracts under it, and the Construction Act's prompt payment and interim adjudication machinery standing behind every invoice. And there is a load: the computing the building exists to house, under a technology contract with its own service levels, capacity terms and data terms.
|
||||
|
||||
Each has a different decision-maker, a different vocabulary, and a different idea of what a deadline is. They converge on the date the facility can energise. That convergence is the shape of the dispute, and a dispute clause drafted for one of the three contracts alone will not hold it.
|
||||
|
||||
## How a connection is assessed and approved
|
||||
|
||||
The terminology is precise and the wrong word travels badly, so it is worth taking from the IESO's own description of the connection process. Obtaining conditional approval runs through the IESO's and transmitter's connection assessment and approval (CAA) process. Within it the IESO performs a System Impact Assessment (SIA), or an expedited SIA where the application qualifies, and assigns a unique CAA ID. The transmitter performs a Customer Impact Assessment (CIA), which the IESO says the transmitter generally initiates after the draft SIA report. The SIA agreement is prepared in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules. The IESO issues a draft SIA report to the applicant and the transmitter for comment, then a final report, and with it either a Notification of Conditional Approval or a Notification of Disapproval with Reasons.
|
||||
|
||||
The IESO's published connection process runs to as many as six stages. Connections to a transmitter's system are generally subject to all six; connections to a distributor's system may be subject only to the first three. On the IESO's own figures, obtaining conditional approval "typically takes one year", registering equipment "takes at least three months", and the whole process can run "anywhere from a few months for small modifications to existing facilities, to more than three years for major modifications or to connect new facilities".
|
||||
|
||||
Two features matter to anyone drafting a dispute clause. The SIA assesses the proposed connection's impact on the reliability of the integrated power system; what comes out of it is a report and a notification, not a ruling between parties. And there is no ordered line to be moved up. The IESO says so in terms in its connection-process FAQ: it works from "committed projects", a concept defined in section 3.3 of Market Manual 1.4, Connection Assessment and Approval, each assessment following section 5.8 of the same manual. The four IESO connection-process pages read for this piece describe only the six-stage process; no large-load or data-centre variant appears. This is the process I write about under [energy, grid and regulatory disputes](/practice/energy/).
|
||||
|
||||
## The statutory gate is in force; the regulation behind it is still a consultation
|
||||
|
||||
There is now a second gate, aimed squarely at this sector. Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025, through Bill 40 of the 44th Parliament, first session — the Protect Ontario by Securing Affordable Energy for Generations Act, 2025, chapter 22 of the Statutes of Ontario, 2025.
|
||||
|
||||
Section 28.1 provides that unless a transmitter or distributor is satisfied that the "specified connection requirements" have been complied with, it "shall not" connect a "specified load facility" — or reconnect one that was disconnected for breaching those requirements. A specified load facility is a data centre meeting whatever criteria the regulations may set, or a facility drawing from the grid with demand at the point of connection above a regulation-prescribed threshold, meeting any other prescribed criteria. Subsection (6) carries a transition: the section does not apply to a facility whose connection request under the Transmission System Code or the Distribution System Code was submitted before 3 June 2025.
|
||||
|
||||
The regulation is the part to watch, because as this is written at the end of August 2026 the ministry consulting on it still describes it as prospective. The Ministry of Energy and Mines' consultation notice on an Economic and Strategic Assessment Framework for New Data Centres, ERO 026-0853, open for comment from 13 August to 12 September 2026, says that "the province is considering drafting a proposed regulation" that would require new large data centres to obtain the approval of the government to connect or reconnect. The same notice proposes a Data Centre Playbook, one of whose three pillars is protecting data security and digital sovereignty. It records the ministry's estimate that data-centre connection proposals could cumulatively total more than 10,000 MW.
|
||||
|
||||
The notice puts the Playbook forward to attract investments that, among other things, "ensure Canadians' data remains in Canada". That phrase lives in a technology contract long before it reaches a grid application: it is a question about where workloads run, which subprocessors touch them, and what the operator has promised its own customers. An SIA report will not answer it, which is why the [technology](/practice/technology/) side of a data-centre project cannot be quarantined from the energy side.
|
||||
|
||||
## The construction contract runs on a different clock
|
||||
|
||||
Underneath the connection sits an ordinary Ontario construction project, on the statutory payment timetable. Proper invoices go to the owner monthly unless the contract says otherwise. The owner pays within 28 days, or gives a notice of non-payment within 14 days detailing the reasons. A contractor paid in full passes payment down within seven days; a contractor the owner has not paid must pay its subcontractors within 35 days of giving the invoice unless it serves a notice of non-payment, and one route through that notice requires an undertaking to refer the matter to adjudication within 21 days.
|
||||
|
||||
Interim adjudication under Part II.1 of the Construction Act has been available since October 2019, administered by Ontario Dispute Adjudication for Construction Contracts as the Authorized Nominating Authority, with amendments in force from 1 January 2026. What may be adjudicated without the other side's agreement is a prescribed list, now in section 19(1) of O. Reg. 264/25: the valuation of services or materials; payment under the contract, including a change order, approved or not, or a proposed change order; a notice-of-non-payment dispute; amounts retained by way of set-off; payment of a holdback; and, only where reasonably necessary to resolve another adjudicable matter, the scope of work, a change-in-price request, and an extension-of-time request.
|
||||
|
||||
Then the pace. The adjudicator must determine the matter no later than 30 days after receiving the claimant's documents, which are due within five days of the appointment. That deadline can be extended by up to 14 days at the adjudicator's request with written consent, or for a period the parties agree in writing, subject to the adjudicator's consent. A determination made late is "of no force or effect". A party ordered to pay must pay within 15 days. The determination binds until a court or an Arbitration Act, 1991 arbitration determines the matter, or the parties agree otherwise in writing; judicial review needs leave of the Divisional Court. An adjudication addresses a single dispute unless the parties and the adjudicator agree otherwise.
|
||||
|
||||
Put the two clocks beside each other. Conditional approval to connect typically takes a year. An adjudication is designed to be finished, with written reasons and a payment obligation, about five weeks after the adjudicator is appointed — seven if the deadline is extended. The prescribed list is a payment list. It does not reach the question the project turns on — when the facility will connect. That question reaches adjudication only if both parties agree to send it there, which nobody negotiates once the date has slipped. A slipped connection date arrives as a bundle: valuation, delay, scope, and a change order nobody approved, split between what the list reaches and what it does not. That is the [construction](/practice/construction/) half of the problem.
|
||||
|
||||
## Why litigating first and mediating late fits this badly
|
||||
|
||||
The Construction Act fixes no mediation step, so on a file where the contract is silent, the timing of any mediation is set by the litigation timetable rather than by the connection timetable. That assumes the amount in dispute is fixed and the commercial relationship has finished. On a live connection neither is true. The assessment is still running, the transmitter is still a counterparty rather than a witness, and every month of argument moves the energisation date all three contracts are priced against.
|
||||
|
||||
The statutory design points the other way. An interim determination is expressly provisional: it binds until a court, an arbitrator or the parties' own written agreement replaces it, and both a court and an arbitrator may consider the merits afresh. The lien timetable is short at the front and long at the back: 60 days to preserve, a further 90 to perfect, and a perfected lien expires immediately after the second anniversary of the action that perfected it unless that action has been set down or ordered to trial. A mediation two years into that arc is a mediation of a project whose connection window has closed.
|
||||
|
||||
The Construction Act contains no mediation provision at all: no mediation part, no step, no mediator's role. Whatever mediated step happens on a construction file comes from somewhere other than that statute.
|
||||
|
||||
## What I would fix before the first proper invoice
|
||||
|
||||
Most of the work here is sequencing rather than drafting.
|
||||
|
||||
The process and the neutral are worth naming before the draft SIA report lands, not after it. The point at which the connection date first moves is the point at which positions harden, and a poor moment to negotiate who resolves what. [Mediation](/mediation/) is available at that stage without characterising anything.
|
||||
|
||||
The same words are worth carrying across the three contracts. Milestones defined one way in the construction contract, another way in the technology contract, and a third way against a Notification of Conditional Approval produce disputes about which document governs before anyone reaches the merits.
|
||||
|
||||
Which questions go to adjudication and which go to [commercial arbitration](/arbitration/) is worth deciding in advance. A determination is provisional by design and the merits stay open, so an adjudication treated as final is a dispute deferred rather than resolved.
|
||||
|
||||
Where the parties want one neutral to mediate and then arbitrate, that switch is worth settling at the outset rather than in the room. What I undertake about the switch, and about caucus material afterwards, is set out on [med-arb](/med-arb/); [how I run a file](/process/) sets out the rest.
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: 'What a System Impact Assessment actually evaluates'
|
||||
description: 'What an IESO System Impact Assessment evaluates, who performs it, where the transmitter customer impact assessment sits, and what to look for in one.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['technical-explainer']
|
||||
practiceAreas: ['energy', 'technology']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
## An SIA is not an assessment of the project
|
||||
|
||||
A connection date is a common term in Ontario energy contracts: EPC schedules,
|
||||
equipment supply terms, the covenants around a commercial operation date. When
|
||||
it moves, the System Impact Assessment is the document the argument turns to,
|
||||
and it invites one specific misreading. An SIA does not assess the project; it
|
||||
assesses what happens to the grid if the project connects to it.
|
||||
|
||||
The term is the Independent Electricity System Operator's own, and so is its
|
||||
companion. In the IESO's description of the connection process, "New connections
|
||||
or modifications to facilities connected to a transmitter's system are subject
|
||||
to the IESO's system impact assessment (SIA) and the transmitter's customer
|
||||
impact assessment (CIA)." Two documents, two authors. The IESO conducts the SIA.
|
||||
The transmitter conducts the CIA. Treating the pair as one exhibit loses the
|
||||
distinction most of these disputes turn on. Both sit in the IESO's and
|
||||
transmitter's connection assessment and approval process, CAA in the IESO's
|
||||
usage, and each application is given a unique CAA ID.
|
||||
|
||||
## What the assessment is actually of
|
||||
|
||||
The IESO describes its study step as assessing "the impact of [the] proposed new
|
||||
or modified connection on the reliability of the integrated power system". Stage
|
||||
one of the same process puts it more broadly: planned connections and
|
||||
modifications "must be assessed to identify and mitigate any potential adverse
|
||||
effect on the reliability of the electricity grid and its existing customers".
|
||||
|
||||
The subject of the assessment is the system, not the applicant. The IESO
|
||||
describes its own function as coordinator and integrator of Ontario's
|
||||
electricity system, balancing supply against provincial demand in real time and
|
||||
directing the flow across the transmission lines, and it names five pillars of
|
||||
reliability it is responsible for meeting: capacity, energy, transmission,
|
||||
operability and ancillary services. An SIA asks whether a new connection
|
||||
disturbs those.
|
||||
|
||||
That is also how to read a condition: the assessment's subject is the system,
|
||||
so a condition speaks to how the system behaves with the facility on it. The
|
||||
published process does not describe what conditions a report may carry — that
|
||||
question is answered in the report. A pleading that reads a condition as an
|
||||
admission of defective work is reading the document as though the other side
|
||||
had commissioned it.
|
||||
|
||||
The IESO's connection-process FAQ names the tools: "The IESO uses DSA and PSSE
|
||||
tools to conduct SIA studies." Naming the tools is not describing the study, and
|
||||
the published process description does not say what a given study assumed,
|
||||
modelled or tested. Where the argument is about the study itself, the report and
|
||||
the record behind it are what answer it — not this outline of the process that
|
||||
produced it.
|
||||
|
||||
## Where it sits, and how long it takes
|
||||
|
||||
The IESO runs connection in up to six stages: prepare application; obtain
|
||||
conditional approval to connect; design and build; authorize market and program
|
||||
participation; register equipment; commission equipment and validate
|
||||
performance.
|
||||
|
||||
The SIA and the CIA both live in stage two, which "typically takes one year" on
|
||||
the IESO's figure. Stage four typically takes about a month, stage five at least
|
||||
three months, and the whole process "can take anywhere from a few months for
|
||||
small modifications to existing facilities, to more than three years for major
|
||||
modifications or to connect new facilities". All applicable stages have to be
|
||||
completed before final approval to connect and the start of commercial
|
||||
operation.
|
||||
|
||||
Which stages apply depends on what the facility connects to: "New or modified
|
||||
connections to a transmitter's system are generally subject to all six stages,
|
||||
while new or modified connections to a distributor's system may only be subject
|
||||
to the first three."
|
||||
|
||||
That last point is about parties as much as engineering. Distribution
|
||||
connections run through the distributor's own assessment process, and the IESO
|
||||
records that a distributor may itself need to participate in the IESO's and the
|
||||
transmitter's processes on the applicant's behalf. The entity handling the
|
||||
assessment correspondence is not always the entity whose contract is in dispute.
|
||||
|
||||
## Two documents, two authors, two agreements
|
||||
|
||||
The sequence is where the SIA and the CIA come apart.
|
||||
|
||||
On the IESO's account of stage two, a pre-application meeting comes first. The
|
||||
IESO then determines whether the application qualifies for a system impact
|
||||
assessment or an expedited system impact assessment (ESIA). Once the application
|
||||
and its deposit are in, it prepares an SIA agreement, "in accordance with
|
||||
section 6.1.15.3 of chapter 0.4 of the Market Rules", for execution by the
|
||||
applicant's authorized representative. Once all required information has been
|
||||
provided, it carries out the studies and issues a draft SIA report to the
|
||||
applicant and the transmitter for review and comments. After addressing the
|
||||
comments on the draft or on a revised draft, it sends the final report to both,
|
||||
with either a "Notification of conditional approval (NoCA)" or a "Notification
|
||||
of disapproval with reasons (NoDR)".
|
||||
|
||||
The CIA runs on a different clock. The transmitter "generally initiates the
|
||||
customer impact assessment (CIA) after the draft SIA report from the IESO", and
|
||||
the CIA has its own agreement, between the applicant and the transmitter.
|
||||
|
||||
Three consequences follow. The assessments are generally sequenced rather than
|
||||
parallel, so a slipped draft SIA ordinarily pushes the CIA start behind it.
|
||||
There are two contracts before there are two reports, and the obligations
|
||||
parties argue about, which information was owed and by when, live in those two
|
||||
agreements. And the draft-and-comment step is a record: what a party said about
|
||||
a study assumption at draft stage, and what it declined to say, sits in that
|
||||
record alongside the final report.
|
||||
|
||||
## What to ask for, and what the record will not support
|
||||
|
||||
Where a dispute turns on an SIA, the productive order is the order in which the
|
||||
record was made, not the order of the pleadings. The application first, and the
|
||||
IESO's FAQ names the instrument: Form 128 initiates the SIA process. Then the
|
||||
two agreements. Then the information the applicant supplied, with dates, because
|
||||
the study step begins once all required information has been provided:
|
||||
completeness is the hinge on which a year-long stage moves. Then the draft SIA
|
||||
report and each set of comments on it. Then any revised draft. Then the final
|
||||
report with the NoCA or the NoDR. Then the CIA.
|
||||
|
||||
The final report may already be public: the IESO states that it "will be
|
||||
published on the IESO website in the Application Status table at the end of the
|
||||
month in which it was finalized". Upstream of all this sits an optional
|
||||
technical feasibility study, a "confidential service" provided "on a
|
||||
cost-recovery basis to identify and mitigate potential issues with various
|
||||
connection options"; whether one was run often explains why a particular option
|
||||
was chosen.
|
||||
|
||||
Two arguments the published process will not carry. First, the queue. Ontario
|
||||
has no interconnection queue. The IESO is explicit: it "is not using an
|
||||
'interconnection queue'", adopting instead "the concept of 'committed projects'
|
||||
that is defined in Section 3.3 of Market Manual 1.4: Connection Assessment and
|
||||
Approval", and there is "no option to 'skip the interconnection queue'". Each
|
||||
assessment follows the timelines in section 5.8 of that manual. A head of loss
|
||||
framed as a lost place in a queue rests on a mechanism the system operator says
|
||||
it does not operate.
|
||||
|
||||
Second, differential treatment. Renewable generation is not assessed
|
||||
differently: "The treatment of new renewable generation facilities is no
|
||||
different than any other new facility, the normal System Impact Assessment (SIA)
|
||||
process applies to the connection of all generation facilities, renewable or
|
||||
non-renewable, equally." A delay theory resting on technology-specific handling
|
||||
has nothing in the published process to stand on.
|
||||
|
||||
## Why more contracts are about to depend on this
|
||||
|
||||
As this is written in August 2026, the gate in front of large loads is being
|
||||
rebuilt around the assessment, not in place of it.
|
||||
|
||||
Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025.
|
||||
Unless a transmitter or distributor is satisfied that the "specified connection
|
||||
requirements" have been complied with, it "shall not" connect or reconnect a
|
||||
"specified load facility". That category is defined to include a data centre
|
||||
meeting criteria that may be set out in the regulations, and a facility whose
|
||||
demand at the point of connection exceeds a prescribed amount. The section
|
||||
arrived through Bill 40 of the 44th Parliament, 1st Session — the Protect
|
||||
Ontario by Securing Affordable Energy for Generations Act, 2025 — which
|
||||
received Royal Assent on 11 December 2025 as chapter 22 of the Statutes of
|
||||
Ontario, 2025. Its transition rule turns on a date and a form: the section does
|
||||
not apply where a connection request made in accordance with the Transmission
|
||||
System Code or the Distribution System Code was submitted to the transmitter or
|
||||
distributor before 3 June 2025, the day Bill 40 had First Reading.
|
||||
|
||||
The regulation that would fill in those criteria is the part to watch. The
|
||||
Ministry of Energy and Mines' August 2026 consultation on an economic and
|
||||
strategic assessment framework for new data centres describes the province as
|
||||
"considering drafting" a regulation that would require new large data centres to
|
||||
obtain government approval to connect or reconnect. Its comment period runs to
|
||||
12 September 2026, and the same notice carries the Ministry's estimate that
|
||||
data-centre connection proposals could total more than 10,000 MW cumulatively.
|
||||
|
||||
None of that displaces the SIA; it sits on top of it. A large load will still be
|
||||
assessed for its effect on the reliability of the integrated power system, in
|
||||
stage two, and its transmitter will still run a CIA. What changes is the number
|
||||
of contracts written against a connection date whose gating conditions were
|
||||
still under consideration as at August 2026.
|
||||
|
||||
## Reading the study and the contract on the same page
|
||||
|
||||
Grid connection disputes are argued through technical studies. I work as a
|
||||
machine-learning and DevOps infrastructure engineer. The study assumptions, the
|
||||
modelling inputs and the constraint that produced a condition are documents I
|
||||
read directly and work through with the parties.
|
||||
|
||||
In a [mediation](/mediation/) that means a technical disagreement can be tested
|
||||
in the room rather than deferred to an expert exchange. In a
|
||||
[commercial arbitration](/arbitration/) it means the first procedural order can
|
||||
be built around the documents that decide the matter.
|
||||
[The shape of an engagement](/process/) sets out where each one starts.
|
||||
|
||||
Connection is one of the areas I take appointments in, set out at
|
||||
[energy and grid disputes](/practice/energy/); its large-load half overlaps
|
||||
with [technology and data disputes](/practice/technology/). Every date above is
|
||||
as at August 2026, and the instruments move. Nothing here is applied to a
|
||||
particular matter, and each party to a dispute should have their own legal
|
||||
advice.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 'When Med-Arb is the right answer, and when it is not'
|
||||
description: 'Med-arb is mediation that converts to binding arbitration if it does not resolve. What it is, the fairness objection, and when it does not fit.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['process-explainer']
|
||||
practiceAreas: ['construction', 'shareholder']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { CONDUCT_UNDERTAKINGS } from '../../data/site';
|
||||
|
||||
## What med-arb is, and what one appointment buys
|
||||
|
||||
Med-arb is mediation that converts to binding arbitration if the mediation does not resolve the dispute. One neutral is appointed for both phases. The matter is mediated. Whatever settles is recorded and is finished. Whatever does not settle moves to arbitration in front of the same neutral, on the terms the parties agreed before any of it began, and ends in an award. The two phases are the same two processes I offer on their own: [mediation](/mediation/) and [commercial arbitration](/arbitration/).
|
||||
|
||||
The commercial case for it is the gap it closes. A mediation that does not settle ordinarily means starting over. A new neutral, a second round of briefs, a fresh procedural timetable, and the same argument re-run in front of someone who did not watch the first attempt. Whatever narrowing the mediation achieved is re-argued, because nobody in the second room is bound by a concession made in the first. Med-arb keeps that work inside one appointment and one agreement.
|
||||
|
||||
## The objection is the right one
|
||||
|
||||
A mediator learns things a decision-maker is not supposed to know. What a party would actually take. What it is afraid of. What its own counsel thinks of the weak limb of its case. In med-arb the person holding that knowledge may go on to decide the matter.
|
||||
|
||||
Counsel who refuse med-arb on that ground are not being obstructive. The problem is structural rather than hypothetical, and no amount of drafting makes it disappear. What drafting decides is who carries it, and on what terms.
|
||||
|
||||
Two things carry it. The first is consent that is real: informed, in writing, and settled before the mediation phase starts, with the trigger for the switch and the treatment of caucus material dealt with in terms rather than left to good faith. Vagueness about either is what turns a procedural objection into a live one.
|
||||
|
||||
The second is what the neutral will actually do. That is a different question, and it is the one a party weighs when choosing between candidates. It is answered below rather than left to be inferred from the drafting.
|
||||
|
||||
## What I undertake
|
||||
|
||||
{/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says
|
||||
so in terms: "The strings live in `CONDUCT_UNDERTAKINGS` in
|
||||
`src/data/site.ts` and the pages render them, so the diff that would soften
|
||||
one is visible on one constant rather than distributed through three
|
||||
templates." They were hand-typed here in the first draft, which put a fourth
|
||||
hand-copy of a published commitment outside that mechanism — and the
|
||||
characteristic failure mode of this class is SILENT: nothing in a build fails
|
||||
when a promise gets a little smaller, and the diff reads like tightening.
|
||||
⚠️ AND IF THIS ARTICLE IS APPROVED FOR PUBLICATION, §4's rows (a), (b) and
|
||||
(c) EACH GAIN A SURFACE and their "where it ships" column has to say so. */}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbSwitch}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbCaucus}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbStepOut}
|
||||
|
||||
The third is the expensive one, and its cost falls on the neutral rather than on the parties. It is also less costly in practice than it sounds. The arbitral phase runs on the evidentiary record, not on the caucus. Where the switch sits inside a whole engagement is set out under [the shape of an engagement](/process/).
|
||||
|
||||
## The rule set ADRIC publishes, and what a summary of it is worth
|
||||
|
||||
The ADR Institute of Canada's rules page carries ADRIC Med-Arb Rules. A discussion draft was presented to the membership at ADRIC's annual conference in November 2019. ADRIC's own framing of the process is worth quoting rather than paraphrasing:
|
||||
|
||||
> "Med-Arb is not merely the merging of separate mediation and arbitration processes, but a unique process designed to meet the needs of particular disputants. It involves nuances and complexities that can be fine-tuned to the needs of the parties as a customized dispute resolution process…"
|
||||
|
||||
ADRIC states that the rules are "designed to work in tandem with ADRIC's existing Mediation Rules and Arbitration Rules, integrating seamlessly", and on scope: "Although the Med-Arb Rules were drafted to assist in resolving domestic commercial disputes, parties may want to apply them to international or non-commercial disputes."
|
||||
|
||||
The two rule sets they sit alongside are published in their own right, as The ADRIC National Mediation Rules and as ADRIC Arbitration Rules – Effective 2025. The mediation document also carries a Model Dispute Resolution Clause, whose wording refers a dispute to mediation "pursuant to the National Mediation Rules of the ADR Institute of Canada, Inc."
|
||||
|
||||
Two cautions, and both apply to any account of a rule set, this one included. The first is currency. ADRIC records that "As of 2025, the ADRIC Mediation Committee is currently reviewing the Mediation Rules", and that "the existing rules remain in effect and should continue to be used until any updates are formally adopted". ADRIC's rules page carried that note when this piece was written, in August 2026; the current state of the review is on ADRIC's own page.
|
||||
|
||||
The second is that a description is not the rule set. What the rules require of the parties, of the neutral, and of caucus material sits in the documents themselves, not in anything quoted here. Nothing above states what any of them provides. Where this piece says what happens to caucus material, that is my own undertaking and not a rule.
|
||||
|
||||
## Where med-arb fits
|
||||
|
||||
**A deadlock that has to end by a date.** A closing, a fiscal year end, a lender's deadline, a milestone with liquidated damages behind it. Mediation on its own cannot promise an end. Arbitration on its own reaches one, and spends the interval as a contest. Med-arb reaches the date either way, and the parties know at the outset which way it will be reached if the room does not settle.
|
||||
|
||||
**A relationship that has to survive the dispute.** Shareholders in a closely held company. A general contractor and a trade it will meet again on the next tender. A distributor in the middle of a term. A unanimous shareholder agreement can specify how a dispute under it is resolved: section 108(6)(b) of Ontario's Business Corporations Act contemplates that where shareholders who are parties to such an agreement cannot agree on or resolve a matter pertaining to it, the matter may be referred to arbitration "under such procedures and conditions as are specified in the unanimous shareholder agreement". What that means for a particular company is a question for each party's own counsel. The dispute types are on [shareholder and family business](/practice/shareholder/).
|
||||
|
||||
**A narrow set of remaining issues.** Med-arb earns its keep when the mediation has done most of the work and two or three points are left, each capable of being decided on the documents. The parties take their settlement on everything they agreed and a decision on the residue, from one appointment, without a second procedural runway.
|
||||
|
||||
## Where it does not fit
|
||||
|
||||
**Where a statutory route already gives what med-arb is being asked to give.** Construction payment is the Ontario example. Part II.1 of the Construction Act, headed Construction Dispute Interim Adjudication, has been in force since 1 October 2019, and further amendments to the Act came into force on 1 January 2026. O. Reg. 264/25 prescribes the matters that may be adjudicated, among them the valuation of services or materials provided under the contract and payment in respect of a change order, whether approved or not. Ontario Dispute Adjudication for Construction Contracts, which states on its own site that it is the Authorized Nominating Authority under the Act, describes adjudication as "available as a right" and says a party "can commence an adjudication without the other Party's consent". An adjudicator must determine the referred matter no later than 30 days after receiving the referring party's documents, unless that date is extended in the way the Act allows, and the Act treats that determination as binding on the parties until the matter is determined by a court, by arbitration under the Arbitration Act, 1991, or by written agreement. A party that wants money moving on a change order does not need the other side's agreement to a process, and med-arb needs exactly that. Where the dispute is not a prescribed matter, or where the parties want the whole of it finally decided, the calculation changes. The dispute types are on [construction and infrastructure](/practice/construction/).
|
||||
|
||||
**Where the parties are not equally advised.** The caucus asymmetry compounds. One side with counsel and one without, or one a repeat player in this kind of dispute and the other in it once, is the configuration where a single neutral holding both roles is hardest to justify.
|
||||
|
||||
**Where one side needs a finding more than a settlement.** A party facing the same argument from a row of counterparties may want a reasoned determination on the record more than it wants this dispute closed quietly. Med-arb is built to settle first.
|
||||
|
||||
**Where consent is grudging.** A party that agrees to med-arb reluctantly has not agreed to it in the sense that matters, and the reluctance tends to come back in the arbitral phase as a complaint about the process. That is a reason not to take the appointment rather than a drafting problem.
|
||||
|
||||
## The name in the contract is worth reading twice
|
||||
|
||||
It is not arb-med. The two names are one syllable apart and the processes are not interchangeable. Where a contract names one of them, the thing to check is which one, and to check it against the rule set the contract adopts rather than against a page like this one.
|
||||
|
||||
Ontario's statute book names a version of the process, in a place written for family arbitration. O. Reg. 134/07 under the Arbitration Act, 1991 defines a "mediation-arbitration agreement" as a family arbitration agreement providing that "a mediation between the parties is to be conducted before any arbitration is conducted" and that "if the mediation fails, the mediator shall arbitrate the dispute and make a binding resolution of it". The same regulation requires that every arbitrator who conducts a family arbitration "shall have received the training approved by the Attorney General". I do not accept family law matters. The regulation is worth knowing about anyway: a search for the term surfaces it, and a definition written for family arbitration is easy to mistake for a general one.
|
||||
|
||||
I accept med-arb appointments in commercial matters. [Med-arb](/med-arb/) sets out the process and the objection at greater length. The part that cannot be fixed later is the switch, and it is settled in writing before the mediation starts or it is not settled at all.
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Insights vocabulary. Build step 7b. The six territories are the strategy
|
||||
* brief's (§VII), restated in `docs/03-content-spec.md` §Insights.
|
||||
*
|
||||
* `TOPIC_LABELS` is annotated `Record<InsightTopic, string>`, so adding a topic
|
||||
* without labelling it does not compile. `src/content.config.ts` imports the
|
||||
* tuple rather than repeating it.
|
||||
*
|
||||
* ⚠️ These are EDITORIAL categories, not claims. `credentialing` in particular
|
||||
* labels writing *about* credentialing in the field — never a credential of his.
|
||||
*/
|
||||
export const INSIGHT_TOPICS = [
|
||||
'process-explainer',
|
||||
'regulatory-commentary',
|
||||
'industry-commentary',
|
||||
'reflection',
|
||||
'technical-explainer',
|
||||
'credentialing',
|
||||
] as const;
|
||||
|
||||
export type InsightTopic = (typeof INSIGHT_TOPICS)[number];
|
||||
|
||||
/** Pill text. Sentence case, because these sit beside a serif headline rather
|
||||
* than in the mono eyebrow style — `Pill` sets its own type. */
|
||||
export const TOPIC_LABELS: Record<InsightTopic, string> = {
|
||||
'process-explainer': 'Process',
|
||||
'regulatory-commentary': 'Regulatory',
|
||||
'industry-commentary': 'Industry',
|
||||
reflection: 'Reflection',
|
||||
'technical-explainer': 'Technical',
|
||||
credentialing: 'Credentialing',
|
||||
};
|
||||
|
||||
/**
|
||||
* Date display. `en-CA` with an explicit UTC time zone, and the time zone is the
|
||||
* load-bearing part: `src/content.config.ts` parses frontmatter dates as
|
||||
* midnight UTC, so formatting them in a local zone west of Greenwich renders
|
||||
* the day before — a published date one day early, on every article, silently.
|
||||
* Same failure the schema's round-trip check exists to stop, one layer down.
|
||||
*/
|
||||
export function formatArticleDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** `<time datetime>` wants the date-only ISO form, in the same zone. */
|
||||
export function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* The intake form's fields. Spec: docs/05-backend-spec.md §Form fields.
|
||||
*
|
||||
* ⚠️ **THE LAMBDA HAS ITS OWN COPY OF THIS TABLE, AND THAT DUPLICATION IS
|
||||
* DELIBERATE — IT IS NOT THE SES-DKIM SHAPE.** `docs/05` is explicit: *"Client
|
||||
* side validation is a convenience. The Lambda re-validates everything."* A
|
||||
* server that validates against a list the client shipped it is not validating;
|
||||
* it is asking the attacker what the rules are. So `backend/intake/handler.mjs`
|
||||
* carries an independent table and trusts nothing from here.
|
||||
*
|
||||
* What stops the two drifting is a check rather than a shared import:
|
||||
* **`npm run check:intake`** asserts that the two tables agree on every field
|
||||
* name, on which are required, on every length cap, and — since 2026-09-04 — on
|
||||
* both honeypot names.
|
||||
*
|
||||
* ⚠️ **IT IS A KEYBOARD GATE, NOT A DEPLOY GATE, AND THIS COMMENT SAID IT "fails
|
||||
* the build script".** It does not: `npm run build` is `astro build`, and
|
||||
* `scripts/deploy-local.sh` runs `check`, `build` and `check:claims` and not this
|
||||
* one. Run it yourself. A control described as running where it does not is
|
||||
* `AGENTS.md` Q22, and this change set makes this check the only thing keeping
|
||||
* the second honeypot's two names in step. Independent validation, mechanically cross-checked. If
|
||||
* you add a field here, add it there, and the check will tell you if you didn't.
|
||||
*
|
||||
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
|
||||
* live legal dispute this collects the inquirer's identity, **the names of
|
||||
* opposing parties and their counsel**, and the nature of the dispute. That is
|
||||
* personal information about identifiable third parties who have not consented
|
||||
* and do not know the submission happened. Hence: no dollar amounts, no
|
||||
* uploads, an explicit unchecked consent box, and a matter summary whose hint
|
||||
* tells the writer not to put privileged detail in it.
|
||||
*/
|
||||
|
||||
export type IntakeField = {
|
||||
name: string;
|
||||
label: string;
|
||||
/** `select` and `radio` carry `options`; everything else does not. */
|
||||
type: 'text' | 'email' | 'tel' | 'select' | 'radio' | 'textarea' | 'checkbox';
|
||||
required: boolean;
|
||||
/** Maximum characters. The Lambda REJECTS over this rather than truncating —
|
||||
* a silently truncated matter summary is a misread file. */
|
||||
max?: number;
|
||||
options?: readonly string[];
|
||||
/** Rendered under the field. */
|
||||
hint?: string;
|
||||
/** `autocomplete` token, where one genuinely applies. Omitted rather than
|
||||
* guessed: a wrong token makes a browser fill the wrong value. */
|
||||
autocomplete?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ **DO NOT ADD A DOLLAR-AMOUNT FIELD.** `docs/05`: *"Do not collect dollar
|
||||
* amounts, document uploads, or anything the inquirer might reasonably treat as
|
||||
* privileged. The intake call is for that."* The old site invented matter values;
|
||||
* this form is the one place a real one could arrive and then need storing.
|
||||
*/
|
||||
export const INTAKE_FIELDS: readonly IntakeField[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Your name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
max: 120,
|
||||
autocomplete: 'name',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
max: 254, // RFC 5321 maximum path length; not a round number by choice.
|
||||
autocomplete: 'email',
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
label: 'Phone',
|
||||
type: 'tel',
|
||||
required: false,
|
||||
max: 40,
|
||||
autocomplete: 'tel',
|
||||
hint: 'Optional.',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Your role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
|
||||
},
|
||||
{
|
||||
name: 'organisation',
|
||||
label: 'Firm or organisation',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 160,
|
||||
autocomplete: 'organization',
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
label: 'Process sought',
|
||||
type: 'select',
|
||||
required: true,
|
||||
/* The five from docs/05. "ENE" is expanded here because this is a form label
|
||||
read by a party as well as by counsel, and §11's glossary authority is
|
||||
about site copy rather than about abbreviating in a select. */
|
||||
options: [
|
||||
'Mediation',
|
||||
'Arbitration',
|
||||
'Med-Arb',
|
||||
'Early neutral evaluation',
|
||||
'Not sure',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'practiceArea',
|
||||
label: 'Subject matter',
|
||||
type: 'select',
|
||||
required: true,
|
||||
/* THE SIX AREAS PLUS OTHER. Deliberately the short display names rather
|
||||
than `PRACTICE_AREAS[].name`: those carry the full "Construction &
|
||||
Infrastructure" form for a card heading, and a select is not a card. The
|
||||
cross-check in `scripts/check-intake.mjs` compares these against the
|
||||
handler's list, and `PRACTICE_SLUGS` remains the site's own source for
|
||||
which areas exist. */
|
||||
options: [
|
||||
'Construction',
|
||||
'Technology',
|
||||
'Energy',
|
||||
'Insurance',
|
||||
'Shareholder',
|
||||
'Cross-border',
|
||||
'Other',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'otherParties',
|
||||
label: 'Other parties',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 300,
|
||||
hint: 'Needed to run a conflicts check. Names only.',
|
||||
},
|
||||
{
|
||||
name: 'opposingCounsel',
|
||||
label: 'Opposing counsel',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 300,
|
||||
hint: 'Also for the conflicts check.',
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
label: 'What the dispute is about',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
max: 2000,
|
||||
hint: 'A few sentences is enough. Please do not include privileged or confidential detail — that is what the intake call is for.',
|
||||
},
|
||||
{
|
||||
name: 'timing',
|
||||
label: 'Timing',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
|
||||
},
|
||||
{
|
||||
name: 'preferredContact',
|
||||
label: 'Preferred reply',
|
||||
type: 'radio',
|
||||
required: false,
|
||||
options: ['Email', 'Phone'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* THE CONSENT TEXT, VERBATIM FROM `docs/05` §Consent text. It is a legal notice
|
||||
* the inquirer agrees to, so it is rendered from here and never retyped or
|
||||
* reworded to fit a layout. Note that it says the same three things
|
||||
* `NO_RETAINER_NOTICE` says — that constant is the site-wide statement and this
|
||||
* is the one the inquirer ticks; both ship on `/contact/`, which is deliberate:
|
||||
* `docs/01` requires the page to carry the notice, and `docs/05` requires the
|
||||
* checkbox to carry it too.
|
||||
*
|
||||
* ⚠️ **IT NAMES SML COMPANY LTD — Pouya's ruling, 2026-09-02 — AND THREE
|
||||
* CONSTRAINTS RIDE ON THAT.** **Name only, no terminal period**, and never
|
||||
* beside the licence-status row (`AGENTS.md` §4). **`docs/05` §Consent text is a
|
||||
* byte-identical second copy with no `check:` script over it**, so it moves with
|
||||
* this string. And **`/legal/privacy/` must keep naming the same party** — it
|
||||
* does, under §Why it is collected; a consent naming a company the linked policy
|
||||
* never mentions is an accountability gap, not a matter of voice.
|
||||
*/
|
||||
export const CONSENT_TEXT =
|
||||
'I consent to SML Company Ltd storing and using the information in this form ' +
|
||||
'to respond to my inquiry and to run a conflicts check. I understand that ' +
|
||||
'submitting this form does not create a retainer, does not appoint a neutral, ' +
|
||||
'and does not itself establish a mediator–party relationship.';
|
||||
|
||||
/**
|
||||
* The honeypot. `docs/05`: *"hidden from sighted and screen-reader users, must
|
||||
* be empty"*.
|
||||
*
|
||||
* ⚠️ **`display: none` PLUS `tabindex="-1"` PLUS `aria-hidden`, AND THE NAME
|
||||
* MATTERS.** A honeypot named `honeypot` is skipped by any bot worth stopping;
|
||||
* one named like a real field is filled. `company_website` is a plausible field
|
||||
* on a professional intake form and is not one this form has. It must never be
|
||||
* reachable by keyboard or announced by a screen reader — a honeypot that traps
|
||||
* a screen-reader user is an accessibility defect that also loses a real inquiry.
|
||||
*/
|
||||
export const HONEYPOT_FIELD = 'company_website';
|
||||
|
||||
/**
|
||||
* THE SECOND HONEYPOT, AND IT IS A DIFFERENT TRAP RATHER THAN A SECOND COPY OF
|
||||
* THE FIRST. Pouya's ruling, 2026-09-04, after two automated submissions walked
|
||||
* through `HONEYPOT_FIELD` (`docs/05` §Observed abuse).
|
||||
*
|
||||
* ⚠️ **THE MECHANISM IS INVERTED, WHICH IS THE POINT.** `HONEYPOT_FIELD` is a
|
||||
* text input that must arrive EMPTY — it catches a bot that fills every input it
|
||||
* finds. The pair of 2026-09-04 did not fill it, so a second field of the same
|
||||
* kind would catch them exactly as well as the first did: not at all.
|
||||
*
|
||||
* This is a CHECKBOX, and what it catches is a bot that sets every control it
|
||||
* enumerates rather than one that fills every text field.
|
||||
*
|
||||
* ⚠️ **WHAT IT IS AIMED AT, AND WHAT THE EVIDENCE ACTUALLY SUPPORTS — READ THIS
|
||||
* BEFORE RELYING ON IT.** An earlier version of this comment said the decoy
|
||||
* targets *"a behaviour anything reaching validation must have"*, because the
|
||||
* consent box is required and unchecked by default, so a submission that
|
||||
* validated must have sent `consent=on`. **That argument does not survive its own
|
||||
* premise.** The 2026-09-04 pair did NOT fill the text honeypot, so they are
|
||||
* selective about hidden fields — and a bot selective enough to skip a hidden
|
||||
* text input is selective enough to skip a hidden checkbox. Sending `consent=on`
|
||||
* shows only that it knows one field name, not that it ticks everything it finds.
|
||||
*
|
||||
* **So this trap is very likely INERT against the traffic it was built from**,
|
||||
* and it is defence in depth against a different and common class: the bot that
|
||||
* enumerates controls and sets all of them. That is worth having and it is not
|
||||
* what the observation proved. `docs/05` §Observed abuse states the same limit;
|
||||
* the two must not drift, because the tempting sentence is the confident one.
|
||||
*
|
||||
* ⚠️ **ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE.** A browser sends nothing
|
||||
* at all for an unchecked box, so every way this field can fail to arrive — a
|
||||
* stripping extension, a proxy, a future template that drops it — reads as a
|
||||
* HUMAN; and a serialiser that emits `updates_optin=` without reading the
|
||||
* checked state reads as one too, because the handler tests for a NON-EMPTY
|
||||
* value rather than for presence. The failure mode of a trap is a lost legal
|
||||
* inquiry that looks like a successful one, and this trap fires only on
|
||||
* something that deliberately ticked a box no person can see.
|
||||
*
|
||||
* The name is a plausible marketing opt-in, which is what a bot expects to find
|
||||
* and a real form here does not have. Hidden the same way as the first — the
|
||||
* hiding is standard, the mechanism is not.
|
||||
*/
|
||||
export const DECOY_CHECKBOX_FIELD = 'updates_optin';
|
||||
|
||||
/**
|
||||
* WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
|
||||
* HOSTNAME. This is a design decision with four consequences, taken at step 8
|
||||
* and recorded because the obvious implementation is the other one.
|
||||
*
|
||||
* The obvious version posts to the execute-api hostname `AGENTS.md` §7 records.
|
||||
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
|
||||
* to that origin:
|
||||
*
|
||||
* ⚠️ **AND IT IS ALL LIVE SINCE 2026-09-02** — see the closing paragraph of this
|
||||
* block. The same stale sentence was corrected in `handler.mjs`, `docs/01` and
|
||||
* `docs/05` before it was corrected here; this note was added, in the same pass,
|
||||
* ABOVE a paragraph that still said the opposite twenty lines below it. **A note
|
||||
* asserting a correction is not the correction**, and the two sat contradicting
|
||||
* each other until `adversarial-reviewer` round 2.
|
||||
*
|
||||
* 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies
|
||||
* `form-action 'self' <api-endpoint>`; with a same-origin post the second
|
||||
* term is unnecessary, so the policy is strictly tighter.
|
||||
* 2. **No cross-origin POST at all**, so no CORS question for the form. (CORS
|
||||
* never governed it anyway — a form POST is a top-level navigation, not an
|
||||
* 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.** ⚠️ 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.
|
||||
*
|
||||
* ⚠️ **THE COST, WHICH WAS REAL AND IS NOW PAID: THE FORM DID NOT WORK UNTIL
|
||||
* THAT CLOUDFRONT BEHAVIOUR EXISTED AND THE HANDLER WAS DEPLOYED.** Both ran at
|
||||
* cutover on 2026-09-02 — `AGENTS.md` §7 holds the state and this comment does
|
||||
* not restate it. `/contact/` still publishes the email address beside the form,
|
||||
* which is now a courtesy rather than a fallback.
|
||||
*/
|
||||
export const INTAKE_ACTION = '/api/intake';
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The Open Graph card registry — one entry per page that does NOT use the
|
||||
* portrait. Spec: docs/04-seo-spec.md §Metadata; discharges `AGENTS.md` R15.
|
||||
*
|
||||
* ⚠️ **EVERY HEADLINE HERE IS ITS PAGE'S OWN `<h1>`, VERBATIM, AND THAT IS A
|
||||
* COMPLIANCE MECHANISM RATHER THAN A CONVENIENCE.** Text baked into a JPEG is
|
||||
* text `npm run check:claims` cannot see, and under D20 that script is the only
|
||||
* per-step claims control there is. New prose on a card would therefore be the
|
||||
* one kind of copy on this site with no mechanical check over it at all.
|
||||
*
|
||||
* So a card asserts nothing its page does not already assert in auditable HTML —
|
||||
* and `npm run og:proof` **verifies it**, by pulling the `<h1>` out of each
|
||||
* built page and comparing. A headline edited here without editing the page
|
||||
* fails that check; so does the reverse. `scripts/og-proof.mjs` is where the
|
||||
* comparison lives.
|
||||
*
|
||||
* THE EYEBROWS ARE EACH PAGE'S FIRST `.eyebrow`, VERBATIM, on the same
|
||||
* reasoning. `/` and `/about/` are absent by design — Q40 decided the portrait
|
||||
* for those two and called it "not an interim".
|
||||
*
|
||||
* ONE ENTRY PER PAGE, AND A PAGE WITHOUT ONE IS A BUILD ERROR (`SEO.astro`).
|
||||
* The alternative — falling back to the portrait when no entry exists — is how
|
||||
* "portrait everywhere" became an eighteen-page interim in the first place: it
|
||||
* fails silently and looks intentional.
|
||||
*/
|
||||
|
||||
/** Articles are not listed here. Their cards come from the collection itself —
|
||||
* see `src/pages/og/[...slug].jpg.ts`, which is the only place that knows
|
||||
* about both sources, so the two cannot disagree about which cards exist. */
|
||||
export const OG_CARDS: Record<string, { eyebrow: string; headline: string }> = {
|
||||
'/mediation/': {
|
||||
eyebrow: 'Mediation',
|
||||
headline: 'A mediator decides nothing.',
|
||||
},
|
||||
'/arbitration/': {
|
||||
eyebrow: 'Arbitration',
|
||||
headline: 'Sole, party-appointed, co-arbitration.',
|
||||
},
|
||||
'/med-arb/': {
|
||||
eyebrow: 'Med-Arb',
|
||||
headline: 'One neutral. Two processes. One agreement, written first.',
|
||||
},
|
||||
'/practice/': {
|
||||
eyebrow: 'Practice',
|
||||
headline: 'Six areas, one reason.',
|
||||
},
|
||||
'/practice/construction/': {
|
||||
eyebrow: 'Construction',
|
||||
headline: 'The dispute is in the change orders.',
|
||||
},
|
||||
'/practice/technology/': {
|
||||
eyebrow: 'Technology',
|
||||
headline: 'I read the contract and the system.',
|
||||
},
|
||||
'/practice/energy/': {
|
||||
eyebrow: 'Energy',
|
||||
headline:
|
||||
'Grid disputes are engineering disputes with a regulator attached.',
|
||||
},
|
||||
'/practice/insurance/': {
|
||||
eyebrow: 'Insurance',
|
||||
headline: "Private mediation, not the Tribunal's case conference.",
|
||||
},
|
||||
'/practice/shareholder/': {
|
||||
eyebrow: 'Shareholder',
|
||||
headline: 'The company still has to trade on Monday.',
|
||||
},
|
||||
'/practice/cross-cultural/': {
|
||||
eyebrow: 'Cross-cultural',
|
||||
headline: 'A session in the language the deal was made in.',
|
||||
},
|
||||
'/process/': {
|
||||
eyebrow: 'Process',
|
||||
headline: 'The shape of an engagement.',
|
||||
},
|
||||
'/for-parties/': {
|
||||
eyebrow: 'For parties',
|
||||
headline: 'What happens at a mediation.',
|
||||
},
|
||||
'/fees/': {
|
||||
eyebrow: 'Fees',
|
||||
headline: 'Published in full, including what overruns cost.',
|
||||
},
|
||||
'/insights/': {
|
||||
eyebrow: 'Insights',
|
||||
headline: 'Notes on process, regulation, and the technical record.',
|
||||
},
|
||||
'/contact/': {
|
||||
eyebrow: 'Contact',
|
||||
headline: 'Start with a confidential call.',
|
||||
},
|
||||
/* The two POST-redirect-GET landing pages. Both are `noindex` and excluded
|
||||
from the sitemap, and neither is a URL anyone would share — but they get
|
||||
cards for the same reason every other page does: `SEO.astro` throws without
|
||||
an entry, and the alternative is a silent portrait fallback, which is the
|
||||
failure R15 exists to prevent. Cheap, and it keeps one rule with no
|
||||
exceptions. */
|
||||
'/contact/received/': {
|
||||
eyebrow: 'Received',
|
||||
headline: 'Your inquiry has been received.',
|
||||
},
|
||||
'/contact/could-not-send/': {
|
||||
eyebrow: 'Not sent',
|
||||
headline: 'That inquiry was not recorded.',
|
||||
},
|
||||
/* `/bio/` is the source of the one-page PDF (R16). `noindex` and out of the
|
||||
sitemap, but it still needs an entry — one rule, no exceptions. */
|
||||
'/bio/': {
|
||||
eyebrow: 'Bio',
|
||||
headline: 'Pouya Lajevardi',
|
||||
},
|
||||
/* `/404/` builds to `dist/404.html` and is `noindex`, but a shared 404 link is
|
||||
exactly the kind of URL that gets pasted into a chat window — so it gets a
|
||||
card on the same one-rule-no-exceptions basis as `/bio/`. */
|
||||
'/404/': {
|
||||
eyebrow: 'Not found',
|
||||
headline: 'That page is not here.',
|
||||
},
|
||||
'/legal/privacy/': {
|
||||
eyebrow: 'Privacy',
|
||||
headline: 'What the intake form collects, and for how long.',
|
||||
},
|
||||
'/legal/terms/': {
|
||||
eyebrow: 'Terms',
|
||||
headline: 'Terms of use for this site.',
|
||||
},
|
||||
};
|
||||
|
||||
/** `/` and `/about/` — the portrait, decided rather than deferred (Q40). */
|
||||
export const PORTRAIT_PAGES = ['/', '/about/'] as const;
|
||||
|
||||
/**
|
||||
* AN ARTICLE'S CARD, DERIVED HERE RATHER THAN IN THE ENDPOINT — and the move is
|
||||
* the fix for a defect, not a tidy-up.
|
||||
*
|
||||
* `scripts/og-proof.mjs` compares every card's headline against its page's own
|
||||
* `<h1>`, which is what keeps card copy inside the claim register — text baked
|
||||
* into a JPEG is text `check:claims` cannot grep. Articles have no registry
|
||||
* entry, so the first version of that check **skipped them entirely**, and
|
||||
* `adversarial-reviewer` proved it by putting `DELIBERATELY WRONG CARD TEXT` in
|
||||
* the endpoint and watching the check pass. **The first repair was worse**: it
|
||||
* compared the article's `<h1>` against itself, which is a tautology, and the
|
||||
* same probe passed again.
|
||||
*
|
||||
* The working fix is not a cleverer comparison — it is to leave nothing to
|
||||
* compare. The derivation lives here, both the endpoint and the proof script
|
||||
* call it, and the endpoint no longer holds a headline literal that could
|
||||
* disagree with anything. What the proof script then checks is the one thing
|
||||
* still capable of drifting: whether the article's own `title` is what the route
|
||||
* renders as its `<h1>`.
|
||||
*/
|
||||
export function articleCard(title: string): {
|
||||
eyebrow: string;
|
||||
headline: string;
|
||||
} {
|
||||
return {
|
||||
eyebrow: 'Insights',
|
||||
/* The headline is the article's title, which is also its `<title>` (docs/04)
|
||||
and its `<h1>`. Never `description`: a 140–160 character sentence cannot
|
||||
set as a display line, and truncating it would put half a sentence in
|
||||
front of the reader the card exists for. */
|
||||
headline: title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/practice/construction/` → `practice-construction`, and back again in the
|
||||
* endpoint. Flattening rather than nesting because an Astro rest route serving
|
||||
* `/og/a/b.jpg` has to reassemble the path anyway, and one transform in one
|
||||
* place is cheaper to keep true than two.
|
||||
*
|
||||
* A hyphen cannot collide here: no page slug in `docs/01`'s sitemap contains
|
||||
* one at a position that would reproduce another page's flattened form, and
|
||||
* `PRACTICE_SLUGS` is the only nested namespace besides `/insights/` and
|
||||
* `/legal/`. If a slug is ever added that would collide, `og:proof` catches it —
|
||||
* two pages resolving to one card file means one page's `<h1>` will not match.
|
||||
*/
|
||||
export function ogSlug(pathname: string): string {
|
||||
return pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-');
|
||||
}
|
||||
|
||||
/** The site-root-relative path of a page's card. */
|
||||
export function ogCardPath(pathname: string): string {
|
||||
return `/og/${ogSlug(pathname)}.jpg`;
|
||||
}
|
||||
+69
-17
@@ -151,7 +151,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
paragraphs: [
|
||||
{
|
||||
lead: 'Interim adjudication.',
|
||||
text: 'Part II.1 of the Construction Act — "Construction Dispute Interim Adjudication" — has been in force since 1 October 2019. An adjudicator must determine the referred matter no later than 30 days after receiving the referring party\'s documents, and a determined amount is payable within 15 days of the determination being communicated. Judicial review is available only with leave of the Divisional Court.',
|
||||
text: 'Part II.1 of the Construction Act — "Construction Dispute Interim Adjudication" — has been in force since 1 October 2019. An adjudicator must determine the referred matter no later than 30 days after receiving the referring party\'s documents, unless that date is extended in the way the Act allows. A determined amount is payable within 15 days of the determination being communicated. Judicial review is available only with leave of the Divisional Court.',
|
||||
},
|
||||
{
|
||||
lead: 'A designated authority runs it.',
|
||||
@@ -177,7 +177,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
text: 'Ontario Power Generation holds a licence to construct a BWRX-300 small modular reactor at Darlington, granted by the Canadian Nuclear Safety Commission in April 2025, and applied in March 2026 for a licence to operate it. Bruce Power has a federal impact assessment under way for the Bruce C project, aimed at creating an option for up to 4,800 megawatts at the existing site, with reactor technology not yet selected.',
|
||||
},
|
||||
{
|
||||
text: 'Programmes on that scale run for years, through dozens of trade contracts, and they produce exactly the disputes above. This practice is built to facilitate procurement and subcontract disputes on that pipeline. I am naming it as the shape of the market, not as a list of files — nothing here is a claim to be on any of these projects.',
|
||||
text: 'Programmes on that scale run for years, through dozens of trade contracts. This practice is built to facilitate procurement and subcontract disputes on that pipeline. I am naming it as the shape of the market, not as a list of files — nothing here is a claim to be on any of these projects.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -255,18 +255,37 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
text: 'The Personal Information Protection and Electronic Documents Act remains the federal private-sector privacy statute. Bill C-27, which would have enacted the Consumer Privacy Protection Act and the Artificial Intelligence and Data Act, died without royal assent when the session ended, and was not reinstated. A newer bill — C-36, for a Protecting Privacy and Consumer Data Act — was introduced in June 2026 and was at second reading when this page was written. Canada has no federal AI statute.',
|
||||
},
|
||||
{
|
||||
lead: 'Ontario has one AI instrument, and it is mostly not switched on.',
|
||||
text: 'The Enhancing Digital Security and Trust Act, 2024 conditions each of its artificial-intelligence obligations on regulations prescribing who they apply to and when. Two regulations have been made under it — one on cyber security, one on digital technology affecting people under 18 — and neither is the AI one.',
|
||||
lead: "Ontario's AI-relevant statute has its artificial-intelligence obligations switched off.",
|
||||
text: 'The Enhancing Digital Security and Trust Act, 2024 conditions the artificial-intelligence obligations in its section 5 on regulations prescribing which public sector entities they apply to and in what circumstances. Two regulations have been made under it — one on cyber security, one on digital technology affecting people under 18 — and neither is the AI one.',
|
||||
},
|
||||
{
|
||||
lead: 'And no federal or Ontario statute requires data to be stored in Canada.',
|
||||
text: "This is the one worth stating plainly, because data-residency clauses are often drafted against the opposite assumption. The federal Privacy Commissioner's own guidance says PIPEDA does not prohibit an organisation in Canada from transferring personal information to another jurisdiction for processing; what the Act requires instead is accountability — the organisation stays responsible for information it has transferred to a third party. Ontario's health privacy statute imposes no storage-location rule either.",
|
||||
/* THE LEAD WAS "And no federal or Ontario statute requires data to
|
||||
be stored in Canada" — a universal over the FOUR instruments the
|
||||
extract actually checked (PIPEDA, Ontario FIPPA, PHIPA and
|
||||
O. Reg. 329/04), which is the shape §4 Forbidden's struck Q39
|
||||
universal bars in both directions: this repository does not
|
||||
conclude a proposition of law. Named instruments only. */
|
||||
/* ⚠️ NAMED, AND THE FIRST CORRECTION ONLY NARROWED THE CLASS.
|
||||
"Ontario's public-sector privacy statutes" is a class of two —
|
||||
FIPPA and MFIPPA — and the extract records a residency finding for
|
||||
FIPPA and none for MFIPPA, so the narrowed lead was still a
|
||||
universal over an unchecked instrument. Same shape, smaller.
|
||||
The lead names the three ACTS the extract searched. O. Reg. 329/04
|
||||
is searched too and is deliberately not named: it is a regulation
|
||||
under PHIPA, so naming the Act covers it without putting a
|
||||
regulation number on a marketing page. */
|
||||
lead: "And neither PIPEDA, nor Ontario's Freedom of Information and Protection of Privacy Act, nor its Personal Health Information Protection Act requires data to be stored in Canada.",
|
||||
text: 'This is the one worth stating plainly, because data-residency clauses are often drafted against the opposite assumption. The federal Privacy Commissioner\'s own guidance says PIPEDA does not prohibit an organisation in Canada from transferring personal information to another jurisdiction for processing; what the Act requires instead is accountability — the organisation stays responsible for information it has transferred to a third party. Neither Ontario statute contains a storage-location rule either — FIPPA has no data-localisation provision, and PHIPA\'s "Disclosure outside Ontario" section is a disclosure permission rather than a rule about where records sit.',
|
||||
},
|
||||
{
|
||||
text: 'Which matters in a dispute because the parties are often arguing about a clause neither of them can point to a source for. Establishing what the obligation actually is, rather than what both sides assumed it was, frequently narrows the disagreement to something a mediation can close in a day.',
|
||||
},
|
||||
],
|
||||
note: "Described as the state of the instruments, not applied to anyone's file, and the residency point is the Privacy Commissioner's own words rather than a conclusion of mine. All of it is sourced in docs/reference/canada-privacy-technology.md and all of it can change — a bill at second reading in August 2026 is not a bill at second reading forever. What any of it means for a particular contract is a question for each party's own counsel.",
|
||||
/* THE NOTE CLAIMED THE WHOLE RESIDENCY POINT WAS THE COMMISSIONER'S
|
||||
WORDS. It is his words for PIPEDA and a reading of the Ontario
|
||||
statutes for the rest — so the note disclaimed a conclusion the page
|
||||
does in fact draw, which is worse than drawing it openly. */
|
||||
note: "Described as the state of the instruments, not applied to anyone's file. On residency the PIPEDA half is the federal Privacy Commissioner's own words; the Ontario half is what FIPPA and PHIPA say, and all three are named rather than described as a class. All of it is sourced in docs/reference/canada-privacy-technology.md and all of it can change — a bill at second reading in August 2026 is not a bill at second reading forever. What any of it means for a particular contract is a question for each party's own counsel.",
|
||||
ground: 'inverse',
|
||||
},
|
||||
{
|
||||
@@ -302,8 +321,14 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
'connection regime in Ontario.',
|
||||
h1: 'Grid disputes are engineering disputes with a regulator attached.',
|
||||
lede:
|
||||
'Ontario has spent the last year rewriting how large loads and new ' +
|
||||
'generation get connected. That produces commercial disputes between ' +
|
||||
/* "and new generation" was here and is struck: the extract establishes a
|
||||
change for large loads (Electricity Act s. 28.1) and for what the Board
|
||||
may weigh on a leave-to-construct application, and its one quotation on
|
||||
generation runs the other way — the normal System Impact Assessment
|
||||
"applies to the connection of all generation facilities, renewable or
|
||||
non-renewable, equally". A class asserted from one instance. */
|
||||
'Ontario has spent the last year rewriting how large loads ' +
|
||||
'get connected. That produces commercial disputes between ' +
|
||||
'proponents, distributors, transmitters and municipalities long before ' +
|
||||
'anything reaches a regulator.',
|
||||
disputeTypesLede:
|
||||
@@ -312,7 +337,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
disputeTypes: [
|
||||
{
|
||||
name: 'Connection assessment',
|
||||
body: "Disputes arising out of the IESO connection assessment and approval process — the system impact assessment, the transmitter's customer impact assessment, and the conditions attached to either.",
|
||||
body: "Disputes arising out of the IESO's and transmitter's connection assessment and approval process — the system impact assessment, the transmitter's customer impact assessment, and the conditions attached to either.",
|
||||
},
|
||||
{
|
||||
name: 'Leave to construct',
|
||||
@@ -361,11 +386,28 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
lead: 'Connection runs through the IESO, and it is not a queue.',
|
||||
text: 'The IESO operates a six-stage connection process and calls it connection assessment and approval. An application is assessed by system impact assessment, and the transmitter generally runs a customer impact assessment after the draft. The IESO states plainly that it does not use an interconnection queue — it works from a defined set of committed projects instead, so "our place in the queue" describes nothing.',
|
||||
text: 'The IESO operates a connection process of up to six stages. An application is assessed by system impact assessment, and the transmitter generally runs a customer impact assessment after the draft. The IESO states plainly that it does not use an interconnection queue — it works from a defined set of committed projects instead, so "our place in the queue" describes nothing.',
|
||||
},
|
||||
{
|
||||
lead: 'And large loads now have their own gate.',
|
||||
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025 and creates a connection-approval requirement for a "specified load facility", a category defined to include data centres meeting criteria to be set by regulation. The regulation that would set them had not been made as of August 2026 — the Ministry described it then as something the province was considering drafting — and the Ministry posted a consultation on an assessment framework for new data centres in August 2026, with a comment period running to 12 September.',
|
||||
/* ⚠️ THIS SENTENCE ASSERTED THE ABSENCE OF A REGULATION AND THE
|
||||
EXTRACT FORBIDS ASSERTING IT. It read: "The regulation that would
|
||||
set them had not been made as of August 2026". The source,
|
||||
`docs/reference/ontario-energy-regulatory.md`, records the outcome
|
||||
of exactly that question as **"NOT ESTABLISHED either way, and DO
|
||||
NOT ASSERT ITS ABSENCE"** — because its 50-item e-Laws regulation
|
||||
list may have been truncated by a page cap, and criteria could be
|
||||
added to an existing regulation rather than a new one. It even
|
||||
supplies safe wording, which is what this now uses.
|
||||
|
||||
Found 2026-08-31 by the compliance audit on a step-7c ARTICLE
|
||||
DRAFT that had copied the same construction — so a defect in an
|
||||
unpublished draft surfaced a shipped one. Neither of step 5's
|
||||
review passes caught it, because both read the sentence against
|
||||
the extract's *quotations* rather than against its adversarial
|
||||
check. R18(b) tracks this fact as volatile; that is a different
|
||||
problem from never having been established. */
|
||||
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025. It bars a transmitter or distributor from connecting a "specified load facility" unless it is satisfied that the connection requirements the regulations specify have been complied with. That category is defined to include data centres meeting criteria that may be set by regulation. The enabling section is in force; the Ministry\'s August 2026 consultation still described the connection-approval regulation as under consideration, and described it as something the province was considering drafting. That consultation, on an assessment framework for new data centres, ran a comment period to 12 September 2026.',
|
||||
},
|
||||
],
|
||||
note: "Described so the process is legible, not applied to anyone's file — and the terms above are the ones these bodies actually use. Sourced in docs/reference/ontario-energy-regulatory.md.",
|
||||
@@ -418,8 +460,8 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
body: 'Whether an impairment falls inside the minor injury definition, and the monetary limit that follows if it does.',
|
||||
},
|
||||
{
|
||||
name: 'Treatment and assessment plans',
|
||||
body: 'Denied or partially approved plans, competing assessments, and disputes about the reasonableness and necessity of proposed treatment.',
|
||||
name: 'Medical and rehabilitation benefits',
|
||||
body: 'Which treatment, services or goods are payable, and the conditions a guideline may attach to them.',
|
||||
},
|
||||
{
|
||||
name: 'Catastrophic impairment',
|
||||
@@ -446,11 +488,21 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
lead: "Its case conference is the Tribunal's own, and I am not appointed to it.",
|
||||
text: "The Tribunal's settlement step is a case conference led by one of its adjudicators, who is then disqualified from hearing the matter. It is sometimes called a pre-hearing, which is the Tribunal's own label for it. A privately retained neutral does not conduct it and cannot be appointed to it, and nothing on this page should be read as offering that.",
|
||||
/* "who is then disqualified from hearing the matter" was here and
|
||||
overstated the rule. LAT Rule 14.3 disqualifies the Member
|
||||
"except with the consent of the parties" — an absolute where the
|
||||
rule is qualified. */
|
||||
text: "The Tribunal's settlement step is a case conference led by one of its adjudicators, who does not then sit on the hearing panel unless the parties consent. It is sometimes called a pre-hearing, which is the Tribunal's own label for it. A privately retained neutral does not conduct it and cannot be appointed to it, and nothing on this page should be read as offering that.",
|
||||
},
|
||||
{
|
||||
lead: 'What I offer is private mediation.',
|
||||
text: 'Retained by the parties, on their own terms, under an agreement to mediate they sign. The Tribunal\'s own materials point parties toward mediation: under the heading "Consider other ways to resolve your dispute", the accident-benefits page says that before you apply, you may want to consider negotiation or mediation services.',
|
||||
/* "The Tribunal's own materials point parties toward mediation" was
|
||||
here: a plural class, and a direction, resting on one permissive
|
||||
sentence on one page that pairs mediation with negotiation and
|
||||
ranks neither. The extract's own adversarial check named both
|
||||
over-reads; this is the same gloss one notch weaker, and it
|
||||
survived the correction to the quotation beside it. */
|
||||
text: 'Retained by the parties, on their own terms, under an agreement to mediate they sign. The Tribunal\'s accident-benefits page names mediation as something to consider before applying: under the heading "Consider other ways to resolve your dispute", it says that before you apply, you may want to consider negotiation or mediation services.',
|
||||
},
|
||||
],
|
||||
note: 'That quotation is about mediation before an application is filed, and it is quoted no wider than it goes. Sourced in docs/reference/lat-case-conference.md, which carries the full passage and a correction to an earlier reading of it.',
|
||||
@@ -556,7 +608,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
lead: 'And the end of the road.',
|
||||
text: 'Both statutes also provide for the company to be wound up, or liquidated and dissolved, including on the ground that it is just and equitable, and the Ontario Partnerships Act lets a partner apply to the court to dissolve a partnership on grounds that include conduct making it not reasonably practicable to carry on business together.',
|
||||
text: 'Both statutes also provide for the company to be wound up, or liquidated and dissolved, including on the ground that it is just and equitable, and the Ontario Partnerships Act lets a partner apply to the court to dissolve a partnership on grounds that include conduct by a partner other than the one suing, in matters relating to the partnership business, that makes it not reasonably practicable for the other partners to carry on the business in partnership with that partner.',
|
||||
},
|
||||
{
|
||||
lead: 'One provision points the other way.',
|
||||
|
||||
+136
-24
@@ -26,8 +26,10 @@
|
||||
* that `src/pages/about.astro` deliberately removed from visible prose as
|
||||
* *"a corporate-structure claim"* — scoping the value to a bare name does
|
||||
* not reach that. See `PRACTICE_JOB_TITLE` in `site.ts`.
|
||||
* 3. `priceRange` — omitted until `/fees/` exists (build step 9). docs/04
|
||||
* gates it on that page being real.
|
||||
* 3. `priceRange` — **omitted, and no longer "until `/fees/` exists".** That
|
||||
* page exists as of build step 9, the field went in, and it came out the
|
||||
* same day: its ends had different units and its floor was a quarter of the
|
||||
* real entry price for a mediation. See `professionalServiceNode`.
|
||||
*
|
||||
* AND `hasCredential` NOW CARRIES BOTH DESIGNATIONS. It was Q.Med-only until
|
||||
* 2026-08-29 because Q.Arb was a commenced pathway and the property means
|
||||
@@ -35,6 +37,9 @@
|
||||
* mapping `CREDENTIALS.designations` rather than indexing it, so a designation
|
||||
* added to §4 and to that constant cannot be silently omitted here.
|
||||
*/
|
||||
/* No `FEES` import. It was here for `priceRange`, which is gone — see
|
||||
`professionalServiceNode`. Nothing in this file carries a number now, which is
|
||||
the right shape: money is `/fees/`'s, with the conditions attached. */
|
||||
import {
|
||||
CONTACT,
|
||||
CREDENTIALS,
|
||||
@@ -119,7 +124,11 @@ export function personNode(
|
||||
|
||||
R10 fires on an event, not a date: re-confirm before any cutover or major
|
||||
republish, and re-stamp §4. That covers this field as well as the visible
|
||||
list. */
|
||||
list. **Last fired 2026-09-02** — all four re-confirmed by Pouya on the day
|
||||
of cutover. ⚠️ **The stamp lives on `CREDENTIALS.memberships` in site.ts
|
||||
and THIS FIELD READS `MEMBERSHIP_ORGS`** — a different array, as the note
|
||||
seven lines above says. Content parity between them is manual, so
|
||||
re-stamping is not the same act as re-checking that the two agree. */
|
||||
...(opts.memberships
|
||||
? {
|
||||
memberOf: MEMBERSHIP_ORGS.map((name) => ({
|
||||
@@ -203,7 +212,7 @@ export function professionalServiceNode(imageUrl?: string) {
|
||||
slogan: SITE.tagline,
|
||||
url: `${SITE.url}/`,
|
||||
description:
|
||||
'Commercial mediation and arbitration for construction, technology, ' +
|
||||
'Mediation and commercial arbitration for construction, technology, ' +
|
||||
'energy, insurance, shareholder and cross-border disputes. Toronto, ' +
|
||||
'by appointment. Q.Med and Q.Arb designations held.',
|
||||
provider: { '@id': PERSON_ID },
|
||||
@@ -217,6 +226,15 @@ export function professionalServiceNode(imageUrl?: string) {
|
||||
'Mediation-arbitration (med-arb)',
|
||||
],
|
||||
email: `mailto:${CONTACT.email}`,
|
||||
/**
|
||||
* ⚠️ **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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -278,8 +296,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`,
|
||||
@@ -416,25 +440,58 @@ export function practiceAreaGraph(opts: {
|
||||
}
|
||||
|
||||
/**
|
||||
* `/practice/` — the index. **The Person node and nothing else.**
|
||||
* A page that offers no `Service` of its own — the Person node alone.
|
||||
*
|
||||
* NO `Service` NODES FOR THE SIX AREAS (each page emits its own), NONE FOR THE
|
||||
* "also offered" STRIP, AND NO `CollectionPage`. The strip is the constraint to
|
||||
* keep: all three are PUBLISHABLE (§4, Q46(a)) but gate 1 on each reads
|
||||
* `[Pouya's stated basis]`, not `[verified]`, and ENE is §4's "offering nearest
|
||||
* the NOT-NEGOTIABLE boundary" — a visible strip read in context is not the
|
||||
* same claim as a machine-readable service type a crawler lifts alone. Adding
|
||||
* any of them needs docs/04 changed first, not this file.
|
||||
* `/practice/` and `/process/` are both this shape: they describe how the
|
||||
* practice works rather than offering something a crawler should lift as a
|
||||
* service. `/process/` in particular must NOT emit one — the five timings are
|
||||
* publishable only under Q43's framing sentence, and a `Service` node would
|
||||
* carry the numbers into a field where no framing travels with them.
|
||||
*
|
||||
* No `BreadcrumbList`: one hop from the root, no visible trail.
|
||||
* No `BreadcrumbList` on either: one hop from the root, no visible trail.
|
||||
*/
|
||||
export function practiceIndexGraph(imageUrl?: string) {
|
||||
export function pageGraph(imageUrl?: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [personNode(imageUrl)],
|
||||
};
|
||||
}
|
||||
|
||||
/** `FAQPage` — built from the array the page renders, never a second copy. */
|
||||
function faqNode(path: string, faq: ReadonlyArray<{ q: string; a: string }>) {
|
||||
return {
|
||||
'@type': 'FAQPage',
|
||||
'@id': `${SITE.url}${path}#faq`,
|
||||
mainEntity: faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/for-parties/` — the Person node and a `FAQPage`. **No `Service` node.**
|
||||
*
|
||||
* docs/04 lists `FAQPage` for this page, under the condition that decides it:
|
||||
* *"Only where the visible page genuinely is Q&A. Never fabricate questions to
|
||||
* farm a rich result."* The page is written as questions a party actually asks,
|
||||
* and this node is built from the same array it renders — so a question cannot
|
||||
* enter the structured data without appearing on the page.
|
||||
*
|
||||
* The Service nodes for mediation live on `/mediation/`. This page explains a
|
||||
* process to the party on the other side of it; it is not a second offer of it.
|
||||
*/
|
||||
export function forPartiesGraph(opts: {
|
||||
faq: ReadonlyArray<{ q: string; a: string }>;
|
||||
imageUrl?: string;
|
||||
}) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [personNode(opts.imageUrl), faqNode('/for-parties/', opts.faq)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/med-arb/`'s graph — the Service, the Person, and a `FAQPage`.
|
||||
*
|
||||
@@ -460,17 +517,72 @@ export function medArbGraph(opts: {
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
'@graph': [...base['@graph'], faqNode('/med-arb/', opts.faq)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `Article` — build step 7b. docs/04: *"`headline`, `description`,
|
||||
* `datePublished`, `dateModified`, `author` → Person, `image`"*.
|
||||
*
|
||||
* `author` IS `{'@id': PERSON_ID}` AND THE PERSON NODE TRAVELS IN THE SAME
|
||||
* `@graph` — `homeGraph`'s reasoning, applied a fifth time. A bare `@id` pointing
|
||||
* at another document relies on a crawler fetching and joining two; inside one
|
||||
* `@graph` it resolves in the document it arrives in.
|
||||
*
|
||||
* `dateModified` FALLS BACK TO `datePublished` RATHER THAN BEING OMITTED. An
|
||||
* article with no `updatedDate` has not been modified since publication, which is
|
||||
* a fact; omitting the field says nothing, and Google reads a missing
|
||||
* `dateModified` as unknown rather than as "same as published".
|
||||
*
|
||||
* ⚠️ **NO `publisher`, AND NO `Organization` NODE ANYWHERE NEAR THIS.** The
|
||||
* obvious shape for a blog is `publisher: { '@type': 'Organization', name: … }`,
|
||||
* and on this site the only name available for it is SML Company Ltd — which
|
||||
* would assert in machine-readable form that the company publishes the practice's
|
||||
* writing. §4 rows *"Operator of SML Company Ltd **alongside** the practice"* and
|
||||
* nothing more; `schema.ts` already declines `Person.worksFor` for the same
|
||||
* reason (Q49(b)). A personal byline needs no publisher: `author` is the Person.
|
||||
*
|
||||
* ⚠️ **NO `wordCount`, NO `articleSection` KEYWORD STUFFING, AND NO
|
||||
* `interactionStatistic`.** The first is derivable and adds nothing; the last is
|
||||
* where a view count would go, and §4 Forbidden's reasoning about unverifiable
|
||||
* numbers applies to a field nobody reads exactly as it applies to a page.
|
||||
*/
|
||||
export function articleGraph(opts: {
|
||||
slug: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
datePublished: Date;
|
||||
dateModified?: Date;
|
||||
/** The article's own OG card, absolute. docs/04 lists `image` on `Article`. */
|
||||
imageUrl: string;
|
||||
/** The Person node's image — the portrait, not the card. Two different
|
||||
* claims: this one is a photograph of a person. */
|
||||
personImageUrl?: string;
|
||||
}) {
|
||||
const path = `/insights/${opts.slug}/`;
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
...base['@graph'],
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': `${SITE.url}/med-arb/#faq`,
|
||||
mainEntity: opts.faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||
})),
|
||||
'@type': 'Article',
|
||||
'@id': `${SITE.url}${path}#article`,
|
||||
headline: opts.headline,
|
||||
description: opts.description,
|
||||
url: `${SITE.url}${path}`,
|
||||
datePublished: iso(opts.datePublished),
|
||||
dateModified: iso(opts.dateModified ?? opts.datePublished),
|
||||
author: { '@id': PERSON_ID },
|
||||
image: opts.imageUrl,
|
||||
inLanguage: 'en-CA',
|
||||
},
|
||||
personNode(opts.personImageUrl),
|
||||
breadcrumbNode(path, [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Insights', href: '/insights/' },
|
||||
{ name: opts.headline, href: path },
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+121
-17
@@ -75,9 +75,10 @@ export const CREDENTIALS = {
|
||||
],
|
||||
languages: ['English', 'Farsi'],
|
||||
/**
|
||||
* [verified 2026-08-28 — Pouya, AGENTS.md Q44] — re-stamped when Q44 closed;
|
||||
* the original confirmation was 2026-08-26 (Q28 plus the CTF addition of the same
|
||||
* date] — and FOR NOW.
|
||||
* [verified 2026-09-02 — Pouya, R10's cutover fire] — re-confirmed on the day
|
||||
* of cutover: ADRIC, ADRIO, the three OBA sections and the CTF all current.
|
||||
* Earlier stamps: 2026-08-28 (Q44), 2026-08-26 (Q28 plus the CTF addition of
|
||||
* the same date). AND FOR NOW — the stamp is a snapshot, not a warranty.
|
||||
*
|
||||
* WHAT §4 ACTUALLY SAYS ABOUT RENEWAL, because a widened version of it reached
|
||||
* a public page. §4, quoted exactly: *"the OBA sections and the CTF renew
|
||||
@@ -95,8 +96,11 @@ export const CREDENTIALS = {
|
||||
*
|
||||
* **PUBLISHED FROM 2026-08-28 — Q44 CLOSED.** Pouya re-confirmed all four as
|
||||
* current, which discharges R10's prohibition, and `/about/` now renders a
|
||||
* Memberships group from this array. Re-stamped `[verified 2026-08-28 —
|
||||
* Pouya]`; the earlier stamp was 2026-08-26.
|
||||
* Memberships group from this array.
|
||||
*
|
||||
* **RE-CONFIRMED 2026-09-02 — R10's cutover event, and that is the stamp
|
||||
* above.** R10 fires on an event and cutover is one of its two; a stamp is not
|
||||
* a renewal receipt, so this was asked again rather than read again.
|
||||
*
|
||||
* ⚠️ **RENDER THE LIST; NEVER RENDER A CLAIM ABOUT ITS CURRENCY.** No
|
||||
* "renewed annually", no "current as of", no "listed as current", no stamp
|
||||
@@ -299,7 +303,12 @@ export const NEUTRAL_ROLE_LINE =
|
||||
'party should have their own legal advice.';
|
||||
|
||||
/**
|
||||
* THE SIX CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29.
|
||||
* THE CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29, plus (g).
|
||||
*
|
||||
* ⚠️ **(a)–(f) ARE Q54's SIX. (g) IS NOT** — it was attested 2026-09-03 to
|
||||
* close D20 finding 13 and carries its own stamp on the object below. The
|
||||
* heading no longer states a count: this comment said "THE SIX" while the
|
||||
* object held seven for exactly as long as it took to notice.
|
||||
*
|
||||
* A THIRD CLASS OF CLAIM, and the class is his: not a credential (a fact about
|
||||
* him, §4 Verified) and not an offering (a process the practice conducts, §4
|
||||
@@ -361,7 +370,26 @@ export const CONDUCT_UNDERTAKINGS = {
|
||||
arbitrationAwardDate:
|
||||
'The date the award is due is fixed in the first procedural order rather ' +
|
||||
'than left open.',
|
||||
} as const; // [verified 2026-08-29 — Pouya, Q54]
|
||||
/**
|
||||
* (g) `/legal/privacy/` — the conflicts check. **ATTESTED 2026-09-03 by
|
||||
* Pouya, closing D20 finding 13.** It is NOT one of the Q54 six: its own
|
||||
* date, its own ruling, and it is stamped separately below.
|
||||
*
|
||||
* The page was already stating a conflicts undertaking in prose, and §4's
|
||||
* gate for this class is one line — he must have made it IN TERMS. He now
|
||||
* has, so the sentence is rendered from here rather than typed there.
|
||||
*
|
||||
* ⚠️ **IT IS HIS WORDING, NOT A RENDERING OF IT, AND THAT IS THE WHOLE GATE.**
|
||||
* The attestation is *"runs a conflicts check on every inquiry before
|
||||
* engaging"*. This string shipped for one round as *"before I accept an
|
||||
* appointment"* — the site's own vocabulary, defensible, and **a paraphrase of
|
||||
* a commitment the page publishes as his**. §4's gate for this class is that
|
||||
* he made it IN TERMS, and a substitution recorded in a code comment is not
|
||||
* that. Do not smooth it back. If "engaging" turns out to be the wrong verb,
|
||||
* the fix is a second attestation, never an edit here.
|
||||
*/
|
||||
conflictsCheck: 'I run a conflicts check on every inquiry before engaging.',
|
||||
} as const; // (a)–(f) [verified 2026-08-29 — Pouya, Q54]; (g) [attested 2026-09-03 — Pouya]
|
||||
|
||||
/**
|
||||
* THE HELD-DESIGNATIONS SENTENCE, RENDERED AND NEVER RETYPED.
|
||||
@@ -437,6 +465,16 @@ export const ANALYTICS = {
|
||||
*/
|
||||
provider: 'plausible' as 'plausible' | 'fathom',
|
||||
domain: 'adr.smlcompany.ca',
|
||||
/**
|
||||
* ⚠️ **NOT INSTALLED — D15 decided the provider; deciding is not installing.**
|
||||
* `/legal/privacy/` renders its analytics paragraph from this flag, so the
|
||||
* policy states the fact rather than the intention: a policy naming a
|
||||
* processor that processes nothing is a false disclosure, and a silent one.
|
||||
*
|
||||
* **Flipping this is a change to a published disclosure.** The policy's
|
||||
* "last updated" date moves on the same build.
|
||||
*/
|
||||
installed: false, // [verified 2026-08-31 — no script on any built page]
|
||||
} as const;
|
||||
|
||||
export const CONTACT = {
|
||||
@@ -481,13 +519,45 @@ export const FEES = {
|
||||
currency: 'CAD',
|
||||
taxNote: 'All fees are plus HST.',
|
||||
mediation: {
|
||||
/** Prep is bundled AND stated on the page — [verified 2026-08-26].
|
||||
* Do not hide it: at these rates, saying preparation is included is the
|
||||
* point, not a detail. */
|
||||
halfDay: { amount: 2000, hours: 3.5, prepIncluded: 2 },
|
||||
fullDay: { amount: 4000, hours: 7, prepIncluded: 3 },
|
||||
/**
|
||||
* TWO SEPARATE ALLOWANCES, AND BOTH PUBLISH AS A CAP — Q58, 2026-08-31.
|
||||
* `hours` is the **session**; `prepIncluded` is preparation, bundled but
|
||||
* capped. `docs/07` requires each published as a cap and with its noun:
|
||||
* *"up to 3 hours of session"*, *"including up to 2 hours of preparation"*.
|
||||
* A flat "including 2 hours" sells an entitlement and a bare "preparation
|
||||
* included" sells an uncapped allowance.
|
||||
*/
|
||||
halfDay: { amount: 2000, hours: 3, prepIncluded: 2 },
|
||||
fullDay: { amount: 4000, hours: 6, prepIncluded: 3 },
|
||||
additionalParty: 500, // each party beyond two
|
||||
overtimePerHour: 500, // [verified 2026-08-26]
|
||||
/**
|
||||
* ✅ **Q59 RULED — Pouya, 2026-08-31. OVERTIME RUNS FROM THE SESSION CAP**,
|
||||
* i.e. from the fourth hour of a half day and the seventh of a full day —
|
||||
* `hours` above, not the billed envelope.
|
||||
*
|
||||
* ⚠️ **THERE IS NO BOOLEAN FOR THAT, AND THERE WAS ONE FOR AN HOUR.**
|
||||
* `overtimeStartsAfterSessionHours: true` sat here with a 21-line comment
|
||||
* instructing that *"the page must say so wherever it publishes the overtime
|
||||
* rate"* — and `grep -rn overtimeStartsAfterSessionHours src/ scripts/
|
||||
* backend/` returned exactly one line: the declaration. Nothing read it.
|
||||
* `/fees/` and `/bio/` both hardcode the session-cap wording in template
|
||||
* strings, so reversing the flag would have changed nothing and failed
|
||||
* nothing. **A flag that looks like a control and is not is `AGENTS.md` Q22
|
||||
* at constant scope**, which is the defect this project has paid for most
|
||||
* often. Deleted by `adversarial-reviewer`'s finding, 2026-08-31; the ruling
|
||||
* lives in `docs/07` and in §9 Q59, which is where a ruling belongs.
|
||||
*
|
||||
* ⚠️ **`reservation` BELOW IS REAL AND MUST STAY.** It is interpolated into
|
||||
* `/fees/`'s overtime row and into `/bio/`, and it is the half of Q59's
|
||||
* ruling that answers the rate card's arithmetic anomaly: the full-day fee
|
||||
* buys the **day**, so `2000 + 500 × 3 = 3500` against `4000` is not a
|
||||
* penalty for booking properly. A reader who takes the number and skips this
|
||||
* sentence has read a different offer — §12 R5 carries the anomaly.
|
||||
*/
|
||||
reservation:
|
||||
'A full day reserves the day. Half-day overtime is subject to ' +
|
||||
'availability.', // [verified 2026-08-31 — Pouya, Q59]
|
||||
},
|
||||
arbitration: {
|
||||
perHour: 500,
|
||||
@@ -506,6 +576,40 @@ export const FEES = {
|
||||
* it, and do not price it.
|
||||
*/
|
||||
hourly: 500, // [verified 2026-08-26]
|
||||
/**
|
||||
* MED-ARB IS BILLED BY PHASE, AND THAT IS WHY THERE IS NO NUMBER IN HERE.
|
||||
*
|
||||
* Pouya's ruling, 2026-09-03, closing D20 finding 10. `/fees/` opens *"Every
|
||||
* figure is on this page"* while §4 Offerings carries a **Med-Arb** row that
|
||||
* `docs/07-fees.md` priced nowhere — so the promise was wider than the card.
|
||||
* The ruling closes it by pricing the offering out of the two rate sets that
|
||||
* are already published rather than by narrowing the promise: each phase is
|
||||
* charged at the rates for that process, so no third set of figures exists
|
||||
* and the sentence becomes true as written.
|
||||
*
|
||||
* ⚠️ **THERE IS NO `amount` HERE ON PURPOSE. Do not add one.** A med-arb
|
||||
* figure would be a fourth price for a process priced twice already, and the
|
||||
* first thing it would do is disagree with one of them.
|
||||
*
|
||||
* ⚠️ **`termsApply` SAYS "as they apply to that process on its own", NOT
|
||||
* "to both phases".** The additional-party fee is a MEDIATION row; the
|
||||
* arbitration card has no equivalent. Saying the terms apply to each phase as
|
||||
* they apply to that process invents nothing; saying they apply throughout
|
||||
* would invent an additional-party charge in the arbitral phase.
|
||||
*
|
||||
* INTERIM, set 2026-09-03, reviewed at the §12 R5 twelve-month fee review.
|
||||
* `docs/07` §Med-arb — billed by phase carries the rule and the same stamp.
|
||||
*/
|
||||
medArb: {
|
||||
rule:
|
||||
'Med-arb is billed by phase. The mediation phase is charged at the ' +
|
||||
'mediation rates above. If the matter proceeds to arbitration, that ' +
|
||||
'phase is charged at the arbitration rates above.',
|
||||
noSeparateFee: 'There is no separate med-arb fee.',
|
||||
termsApply:
|
||||
'The additional-party and cancellation terms apply to each phase as ' +
|
||||
'they apply to that process on its own.',
|
||||
}, // [verified 2026-09-03 — Pouya, interim; R5]
|
||||
cancellation: [
|
||||
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
|
||||
{ window: '15 to 30 days before', fee: '50% of the booked fee.' },
|
||||
@@ -754,11 +858,11 @@ export type _DesignationRowParity = _AssertTrue<
|
||||
* "Binding conclusion" alone would read as though a mediation binds, which it
|
||||
* does not until the parties sign.
|
||||
*
|
||||
* NO FEE CLAIM IN ANY BODY. `docs/07` bundles a CAPPED preparation allowance
|
||||
* (2 h in the half day, 3 h in the full day) and says in terms that it "must be
|
||||
* stated on the page... Do not quietly fold it into the hours figure." A
|
||||
* five-word strip cannot state it properly, and stating it improperly
|
||||
* misdescribes money. `/fees/` at step 9.
|
||||
* NO FEE CLAIM IN ANY BODY. `docs/07` §All parameters confirmed requires the
|
||||
* bundled preparation allowance published **in hours and as a cap**, and a
|
||||
* five-word strip cannot do that. Stating it improperly misdescribes money.
|
||||
* `/fees/` at step 9. (Cited, not quoted — the quotation that stood here went
|
||||
* stale when Q58 corrected the section it came from.)
|
||||
*/
|
||||
export const PROCESS = [
|
||||
{
|
||||
|
||||
@@ -56,7 +56,11 @@ const { preloadSerifItalic = false, ...seo } = Astro.props;
|
||||
/* No SVG favicon. The mark is a shaded ribbon, not flat vector paths, so
|
||||
there is no honest SVG of it to serve — see InfinityMark.astro and
|
||||
AGENTS.md Q38. The .ico carries 16/32/48, and is what crawlers request
|
||||
at the root regardless of what is declared here. */
|
||||
at the root regardless of what is declared here.
|
||||
|
||||
The .ico is transparent and the touch icon is opaque cream ON PURPOSE —
|
||||
do not change either to match the other:
|
||||
docs/reference/brand-assets.md §The icon set. */
|
||||
}
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
@@ -77,7 +81,7 @@ const { preloadSerifItalic = false, ...seo } = Astro.props;
|
||||
a fallback first, and a serif-to-fallback swap inside a 96px headline
|
||||
moves the whole last line.
|
||||
|
||||
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 12px, uppercase,
|
||||
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 13px, uppercase,
|
||||
0.18em tracking — and the credential labels. A swap there costs one short
|
||||
line of reflow at a size where the fallback is metrically close, and
|
||||
preloading it would put 95,688 B of font on the critical path instead of
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The Open Graph card generator. Spec: docs/04-seo-spec.md §Metadata —
|
||||
* *"the site's own type and palette: display headline on cream, infinity mark,
|
||||
* designation line"*. Discharges `AGENTS.md` R15.
|
||||
*
|
||||
* WHY THE CARDS MATTER AND WHY NOBODY HERE WOULD EVER NOTICE THEM. R15's own
|
||||
* reasoning: a link preview is rendered by LinkedIn, Slack and Teams for a
|
||||
* reader who is not us. Nineteen pages with unique titles previewing
|
||||
* identically is the defect, and it is invisible from inside the repo.
|
||||
*
|
||||
* ⚠️ **TEXT BAKED INTO AN IMAGE IS UNREACHABLE BY `npm run check:claims`.**
|
||||
* That script greps `dist/`'s HTML; a claim rendered into a JPEG is a claim no
|
||||
* mechanical control on this project can see, and under D20 `check:claims` is
|
||||
* the only per-step claims control there is. So the rule for card copy is
|
||||
* structural rather than editorial:
|
||||
*
|
||||
* **A card renders strings that already exist elsewhere in the repo.** The
|
||||
* kicker is `CREDENTIALS.designations`, rendered. The eyebrow and headline
|
||||
* come from `src/data/og-cards.ts`, whose entries are short subject labels
|
||||
* for pages that already ship — not new prose, and never a claim that is not
|
||||
* already made in auditable HTML on the page the card is for.
|
||||
*
|
||||
* COLOURS ARE PARSED OUT OF `tokens.css`, NOT COPIED. `CLAUDE.md` requires
|
||||
* every colour to come from a token, and this file is not CSS — so the choice
|
||||
* was a duplicated hex table or a parse. A duplicated hex table is the SES-DKIM
|
||||
* shape: two copies of one fact, and the stale one is the copy nobody re-reads.
|
||||
* A missing token throws rather than falling back, because a silent fallback
|
||||
* would render a card in the wrong palette and look deliberate.
|
||||
*
|
||||
* FONTS ARE THE STATIC `@fontsource` CUTS, NOT `public/fonts/`, AND THAT IS
|
||||
* FORCED. Measured 2026-08-31: satori parses TTF/OTF/WOFF and not WOFF2, and
|
||||
* decompressing `public/fonts/geist-latin-wght-normal.woff2` to TTF then
|
||||
* **throws** inside satori's `opentype.js` fork —
|
||||
* `parseFvarAxis: Cannot read properties of undefined` — because Fontsource's
|
||||
* subsetting drops the `name` records that the variable font's `fvar` table
|
||||
* points at. `@fontsource/geist` and `@fontsource/instrument-serif` ship static
|
||||
* 400 cuts as `.woff`, which satori reads directly. Same typefaces, same
|
||||
* upstream version (5.3.0) as `docs/reference/fonts-provenance.md` records for
|
||||
* the site's own files, same weight. Build-time only: no visitor fetches these.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import satori from 'satori';
|
||||
import sharp from 'sharp';
|
||||
import { CREDENTIALS } from '../data/site';
|
||||
|
||||
export const OG_WIDTH = 1200;
|
||||
export const OG_HEIGHT = 630;
|
||||
|
||||
/**
|
||||
* ⚠️ PATHS ARE RESOLVED FROM `process.cwd()`, NOT FROM `import.meta.url`, AND
|
||||
* THAT IS NOT A STYLE CHOICE. Measured: with `import.meta.url` the build fails
|
||||
* with `ENOENT ... /dist/.prerender/chunks/../styles/tokens.css`, because Astro
|
||||
* bundles this module into `dist/.prerender/chunks/` and `import.meta.url` is
|
||||
* the CHUNK's location, not this file's. It works under `astro dev`, where the
|
||||
* module is served from source — the same dev-passes / build-fails shape as the
|
||||
* `animation-timeline` minifier defect, and the reason `/build` Phase 5 checks
|
||||
* the built output rather than the dev server.
|
||||
*
|
||||
* `astro build` runs with the project root as cwd. Every read below is
|
||||
* build-time only and throws with the path if it is wrong, so a future runner
|
||||
* with a different cwd fails loudly rather than shipping a blank card.
|
||||
*/
|
||||
const fromRoot = (...parts: string[]) => join(process.cwd(), ...parts);
|
||||
const TOKENS_CSS = fromRoot('src', 'styles', 'tokens.css');
|
||||
const MARK_PNG = fromRoot('src', 'assets', 'brand', 'sml-infinity-mark.png');
|
||||
const SERIF_WOFF = fromRoot(
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'instrument-serif',
|
||||
'files',
|
||||
'instrument-serif-latin-400-normal.woff',
|
||||
);
|
||||
const SANS_WOFF = fromRoot(
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'geist',
|
||||
'files',
|
||||
'geist-latin-400-normal.woff',
|
||||
);
|
||||
|
||||
/** The tokens this card uses, by their `tokens.css` names. */
|
||||
const NEEDED = ['cream', 'ink', 'ink-soft', 'maroon', 'gold'] as const;
|
||||
type TokenName = (typeof NEEDED)[number];
|
||||
|
||||
async function loadPalette(): Promise<Record<TokenName, string>> {
|
||||
const css = await readFile(TOKENS_CSS, 'utf8');
|
||||
const palette = {} as Record<TokenName, string>;
|
||||
for (const name of NEEDED) {
|
||||
// Only the literal hex declarations in the palette block, never an alias
|
||||
// like `--bg: var(--cream)` — resolving one level of indirection here would
|
||||
// invite resolving two, and this generator has no cascade.
|
||||
const match = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{3,8})\\s*;`).exec(
|
||||
css,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`src/styles/tokens.css has no literal --${name} hex declaration. ` +
|
||||
'The OG card generator reads the palette from that file so the card ' +
|
||||
'and the site cannot drift; add the token there rather than a hex ' +
|
||||
'value here.',
|
||||
);
|
||||
}
|
||||
palette[name] = match[1];
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark, resized once and inlined as a data URI. satori resolves no URLs, so
|
||||
* a data URI is the only way in — and `CLAUDE.md`'s rule against base64-inlining
|
||||
* an image is about bytes shipped to a visitor in HTML. Nothing here reaches a
|
||||
* visitor: this string exists for the few milliseconds before sharp flattens
|
||||
* the SVG to a JPEG.
|
||||
*
|
||||
* 132 px wide at the mark's own 2668 × 1704 (1.5657:1), so it renders at its
|
||||
* true proportion — the ratio `InfinityMark.astro` records as measured, and the
|
||||
* one a hand-traced path got wrong (Q32).
|
||||
*/
|
||||
const MARK_W = 132;
|
||||
const MARK_H = Math.round((MARK_W * 1704) / 2668);
|
||||
|
||||
type Assets = {
|
||||
palette: Record<TokenName, string>;
|
||||
serif: Buffer;
|
||||
sans: Buffer;
|
||||
mark: string;
|
||||
};
|
||||
|
||||
/** Read once per build, not once per card — seventeen pages plus every article
|
||||
* go through here in one `astro build`. */
|
||||
let assets: Promise<Assets> | null = null;
|
||||
|
||||
function loadAssets(): Promise<Assets> {
|
||||
assets ??= (async () => {
|
||||
const [palette, serif, sans, markPng] = await Promise.all([
|
||||
loadPalette(),
|
||||
readFile(SERIF_WOFF),
|
||||
readFile(SANS_WOFF),
|
||||
readFile(MARK_PNG),
|
||||
]);
|
||||
const markResized = await sharp(markPng)
|
||||
.resize({ width: MARK_W * 2, withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer();
|
||||
return {
|
||||
palette,
|
||||
serif,
|
||||
sans,
|
||||
mark: `data:image/png;base64,${markResized.toString('base64')}`,
|
||||
};
|
||||
})();
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline size, chosen from length rather than measured. satori does not
|
||||
* 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 -- --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;
|
||||
if (headline.length > 42) return 68;
|
||||
return 80;
|
||||
}
|
||||
|
||||
export type OgCard = {
|
||||
/** Short, uppercased on the card. The page's own eyebrow where it has one. */
|
||||
eyebrow: string;
|
||||
/** The card's display line. A subject label, not new prose — see the header. */
|
||||
headline: string;
|
||||
};
|
||||
|
||||
export async function renderOgCard(card: OgCard): Promise<Buffer> {
|
||||
const { palette, serif, sans, mark } = await loadAssets();
|
||||
const pad = 72;
|
||||
|
||||
const svg = await satori(
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: `${OG_WIDTH}px`,
|
||||
height: `${OG_HEIGHT}px`,
|
||||
backgroundColor: palette.cream,
|
||||
padding: `${pad}px`,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
fontFamily: 'Geist',
|
||||
fontSize: 22,
|
||||
letterSpacing: 4,
|
||||
textTransform: 'uppercase',
|
||||
color: palette.maroon,
|
||||
},
|
||||
children: card.eyebrow,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginTop: 44,
|
||||
fontFamily: 'Instrument Serif',
|
||||
fontSize: headlineSize(card.headline),
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: -1,
|
||||
color: palette.ink,
|
||||
},
|
||||
children: card.headline,
|
||||
},
|
||||
},
|
||||
// Pushes the footer to the bottom edge whatever the headline does.
|
||||
{ type: 'div', props: { style: { display: 'flex', flexGrow: 1 } } },
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
height: '1px',
|
||||
backgroundColor: palette.gold,
|
||||
marginBottom: 28,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'Geist',
|
||||
color: palette['ink-soft'],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: { display: 'flex', fontSize: 30 },
|
||||
children: 'Pouya Lajevardi',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginTop: 8,
|
||||
fontSize: 21,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Rendered from §4's own designation strings, never
|
||||
// retyped. `Q.Arb (ADRIC / ADRIO)` is the publishable
|
||||
// form and no acquisition date appears anywhere.
|
||||
children: CREDENTIALS.designations.join(' · '),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'img',
|
||||
props: { src: mark, width: MARK_W, height: MARK_H },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
fonts: [
|
||||
{ name: 'Instrument Serif', data: serif, weight: 400, style: 'normal' },
|
||||
{ name: 'Geist', data: sans, weight: 400, style: 'normal' },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// JPEG, for the reason SEO.astro already gives for the portrait: link-preview
|
||||
// crawlers are not browsers and several still do not decode WebP at all.
|
||||
// 4:4:4 because the card is type on a flat ground, where chroma subsampling
|
||||
// is visible on the letterforms rather than free.
|
||||
return sharp(Buffer.from(svg))
|
||||
.jpeg({ quality: 88, chromaSubsampling: '4:4:4', mozjpeg: true })
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
/**
|
||||
* The 404 page. `docs/04-seo-spec.md`: "Real, styled, with search-intent links
|
||||
* out. CloudFront must return it with a genuine 404 status."
|
||||
*
|
||||
* ⚠️ THE PAGE IS HALF OF THE FIX AND THE DISTRIBUTION IS THE OTHER HALF. Astro
|
||||
* emits this as `dist/404.html`; nothing in the build can make CloudFront serve
|
||||
* it. Until the custom error response exists, a missing URL returns S3's
|
||||
* `AccessDenied` XML — measured 2026-09-01, not assumed: `/about/` and
|
||||
* `/definitely-not-a-page/` both answered **403, `application/xml`, 111 bytes**
|
||||
* on the live distribution. `docs/06` carries the two commands and the reason
|
||||
* the mapping is on 404 rather than 403.
|
||||
*
|
||||
* `noindex`, because a 404 that invites indexing is a 404 that gets indexed.
|
||||
* `robots` is `noindex,follow` so the links out are still crawled, which is the
|
||||
* whole point of a page with links out.
|
||||
*
|
||||
* NO CLAIM ABOUT PRACTICE OR CREDENTIALS APPEARS IN THE VISIBLE COPY, and the
|
||||
* omission is the design rather than an oversight. An error page has no reader
|
||||
* who came for a credential, so a sentence it adds is a sentence `AGENTS.md` §4
|
||||
* has to carry for no return. It names pages and nothing else.
|
||||
*
|
||||
* ⚠️ THE JSON-LD IS A DIFFERENT MATTER AND THIS COMMENT USED TO DENY IT. The
|
||||
* `pageGraph()` above is the shared graph and it DOES emit the §4 Person node —
|
||||
* `jobTitle`, the `description`, and `hasCredential` for Q.Med and Q.Arb. Every
|
||||
* one of those is registered, so it is not a §4 breach; the false statement was
|
||||
* this comment, which invited the next editor to treat the 404 page as outside
|
||||
* the register's blast radius. It is not: this is machine-readable credential
|
||||
* assertion served on every unmatched URL, to exactly the reader `robots.txt`
|
||||
* names — "an assistant that counsel is using to shortlist a neutral". Found by
|
||||
* `adversarial-reviewer`, 2026-09-01.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { CONTACT } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/* The routes worth offering, in the order a lost reader is most likely to want
|
||||
them. Not the full sitemap — the footer on this page already carries that. */
|
||||
const routes = [
|
||||
{ href: '/mediation/', label: 'Mediation' },
|
||||
{ href: '/arbitration/', label: 'Arbitration' },
|
||||
{ href: '/med-arb/', label: 'Med-Arb' },
|
||||
{ href: '/practice/', label: 'Practice areas' },
|
||||
{ href: '/fees/', label: 'Fees' },
|
||||
{ href: '/about/', label: 'About' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Page Not Found · Dispute Resolution · Pouya Lajevardi"
|
||||
description="That page is not here. Mediation, arbitration and med-arb each have a page, the practice areas are listed, and an inquiry can be sent from contact."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Not found</Eyebrow>
|
||||
<h1 class="display hero-h">That page is not here.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">
|
||||
The address may have changed, or it may never have existed.
|
||||
</p>
|
||||
<p>
|
||||
If you were looking for something specific, email <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
> and say what it was.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="routes" aria-label="Main pages">
|
||||
<ul role="list">
|
||||
{
|
||||
routes.map((route) => (
|
||||
<li>
|
||||
<a href={route.href}>{route.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="cta">
|
||||
<Button href="/">Start at the beginning</Button>
|
||||
<Button href="/contact/" variant="ghost">Send an inquiry →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.routes {
|
||||
margin-block-start: var(--space-7);
|
||||
padding-block-start: var(--space-5);
|
||||
border-block-start: 1px solid var(--rule);
|
||||
}
|
||||
.routes ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-6);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.routes a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* BOTH AXES. `min-block-size` alone left "Fees" at 37 x 44 px — measured, and
|
||||
under `docs/02`'s 44 x 44 floor on the inline axis while the comment beside
|
||||
it claimed compliance. WCAG 2.5.8's 24 x 24 AA minimum was still met via the
|
||||
`--space-6` gap; this is the project's own stricter floor.
|
||||
`padding-inline` as well as the minimum, so a short label is a wide target
|
||||
rather than a narrow one centred in a wide box. */
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
padding-inline: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.routes a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
+88
-17
@@ -203,8 +203,15 @@ const designationLine = [
|
||||
*
|
||||
* **Q44 closed 2026-08-28.** Pouya re-confirmed all four as current — ADRIC,
|
||||
* ADRIO, the three OBA sections, and the Canadian Tax Foundation — which
|
||||
* discharges R10's prohibition and puts the group back on the page. §4 is
|
||||
* re-stamped `[verified 2026-08-28 — Pouya]`.
|
||||
* discharges R10's prohibition and puts the group back on the page.
|
||||
*
|
||||
* **RE-CONFIRMED AGAIN 2026-09-02 — R10's cutover fire.** All four current;
|
||||
* §4 and `CREDENTIALS.memberships` re-stamped `[verified 2026-09-02 — Pouya]`.
|
||||
* ⚠️ **THIS COMMENT IS A FOURTH STAMP-BEARING SITE and it carried the
|
||||
* 2026-08-28 date in the present tense after the re-stamp** — `docs/06`'s R10
|
||||
* instruction named three files and not this one, so the next fire would have
|
||||
* missed it again (`adversarial-reviewer`, round 2). The instruction now names
|
||||
* four.
|
||||
*
|
||||
* **DO NOT ADD A CURRENCY SENTENCE.** Not "renewed annually", not "current as
|
||||
* of", not "listed as current", not a stamp date in the markup. His ruling is
|
||||
@@ -304,22 +311,19 @@ const CREDENTIAL_GROUPS = [
|
||||
precisely because this page is where the framing now does its
|
||||
heaviest work.
|
||||
|
||||
"I MEDIATE COMMERCIAL DISPUTES" STAYS, and a round-2 finding against
|
||||
it was declined rather than missed. The objection: the same ground
|
||||
that struck "The matters I take are commercial" from the biography
|
||||
should reach this sentence too. It does not. **That one was a
|
||||
universal** — it said nothing he takes is non-commercial, which is an
|
||||
exclusion §4 has no row for. **This one is existential**: he mediates
|
||||
commercial disputes, which §4 Offerings' mediation row supports and
|
||||
which excludes nothing. A narrower claim than the row grants is
|
||||
safe; a claim the row does not reach is not. */
|
||||
THE MEDIATION HALF CARRIES NO SCOPE, AND THE ASYMMETRY IS
|
||||
DELIBERATE — Q56, ruled 2026-08-30. Arbitration is scoped commercial
|
||||
because of a legal gate (Q39: family arbitration in Ontario requires
|
||||
prescribed training). Mediation has no such gate and §4's row is
|
||||
unscoped. Do not restore "commercial" to the mediation half to make
|
||||
the two read as a pair. */
|
||||
}
|
||||
<p class="hero-lede">
|
||||
I am {ROLE.title} at {ROLE.at}, with {ROLE.litigationLine} across{' '}
|
||||
{ROLE.litigationAreas.slice(0, -1).join(', ')} and{' '}
|
||||
{ROLE.litigationAreas.at(-1)}. I mediate commercial disputes and I
|
||||
accept arbitration appointments in commercial matters. I also work as
|
||||
a machine-learning and infrastructure engineer.
|
||||
{ROLE.litigationAreas.at(-1)}. I mediate. I accept arbitration
|
||||
appointments in commercial matters. I also work as a machine-learning
|
||||
and infrastructure engineer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -591,8 +595,48 @@ const CREDENTIAL_GROUPS = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3b. The one-page PDF ------------------------------------------- */}
|
||||
{
|
||||
/* ✅ **R16 / Q45 DISCHARGED — build step 9.** `docs/01` §`/about/` item 7 has
|
||||
carried a pending note since step 3: the PDF did not exist, and a link to a
|
||||
file that does not exist is a broken link on the page an appointing body
|
||||
reads. It exists now, it is committed, and this is the link.
|
||||
**`/bio/` is the source and the PDF is a rendering of it** — so every line
|
||||
of the document circulated with an appointment proposal is on a page that
|
||||
`check:claims`, the adversarial review and the cutover claims pass all see.
|
||||
That was R16's actual objection: *"a PDF circulated with an appointment
|
||||
proposal is read once, by the reader who matters most, and never seen by a
|
||||
reviewer again."*
|
||||
It carries NOTHING the site does not — R16's second open sub-decision, and
|
||||
the answer that avoids the §4 question it flagged. No matter list, no
|
||||
referees, no figure that is not on `/fees/`. */
|
||||
}
|
||||
<section class="section bio-download reveal">
|
||||
<div class="wrap">
|
||||
<p class="download-line">
|
||||
<a href="/pouya-lajevardi-bio.pdf" download>
|
||||
Download a one-page PDF of this record
|
||||
</a>
|
||||
<span class="download-note">
|
||||
— designations, education, memberships, the processes offered and the
|
||||
rates, on one sheet. The same page is at <a href="/bio/">/bio/</a>.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. Credentials, structured ------------------------------------ */}
|
||||
<section class="section section-alt creds reveal">
|
||||
{
|
||||
/* ⚠️ `section-inverse`, NOT `section-alt` — approved by Pouya at build step
|
||||
6 and applied at step 7b. The arc section struck on 2026-08-29 was this
|
||||
page's only dark band, so removing it left `/about/` with four cream
|
||||
sections and the accent contact band, and the alternating rhythm
|
||||
`docs/02` sets went with it.
|
||||
Exactly ONE rule had to change — `.cred-title`. The measured ratios are on
|
||||
that rule below, which is where a future editor changing a colour will be
|
||||
looking. Everything else inherits cream from `.section-inverse`. */
|
||||
}
|
||||
<section class="section section-inverse creds reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Credentials" level={2}>
|
||||
@@ -838,6 +882,24 @@ const CREDENTIAL_GROUPS = [
|
||||
page. "Provincial Offences Act" is set in roman. If a statute name ever
|
||||
needs italics here, load a face for it first. */
|
||||
|
||||
/* --- 3b. The one-page PDF ------------------------------------------- */
|
||||
|
||||
/* A quiet band between the bio and the credentials, not a call to action: the
|
||||
reader an appointing body sends here is looking for the record, and a
|
||||
download button styled like the contact CTA would compete with it. */
|
||||
.bio-download {
|
||||
padding-block: var(--space-7);
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
.download-line {
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.download-note {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- 3. Credentials -------------------------------------------------- */
|
||||
|
||||
.cred-grid {
|
||||
@@ -851,7 +913,8 @@ const CREDENTIAL_GROUPS = [
|
||||
not a heading and never carries the <h*>." `Eyebrow.astro` restates it.
|
||||
|
||||
The measurable consequence was worse than the rule breach: at 11px these
|
||||
<h3>s were SMALLER than the 12px eyebrow above them and 5px smaller than
|
||||
<h3>s were SMALLER than the eyebrow above them (12px at the time, 13px
|
||||
since 2026-08-31) and 5px smaller than
|
||||
the 16px list items they head, so "MEMBERSHIPS" was the least legible text
|
||||
on the page an appointing body reads.
|
||||
|
||||
@@ -863,7 +926,15 @@ const CREDENTIAL_GROUPS = [
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-secondary);
|
||||
/* THE ONE COLOUR THAT HAD TO MOVE WITH THE BAND. This was
|
||||
`--text-secondary`, which is `--ink-soft` — **1.43:1** on the ink ground
|
||||
this section now has, i.e. worse than the gold-on-cream 2.10:1 this
|
||||
project treats as the defect that must never ship. `--text-inverse-2` is
|
||||
gold-l: 11.09:1 on ink (docs/02). The list items below inherit cream from
|
||||
`.section-inverse` at 16.81:1 and are untouched.
|
||||
The gold border is a 1px divider, which tokens.css sanctions gold for on
|
||||
any ground, and gold on ink measures 8.00:1 regardless. */
|
||||
color: var(--text-inverse-2);
|
||||
padding-block-end: var(--space-3);
|
||||
border-block-end: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
---
|
||||
/**
|
||||
* `/bio/` — the one-page bio, and the SOURCE of the PDF. Build step 9.
|
||||
* Discharges `AGENTS.md` R16 / Q45.
|
||||
*
|
||||
* ⚠️ **R16 LEFT TWO DECISIONS OPEN AND BOTH ARE TAKEN HERE, UNDER STANDING
|
||||
* AUTHORISATION. Read them before changing anything.**
|
||||
*
|
||||
* **(a) Generated at build, or authored once as a designed artefact?** Neither,
|
||||
* exactly — and the third option is better than both. The bio is a PAGE in this
|
||||
* repository, so every line of it is reviewed by the same apparatus that reviews
|
||||
* every other page: `astro check`, `npm run check:claims` on the built HTML, the
|
||||
* adversarial review, and the cutover claims pass. The PDF is then RENDERED from
|
||||
* this page by `npm run bio:pdf`, deterministically, with no new dependency —
|
||||
* `chrome-launcher` is already a devDependency because Lighthouse needs it.
|
||||
*
|
||||
* That answers R16's actual worry, which was never about tooling: *"It is the
|
||||
* one artefact class this project's review apparatus cannot reach. A web page is
|
||||
* re-reviewed by every audit; a PDF circulated with an appointment proposal is
|
||||
* read once, by the reader who matters most, and never seen by a reviewer
|
||||
* again."* Making the PDF a rendering of a reviewed page puts it back inside the
|
||||
* apparatus. **It is not generated during `astro build`** — CI has no Chrome, and
|
||||
* a build step that cannot run in CI is the Q22 shape again.
|
||||
*
|
||||
* **(b) Does it carry anything the site does not? NO.** Every line here renders
|
||||
* from the same constants as the pages: `CREDENTIALS`, `ROLE`, `BOUTIQUE`,
|
||||
* `PRACTICE_AREAS`, `FEES`, `CONTACT`. R16 flagged that both open sub-decisions
|
||||
* were "each a §4 question of its own, and the matter list would collide with §4
|
||||
* Forbidden directly" — so the answer that avoids both is a bio that adds
|
||||
* nothing. No matter list, no referees, no figure that is not on `/fees/`. The
|
||||
* fee summary IS here, because R16's own reasoning says an appointment proposal
|
||||
* needs the rate card as much as the bio, and every figure in it is `/fees/`'s.
|
||||
*
|
||||
* `noindex`, and excluded from the sitemap in `astro.config.mjs`: it is a
|
||||
* condensed duplicate of `/about/` and `/fees/`, and two URLs competing on the
|
||||
* same content is the one thing `docs/04` is most concerned with.
|
||||
*
|
||||
* PRINT LAYOUT. `global.css`'s `@media print` block already hides the header,
|
||||
* the footer and the skip link, neutralises the inverse grounds, and disables
|
||||
* the reveal — all of it added because `/about/` is printed by people evaluating
|
||||
* an appointment. This page adds only what makes it fit ONE sheet, and
|
||||
* `scripts/bio-pdf.mjs` ASSERTS the page count rather than trusting it.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import {
|
||||
BOUTIQUE,
|
||||
CONTACT,
|
||||
CREDENTIALS,
|
||||
FEES,
|
||||
PRACTICE_AREAS,
|
||||
ROLE,
|
||||
SITE,
|
||||
} from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const money = (amount: number) =>
|
||||
new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: FEES.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
|
||||
const { halfDay, fullDay } = FEES.mediation;
|
||||
|
||||
/* The processes, each with a §4 Offerings row. Arbitration is scoped commercial
|
||||
because Q39's gate is a legal one; mediation is unscoped because it has no
|
||||
such gate (Q56). The asymmetry is designed — do not tidy it. */
|
||||
const PROCESSES = [
|
||||
'Mediation — sole mediator',
|
||||
'Commercial arbitration — sole, party-appointed, co-arbitration',
|
||||
'Med-arb — mediation converting to binding arbitration, agreed in advance',
|
||||
'Early neutral evaluation — delivered to both parties together',
|
||||
'Dispute-system design',
|
||||
'Pre-dispute technical advisory',
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="One-Page Bio · Pouya Lajevardi · Mediator · Toronto"
|
||||
description="A one-page record for circulation with an appointment proposal: designations, education, memberships, the processes offered, the practice areas, and the rates."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section bio-sheet">
|
||||
<div class="wrap">
|
||||
{
|
||||
/* The download sits above the sheet and is `.no-print`, so the printed
|
||||
copy does not carry a link to itself. */
|
||||
}
|
||||
<p class="no-print sheet-note">
|
||||
This page is the source of the one-page PDF.
|
||||
<a href="/pouya-lajevardi-bio.pdf" download>Download the PDF</a>, or
|
||||
print this page.
|
||||
</p>
|
||||
|
||||
<header class="sheet-head">
|
||||
{
|
||||
/* THE EYEBROW IS `.no-print`, AND IT IS HERE BECAUSE `og:proof` ASKED
|
||||
FOR IT. That check compares each card's eyebrow against its page's
|
||||
first `.eyebrow`, and this page had none — so the card said "Bio"
|
||||
against nothing. The options were to weaken the check or to give the
|
||||
page the element every other page has; weakening a check to match an
|
||||
artefact is how a control stops controlling. On paper the sheet leads
|
||||
with the name, so the eyebrow prints away. */
|
||||
}
|
||||
<div class="no-print">
|
||||
<Eyebrow dot>Bio</Eyebrow>
|
||||
</div>
|
||||
<h1 class="sheet-name">{SITE.name}</h1>
|
||||
<p class="sheet-desigs">{CREDENTIALS.designations.join(' · ')}</p>
|
||||
<p class="eyebrow sheet-strap">{SITE.tagline}</p>
|
||||
</header>
|
||||
|
||||
<div class="sheet-grid">
|
||||
<section class="block block-wide">
|
||||
<h2 class="eyebrow">The practice</h2>
|
||||
<p>
|
||||
{
|
||||
/* ⚠️ NO LEADING SCOPE. This sentence read "I act as a neutral in
|
||||
commercial disputes — as a mediator, as an arbitrator in
|
||||
commercial matters, and in med-arb…", and the leading clause
|
||||
scoped ALL THREE, mediation included. §4's mediation row is
|
||||
unscoped deliberately (Q56) and says in terms: "do not scope it
|
||||
on a page." It is the `/practice/` shape exactly — the two words
|
||||
never appear in the same element, so no proximity grep reaches
|
||||
it — and it was found by reading the rendered PDF. The scope
|
||||
belongs on the arbitration clause alone, where Q39's legal gate
|
||||
puts it.
|
||||
|
||||
⚠️ AND THE VERB IS `accept appointments`, NOT `act as`. §4
|
||||
verifies exactly one practised role — "Mediator" — and says in
|
||||
terms that "Arbitrator" as a practised role is NOT a row; what it
|
||||
verifies is that appointments are ACCEPTED. `/` and `/about/`
|
||||
carry the same construction. */
|
||||
}
|
||||
I act as a neutral. I accept appointments as a mediator, as an arbitrator
|
||||
in commercial matters, and in med-arb where the parties want one neutral
|
||||
across both phases. I read the contract and the technical record underneath
|
||||
it rather than either side's summary of them.
|
||||
</p>
|
||||
<p>
|
||||
I am {ROLE.title} at {BOUTIQUE}, with {ROLE.litigationLine} across
|
||||
{' '}{ROLE.litigationAreas.join(', ')} matters. I am also a practising
|
||||
machine-learning and infrastructure engineer, which is what lets me work
|
||||
through a technical record at first hand.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="eyebrow">Designations</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.designations.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2 class="eyebrow">Education</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.education.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2 class="eyebrow">Certifications</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.certifications.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="eyebrow">Memberships</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.memberships.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2 class="eyebrow">Languages</h2>
|
||||
<ul role="list">
|
||||
<li>
|
||||
{CREDENTIALS.languages.join(' and ')}, without an interpreter
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="eyebrow">Processes</h2>
|
||||
<ul role="list">
|
||||
{PROCESSES.map((p) => <li>{p}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="eyebrow">Subject matter</h2>
|
||||
<ul role="list">
|
||||
{PRACTICE_AREAS.map((area) => <li>{area.name}</li>)}
|
||||
</ul>
|
||||
<p class="fine">
|
||||
Family arbitration under the <em>Family Law Act</em> is not offered.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block block-wide">
|
||||
<h2 class="eyebrow">Rates</h2>
|
||||
<ul role="list" class="rates-list">
|
||||
<li>
|
||||
Half day, up to {halfDay.hours} hours of session — {
|
||||
money(halfDay.amount)
|
||||
}. Fee includes up to {halfDay.prepIncluded} hours of preparation.
|
||||
</li>
|
||||
<li>
|
||||
Full day, up to {fullDay.hours} hours of session — {
|
||||
money(fullDay.amount)
|
||||
}. Fee includes up to {fullDay.prepIncluded} hours of preparation.
|
||||
</li>
|
||||
<li>
|
||||
Each party beyond two — {money(FEES.mediation.additionalParty)}.
|
||||
Overtime beyond the session hours the fee covers —
|
||||
{' '}{money(FEES.mediation.overtimePerHour)} an hour.
|
||||
{' '}{FEES.mediation.reservation}
|
||||
</li>
|
||||
<li>
|
||||
Arbitration — {money(FEES.arbitration.perHour)} an hour,
|
||||
{' '}{money(FEES.arbitration.hearingDay)} a hearing day, or a flat fee
|
||||
for documents-only and expedited references.
|
||||
</li>
|
||||
<li>
|
||||
{FEES.taxNote} The full card, the cancellation schedule and the terms
|
||||
are published at {SITE.url}/fees/.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block block-wide sheet-contact">
|
||||
<h2 class="eyebrow">Contact</h2>
|
||||
<p>
|
||||
{CONTACT.email} · {CONTACT.phoneFallback} · {CONTACT.location}
|
||||
<br />
|
||||
{CONTACT.responseTime} · {SITE.url} · {CONTACT.linkedin}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.sheet-note {
|
||||
margin-block-end: var(--space-7);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.bio-sheet {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
|
||||
.sheet-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-block-end: var(--space-5);
|
||||
border-block-end: 2px solid var(--rule);
|
||||
}
|
||||
.sheet-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.sheet-desigs {
|
||||
margin-block-start: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Type comes from the global `.eyebrow` class on the element; only the margin is
|
||||
here. This rule and `.block h2` below were byte-for-byte copies of
|
||||
`.eyebrow`'s declarations at `--text-2xs` — the same escape the footer's
|
||||
column headings were. The print block below sets both to 7pt, so the screen
|
||||
size never reached the PDF. */
|
||||
.sheet-strap {
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.sheet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
|
||||
gap: var(--space-6);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.block-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* See `.sheet-strap` above: type from `.eyebrow`, only the rule under it here. */
|
||||
.block h2 {
|
||||
padding-block-end: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
.block h2:not(:first-child) {
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
.block ul {
|
||||
/* `global.css` strips the marker and padding from `ul[role='list']`. */
|
||||
margin-block-start: var(--space-3);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.block p {
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.block p + p {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
/* `anywhere`, and the cause is ONE STRING: the last row prints the fee-card URL,
|
||||
which has no break opportunity and sized this single-column grid track. The
|
||||
URL has to stay — printed sheet, the reader cannot click it. `docs/02`
|
||||
§Reflow, instrument finding 1. */
|
||||
.rates-list {
|
||||
max-inline-size: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fine {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.sheet-contact p {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
max-inline-size: none;
|
||||
}
|
||||
|
||||
/* --- One sheet of paper ------------------------------------------------ */
|
||||
|
||||
/* `global.css`'s print block already hides the header, footer and skip link,
|
||||
neutralises the inverse grounds and disables the reveal. This is only what
|
||||
makes the content FIT, and `scripts/bio-pdf.mjs` asserts the page count
|
||||
rather than this comment claiming it. */
|
||||
@media print {
|
||||
.bio-sheet {
|
||||
padding-block: 0;
|
||||
}
|
||||
.wrap {
|
||||
max-inline-size: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.sheet-grid {
|
||||
/* Two fixed columns rather than auto-fit: on paper there is no viewport
|
||||
to fit to, and a print UA resolves `auto-fit` against the sheet width
|
||||
inconsistently. */
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10pt 18pt;
|
||||
margin-block-start: 10pt;
|
||||
}
|
||||
.sheet-name {
|
||||
font-size: 22pt;
|
||||
}
|
||||
.sheet-desigs {
|
||||
font-size: 9pt;
|
||||
}
|
||||
.sheet-strap {
|
||||
font-size: 7pt;
|
||||
}
|
||||
.sheet-head {
|
||||
padding-block-end: 8pt;
|
||||
}
|
||||
/* ⚠️ `font-weight` IS FROZEN AT 400 BY RULING, 2026-09-01. The circulated
|
||||
PDF's typography changes only when its CONTENT is deliberately revised,
|
||||
never as a side effect of a screen refactor — so print keeps 400 while
|
||||
screen takes the 500 every other eyebrow has. `docs/02` §Accessibility
|
||||
floor carries the reasoning and the byte figures. */
|
||||
.block h2,
|
||||
.sheet-strap {
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
.block h2 {
|
||||
font-size: 7pt;
|
||||
padding-block-end: 3pt;
|
||||
}
|
||||
.block h2:not(:first-child) {
|
||||
margin-block-start: 9pt;
|
||||
}
|
||||
.block ul,
|
||||
.block p {
|
||||
margin-block-start: 5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.block ul {
|
||||
gap: 2pt;
|
||||
}
|
||||
.sheet-contact p {
|
||||
font-size: 8pt;
|
||||
}
|
||||
/* A block must not be split across a page break — on a one-sheet document
|
||||
that would mean a second sheet carrying two lines. */
|
||||
.block {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,572 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/` — build step 8. Spec: docs/01 §`/contact/`, docs/05-backend-spec.md.
|
||||
*
|
||||
* ⚠️ **THE FORM USES NO JAVASCRIPT, AND THAT IS NOT A CONSTRAINT WORKED AROUND —
|
||||
* IT IS THE DESIGN.** A plain `<form method="post">` to a same-origin path; the
|
||||
* handler answers `303 See Other` to `/contact/received/`. So it works with
|
||||
* script disabled, cannot double-submit on refresh, and never shows the visitor a
|
||||
* raw JSON response. `backend/intake/handler.mjs` carries the reasoning in full.
|
||||
*
|
||||
* Consequences that shape the markup:
|
||||
* - **Validation errors land on `/contact/could-not-send/`**, because a static
|
||||
* page cannot read a query string without script. In practice the browser's
|
||||
* own `required` / `type="email"` / `maxlength` handling catches the real
|
||||
* cases and announces them natively, which is what `docs/05`'s
|
||||
* "errors announced with `role="alert"`" asks for; a server rejection is
|
||||
* almost always a bot, and a bot gets the success page (see the handler).
|
||||
* - **No booking embed — R6.** Parked by Pouya 2026-08-26. `docs/01` asks for a
|
||||
* "reserved slot for an embed", so the slot is `CONTACT.bookingUrl` being
|
||||
* `null`: nothing renders, and when a URL exists the block appears without a
|
||||
* rebuild of this page. **Nothing on this page mentions booking**, because a
|
||||
* page that says "book a call" with no way to book it is worse than one that
|
||||
* says to email.
|
||||
*
|
||||
* ⚠️ **THE RESPONSE-TIME SENTENCE IS A PUBLIC COMMITMENT (§4, Q27) AND MUST READ
|
||||
* IDENTICALLY HERE, IN THE CONFIRMATION EMAIL, AND IN ANY BIO.** It is rendered
|
||||
* from `CONTACT.responseTime`; the handler takes the same string from its
|
||||
* environment. Never retype it, and never soften it to "usually".
|
||||
*
|
||||
* ⚠️ **`NO_RETAINER_NOTICE` AND `CONSENT_TEXT` BOTH SHIP, AND THAT IS NOT
|
||||
* DUPLICATION.** `docs/01` requires the page to carry the notice; `docs/05`
|
||||
* requires the consent the inquirer TICKS to carry it too. One is a statement the
|
||||
* page makes, the other is a thing the inquirer agrees to. Neither is retyped.
|
||||
*
|
||||
* ⚠️ **NO PHONE NUMBER — Q3. §4 verifies "no public phone number".** Render
|
||||
* `CONTACT.phoneFallback` wherever a number would go rather than leaving the slot
|
||||
* visually empty.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import Undertaking from '../components/Undertaking.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import {
|
||||
CONDUCT_UNDERTAKINGS,
|
||||
CONTACT,
|
||||
NO_RETAINER_NOTICE,
|
||||
} from '../data/site';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
DECOY_CHECKBOX_FIELD,
|
||||
HONEYPOT_FIELD,
|
||||
INTAKE_ACTION,
|
||||
INTAKE_FIELDS,
|
||||
} from '../data/intake';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* No `Service` node. `/contact/` offers nothing — it is the way in to what the
|
||||
other pages offer, and a `Service` here would duplicate an `@id` that already
|
||||
resolves on `/mediation/`. Person alone, the `/practice/` and `/process/`
|
||||
shape. No `BreadcrumbList`: one hop from the root, no visible trail. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const hintId = (name: string) => `${name}-hint`;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact · Request a Consultation · Pouya Lajevardi"
|
||||
description="Request a confidential intake call about a mediation, arbitration or med-arb appointment in Ontario. Inquiries are answered within two business days."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Contact</Eyebrow>
|
||||
<h1 class="display hero-h">Start with a confidential call.</h1>
|
||||
<p class="hero-lede">
|
||||
The first step is a scheduled call to scope the matter, identify the
|
||||
parties, and run conflicts. Send the form below, or email me directly.
|
||||
</p>
|
||||
<dl class="direct">
|
||||
<div>
|
||||
<dt>Email</dt>
|
||||
<dd><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Phone</dt>
|
||||
{
|
||||
/* Q3: no public number. The fallback fills the slot rather than
|
||||
leaving a labelled row visually empty. */
|
||||
}
|
||||
<dd>{CONTACT.phoneFallback}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{CONTACT.location}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Response</dt>
|
||||
<dd>{CONTACT.responseTime}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. What an inquiry does and does not do ------------------------ */}
|
||||
<section class="section section-inverse reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Before you write" level={2}>
|
||||
<span slot="heading">What an inquiry is, and what it is not.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p class="statement">{NO_RETAINER_NOTICE}</p>
|
||||
<p>I ask for the other parties and their counsel for one reason.</p>
|
||||
{
|
||||
/* RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — undertaking (g).
|
||||
⚠️ THIS PAGE HAND-TYPED THE SAME PROPOSITION AS *"I cannot accept an
|
||||
appointment before conflicts are checked"* UNTIL 2026-09-04, and it
|
||||
survived the change set that struck the identical sentence from
|
||||
`/legal/privacy/` — one file swept, its sibling missed, which is the
|
||||
shape R8 exists for. §4 row (g) lists BOTH surfaces. */
|
||||
}
|
||||
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
|
||||
<p>
|
||||
That check needs names, and the call above is where it happens. Please
|
||||
keep the summary short and leave privileged or confidential detail out
|
||||
of it — the call is for that.
|
||||
</p>
|
||||
<p>
|
||||
What is collected, where it is stored, how long it is kept, and how to
|
||||
have it deleted are set out in the <a href="/legal/privacy/"
|
||||
>privacy policy</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. The intake form -------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Intake"
|
||||
level={2}
|
||||
lede="Required fields are marked. Nothing here is a retainer or an appointment."
|
||||
>
|
||||
<span slot="heading">Tell me about the matter.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* `novalidate` IS DELIBERATELY ABSENT. The browser's own validation is
|
||||
the only client-side validation on this page, and with no script it is
|
||||
also the only thing that can announce an error inline — which is what
|
||||
`docs/05`'s `role="alert"` item is really asking for. The Lambda
|
||||
re-validates everything regardless; see `src/data/intake.ts`. */
|
||||
}
|
||||
<form class="intake" method="post" action={INTAKE_ACTION}>
|
||||
{
|
||||
INTAKE_FIELDS.map((field) => (
|
||||
<div class={`field field-${field.type}`}>
|
||||
{field.type === 'radio' ? (
|
||||
<fieldset>
|
||||
<legend>{field.label}</legend>
|
||||
<div class="radios">
|
||||
{field.options?.map((option) => (
|
||||
<label class="radio">
|
||||
{/* No default selection. `preferredContact` is
|
||||
optional, and pre-checking "Email" would submit a
|
||||
preference the inquirer never expressed. */}
|
||||
<input type="radio" name={field.name} value={option} />
|
||||
<span>{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : (
|
||||
<>
|
||||
<label for={field.name}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<>
|
||||
{' '}
|
||||
<span class="req" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
<span class="visually-hidden">(required)</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
>
|
||||
{/* An empty first option, so a required select cannot be
|
||||
satisfied by whichever value happened to be first. */}
|
||||
<option value="">Choose one</option>
|
||||
{field.options?.map((option) => (
|
||||
<option value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
rows="6"
|
||||
maxlength={field.max}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
maxlength={field.max}
|
||||
autocomplete={field.autocomplete}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.hint && (
|
||||
<p class="hint" id={hintId(field.name)}>
|
||||
{field.hint}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
/* THE HONEYPOT. Hidden from sighted users by `display: none` on the
|
||||
wrapper, from assistive technology by `aria-hidden`, and from the
|
||||
keyboard by `tabindex="-1"` — all three, because any one alone leaves
|
||||
a real visitor able to reach a field that silently discards their
|
||||
inquiry. `autocomplete="off"` matters more here than anywhere else on
|
||||
the form: a browser that helpfully fills a plausible-looking field
|
||||
would make a human look like a bot. */
|
||||
}
|
||||
{
|
||||
/* `hidden` ADDED 2026-09-04, for the reason spelled out on the decoy
|
||||
below: a class-only rule leaves this field on screen wherever author
|
||||
styles do not apply, and a visitor who fills it loses their inquiry
|
||||
behind a success page. */
|
||||
}
|
||||
<div class="honeypot" hidden aria-hidden="true">
|
||||
<label for={HONEYPOT_FIELD}>Company website</label>
|
||||
<input
|
||||
type="text"
|
||||
id={HONEYPOT_FIELD}
|
||||
name={HONEYPOT_FIELD}
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ⚠️ 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">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
value="on"
|
||||
required
|
||||
aria-describedby="consent-privacy"
|
||||
/>
|
||||
<span>{CONSENT_TEXT}</span>
|
||||
</label>
|
||||
<p class="consent-note" id="consent-privacy">
|
||||
How that information is handled, and how to have it deleted: <a
|
||||
href="/legal/privacy/">privacy policy</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* THE SECOND HONEYPOT — a decoy CHECKBOX. The mechanism, and the
|
||||
limits of what the observed spam supports, are in `src/data/intake.ts`
|
||||
and are not restated here. Four properties of the MARKUP, each of
|
||||
which is what stops this field costing a real inquiry:
|
||||
|
||||
· its own CLASS NAME, not `.honeypot` — one selector must not
|
||||
match both traps. They share a declaration block below, which is
|
||||
presentation; what matters is that `.honeypot` does not select
|
||||
this one;
|
||||
· placed after the consent block, not beside the other honeypot;
|
||||
· `hidden` as well as the CSS rule, so it stays hidden where
|
||||
author styles do not apply;
|
||||
· a label that tells a human not to tick it. With `hidden` in
|
||||
place a human essentially cannot see it, so this is the last
|
||||
line rather than the first — and it costs almost nothing,
|
||||
because the PLAUSIBLE NAME is what a bot matches on and the name
|
||||
is unchanged.
|
||||
|
||||
⚠️ NO `required`, AND NO `checked`. An unchecked box sends nothing,
|
||||
so absence is the pass — and the handler tests for a NON-EMPTY value,
|
||||
so an empty one passes too. */
|
||||
}
|
||||
<div class="optin-decoy" hidden aria-hidden="true">
|
||||
<label for={DECOY_CHECKBOX_FIELD}>Leave this box unticked.</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={DECOY_CHECKBOX_FIELD}
|
||||
name={DECOY_CHECKBOX_FIELD}
|
||||
value="on"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`.
|
||||
`.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw
|
||||
button carrying those class names compiles against this page's cid,
|
||||
matches nothing, and renders as an unstyled default button — the
|
||||
parent-scope trap `CLAUDE.md` records, arrived at from the other
|
||||
direction. The first version of this file did exactly that.
|
||||
AND IT IS WRAPPED IN A DIV THIS PAGE OWNS, for the same rule read
|
||||
forwards: `.submit` on `<Button>` itself would compile to
|
||||
`.submit[cid-of-this-page]` and never match the rendered element. */
|
||||
}
|
||||
<div class="submit">
|
||||
<Button type="submit">Send the inquiry</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The direct-contact block. A `<dl>` because each row is genuinely a term and
|
||||
its value, which is what gives the labels their semantics without spending a
|
||||
heading level on them. */
|
||||
.direct {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
/* `--text-eyebrow`, not `--text-2xs`: these `<dt>`s are label text on the page
|
||||
that collects inquiries, so they move with the `<label>`s below rather than
|
||||
sitting a step behind them. Pouya's ruling, 2026-08-31. */
|
||||
.direct dt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.direct dd {
|
||||
margin-block-start: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-snug);
|
||||
/* The email address has no break opportunity and overflowed at a 200% default
|
||||
font size. It must stay selectable and correct, so it breaks rather than
|
||||
being truncated. `docs/02` §Reflow carries the measurement. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The no-retainer sentence, set larger than the paragraphs under it. On an
|
||||
inverse ground it inherits cream (16.81:1) from `.section-inverse`. */
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
/* --- The form -------------------------------------------------------- */
|
||||
|
||||
.intake {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
|
||||
gap: var(--space-5) var(--space-6);
|
||||
max-inline-size: 56rem;
|
||||
}
|
||||
/* The two long fields span the whole form rather than sitting in a column
|
||||
20rem wide. `1 / -1` works at every column count the auto-fit produces. */
|
||||
.field-textarea,
|
||||
.field-consent,
|
||||
.submit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* NOT the `.eyebrow` class, deliberately: `--text-secondary` (11.75:1) rather
|
||||
than `.eyebrow`'s `--text-meta` (5.47:1), because a form label is operative
|
||||
text. Everything else matches it, `font-weight` included — without that these
|
||||
rendered at 400 under a `p.eyebrow` of the same size and colour.
|
||||
`overflow-wrap` because at a 200% default font size "Firm or organisation"
|
||||
ran 38px outside its own box at 320px: `docs/02` §Reflow, instrument
|
||||
finding 2. */
|
||||
label,
|
||||
legend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Maroon on cream is 12.29:1, so the asterisk is legible — but it is
|
||||
`aria-hidden` and paired with a visually-hidden "(required)", because
|
||||
colour and a glyph must never be the only carrier of meaning (docs/02). */
|
||||
.req {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
inline-size: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-sans);
|
||||
/* 1rem, not smaller. iOS Safari zooms the viewport on focus for any font
|
||||
size under 16px, which on a form this long throws the layout sideways. */
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
/* 44px minimum target (WCAG 2.5.8) comes from the padding plus this
|
||||
line-height; measured rather than set with a fixed height, so a longer
|
||||
label or a zoomed root does not crush it. */
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
.radios {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
.radio,
|
||||
.consent {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
/* The label text next to a control is sentence case and normal size — the
|
||||
mono uppercase treatment above is for the field's own name, and applying
|
||||
it to a paragraph of consent text would be unreadable. */
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
text-transform: none;
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
.consent {
|
||||
align-items: flex-start;
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
/* Indented to the label's text column so it reads as belonging to the
|
||||
checkbox — 1.15rem control plus the flex gap. */
|
||||
.consent-note {
|
||||
margin-block-start: var(--space-3);
|
||||
margin-inline-start: calc(1.15rem + var(--space-3));
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.radio input,
|
||||
.consent input {
|
||||
inline-size: 1.15rem;
|
||||
block-size: 1.15rem;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
/* The checkbox sits on the first line of its own label text rather than at
|
||||
the top of the box, which is where `flex-start` alone would put it. */
|
||||
margin-block-start: 0.25em;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* THE HONEYPOT. `display: none` is what keeps it out of the layout AND out of
|
||||
the accessibility tree; `aria-hidden` on the wrapper and `tabindex="-1"` on
|
||||
the input are belt and braces for the case where a future stylesheet
|
||||
un-hides it. Do not swap this for `visibility` or an off-screen position:
|
||||
an off-screen input is still focusable and still announced. */
|
||||
.honeypot,
|
||||
.optin-decoy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.submit {
|
||||
justify-self: start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/could-not-send/` — the failure half of the intake form's
|
||||
* POST-redirect-GET. Build step 8.
|
||||
*
|
||||
* WHY THIS PAGE EXISTS AT ALL. The site ships zero JavaScript, so a static page
|
||||
* cannot read `?error=` and render a message. The alternatives were: return an
|
||||
* error body from the API (the visitor lands on the API hostname with none of
|
||||
* the site around it), or say nothing (the visitor cannot tell whether the
|
||||
* inquiry arrived, on a form about a live dispute). A named page is the only one
|
||||
* of the three that leaves the reader knowing what happened.
|
||||
*
|
||||
* ⚠️ **IT DOES NOT LIST WHICH FIELD FAILED, AND THAT IS DELIBERATE ON TWO
|
||||
* COUNTS.** The handler deliberately does not return the error list — an
|
||||
* enumeration of the validation rules is a gift to whoever is probing them — and
|
||||
* the browser's own `required` / `type="email"` / `maxlength` handling has
|
||||
* already caught every case a person is likely to hit, inline and announced. A
|
||||
* server-side rejection means the submission was not made by that markup.
|
||||
*
|
||||
* ⚠️ **NO APOLOGY AND NO GUESS AT THE CAUSE.** "Something went wrong on our end"
|
||||
* is a claim about which end, and this page cannot know. It says what is true —
|
||||
* the inquiry was not recorded — and gives a route that does not depend on the
|
||||
* form working.
|
||||
*
|
||||
* `noindex`, and excluded from the sitemap in `astro.config.mjs`.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { CONTACT } from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Inquiry Not Sent · Contact · Pouya Lajevardi · Toronto"
|
||||
description="The inquiry was not recorded, so nothing has been received. Email the same details directly and they will be answered within two business days."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Not sent</Eyebrow>
|
||||
<h1 class="display hero-h">That inquiry was not recorded.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">
|
||||
Nothing has been received, so there is nothing waiting for a reply.
|
||||
</p>
|
||||
<p>
|
||||
The quickest route is email. Send the same details — your name, your
|
||||
role, the other parties, and a few sentences about the dispute — to <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
>, and leave privileged detail out of it. {CONTACT.responseTime}
|
||||
</p>
|
||||
<p>
|
||||
Or go back to the form and send it again. If it fails a second time,
|
||||
email rather than trying a third.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/contact/">Back to the form</Button>
|
||||
<Button href={`mailto:${CONTACT.email}`} variant="ghost">
|
||||
Email instead →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/received/` — the GET half of the intake form's POST-redirect-GET.
|
||||
* Build step 8. `backend/intake/handler.mjs` sends a `303 See Other` here on
|
||||
* success.
|
||||
*
|
||||
* WHY A PAGE RATHER THAN A RESPONSE BODY. The site ships zero JavaScript, so the
|
||||
* form is a plain POST; without this redirect the visitor would be looking at
|
||||
* whatever the API returned, on the API's own hostname, with none of the site
|
||||
* around it. Landing on a GET also means a refresh cannot resubmit.
|
||||
*
|
||||
* `noindex` — it is a transactional page with no standalone value, and a search
|
||||
* result reading "your inquiry has been received" for someone who has not sent
|
||||
* one is worse than no result. It is excluded from the sitemap in
|
||||
* `astro.config.mjs` for the same reason.
|
||||
*
|
||||
* ⚠️ THE RESPONSE-TIME SENTENCE IS A PUBLIC COMMITMENT (§4, Q27) and must read
|
||||
* identically here, on `/contact/`, and in the confirmation email the handler
|
||||
* sends. Rendered from `CONTACT.responseTime`; never retyped, never softened.
|
||||
*
|
||||
* ⚠️ AND A BOT THAT TRIPS THE HONEYPOT IS SENT HERE TOO — deliberately, see the
|
||||
* handler. So this page must not say anything that is false for that case. It
|
||||
* says what was done, not what will happen to a specific record: "received"
|
||||
* covers a stored submission, and nothing here promises a reply to a submission
|
||||
* that was discarded.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { CONTACT, NO_RETAINER_NOTICE } from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Inquiry Received · Contact · Pouya Lajevardi · Toronto"
|
||||
description="Your inquiry has been received. A confirmation goes to the address you gave, inquiries are answered within two business days, and nothing further is needed."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Received</Eyebrow>
|
||||
<h1 class="display hero-h">Your inquiry has been received.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">{CONTACT.responseTime}</p>
|
||||
{
|
||||
/* ⚠️ "A confirmation HAS BEEN SENT" WAS A STATEMENT OF FACT THAT TWO
|
||||
PATHS REACH THIS PAGE WITHOUT HAVING MADE TRUE, and this file's own
|
||||
header already said it must not be: *"this page must not say anything
|
||||
that is false for that case. It says what was done, not what will
|
||||
happen to a specific record."*
|
||||
|
||||
(a) The honeypot returns `redirect(SUCCESS)` before any write or any
|
||||
send — deliberately, because telling a bot it was detected is how the
|
||||
next bot stops filling the field. (b) The handler sends the two
|
||||
emails with `Promise.allSettled` and redirects here even if both
|
||||
reject, because the submission is already stored and a second attempt
|
||||
would duplicate the record.
|
||||
|
||||
So the receipt is stated as what happens rather than as what
|
||||
happened, and the clause after it is the route out either way. Found
|
||||
by `adversarial-reviewer`, 2026-08-31. */
|
||||
}
|
||||
<p>
|
||||
A confirmation goes to the email address you gave, repeating what you
|
||||
sent and linking to the privacy policy. If it has not arrived within a
|
||||
few minutes, check the address and email me directly at <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
> — that reaches me whether or not the receipt did.
|
||||
</p>
|
||||
<p>{NO_RETAINER_NOTICE}</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/process/" variant="ghost"
|
||||
>What happens next →</Button
|
||||
>
|
||||
<Button href="/fees/" variant="ghost">The rate card →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* No ContactBand: the reader has just used the contact form, and inviting
|
||||
them to contact again is the one place that band would read as a defect. */
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,514 @@
|
||||
---
|
||||
/**
|
||||
* `/fees/` — build step 9. Spec: docs/01 §`/fees/`, docs/03 §Fees,
|
||||
* **docs/07-fees.md is the authority on every number here.**
|
||||
*
|
||||
* ⚠️ **EVERY FIGURE IS INTERPOLATED FROM `FEES`. NOT ONE IS TYPED.** This is the
|
||||
* page D8 commits to and the one where a hand-typed number would be an incorrect
|
||||
* price rather than an untidy fact. `docs/07` §All parameters confirmed sets the
|
||||
* publication rules the interpolation has to satisfy, and two of them are
|
||||
* wording rather than value:
|
||||
*
|
||||
* 1. **The preparation allowance is CAPPED and must READ as capped** —
|
||||
* *"including **up to** 2 hours of preparation"*. Never "including 2 hours",
|
||||
* which sells an entitlement, and never "preparation included", which sells
|
||||
* an uncapped one. `/for-parties/` shipped the flat form for one pass, on the
|
||||
* one page written for a reader with no counsel to catch it.
|
||||
* 2. **The session cap and the preparation allowance are DIFFERENT THINGS with
|
||||
* different nouns** — `hours` is time in the session, `prepIncluded` is
|
||||
* preparation bundled into the fee. Folding them into one figure is the
|
||||
* ambiguity Q58 was opened to fix, and it was an ambiguity in `docs/07`
|
||||
* itself rather than in any copy.
|
||||
*
|
||||
* ✅ **Q59 IS RULED AND THIS PAGE IS WHY IT MATTERED — Pouya, 2026-08-31.**
|
||||
* Overtime runs from the **session cap**: the fourth hour of a half day, the
|
||||
* seventh of a full day. Until that ruling this page could not publish the
|
||||
* $500 rate at all, because a rate printed beside "up to 3 hours" defines its
|
||||
* own trigger by adjacency and there was no other quantity for it to attach to.
|
||||
*
|
||||
* ⚠️ **AND THE RULING'S SECOND HALF IS NOT DECORATION — IT IS WHAT KEEPS THE
|
||||
* PAGE FROM READING AS AN ARITHMETIC MISTAKE.** `FEES.mediation.reservation`:
|
||||
* *a full day reserves the day; half-day overtime is subject to availability.*
|
||||
* Without it a reader adds up `2000 + 500 × 3 = 3500` against `4000` and
|
||||
* concludes the full-day rate is simply worse — which is a real feature of D14's
|
||||
* figures (§12 R5 carries it, with the table in `docs/07` §Recorded dissent) and
|
||||
* is answered by what the full-day fee actually buys. So the reservation
|
||||
* sentence ships **adjacent to the overtime row**, not in a footnote. Same
|
||||
* structural rule as `PROCESS_FRAMING` beside the five timings under Q43.
|
||||
*
|
||||
* ✅ **MED-ARB IS PRICED HERE AS OF 2026-09-03, AND IT CARRIES NO FIGURE.**
|
||||
* Pouya's ruling closing D20 finding 10: it is billed **by phase**, each phase
|
||||
* at the rates already on this page. The finding was that the hero promises
|
||||
* *"Every figure is on this page"* while §4 Offerings carries a Med-Arb row
|
||||
* that `docs/07` priced nowhere — a promise wider than the card. It is closed by
|
||||
* pricing the offering, not by narrowing the promise, so the hero sentence is
|
||||
* unchanged and is now true as written. **Do not give the section a rate row:**
|
||||
* a med-arb figure would be a fourth price for a process priced twice, and the
|
||||
* first thing it would do is disagree with one of them. `FEES.medArb` holds the
|
||||
* three sentences; `docs/07` §Med-arb holds the rule. INTERIM, reviewed at R5.
|
||||
*
|
||||
* ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck
|
||||
* rows in §4 Offerings — the first removed by Pouya from D14, the second by him
|
||||
* as his own error in `docs/01`. **A rate on a fee page is an offer**, which is
|
||||
* exactly why they are struck here rather than merely unpriced.
|
||||
*
|
||||
* ⚠️ **ARBITRATION IS SCOPED COMMERCIAL, MEDIATION IS NOT.** The asymmetry is
|
||||
* designed (Q39, Q56): family arbitration in Ontario carries prescribed training
|
||||
* and is separately NOT OFFERED, so the scope on the arbitration rows is a legal
|
||||
* gate. Mediation has no equivalent gate and is unscoped on purpose. A later
|
||||
* editor tidying these into a matching pair would reintroduce the defect.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { FEES } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* No `Service` node, and no `Offer` node either. The Person alone — the
|
||||
`/practice/` and `/process/` shape. `/mediation/` and `/arbitration/` already
|
||||
carry the `Service` nodes for what is priced here, and a second one on this
|
||||
path would put a duplicate `@id` in the graph. An `Offer` with `price` would
|
||||
be the obvious addition and is deliberately not made: schema.org's `Offer`
|
||||
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.
|
||||
⚠️ 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) =>
|
||||
new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: FEES.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
|
||||
const { halfDay, fullDay } = FEES.mediation;
|
||||
|
||||
/* The two mediation rows, built from the constants so the noun and the "up to"
|
||||
travel with the number rather than being retyped beside it. */
|
||||
const MEDIATION_ROWS = [
|
||||
{
|
||||
item: `Half day — up to ${halfDay.hours} hours of session`,
|
||||
detail: `Fee includes up to ${halfDay.prepIncluded} hours of preparation.`,
|
||||
fee: money(halfDay.amount),
|
||||
},
|
||||
{
|
||||
item: `Full day — up to ${fullDay.hours} hours of session`,
|
||||
detail: `Fee includes up to ${fullDay.prepIncluded} hours of preparation.`,
|
||||
fee: money(fullDay.amount),
|
||||
},
|
||||
{
|
||||
item: 'Each party beyond two',
|
||||
detail: 'Per party, added to the session fee.',
|
||||
fee: money(FEES.mediation.additionalParty),
|
||||
},
|
||||
{
|
||||
item: 'Overtime, per hour',
|
||||
/* Q59: the trigger is the SESSION cap, and the reservation point ships in
|
||||
the same cell as the rate. See the header for why it is not a footnote. */
|
||||
detail: `Charged beyond the ${halfDay.hours} or ${fullDay.hours} session hours the fee covers. ${FEES.mediation.reservation}`,
|
||||
fee: money(FEES.mediation.overtimePerHour),
|
||||
},
|
||||
];
|
||||
|
||||
const ARBITRATION_ROWS = [
|
||||
{
|
||||
item: 'Hourly',
|
||||
detail: 'Procedural work, document review, award writing.',
|
||||
fee: money(FEES.arbitration.perHour),
|
||||
},
|
||||
{
|
||||
item: 'Hearing day',
|
||||
detail: 'In person or by video, at the same rate.',
|
||||
fee: money(FEES.arbitration.hearingDay),
|
||||
},
|
||||
{
|
||||
item: 'Documents-only or expedited — simple',
|
||||
detail: 'Flat fee.',
|
||||
fee: money(FEES.arbitration.documentsOnlySimple),
|
||||
},
|
||||
{
|
||||
item: 'Documents-only or expedited — complex',
|
||||
detail: 'Flat fee.',
|
||||
fee: money(FEES.arbitration.documentsOnlyComplex),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Fees · Mediation and Arbitration Rates · Pouya Lajevardi"
|
||||
description="The full rate card: half-day and full-day mediation, arbitration, cancellation terms and what an overrun costs. Published in full, with no ranges."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Fees</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
Published in full, including what overruns cost.
|
||||
</h1>
|
||||
{
|
||||
/* ⚠️ THIS SENTENCE QUOTED A PHRASE THE SPEC BARS, AND THE QUOTATION WAS
|
||||
THE PROBLEM. It read: *No ranges, no "starting from", and nothing that
|
||||
has to be asked for.* Two defects in one clause. (1) It defines the
|
||||
page against an unnamed practice — an implied comparative, which Q41(b)
|
||||
answers: assert his capability, never the field's. (2) It plants the
|
||||
literal string `starting from` in `dist/`, where a future sweep for
|
||||
`docs/03`'s "no 'starting from' evasions" would hit it and read a
|
||||
negation as a breach — the `I aLSO practise` / `the pLEADINGs` shape,
|
||||
manufactured on purpose by the copy. Stating what the page DOES needs
|
||||
no comparison and leaves nothing to trip over. */
|
||||
}
|
||||
<p class="hero-lede">
|
||||
One rate for all mediation matters, whatever the subject. Every figure
|
||||
is on this page, and none of it has to be asked for. {FEES.taxNote}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. Mediation --------------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Mediation"
|
||||
level={2}
|
||||
lede="One rate for every matter. Preparation is bundled into the fee and is capped."
|
||||
>
|
||||
<span slot="heading">Half day or full day.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
MEDIATION_ROWS.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.item}</span>
|
||||
<span class="rate-detail">{row.detail}</span>
|
||||
</dt>
|
||||
<dd>{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. Arbitration ------------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Arbitration"
|
||||
level={2}
|
||||
lede="Sole, party-appointed and co-arbitration appointments, in commercial matters."
|
||||
>
|
||||
<span slot="heading">Hourly, by hearing day, or flat.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
ARBITRATION_ROWS.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.item}</span>
|
||||
<span class="rate-detail">{row.detail}</span>
|
||||
</dt>
|
||||
<dd>{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
{
|
||||
/* The Q39 scope, stated on the page rather than left to the lede. §4
|
||||
Offerings carries a NOT OFFERED row for family arbitration, and
|
||||
`/practice/shareholder/` makes the same exclusion in one sentence on
|
||||
Pouya's instruction — "one sentence, not a section", because a
|
||||
disclaimer that grows reads as defensive. */
|
||||
}
|
||||
<p class="scope-note">
|
||||
Family arbitration under the <em>Family Law Act</em> is not offered, and family
|
||||
law matters are not accepted.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 4. Med-arb ------------------------------------------------------ */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Med-arb"
|
||||
level={2}
|
||||
lede="One appointment, two processes. Each phase is charged at the rates for that process."
|
||||
>
|
||||
<span slot="heading">Billed by phase.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
{
|
||||
/* NO `<dl class="rates">` HERE, AND THE ABSENCE IS THE POINT — see the
|
||||
header. Every other section on this page pairs an item with a figure;
|
||||
this one has no figure of its own, and giving it a row would mean
|
||||
inventing one. The three sentences come from `FEES.medArb` so the rule
|
||||
lives beside the numbers it points at rather than in this template. */
|
||||
}
|
||||
<ul class="notes" role="list">
|
||||
<li>{FEES.medArb.rule}</li>
|
||||
<li>{FEES.medArb.noSeparateFee}</li>
|
||||
<li>{FEES.medArb.termsApply}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 5. Other services ---------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Also offered"
|
||||
level={2}
|
||||
lede="Charged hourly, with an estimate agreed in the terms of appointment."
|
||||
>
|
||||
<span slot="heading">Three things beside the two processes.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Early neutral evaluation</span>
|
||||
{
|
||||
/* §4's ENE row and `docs/01` §`/practice/` both require this
|
||||
framing, and `docs/07` repeats it for this page in terms:
|
||||
"nothing on `/fees/` may read as a rate for advising one of
|
||||
them." ENE is the offering nearest §4's NOT-NEGOTIABLE boundary,
|
||||
because a neutral assessment of the merits sits closest to
|
||||
providing legal services. */
|
||||
}
|
||||
<span class="rate-detail">
|
||||
A reasoned assessment of the merits, delivered to both parties
|
||||
together. Never advice to one of them.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Dispute-system design</span>
|
||||
<span class="rate-detail">
|
||||
Advising an organisation on how its future disputes should be
|
||||
handled, before there are any.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Pre-dispute technical advisory</span>
|
||||
{
|
||||
/* THE CONFLICT CAUTION IS NOT OPTIONAL. §4's row: "no copy may
|
||||
imply the offering is free of that tension", and it names
|
||||
`/practice/`'s strip as where the temptation would arise. A fee
|
||||
page is the second such place, because a priced line reads as a
|
||||
product. Advisory work for one organisation can conflict against
|
||||
a later appointment in the same matter. */
|
||||
}
|
||||
<span class="rate-detail">
|
||||
Technical review before a dispute exists. Taking it on can rule me
|
||||
out of a later appointment in the same matter, and that is settled
|
||||
in writing before the work starts.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 6. Cancellation ------------------------------------------------ */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Cancellation"
|
||||
level={2}
|
||||
lede="A reserved date is time that cannot be given to another matter. The schedule is published so it is never a surprise."
|
||||
>
|
||||
<span slot="heading">If a date is cancelled.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
FEES.cancellation.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.window}</span>
|
||||
</dt>
|
||||
<dd class="dd-text">{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
<ul class="notes" role="list">
|
||||
{FEES.cancellationNotes.map((note) => <li>{note}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 7. Terms -------------------------------------------------------- */}
|
||||
<section class="section section-inverse reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Terms" level={2}>
|
||||
<span slot="heading">How the account works.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<ul class="notes" role="list">
|
||||
<li>{FEES.taxNote}</li>
|
||||
{FEES.terms.map((term) => <li>{term}</li>)}
|
||||
<li>
|
||||
Travel outside the Greater Toronto Area is billed separately, or
|
||||
bundled at a day rate stated in the terms of appointment.
|
||||
</li>
|
||||
<li>
|
||||
Everything above is confirmed in the terms of appointment before an
|
||||
engagement begins. Nothing on this page is an appointment.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cta">
|
||||
<Button href="/contact/" variant="gold"
|
||||
>Request a consultation →</Button
|
||||
>
|
||||
<Button href="/process/" variant="ghost"
|
||||
>How an engagement runs →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- The rate rows ---------------------------------------------------- */
|
||||
|
||||
/* A `<dl>`, not a `<table>`. Each row is one item and its price — a
|
||||
term-and-value pair — and a two-column table of eight rows reflows badly on
|
||||
a phone, where the price ends up under a wrapped item name with no
|
||||
alignment left to carry the association. The `<dt>`/`<dd>` pair keeps that
|
||||
association semantically whatever the layout does. */
|
||||
.rates {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-inline-size: 56rem;
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
.rate {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
padding-block: var(--space-5);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
.rate dt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
/* Leaves room for the fee on one line at tablet width and up, and wraps
|
||||
under it below that. */
|
||||
flex: 1 1 22rem;
|
||||
}
|
||||
.rate-item {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.rate-detail {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-meta);
|
||||
max-inline-size: 52ch;
|
||||
}
|
||||
.rate dd {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The cancellation column is a sentence, not a figure, so it takes body type
|
||||
and is allowed to wrap. */
|
||||
.rate .dd-text {
|
||||
flex: 1 1 16rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
white-space: normal;
|
||||
}
|
||||
.per {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
margin-block-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.notes {
|
||||
/* No `list-style: none` or `padding: 0` — `global.css` applies both to
|
||||
`ul[role='list']`, and a second copy is a second thing to keep true. */
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.notes li {
|
||||
padding-inline-start: var(--space-5);
|
||||
border-inline-start: 1px solid var(--rule);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
/**
|
||||
* `/for-parties/` — build step 6. Spec: docs/01 §`/for-parties/`, docs/03 §For
|
||||
* parties. The one page written in the second person, at a grade-9 reading
|
||||
* level, for a party who may be arriving without counsel.
|
||||
*
|
||||
* ⚠️ THIS IS THE PAGE `docs/03` NAMES AS THE PLACE THE BARRED PHRASING FEELS
|
||||
* MOST NATURAL AND IS MOST WRONG. Two forms are struck and must never come
|
||||
* back: *"the mediator is not your lawyer"* — which presupposes lawyer status,
|
||||
* and D13 treats implication as hard as assertion — and *"cannot give you legal
|
||||
* advice"*, a denial of a capacity §4 records as `[unestablished]` and
|
||||
* instructs this repository to answer neither way. `NEUTRAL_ROLE_LINE` is the
|
||||
* ratified pattern: role, then consequence for the reader, no verb of capacity.
|
||||
* It is rendered, never retyped, and it took three attempts and two audits.
|
||||
*
|
||||
* ⚠️ AND THE WORD "LAWYER" DOES NOT APPEAR ON THIS PAGE, DELIBERATELY. §4 bars
|
||||
* it *used of Pouya*, and every natural use here is about the READER — "do I
|
||||
* need my own lawyer", "coming without a lawyer". Those are compliant on §4's
|
||||
* wording and `check:claims` still fails the build on them, because a bare
|
||||
* `\blawyer\b` cannot tell whose. The gate is not wrong: this is the page
|
||||
* `docs/03` names as where the barred phrasing is most tempting, and a broad
|
||||
* pattern is the right shape there. The copy moved instead — "your own legal
|
||||
* advice", which is `NEUTRAL_ROLE_LINE`'s own wording. Same call, and the same
|
||||
* direction, as "my client" → "our client" on `/med-arb/`.
|
||||
*
|
||||
* ⚠️ THE FAQ ARRAY IS THE ONLY SOURCE. It is rendered visibly AND fed to the
|
||||
* `FAQPage` node, because docs/04 allows the node here "only where the visible
|
||||
* page genuinely is Q&A" — so a question cannot reach the structured data
|
||||
* without appearing on the page, and the two cannot drift.
|
||||
*
|
||||
* ⚠️ EVERY FEE FIGURE AND EVERY DURATION IS INTERPOLATED FROM `FEES`, NEVER
|
||||
* TYPED — a second hand-typed copy on the page a party reads is the one that
|
||||
* goes stale, silently. The two allowances are different things: `hours` is the
|
||||
* session, `prepIncluded` is preparation, and `docs/07` requires each published
|
||||
* as a cap with its own noun. **The unit here is "hours of mediation", not
|
||||
* "of session"** — `docs/01` bars jargon on this page and this page defines its
|
||||
* other term of art; the plainer noun is ADR Chambers' own, quoted in `docs/07`.
|
||||
*
|
||||
* ⚠️ AND NO OVERTIME FIGURE ON THIS PAGE UNTIL §9 Q59 IS ANSWERED. The rate is
|
||||
* publishable (D14, `docs/07` — **not** §4, which has no row reaching the rate
|
||||
* card). **The point at which overtime STARTS is not settled**, and $500 an hour
|
||||
* printed two answers below an unambiguous "up to 3 hours" defines it by
|
||||
* adjacency: there is no other quantity on the page for it to attach to. Under
|
||||
* the envelope reading a fourth hour carries nothing, so the inference
|
||||
* over-states a party's own exposure — and it also travels alone into the
|
||||
* `FAQPage` node. So the charge is named without its rate or its trigger.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import Undertaking from '../components/Undertaking.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { forPartiesGraph } from '../data/schema';
|
||||
import { CONDUCT_UNDERTAKINGS, FEES, NEUTRAL_ROLE_LINE } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
const money = (amount: number) =>
|
||||
new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: FEES.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: 'What is a mediation?',
|
||||
a: 'It is a meeting to try to settle a dispute without a hearing. Everyone involved comes. I run the meeting. I do not decide who is right.',
|
||||
},
|
||||
{
|
||||
q: 'Are you on anyone’s side?',
|
||||
a: 'No. I run the process for everyone in the room at once. If I ran it for one side it would settle nothing, and the other side would be right not to come.',
|
||||
},
|
||||
{
|
||||
q: 'Should I get my own legal advice?',
|
||||
a: 'What you sign at the end of a mediation is an agreement, so it is worth having someone look at your own position before the day rather than after it. If nobody is advising you, say so at the start.',
|
||||
},
|
||||
{
|
||||
q: 'What happens on the day?',
|
||||
a: `I usually start with everyone in one room. After that the parties often sit separately and I move between them. Some matters run that way from the beginning, with the parties never in the same room. You book either a half day or a full day. A half day is up to ${FEES.mediation.halfDay.hours} hours of mediation, a full day up to ${FEES.mediation.fullDay.hours} hours. Which one you need is settled before the date is fixed.`,
|
||||
},
|
||||
{
|
||||
q: 'Does what I say stay private?',
|
||||
a: 'The terms everyone signs at the start deal with confidentiality. A caucus is when I meet one side on their own, without the other side there. One part of what happens in it is mine rather than the document’s:',
|
||||
/* Rendered through <Undertaking> below, not as body prose — §4's third
|
||||
class gets one treatment across the site so a reader can tell a promise
|
||||
from a description. The graph gets lead and promise as one answer. */
|
||||
undertaking: CONDUCT_UNDERTAKINGS.mediationCaucus,
|
||||
},
|
||||
{
|
||||
q: 'What does it cost, and who pays?',
|
||||
a: `${money(FEES.mediation.halfDay.amount)} for a half day, including up to ${FEES.mediation.halfDay.prepIncluded} hours of preparation, and ${money(FEES.mediation.fullDay.amount)} for a full day, including up to ${FEES.mediation.fullDay.prepIncluded} hours of preparation. Each party beyond two is ${money(FEES.mediation.additionalParty)}, and if a mediation runs beyond the time the fee covers there is an hourly charge as well. ${FEES.taxNote} ${FEES.terms[0]}`,
|
||||
},
|
||||
{
|
||||
q: 'What if we do not settle?',
|
||||
a: 'Then the mediation ends without an agreement. Nothing you said becomes a ruling, because I do not make one. What gets written down is only what everyone agrees to write down.',
|
||||
},
|
||||
{
|
||||
q: 'How do I get ready?',
|
||||
a: 'Bring the documents that matter, not all of them. Know the number you would accept and the number you would walk away at. Bring someone who can agree to a settlement on the day, or be able to reach them quickly. Expect to be asked what the weakest part of your own case is.',
|
||||
},
|
||||
];
|
||||
|
||||
/* ONE SOURCE, TWO SHAPES. The page renders the lead-in and the undertaking as
|
||||
separate elements; a `FAQPage` answer is plain text, so the node gets them
|
||||
joined. Neither is typed twice. */
|
||||
const graph = forPartiesGraph({
|
||||
faq: FAQ.map(({ q, a, undertaking }) => ({
|
||||
q,
|
||||
a: undertaking ? `${a} ${undertaking}` : a,
|
||||
})),
|
||||
imageUrl: new URL(ldImage.src, Astro.site).href,
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="For Parties · What Happens at a Mediation · Pouya Lajevardi"
|
||||
description="What happens at a mediation, in plain language: who the mediator is, what the day looks like, what it costs and who pays, and how to get ready."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>For parties</Eyebrow>
|
||||
<h1 class="display hero-h">What happens at a mediation.</h1>
|
||||
<p class="hero-lede">
|
||||
This page is for you if you are going to a mediation and nobody has
|
||||
explained what one is. It is the same day the rest of this site
|
||||
describes, written without the shorthand.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The role, before anything else ------------------------------ */}
|
||||
{
|
||||
/* NEUTRAL_ROLE_LINE gets its own section rather than a slot in the FAQ.
|
||||
docs/03 requires this page to say it explicitly, and the sentence a party
|
||||
most needs is not one to make them scroll for. */
|
||||
}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="First" level={2}>
|
||||
<span slot="heading">Who I am in the room.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p class="statement">{NEUTRAL_ROLE_LINE}</p>
|
||||
<p>
|
||||
I also do not decide who is right. There is no ruling at the end of a
|
||||
mediation. There is either an agreement everyone signs, or there is
|
||||
not one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. The questions ----------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Questions"
|
||||
level={2}
|
||||
lede="The ones parties ask most often, answered plainly."
|
||||
>
|
||||
<span slot="heading">What you probably want to know.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="faq">
|
||||
{
|
||||
FAQ.map((item) => (
|
||||
<div class="faq-item">
|
||||
<h3 class="faq-q">{item.q}</h3>
|
||||
<p class="faq-a">{item.a}</p>
|
||||
{item.undertaking && (
|
||||
<Undertaking>{item.undertaking}</Undertaking>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/fees/" variant="gold">The full rate card →</Button>
|
||||
<Button href="/process/" variant="ghost">The five steps →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The compliance sentence, set larger than the paragraph under it. A party
|
||||
who reads one line on this page should read this one. */
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
.faq {
|
||||
display: grid;
|
||||
gap: var(--space-7);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.faq-q {
|
||||
margin-block-end: var(--space-2);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.faq-a {
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A row of standalone CTAs, not prose: WCAG 2.5.8's inline-link exception
|
||||
does not cover them, so `.btn` carries the 44px target. */
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
+108
-65
@@ -10,15 +10,26 @@
|
||||
* 1 Hero · 2 Credential row · 3 The approach · 4 Two practices ·
|
||||
* 5 Practice areas · 6 Process preview · 7 Latest insights · 8 Contact band
|
||||
*
|
||||
* SECTION 7 IS NOT BUILT, DELIBERATELY, and this is the only spec item this
|
||||
* page does not deliver. `src/content/insights/` is empty: the collection ships
|
||||
* at build step 7, which is also where `ArticleCard` and the drafted slate
|
||||
* arrive (docs/01 §Build order; docs/03 §Launch article slate, D9). Rendering
|
||||
* the section now means importing a component with nothing to render — its
|
||||
* scoped CSS ships to every visitor for an empty block — and a props surface
|
||||
* with no call site, which is already an open finding against InfinityMark.
|
||||
* SiteHeader gates the Insights NAV item on the same collection, so the page
|
||||
* and the nav appear together. Do not "finish" this by hardcoding placeholders.
|
||||
* SECTION 7 IS BUILT AS OF STEP 7b AND RENDERS NOTHING TODAY. The markup is
|
||||
* behind `latest.length > 0`, so with no published article no card, no heading
|
||||
* and no link is emitted. D9 means the flip is Pouya's — the schema refuses
|
||||
* `draft: false` without `reviewedByPouya: true` — and `SiteHeader` gates the
|
||||
* Insights NAV item on the same predicate at two pieces. Do not "finish" this by
|
||||
* hardcoding a placeholder card.
|
||||
*
|
||||
* ⚠️ **THE STEP-2 REASONING FOR DEFERRING THIS SECTION WAS THAT AN UNRENDERED
|
||||
* COMPONENT STILL SHIPS ITS CSS. THAT IS TRUE, AND IT IS NOW MEASURED RATHER
|
||||
* THAN ARGUED:** importing `ArticleCard` puts **10 rules, 1,496 bytes, 4.4% of
|
||||
* `dist/index.html`** into this page for a block that renders nothing. Astro
|
||||
* bundles a component's scoped styles on IMPORT, not on render, and
|
||||
* `inlineStylesheets: 'auto'` inlines them here.
|
||||
*
|
||||
* It is kept anyway, and the reason is also a measurement: `npm run lighthouse -- /`
|
||||
* returns **performance 99, LCP 2.03 s, CLS 0.000 — identical before and after
|
||||
* the 1,498-byte growth.** So the cost is real in bytes and absent in the metric,
|
||||
* on the one page already at `docs/04`'s LCP budget. The dead weight clears
|
||||
* itself the moment an article publishes, which is the same event that makes the
|
||||
* section visible.
|
||||
*
|
||||
* EVERY FACTUAL CLAIM ON THIS PAGE TRACES TO AGENTS.md §4, and the ones that
|
||||
* carry risk are constants from src/data/site.ts rather than typed here.
|
||||
@@ -35,9 +46,11 @@ import Eyebrow from '../components/Eyebrow.astro';
|
||||
import InfinityMark from '../components/InfinityMark.astro';
|
||||
import PracticeCard from '../components/PracticeCard.astro';
|
||||
import ProcessStep from '../components/ProcessStep.astro';
|
||||
import ArticleCard from '../components/ArticleCard.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import portrait from '../assets/pouya-lajevardi.jpg';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { homeGraph } from '../data/schema';
|
||||
import {
|
||||
ASYMMETRY_LINE,
|
||||
@@ -82,11 +95,19 @@ const ldImage = await getImage({
|
||||
height: 630,
|
||||
});
|
||||
const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/* THE THREE MOST RECENT, and the same `!data.draft` predicate the rest of the
|
||||
site uses — see the `draft` field in `src/content.config.ts`. Sorted here
|
||||
rather than trusting the loader's order: `glob()` returns files in directory
|
||||
order, which is alphabetical by filename and has nothing to do with date. */
|
||||
const latest = (await getCollection('insights', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime())
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${SITE.name} · ${SITE.tagline}`}
|
||||
description="Commercial mediation and arbitration in Toronto. Construction, technology, energy, insurance and shareholder disputes, read as contracts and as engineering."
|
||||
description="Mediation and commercial arbitration in Toronto. Construction, technology, energy, insurance and shareholder disputes, read as contracts and as engineering."
|
||||
imageAlt={PORTRAIT.alt}
|
||||
jsonLd={graph}
|
||||
preloadSerifItalic
|
||||
@@ -114,49 +135,27 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
</h1>
|
||||
|
||||
{
|
||||
/* TWO CORRECTIONS FROM `claims-auditor`, 2026-08-27, both about
|
||||
implication rather than assertion — which is where D13 says the risk
|
||||
actually lives.
|
||||
/* THREE CONSTRAINTS ON THIS PARAGRAPH. Trimmed to them under D19; the
|
||||
history is in the `AGENTS.md` Change Log.
|
||||
|
||||
(a) This read "I mediate and arbitrate commercial disputes". §4
|
||||
verifies that he ACCEPTS arbitral appointments, and separately
|
||||
verifies "multiple completed sole mediations" — there is no
|
||||
counterpart row for a completed arbitration. Present-indicative
|
||||
"arbitrate" beside "mediate" invites the reader to supply a track
|
||||
record for both. The offering-shaped form is what the register
|
||||
actually holds, and it is already the form the arbitration card
|
||||
below uses.
|
||||
|
||||
(b) "facts most neutrals take on faith" is a COMPARATIVE assertion
|
||||
about a population of third parties, and **Q41(b) CLOSED 2026-08-27:
|
||||
it is not restored, and the reason is not only compliance.** Pouya:
|
||||
*"That is an unverifiable empirical claim about other practitioners,
|
||||
and comparative claims must be factual and verifiable. It is also
|
||||
weaker copy: assert his capability, not the field's incapability."*
|
||||
It is struck from `docs/03`'s core positioning statement too — the
|
||||
approved-copy defence is gone, because the approved copy changed.
|
||||
|
||||
His replacement wording is used verbatim: *"built for disputes that
|
||||
turn on the contract, the code, and the engineering documents"*. The
|
||||
interim ("the documents rather than the pleadings") is also gone; it
|
||||
said nothing about other neutrals but it still worked by contrast.
|
||||
|
||||
ON THE ECHO OF THE HEADLINE, because it is deliberate and one edit
|
||||
from being reversed if he reads it as a stumble. The `<h1>` ends
|
||||
"the contract, the code, and the room"; this sentence re-runs the
|
||||
triad and swaps the third term for "the engineering documents". Two
|
||||
of three words repeat forty words apart. Read as a rhyme it does the
|
||||
work of the whole positioning statement in one move; read as an
|
||||
oversight it looks careless. Judged the first, flagged as the
|
||||
second. */
|
||||
1. ARBITRATION STAYS OFFERING-SHAPED. §4 verifies that he ACCEPTS
|
||||
arbitral appointments and separately verifies completed sole
|
||||
mediations; there is no completed-arbitration row. A present
|
||||
indicative beside "mediate" invites a track record §4 does not
|
||||
hold.
|
||||
2. NO COMPARATIVE CLAIM ABOUT OTHER NEUTRALS — Q41(b), closed
|
||||
2026-08-27, and struck from `docs/03`'s positioning statement too.
|
||||
3. THE TAIL IS POUYA'S WORDING, VERBATIM: *"built for disputes that
|
||||
turn on the contract, the code, and the engineering documents"*.
|
||||
Its echo of the `<h1>`'s triad is deliberate. */
|
||||
}
|
||||
<p class="hero-lede">
|
||||
I mediate commercial disputes from Toronto, and I accept commercial
|
||||
arbitration appointments. I also practise as a machine-learning and
|
||||
infrastructure engineer, so the matters I take are the ones that turn
|
||||
on the contract, the code, and the engineering documents: the change
|
||||
order, the model card, the System Impact Assessment, and the
|
||||
regulatory overlay around them.
|
||||
I mediate from Toronto, and I accept commercial arbitration
|
||||
appointments. I also practise as a machine-learning and infrastructure
|
||||
engineer, so the matters I take are the ones that turn on the
|
||||
contract, the code, and the engineering documents: the change order,
|
||||
the model card, the System Impact Assessment, and the regulatory
|
||||
overlay around them.
|
||||
</p>
|
||||
|
||||
<div class="hero-cta">
|
||||
@@ -460,20 +459,13 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
<a href="/mediation/">Mediation</a>
|
||||
</h3>
|
||||
{
|
||||
/* THE FEE CLAIM IS GONE, and it was wrong on two counts —
|
||||
`claims-auditor`, 2026-08-27, checked against `docs/07-fees.md`.
|
||||
"at one published rate" reads as ONE PRICE for half and full day;
|
||||
D14's card sets TWO ($2,000 half, $4,000 full). And "preparation
|
||||
time included" was unqualified where `docs/07` bundles a CAPPED
|
||||
allowance and says in terms: *"must be stated on the page —
|
||||
'including 2 hours of preparation' ... Do not quietly fold it into
|
||||
the hours figure. At these rates, saying preparation is included
|
||||
is the selling point, not a footnote."*
|
||||
|
||||
A home card is the wrong place to state it properly, and stating
|
||||
it improperly misdescribes money. `/mediation/` (step 4) and
|
||||
`/fees/` (step 9) carry the card. "Published" was also
|
||||
forward-looking: `/fees/` does not exist yet. */
|
||||
/* NO FEE CLAIM ON THIS CARD — a constraint, not an omission.
|
||||
There are two ways to get it wrong in one line and both have
|
||||
shipped once: "at one published rate" reads as ONE price where
|
||||
D14's card sets two, and an unqualified "preparation included"
|
||||
sells an uncapped allowance where `docs/07` caps it at stated
|
||||
hours. A card this size cannot state either properly.
|
||||
`/mediation/` and `/fees/` carry the card. */
|
||||
}
|
||||
<p class="feature-body">
|
||||
Sole mediator, Q.Med through ADRIC and ADRIO, with multiple
|
||||
@@ -617,7 +609,40 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 7. Latest insights: NOT BUILT AT STEP 2. See the header note. -- */}
|
||||
{/* ---- 7. Latest insights -------------------------------------------- */}
|
||||
{
|
||||
latest.length > 0 && (
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Insights"
|
||||
level={2}
|
||||
lede="Notes on process, regulatory change, and the technical record underneath commercial disputes."
|
||||
>
|
||||
<span slot="heading">Recently written.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit insights-grid" style="--grid-min: 20rem">
|
||||
{latest.map((entry) => (
|
||||
<ArticleCard
|
||||
href={`/insights/${entry.id}/`}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
date={entry.data.publishDate}
|
||||
topics={entry.data.topics}
|
||||
readingTime={entry.data.readingTime}
|
||||
level={3}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p class="insights-more">
|
||||
<a href="/insights/">Everything written →</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{/* ---- 8. Contact band ---------------------------------------------- */}
|
||||
{
|
||||
@@ -631,6 +656,17 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* --- 7. Latest insights --------------------------------------------- */
|
||||
|
||||
/* `.grid-autofit` (global.css) carries the columns and the `min()` guard. */
|
||||
.insights-grid {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.insights-more {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* --- 1. Hero -------------------------------------------------------- */
|
||||
|
||||
.hero {
|
||||
@@ -651,6 +687,9 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
runs four lines; `text-wrap: balance` (global.css) keeps them even. */
|
||||
font-size: var(--text-6xl);
|
||||
max-inline-size: 22ch;
|
||||
/* `anywhere`, not `break-word` — one word here held the whole hero column
|
||||
open. `docs/02` §Reflow, instrument finding 1. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
@@ -839,6 +878,10 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
flex: 1 1 auto;
|
||||
max-inline-size: 46ch;
|
||||
color: var(--text-secondary);
|
||||
/* `anywhere`, not the `break-word` `global.css` already gives this `<p>` —
|
||||
one token held this card's grid track open. `docs/02` §Reflow, instrument
|
||||
finding 1. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.feature-arrow {
|
||||
font-size: var(--text-xl);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
/**
|
||||
* `/insights/<slug>/` — one route, one page per published article. Build step 7b.
|
||||
* Spec: docs/01 §`/insights/`, docs/03 §Insights, docs/04 §Structured data.
|
||||
*
|
||||
* ⚠️ **`getStaticPaths` FILTERS DRAFTS, AND THAT IS WHERE D9 IS ENFORCED IN THE
|
||||
* BUILD.** `src/content.config.ts` refuses `draft: false` without
|
||||
* `reviewedByPouya: true`; this route refuses to generate a page for anything
|
||||
* still `draft: true`. Between them a piece Pouya has not read cannot become a
|
||||
* URL — not by a forgotten flag, not by a sitemap rule, and not by someone
|
||||
* linking to it. Do not add a preview parameter, and do not build drafts under a
|
||||
* different path "for review": the review D9 asks for is of the MDX, and
|
||||
* `npm run dev` renders it the moment the flag flips.
|
||||
*
|
||||
* THE `<h1>` IS `title`, AND THE `<title>` IS `seoTitle ?? title` — docs/04:
|
||||
* articles carry no ` · Pouya Lajevardi` suffix, because the suffix is 18
|
||||
* characters and would put a headline that already reads 50–60 at 68–78. The
|
||||
* schema enforces the length on whichever string is rendered and names the
|
||||
* offending one in the build error.
|
||||
*
|
||||
* EVERY ARTICLE LINKS TO AT LEAST ONE PRACTICE-AREA PAGE, and that is docs/04's
|
||||
* internal-linking requirement rather than a nicety: *"this is what turns
|
||||
* Insights into ranking power for the pages that convert."* It is rendered from
|
||||
* `practiceAreas` in the frontmatter, which the schema requires non-empty — so
|
||||
* an article cannot ship without one, and the link cannot be forgotten in prose.
|
||||
*/
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||
import ContactBand from '../../components/ContactBand.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import Pill from '../../components/Pill.astro';
|
||||
import Prose from '../../components/Prose.astro';
|
||||
import PracticeCard from '../../components/PracticeCard.astro';
|
||||
import SectionHeading from '../../components/SectionHeading.astro';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { articleGraph } from '../../data/schema';
|
||||
import { PRACTICE_AREAS } from '../../data/site';
|
||||
import { TOPIC_LABELS, formatArticleDate, isoDate } from '../../data/insights';
|
||||
import { ogCardPath } from '../../data/og-cards';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
return published.map((entry) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { data } = entry;
|
||||
const { Content } = await render(entry);
|
||||
|
||||
const path = `/insights/${entry.id}/`;
|
||||
|
||||
/* The Person node's image is the PORTRAIT — a photograph of a person. The
|
||||
Article node's image is the article's own generated card. Two different
|
||||
claims in two different fields; see `articleGraph`. */
|
||||
const ldPortrait = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
const graph = articleGraph({
|
||||
slug: entry.id,
|
||||
headline: data.title,
|
||||
description: data.description,
|
||||
datePublished: data.publishDate,
|
||||
dateModified: data.updatedDate,
|
||||
imageUrl: new URL(ogCardPath(path), Astro.site).href,
|
||||
personImageUrl: new URL(ldPortrait.src, Astro.site).href,
|
||||
});
|
||||
|
||||
/* ONE TRAIL, TWO RENDERINGS — the visible <Breadcrumbs> and the
|
||||
`BreadcrumbList` node inside `articleGraph`, which docs/04 requires to match.
|
||||
`articleGraph` builds its copy from the same three values this renders. */
|
||||
const trail = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Insights', href: '/insights/' },
|
||||
{ name: data.title, href: path },
|
||||
];
|
||||
|
||||
const areas = PRACTICE_AREAS.filter((area) =>
|
||||
(data.practiceAreas as readonly string[]).includes(area.slug),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={data.seoTitle ?? data.title}
|
||||
description={data.description}
|
||||
ogType="article"
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Header ------------------------------------------------------ */}
|
||||
<article>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Breadcrumbs trail={trail} />
|
||||
<Eyebrow dot>Insights</Eyebrow>
|
||||
<h1 class="display hero-h">{data.title}</h1>
|
||||
|
||||
<div class="meta">
|
||||
<time datetime={isoDate(data.publishDate)}>
|
||||
{formatArticleDate(data.publishDate)}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{data.readingTime} min read</span>
|
||||
{
|
||||
data.updatedDate && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
Updated{' '}
|
||||
<time datetime={isoDate(data.updatedDate)}>
|
||||
{formatArticleDate(data.updatedDate)}
|
||||
</time>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<ul class="topics" role="list">
|
||||
{
|
||||
data.topics.map((topic) => (
|
||||
<li>
|
||||
<Pill>{TOPIC_LABELS[topic]}</Pill>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The article ---------------------------------------------- */}
|
||||
<section class="section body-section">
|
||||
<div class="wrap">
|
||||
<Prose>
|
||||
<Content />
|
||||
</Prose>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* ---- 3. Where it applies ------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Where this applies"
|
||||
level={2}
|
||||
lede="The practice areas this piece is about."
|
||||
>
|
||||
<span slot="heading">Read next.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit areas" style="--grid-min: 20rem">
|
||||
{
|
||||
areas.map((area) => (
|
||||
<PracticeCard
|
||||
href={`/practice/${area.slug}/`}
|
||||
chip={area.chip}
|
||||
title={area.name}
|
||||
level={3}
|
||||
>
|
||||
{area.blurb}
|
||||
</PracticeCard>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-7);
|
||||
padding-block-end: 0;
|
||||
}
|
||||
.hero-h {
|
||||
/* --text-5xl, not --text-6xl. A headline here is a sentence of 50–60
|
||||
characters rather than the four or five words a landing page carries, and
|
||||
at 96px it takes four lines on a phone before the reader sees a date. */
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
max-inline-size: 34ch;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
|
||||
/* NOT `.reveal`. The article body is the page's reason for existing, and a
|
||||
scroll-driven opacity animation on the thing a reader came for is the one
|
||||
place this site does not use it — it also puts the whole body at the reveal's
|
||||
`from` state for any reader who never scrolls. The sections around it
|
||||
animate; the text does not. */
|
||||
.body-section {
|
||||
padding-block-start: var(--space-8);
|
||||
}
|
||||
|
||||
.areas {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
/**
|
||||
* `/insights/` — the article index. Build step 7b. Spec: docs/01 §`/insights/`,
|
||||
* docs/03 §Insights, docs/04 §Structured data.
|
||||
*
|
||||
* ⚠️ **THIS PAGE SHIPS BEFORE ITS CONTENT DOES, AND THE STATE IT IS IN TODAY IS
|
||||
* A DECISION RATHER THAN AN OVERSIGHT.** D9 requires Pouya to read every word
|
||||
* before publication and `src/content.config.ts` enforces it — the schema refuses
|
||||
* `draft: false` without `reviewedByPouya: true`. So build step 7 could only ever
|
||||
* produce plumbing plus drafts awaiting him; there is no route by which it
|
||||
* produces a live section.
|
||||
*
|
||||
* `docs/01` is explicit about the risk that creates: *"An empty blog signals
|
||||
* abandonment more loudly than no blog signals anything."* Three things hold that
|
||||
* line, and the first two already existed:
|
||||
*
|
||||
* 1. **`SiteHeader` gates the nav item on two published pieces.** Unchanged.
|
||||
* 2. **Drafts produce no page**, so no ARTICLE URL exists to be linked or
|
||||
* indexed. ⚠️ **This bullet claimed "nothing links into an empty section"
|
||||
* and that was false: `SiteFooter` links `/insights/` from all 22 pages**,
|
||||
* ungated — `grep -rlo 'href="/insights/"' dist --include='*.html' | wc -l`
|
||||
* returns 22. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
* **The footer link stays, and gating it was the wrong fix:** `docs/01`
|
||||
* §Navigation specifies the footer as *"Full sitemap in three columns"*, and
|
||||
* a sitemap with a hole in it is a worse artefact than a link to a page
|
||||
* that says, accurately, that nothing is published yet. What was wrong was
|
||||
* the sentence, so the sentence changed.
|
||||
* 3. **`noindex` while the section is empty** — decided at step 7b. A thin
|
||||
* index is a real, if small, discoverability negative, and a crawler is the
|
||||
* one reader who *will* arrive here with nothing published. It is derived
|
||||
* from the collection on every build, so it clears itself the moment the
|
||||
* first article publishes rather than needing to be remembered.
|
||||
*
|
||||
* **What it does NOT do is leave the sitemap** — `astro.config.mjs`'s filter
|
||||
* cannot see collection data, which that file records in terms, and reaching for
|
||||
* frontmatter from build config to fix a temporary state would be worse than the
|
||||
* state. So while the section is empty this URL is in the sitemap and marked
|
||||
* `noindex`, which Search Console reports accurately as excluded-by-noindex.
|
||||
* Both halves clear together on the first publication.
|
||||
*
|
||||
* NO TOPIC FILTER UI. `docs/01` asks for *"topic filtering by practice area"*,
|
||||
* and with zero published articles a filter is a control with nothing to filter —
|
||||
* shipping its CSS to every visitor for an empty list is the argument `/` used
|
||||
* for deferring its own Insights strip at step 2. The pills on each card carry
|
||||
* the topic, and the practice-area link at the foot of each article carries the
|
||||
* other axis. Build the filter when there is a shelf worth filtering, and build
|
||||
* it as links to real URLs rather than as JavaScript.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArticleCard from '../../components/ArticleCard.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import ContactBand from '../../components/ContactBand.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import SectionHeading from '../../components/SectionHeading.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* THE ONE PREDICATE, everywhere on the site: `!data.draft`. See the `draft`
|
||||
field in `src/content.config.ts` for why it is not four predicates. */
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
published.sort(
|
||||
(a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime(),
|
||||
);
|
||||
|
||||
/* No `Article` nodes here. docs/04 puts `Article` on each article; a list of
|
||||
links is not fifteen articles, and emitting them would put the same `@id`
|
||||
in two documents. `pageGraph` is the Person alone — the shape `/practice/`
|
||||
and `/process/` already use. No `BreadcrumbList`: one hop from the root, and
|
||||
the page shows no visible trail. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Insights · Dispute Resolution Notes · Pouya Lajevardi"
|
||||
description="How mediation and arbitration actually run, what Ontario regulatory change means for a dispute, and how to read the technical record underneath one."
|
||||
jsonLd={graph}
|
||||
noindex={published.length === 0}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Insights</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
Notes on process, regulation, and the technical record.
|
||||
</h1>
|
||||
<p class="hero-lede">
|
||||
Written for counsel choosing a neutral, and for in-house teams who have
|
||||
to explain a process to someone who has never been in one. Each piece
|
||||
names its sources.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The articles, or an honest account of their absence --------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
{
|
||||
published.length > 0 ? (
|
||||
<>
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Articles" level={2}>
|
||||
<span slot="heading">Most recent first.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit list" style="--grid-min: 22rem">
|
||||
{published.map((entry) => (
|
||||
<ArticleCard
|
||||
href={`/insights/${entry.id}/`}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
date={entry.data.publishDate}
|
||||
topics={entry.data.topics}
|
||||
readingTime={entry.data.readingTime}
|
||||
level={3}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Nothing published yet" level={2}>
|
||||
<span slot="heading">
|
||||
The first pieces are drafted and not yet published.
|
||||
</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
{/* ⚠️ THIS BLOCK IS THE EMPTY STATE AND IT MAKES NO PROMISE ABOUT
|
||||
A DATE. "Coming soon", "launching shortly" and a monthly cadence
|
||||
stated on the page are all commitments — the class §4 and Q43
|
||||
treat as publishable only where Pouya has made them in terms. He
|
||||
has committed to monthly cadence in D9, which is a decision about
|
||||
the project; it is not a public undertaking, and R4 exists
|
||||
because a blog that stops is worse than one that never started.
|
||||
So the page says what is true today and stops. */}
|
||||
<div class="prose">
|
||||
<p>
|
||||
Every piece here is read and approved before it is published,
|
||||
which is why this section is empty rather than padded. The
|
||||
drafted pieces cover the Ontario data-centre build-out, when
|
||||
med-arb fits and when it does not, grid connection and Bill 40,
|
||||
what a System Impact Assessment evaluates, and what counsel
|
||||
should ask a neutral before appointing one.
|
||||
</p>
|
||||
<p>
|
||||
In the meantime, the pages below carry the same material in the
|
||||
form it is actually needed in.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/practice/" variant="ghost">
|
||||
The six practice areas →
|
||||
</Button>
|
||||
<Button href="/process/" variant="ghost">
|
||||
How an engagement runs →
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* `.grid-autofit` (global.css) carries the columns and the `min()` guard. */
|
||||
.list {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* A row of standalone CTAs, not prose: WCAG 2.5.8's inline-link exception
|
||||
does not cover them, so `.btn` carries the 44px target. */
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,459 @@
|
||||
---
|
||||
/**
|
||||
* `/legal/privacy/` — build step 10. Spec: docs/01 §`/legal/*`,
|
||||
* docs/05-backend-spec.md §Privacy policy must state.
|
||||
*
|
||||
* ⚠️ **THE GOVERNING INSTRUCTION IS "WRITTEN TO MATCH WHAT IS ACTUALLY BUILT,
|
||||
* NOT WHAT IS TYPICAL" — docs/05 — AND THAT IS WHY THIS PAGE IS BUILT LAST IN
|
||||
* THE ORDER.** `docs/01`: *"/legal/* — written to match the backend as actually
|
||||
* built."* On this page a sentence that describes an intended control rather than
|
||||
* a real one is a false statement to the public in a legal document, and it is
|
||||
* the kind that fails silently: nothing breaks, and the sentence reads correctly.
|
||||
*
|
||||
* So three things are DERIVED rather than written, and each closes a specific
|
||||
* way this page could quietly become untrue:
|
||||
*
|
||||
* 1. **The list of what is collected is rendered from `INTAKE_FIELDS`** — the
|
||||
* same array `/contact/` builds the form from. A field added to the form
|
||||
* appears here on the same build. A hand-written list is the copy nobody
|
||||
* re-reads, which is the SES-DKIM shape in a document with legal weight.
|
||||
* 2. **The retention period is rendered from `RETENTION_MONTHS`**, which is the
|
||||
* figure `backend/intake/handler.mjs` writes into the `ttl` attribute.
|
||||
* docs/05: *"Whatever number ships must match `/legal/privacy/` exactly."*
|
||||
* 3. **The analytics paragraph is rendered from `ANALYTICS.installed`.** D15
|
||||
* decided Plausible; §7 records that no script is on any page. Deciding is
|
||||
* not installing, and a policy naming a processor that processes nothing is
|
||||
* a false disclosure. Today it says there are none.
|
||||
*
|
||||
* ⚠️ **WHAT THIS PAGE DELIBERATELY DOES NOT CLAIM, AND THE OMISSIONS ARE THE
|
||||
* POINT.** docs/05 specifies a customer-managed KMS key, point-in-time recovery,
|
||||
* and DynamoDB TTL. `AGENTS.md` §7 is the register for whether each of the three
|
||||
* is enabled, and **this comment does not restate what it says** — it did once,
|
||||
* went stale within the day, and had to be pulled back (§12 R19). So:
|
||||
*
|
||||
* - "Encrypted at rest" IS stated, because DynamoDB encrypts every table at
|
||||
* rest unconditionally — it is true whether or not the customer-managed key
|
||||
* in docs/05 has been configured.
|
||||
* - The customer-managed key and point-in-time recovery are NOT mentioned.
|
||||
* Neither is a fact a reader needs, and neither is verified.
|
||||
* - **Automatic deletion IS stated, and it asserts a MECHANISM rather than only
|
||||
* a period** — the one promise here whose truth lives entirely outside this
|
||||
* repository. The handler writes the `ttl` attribute, and ⚠️ **writing the
|
||||
* attribute is not the mechanism**: TTL must also be enabled on the table,
|
||||
* which §7 records — **and the setting being on still does not prove a
|
||||
* record is ever deleted.** Only a record written with a near-future `ttl`
|
||||
* and watched to vanish proves that. docs/05's definition of done carries
|
||||
* "TTL set and verified by test record" and `docs/06`'s cutover checklist
|
||||
* names this page as what that item protects. ⚠️ **This read "both halves
|
||||
* before this page is public" and the page went public first — Pouya's
|
||||
* ruling of 2026-09-03: publish, then confirm the deletion, reading from
|
||||
* 2026-09-04.** So the second half is now owed rather than pending, which is
|
||||
* a weaker position and is recorded as one. See the comment on the retention
|
||||
* section below, and §9 Q60.
|
||||
*
|
||||
* ⚠️ **NO LICENSURE CLAIM AND NO ANSWER TO THE CAPACITY QUESTION.** A privacy
|
||||
* policy is where "legal advice" phrasing arrives by convention. §4 records
|
||||
* licence status as `[unestablished]` and instructs this repository to answer
|
||||
* neither way; `docs/03`'s ratified pattern is role, then consequence for the
|
||||
* reader, and no verb of capacity. Applied throughout.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import Undertaking from '../../components/Undertaking.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import {
|
||||
ANALYTICS,
|
||||
CONDUCT_UNDERTAKINGS,
|
||||
CONTACT,
|
||||
SITE,
|
||||
} from '../../data/site';
|
||||
import { INTAKE_FIELDS } from '../../data/intake';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/**
|
||||
* ⚠️ MUST MATCH `RETENTION_MONTHS` IN `backend/intake/handler.mjs`, which is
|
||||
* the figure written into the record's `ttl`. docs/05: "Whatever number ships
|
||||
* must match /legal/privacy/ exactly." The handler is a separately deployed
|
||||
* artefact and cannot be imported here, so this is a second copy — and unlike
|
||||
* the intake field tables there is no `check:` script over it. Treat a change to
|
||||
* either as a change to both, and see docs/06's cutover checklist.
|
||||
*/
|
||||
const RETENTION_MONTHS = 24;
|
||||
|
||||
/** Bump this on ANY substantive edit. A privacy policy with a stale date is a
|
||||
* policy a reader cannot tell they are reading an old version of. */
|
||||
const LAST_UPDATED = '4 September 2026';
|
||||
|
||||
/* Rendered from the form's own field list, so the two cannot drift. `consent`
|
||||
and the honeypot are absent from `INTAKE_FIELDS` deliberately and are
|
||||
described in prose below instead — one is not information about the inquirer,
|
||||
and the other is not information at all. */
|
||||
const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Privacy Policy · Intake and Data Handling · Pouya Lajevardi"
|
||||
description="What the intake form collects, why, where it is stored, how long it is kept, who can see it, and how to have it deleted. Written to match what is built."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Privacy</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
What the intake form collects, and for how long.
|
||||
</h1>
|
||||
<p class="hero-lede">
|
||||
This describes what actually happens to what you send me, not what is
|
||||
typical. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section legal-body">
|
||||
<div class="wrap">
|
||||
<div class="prose">
|
||||
<h2>What is collected</h2>
|
||||
<p>
|
||||
One form on this site collects personal information: the intake form
|
||||
on the <a href="/contact/">contact page</a>. It asks for the
|
||||
following, and the fields marked required on the form are the only
|
||||
ones that must be completed.
|
||||
</p>
|
||||
<ul>
|
||||
{COLLECTED.map((label) => <li>{label}</li>)}
|
||||
</ul>
|
||||
{
|
||||
/* ⚠️ DO NOT WRITE "your IP address" HERE, AND DO NOT CONCLUDE ANYTHING
|
||||
ABOUT WHETHER THE ADDRESS IDENTIFIES ANYONE. The handler stores
|
||||
`requestContext.http.sourceIp` — behind the `/api/*` behaviour that
|
||||
is a CloudFront edge, so the expected value is Amazon's. **Expected,
|
||||
not measured:** `docs/09` Part 7.2 measures it at cutover and
|
||||
enumerates three outcomes, one of which is that the reader's own
|
||||
address does land. This copy therefore states only why the field is
|
||||
kept, which is true in all three. A previous form hedged "usually
|
||||
not yours" and then asserted "not precise enough to identify you" —
|
||||
false in exactly the branch the hedge admitted. `claims-auditor`,
|
||||
then `adversarial-reviewer` round 2. */
|
||||
}
|
||||
<p>
|
||||
Submitting the form also records the date and time and your browser's
|
||||
user-agent string. Those two are kept for investigating abuse of the
|
||||
form and are not used for anything else.
|
||||
</p>
|
||||
<p>
|
||||
It also records the network address the request arrived from. This
|
||||
site sits behind a content delivery network, so that address is
|
||||
normally the network's own rather than your connection's — which is
|
||||
why it is kept simply because it arrives with the request, and not as
|
||||
a way of identifying anyone.
|
||||
</p>
|
||||
<p>
|
||||
Nothing else on this site collects personal information. There is no
|
||||
newsletter, no account, no comment form and no upload.
|
||||
</p>
|
||||
|
||||
<h2>Information about other people</h2>
|
||||
<p>
|
||||
The form asks for the other parties to the dispute and their counsel.
|
||||
That is information about people who have not filled in the form and
|
||||
may not know it was sent. It is asked for one reason, and the reason
|
||||
is a commitment rather than an observation.
|
||||
</p>
|
||||
{
|
||||
/* `<Undertaking>` AND `CONDUCT_UNDERTAKINGS`, NEVER TYPED PROSE —
|
||||
§4's third class, whose characteristic failure mode is that a
|
||||
promise gets quietly smaller and nothing fails. Undertaking (g),
|
||||
attested 2026-09-03.
|
||||
|
||||
⚠️ IT IS THE COMPONENT FOR THE REASON THE COMPONENT EXISTS: one
|
||||
treatment on every page, so a reader can tell a promise from a
|
||||
description. This shipped for one pass as an ordinary paragraph in
|
||||
`“`/`”` — the only such entities in `src/`, and a
|
||||
commitment set as body prose reads as another sentence about
|
||||
process.
|
||||
|
||||
It REPLACED the hand-typed "I cannot accept an appointment before
|
||||
conflicts are checked", which stated the same proposition as a
|
||||
constraint; keeping both would have set the undertaking beside its
|
||||
own paraphrase — the (e)/(f) treatment. */
|
||||
}
|
||||
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
|
||||
<p>
|
||||
The check needs names. Please give names and nothing more about them.
|
||||
The form asks you not to include privileged or confidential detail
|
||||
anywhere in it, and the summary field says so directly. There is
|
||||
deliberately no field for amounts in dispute and no way to attach a
|
||||
document.
|
||||
</p>
|
||||
|
||||
<h2>Why it is collected, and on what basis</h2>
|
||||
<p>
|
||||
To reply to your inquiry and to run a conflicts check. The basis is
|
||||
your consent, which the form asks for explicitly with an unchecked box
|
||||
you have to tick. The wording you agree to is on the form itself, and
|
||||
it names <strong>SML Company Ltd</strong>, the company that holds this
|
||||
practice's systems.
|
||||
</p>
|
||||
<p>
|
||||
It is not used for marketing. It is not sold, rented or shared with
|
||||
anyone for their own purposes.
|
||||
</p>
|
||||
|
||||
<h2>Where it is stored</h2>
|
||||
<p>
|
||||
In a DynamoDB table in Amazon Web Services' Canada Central region, in
|
||||
Canada. It is encrypted at rest. Two emails are sent when you submit
|
||||
the form — a notification to the practice and a confirmation to you —
|
||||
using Amazon Simple Email Service, also in the same Canadian region.
|
||||
</p>
|
||||
<p>
|
||||
The table sits in an Amazon Web Services account that also runs
|
||||
systems unrelated to this practice.
|
||||
</p>
|
||||
{
|
||||
/* ⚠️ TWO PROCESSORS, AND BOTH MUST BE NAMED. `AGENTS.md` §7 records
|
||||
mail hosting as **Google Workspace** and D18 sends the notification to
|
||||
`info@smlcompany.ca`, so Google receives and stores every submission —
|
||||
including the opposing parties and their counsel, the most sensitive
|
||||
thing this form collects. A reader making a PIPEDA access request
|
||||
needs both names. This paragraph replaced one asserting *"No other
|
||||
third party receives it"*; see entry (ao). §7 is cited, not restated —
|
||||
no MX record here. */
|
||||
}
|
||||
<p>
|
||||
Two companies therefore process it, and both are named because a
|
||||
reader asking for a copy or a deletion needs to know where it went. <strong
|
||||
>Amazon Web Services</strong
|
||||
> stores the submission and sends the two emails, in Canada. <strong
|
||||
>Google</strong
|
||||
> receives the notification email, because the practice's mail is on Google
|
||||
Workspace — so a copy of what you send, including any names you give me,
|
||||
sits in that mailbox. If you reply to the confirmation, that reply goes
|
||||
there too.
|
||||
</p>
|
||||
<p>
|
||||
The confirmation sent to you is delivered to whoever runs your email.
|
||||
That is your provider rather than mine, and I have no control over
|
||||
what they keep.
|
||||
</p>
|
||||
<p>
|
||||
No one else is sent it. There is no CRM, no mailing list and no
|
||||
analytics on the submission. Who can read what is stored is a
|
||||
different question from who it is sent to, and it is answered under
|
||||
"Who can see it" below.
|
||||
</p>
|
||||
|
||||
<h2>How long it is kept</h2>
|
||||
{
|
||||
/* The sentence below asserts a MECHANISM, not just a period, and the
|
||||
mechanism is still unobserved — AGENTS.md §9 Q60, open. **Pouya
|
||||
ruled 2026-09-03 that the page publishes now and the deletion is
|
||||
confirmed after launch**; the observation window opened 2026-09-02
|
||||
and the earliest useful reading is 2026-09-04 (`docs/09` Part 10).
|
||||
That decision is why this is no longer a `TODO(pouya)`. The table
|
||||
setting lives in §7 and is deliberately not restated here — it was
|
||||
once, and went stale within the day (§12 R19). Do not answer Q60
|
||||
from the handler code, which only writes the attribute. */
|
||||
}
|
||||
<p>
|
||||
<strong>{RETENTION_MONTHS} months from the date you send it</strong>,
|
||||
after which the record is deleted automatically by the database rather
|
||||
than by someone remembering to do it. That period is long enough to
|
||||
run a conflicts check across the normal life of a matter and no longer
|
||||
than necessary for that purpose.
|
||||
</p>
|
||||
<p>
|
||||
Emails are a separate matter. The notification sits in the practice's
|
||||
mailbox and the confirmation sits in yours, and neither is deleted by
|
||||
that mechanism.
|
||||
</p>
|
||||
|
||||
<h2>Who can see it</h2>
|
||||
{
|
||||
/* ⚠️ THIS SECTION AND THE SENTENCES BELOW ANSWER THE SAME QUESTION
|
||||
AND CHANGE TOGETHER — by OPENING PHRASE, never by count. **Here:**
|
||||
"The record in the table", "The system that receives", "The
|
||||
notification goes to", "The confirmation that went to you".
|
||||
**§Where it is stored:** "In a DynamoDB table", "The table sits in an
|
||||
Amazon Web Services account", "Two companies therefore process it",
|
||||
"The confirmation sent to you", "No one else is sent it". **§How long
|
||||
it is kept:** "Emails are a separate matter".
|
||||
⚠️ **THIS SECTION STATES WHO, NOT HOW — Pouya's ruling, 2026-09-02.
|
||||
NOT TO BE RESTORED HERE:** the measurement paragraph, the
|
||||
root-credential sentence, the single-sign-on and federated-login
|
||||
enumeration, the resource-policy clause, the "company that runs a
|
||||
database" aside, the deploy-credential sentence and the three-copies
|
||||
summary. All true, all still in `AGENTS.md` §7 and
|
||||
`docs/reference/intake-table-access-verification.md`. **No human
|
||||
headcount** — a simulation counts identities, not people.
|
||||
⚠️ **SENTENCE 1 IS SCOPED TO THE STORED RECORD** (paragraph 3 names
|
||||
administrative staff, who read the mailbox and **cannot** read the
|
||||
table) **AND PREDICATED ON ADMINISTERING THE ACCOUNT** (what §7
|
||||
measures). §4 carries the row and the bar: **never widen it to
|
||||
running, founding, practising or acting.**
|
||||
**TWO CLAIMS GO STALE ON THEIR OWN** — who administers the account,
|
||||
and who reads `info@smlcompany.ca`. §7 holds both; §12 **R21** is the
|
||||
trigger.
|
||||
⚠️ **THERE ARE THREE COPIES AND THE THIRD IS THE READER'S OWN** — the
|
||||
handler puts the whole submission into the confirmation it sends the
|
||||
inquirer. Never write "the one other place a copy exists". */
|
||||
}
|
||||
<p>
|
||||
The record in the table: me, and the small number of people who
|
||||
administer the account it sits in with me.
|
||||
</p>
|
||||
<p>
|
||||
The system that receives what you send
|
||||
<strong
|
||||
>can only add a record — it cannot read back what is stored.</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
The notification goes to the practice's mailbox, which is read by me
|
||||
and by administrative staff and is hosted on Google Workspace — so
|
||||
Google holds a copy of whatever you send me.
|
||||
</p>
|
||||
<p>
|
||||
The confirmation that went to you sits with whoever runs your email.
|
||||
That copy is in your hands rather than mine.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and analytics</h2>
|
||||
{
|
||||
ANALYTICS.installed ? (
|
||||
<p>
|
||||
Visits are counted using{' '}
|
||||
{ANALYTICS.provider === 'plausible' ? 'Plausible' : 'Fathom'},
|
||||
which is cookieless and collects no personal information and no
|
||||
cross-site identifiers. There is nothing to consent to and no
|
||||
banner, because it sets no cookies and stores no identifier on
|
||||
your device.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>This site sets no cookies and runs no analytics.</strong>
|
||||
There is no tracking script on any page, and there is therefore
|
||||
nothing to consent to and no banner. If cookies or analytics are
|
||||
ever introduced, this page changes on the same day and its last
|
||||
updated date moves with it. Your browser does cache this site's
|
||||
fonts, stylesheets and images for up to a year so a return visit
|
||||
loads faster, and those are the same files for every visitor.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<p>
|
||||
There are no third-party scripts of any kind on this site, no embedded
|
||||
video, no web fonts fetched from another company's servers, and no
|
||||
social media widgets. The pages you are reading make no request to
|
||||
anyone but this site.
|
||||
</p>
|
||||
|
||||
<h2>Asking for a copy, or asking me to delete it</h2>
|
||||
<p>
|
||||
Email <a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a> and ask. You
|
||||
can ask for a copy of what you sent, ask me to correct it, or ask me to
|
||||
delete it before the {RETENTION_MONTHS} months are up.
|
||||
{' '}{CONTACT.responseTime}
|
||||
</p>
|
||||
{
|
||||
/* ⚠️ THE CLAUSE THAT WAS HERE PROMISED TO DISCLOSE THE OUTCOME OF A
|
||||
CONFLICTS CHECK — *"I will tell you what its outcome was rather than
|
||||
pretending the inquiry did not happen"* — and that is an UNDERTAKING,
|
||||
which §4 may publish only where Pouya has made it in terms. He had
|
||||
not. D20 finding 13, and it is closed by his attestation of
|
||||
2026-09-03, which covers RUNNING the check and says nothing about
|
||||
reporting it. The sentence now states what deletion does not undo and
|
||||
stops there. Do not restore the promise without a second attestation:
|
||||
it is a different commitment from the one he made. */
|
||||
}
|
||||
<p>
|
||||
Deletion removes the record. It does not retract the emails already
|
||||
sent, and it does not undo a conflicts check that has already been
|
||||
run.
|
||||
</p>
|
||||
|
||||
<h2>What an inquiry is not</h2>
|
||||
<p>
|
||||
Sending the form does not create a retainer, does not appoint me as a
|
||||
neutral in your matter, and does not itself establish a mediator–party
|
||||
relationship. It also does not, by itself, complete a conflicts check
|
||||
— it gives me what I need to run one.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this page</h2>
|
||||
<p>
|
||||
If what happens to your information changes, this page is edited on
|
||||
the same day and the date at the top moves. There is no archive of
|
||||
previous versions.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about any of the above:
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>. The site is
|
||||
{' '}{SITE.url}, and correspondence is by email — {CONTACT.location}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
/* --text-4xl, not --text-6xl. A legal page's job is to be read rather than
|
||||
to land; at 96px this headline takes four lines before the reader reaches
|
||||
the date they came to check. */
|
||||
font-size: var(--text-4xl);
|
||||
max-inline-size: 30ch;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* NOT `.reveal`. A legal document is the one page class where content must be
|
||||
at full opacity the moment it renders, whatever the reader's scroll position
|
||||
or motion setting — and where a reader may well arrive via Cmd-F. */
|
||||
.legal-body {
|
||||
padding-block-start: var(--space-7);
|
||||
}
|
||||
|
||||
/* `global.css`'s `.prose` supplies the measure and paragraph spacing. These
|
||||
are the two element types this page introduces that no other page's prose
|
||||
block uses: headings inside a document, and a plain list. */
|
||||
.prose h2 {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.prose h2:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.prose ul {
|
||||
margin-block-start: var(--space-4);
|
||||
padding-inline-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.prose li + li {
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
/**
|
||||
* `/legal/terms/` — build step 10. Spec: docs/01 §`/legal/*`.
|
||||
*
|
||||
* ⚠️ **THIS IS THE PAGE WHERE THE BARRED PHRASING ARRIVES BY CONVENTION, AND
|
||||
* THAT MAKES IT THE SECOND-HIGHEST-RISK PAGE ON THE SITE AFTER
|
||||
* `/for-parties/`.** Every terms-of-use template on the internet contains some
|
||||
* version of *"nothing on this site constitutes legal advice and no
|
||||
* solicitor-client relationship is created"* — and both halves are traps here:
|
||||
*
|
||||
* 1. **"No solicitor-client relationship"** presupposes that there could be
|
||||
* one, which presupposes licensure. §4 Forbidden bars the word "lawyer" used
|
||||
* of Pouya and D13 treats implication as hard as assertion. The relationship
|
||||
* this site must disclaim is the **mediator–party** one, which is the
|
||||
* relationship actually on offer, and `NO_RETAINER_NOTICE` is the ratified
|
||||
* wording for it.
|
||||
* 2. **"Does not constitute legal advice"** is one word away from answering the
|
||||
* capacity question. §4 records licence status as `[unestablished]` and says
|
||||
* to answer it neither way; `docs/03`'s worked example shows both obvious
|
||||
* phrasings failing — *"I do not give legal advice"* elects, *"I cannot"*
|
||||
* denies. So this page describes **what the pages ARE** (general description
|
||||
* of processes) and **what follows for the reader** (get your own advice on
|
||||
* your own matter), and attaches no verb of capacity to him at all. That is
|
||||
* the ratified pattern, and `NEUTRAL_ROLE_LINE` is rendered rather than
|
||||
* paraphrased.
|
||||
*
|
||||
* ⚠️ **AND IT MUST NOT INVENT LEGAL EFFECT.** A terms page is a claim about what
|
||||
* is binding. §4 bars this repository from concluding a proposition of law, so
|
||||
* there is no governing-law clause asserting which court has jurisdiction, no
|
||||
* limitation-of-liability formula, and no warranty disclaimer written from a
|
||||
* template. Those are drafting decisions for Pouya or for counsel — they are in
|
||||
* the batched list for him, and this page says what it can stand behind.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import {
|
||||
CONTACT,
|
||||
NEUTRAL_ROLE_LINE,
|
||||
NO_RETAINER_NOTICE,
|
||||
SITE,
|
||||
} from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/** Bump on any substantive edit. See the note on the privacy page. */
|
||||
const LAST_UPDATED = '3 September 2026';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Terms of Use · This Website · Pouya Lajevardi · Toronto"
|
||||
description="What this site is, what reading it does and does not create, how the fees and timings published here relate to an engagement, and who to contact."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Terms</Eyebrow>
|
||||
<h1 class="display hero-h">Terms of use for this site.</h1>
|
||||
<p class="hero-lede">
|
||||
Short, because there is not much to say about a site that publishes
|
||||
information and one form. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section legal-body">
|
||||
<div class="wrap">
|
||||
<div class="prose">
|
||||
<h2>What this site is</h2>
|
||||
<p>
|
||||
A description of the dispute resolution practice of Pouya Lajevardi,
|
||||
the processes it conducts, the subject matter it works in, and what
|
||||
those processes cost. It is written for counsel choosing a neutral,
|
||||
for in-house teams, and for parties who have been told they are going
|
||||
to a mediation.
|
||||
</p>
|
||||
<p>
|
||||
It describes processes in general terms. It is not a description of
|
||||
your matter, and nothing on it has been written with your matter in
|
||||
view. Anything you are deciding about your own dispute is a question
|
||||
for your own advisers.
|
||||
</p>
|
||||
|
||||
<h2>What my role is</h2>
|
||||
<p class="statement">{NEUTRAL_ROLE_LINE}</p>
|
||||
<p>
|
||||
That holds on every page here. Where this site describes what happens
|
||||
in a mediation, an arbitration or a med-arb, it describes the role of
|
||||
a neutral running a process for everyone in it at once.
|
||||
</p>
|
||||
|
||||
<h2>Reading this site creates nothing</h2>
|
||||
<p>
|
||||
Visiting these pages, reading them, or sending the intake form does
|
||||
not appoint me and does not engage me. {NO_RETAINER_NOTICE}
|
||||
</p>
|
||||
<p>
|
||||
An appointment happens one way: terms of appointment agreed in writing
|
||||
with all parties, after a conflicts check. Until that exists, there is
|
||||
no engagement, whatever has been discussed.
|
||||
</p>
|
||||
|
||||
<h2>The fees and timings published here</h2>
|
||||
<p>
|
||||
The <a href="/fees/">rate card</a> is published in full and is the card
|
||||
I work from. It is confirmed in the terms of appointment before an engagement
|
||||
begins, and that document governs the engagement rather than this page.
|
||||
Fees are quoted before HST.
|
||||
</p>
|
||||
<p>
|
||||
The five stages on the <a href="/process/">process page</a> carry their
|
||||
own framing sentence and it is part of the statement: they are the typical
|
||||
shape of an engagement and not a commitment, because timing depends on party
|
||||
and counsel availability, which I do not control.
|
||||
</p>
|
||||
|
||||
<h2>Accuracy, and what moves</h2>
|
||||
<p>
|
||||
Several pages describe statutes, regulations, tribunal procedures and
|
||||
institutional rule sets, and each names its source. Those things
|
||||
change. Where a page states when a fact was checked, that is the date
|
||||
it was checked and not a promise that it is still true. Nothing here
|
||||
is a substitute for reading the current instrument.
|
||||
</p>
|
||||
<p>
|
||||
If you find something on this site that is wrong, I would rather know:
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>.
|
||||
</p>
|
||||
|
||||
<h2>The intake form</h2>
|
||||
<p>
|
||||
What the form collects, where it is stored, how long it is kept and
|
||||
how to have it deleted are set out in the <a href="/legal/privacy/"
|
||||
>privacy policy</a
|
||||
>. Please do not send privileged or confidential material through it.
|
||||
</p>
|
||||
|
||||
<h2>This site's own content</h2>
|
||||
<p>
|
||||
The writing, the design and the mark on these pages are mine. Quote
|
||||
from them with attribution and a link; do not republish a page whole.
|
||||
Where a page quotes an institution's own published rules, those words
|
||||
belong to that institution and are marked as quotations.
|
||||
</p>
|
||||
<p>
|
||||
Links out go to an institution's published rules and to my LinkedIn
|
||||
profile. I do not control those sites and am not responsible for what
|
||||
they say.
|
||||
</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>
|
||||
These terms can change. The date at the top moves when they do, and
|
||||
there is no archive of previous versions.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>. The site is
|
||||
{' '}{SITE.url} — {CONTACT.location}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-4xl);
|
||||
max-inline-size: 30ch;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* NOT `.reveal` — same reasoning as the privacy page: a legal document must be
|
||||
at full opacity when it renders, and a reader may arrive via Cmd-F. */
|
||||
.legal-body {
|
||||
padding-block-start: var(--space-7);
|
||||
}
|
||||
|
||||
/* The compliance sentence, set larger than the paragraph under it. Same
|
||||
treatment it gets on `/for-parties/` and `/contact/`. */
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.prose h2:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
+22
-27
@@ -25,7 +25,7 @@ import Undertaking from '../components/Undertaking.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { medArbGraph } from '../data/schema';
|
||||
import { CONDUCT_UNDERTAKINGS, DESIGNATIONS_HELD_LINE } from '../data/site';
|
||||
import { CONDUCT_UNDERTAKINGS } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
@@ -61,7 +61,7 @@ const graph = medArbGraph({
|
||||
|
||||
<BaseLayout
|
||||
title="Med-Arb · Pouya Lajevardi · What It Is and When It Fits"
|
||||
description="Med-arb is mediation that converts to arbitration if it does not resolve. What it is, how it differs from arb-med, the fairness objection, and when it fits."
|
||||
description="Med-arb is mediation that converts to arbitration if it does not resolve. What it is, that it is not arb-med, the fairness objection, and when it fits."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
@@ -213,8 +213,9 @@ const graph = medArbGraph({
|
||||
<p>
|
||||
The ADR Institute of Canada publishes <strong
|
||||
>ADRIC Med-Arb Rules</strong
|
||||
>, developed by a task force and presented to the membership at
|
||||
ADRIC's 2019 annual conference, and designed, in ADRIC's words, to <q
|
||||
>, developed by a task force and presented to the membership as a
|
||||
discussion draft at ADRIC's 2019 annual conference, and designed, in
|
||||
ADRIC's words, to <q
|
||||
>work in tandem with ADRIC's existing Mediation Rules and
|
||||
Arbitration Rules</q
|
||||
>. They were drafted for domestic commercial disputes, and ADRIC notes
|
||||
@@ -237,38 +238,32 @@ const graph = medArbGraph({
|
||||
|
||||
{/* ---- 6. Why this practice -------------------------------------------- */}
|
||||
{
|
||||
/* ⚠️ THIS SECTION MUST NOT ANSWER THE ADRIC COMPETENCE QUOTATION ABOVE IT.
|
||||
For one pass it did: the heading read "Med-arb asks one neutral to be
|
||||
competent at both" — a restatement, in this site's voice, of ADRIC's
|
||||
"requires a high level of practitioner competence" quoted in the section
|
||||
immediately above — and the designations then answered it by adjacency.
|
||||
**The source answers its own sentence with a DIFFERENT designation**: the
|
||||
same ADRIC page points at the Chartered Med-Arb and a Med-Arb Foundational
|
||||
Course, and `docs/reference/adrio-designations.md` Finding 3 calls the
|
||||
Q-level designations "an intermediate step". So the page was borrowing a
|
||||
third party's competence standard and supplying an answer that party does
|
||||
not give. `claims-auditor`, round-2 audit, 2026-08-30 — the gloss lens.
|
||||
|
||||
The heading and lede now state what this practice holds and offers, with
|
||||
no competence proposition and no bridge to the quotation above. */
|
||||
/* ⚠️ THIS SECTION MAKES NO CLAIM ABOUT COMPETENCE AND NO CLAIM ABOUT WHAT
|
||||
MED-ARB IS, AND BOTH SILENCES ARE DELIBERATE. §Rules above quotes ADRIC
|
||||
requiring "a high level of practitioner competence", and ADRIC answers its
|
||||
own sentence with the Chartered Med-Arb and a Foundational Course —
|
||||
neither held, and C.Med-Arb is struck from this site entirely (§4). So
|
||||
anything in here that reads as meeting that standard is borrowing a third
|
||||
party's bar and answering it in this site's voice. That has now been
|
||||
removed three times: from the heading (2026-08-30), from the definitional
|
||||
gloss (Pouya's ruling, 2026-09-01) and from the bare designations line
|
||||
that was left sitting directly beneath the quotation (2026-09-02).
|
||||
**Do not refill this paragraph, and do not restate a designation here** —
|
||||
/about/ publishes them, and the JSON-LD carries them. */
|
||||
}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Why this practice" level={2}>
|
||||
<span slot="heading">Both halves, in one practice.</span>
|
||||
<span slot="heading">Both processes, in one practice.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>
|
||||
{DESIGNATIONS_HELD_LINE}. Med-arb is not a third service bolted onto
|
||||
mediation and arbitration; it is the two of them run under one
|
||||
agreement, in the order the agreement sets.
|
||||
</p>
|
||||
<p>
|
||||
I accept med-arb appointments now, in commercial matters. The section
|
||||
above is the part to read before proposing one: the agreement does the
|
||||
work, and it does it before the mediation starts.
|
||||
I accept med-arb appointments now, in commercial matters. The three
|
||||
commitments above are the part to read before proposing one: the
|
||||
med-arb agreement does the work, and it does it before the mediation
|
||||
starts.
|
||||
</p>
|
||||
<p class="onward">
|
||||
<a href="/mediation/">Mediation →</a>
|
||||
|
||||
+27
-20
@@ -21,6 +21,7 @@ import { serviceGraph } from '../data/schema';
|
||||
import {
|
||||
CONDUCT_UNDERTAKINGS,
|
||||
CREDENTIAL_ROW,
|
||||
FEES,
|
||||
NEUTRAL_ROLE_LINE,
|
||||
PRACTICE_AREAS,
|
||||
} from '../data/site';
|
||||
@@ -33,11 +34,11 @@ const ldImage = await getImage({
|
||||
});
|
||||
const graph = serviceGraph({
|
||||
path: '/mediation/',
|
||||
name: 'Commercial mediation',
|
||||
name: 'Mediation',
|
||||
serviceType: 'Mediation',
|
||||
description:
|
||||
'Sole-mediator appointments in commercial disputes. Half day or full ' +
|
||||
'day, in person, by video, or shuttle.',
|
||||
'Sole-mediator appointments. Half day or full day, in person, by ' +
|
||||
'video, or shuttle.',
|
||||
imageUrl: new URL(ldImage.src, Astro.site).href,
|
||||
});
|
||||
|
||||
@@ -70,8 +71,8 @@ const FORMATS = [
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Commercial Mediation · Pouya Lajevardi · Q.Med · Toronto"
|
||||
description="Sole mediator for commercial disputes in Toronto. Q.Med through ADRIC and ADRIO. Session formats, the ADRIC National Mediation Rules, and what to bring."
|
||||
title="Mediation · Pouya Lajevardi · Q.Med · Toronto, Ontario"
|
||||
description="Sole mediator in Toronto, half day or full day. Q.Med through ADRIC and ADRIO. Session formats, the ADRIC National Mediation Rules, and what to bring."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
@@ -107,10 +108,10 @@ const FORMATS = [
|
||||
}
|
||||
<p>{NEUTRAL_ROLE_LINE}</p>
|
||||
<p>
|
||||
What I bring to a commercial file is that I read the contract and the
|
||||
technical record underneath it — the change orders, the schedule, the
|
||||
model card, the system impact assessment — rather than either side's
|
||||
summary of them.
|
||||
What I bring to a file is that I read the contract and the technical
|
||||
record underneath it — the change orders, the schedule, the model
|
||||
card, the system impact assessment — rather than either side's summary
|
||||
of them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,15 +220,18 @@ const FORMATS = [
|
||||
}
|
||||
<Undertaking>{CONDUCT_UNDERTAKINGS.mediationCaucus}</Undertaking>
|
||||
{
|
||||
/* The without-prejudice question is answered by pointing, not by
|
||||
characterising legal effect. AGENTS.md §4 bars this repository from
|
||||
concluding a proposition of law, and docs/03's `[unestablished]`
|
||||
pattern says to write around the capacity question. */
|
||||
/* WITHOUT PREJUDICE IS ATTRIBUTED TO THE AGREEMENT, NEVER ASSERTED
|
||||
AS LAW — and it may be narrowed but NOT deleted. §4 bars this
|
||||
repository from concluding a proposition of law, and no extract
|
||||
establishes the effect. But docs/01 §/mediation/ item 5 requires the
|
||||
without-prejudice framing and docs/03 keeps the term as permitted,
|
||||
so removing it breaches the spec that requires it. */
|
||||
}
|
||||
<p>
|
||||
Mediation is conducted on a without-prejudice basis. What that means
|
||||
for a particular file, and what survives it, is a question for each
|
||||
party's own counsel rather than for the neutral.
|
||||
Whether the session is without prejudice, and what that covers, is
|
||||
settled by the agreement to mediate. What being without prejudice
|
||||
means for a particular file, and what survives the session, is a
|
||||
question for each party's own counsel rather than for the neutral.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,7 +244,7 @@ const FORMATS = [
|
||||
<SectionHeading
|
||||
eyebrow="Subject matter"
|
||||
level={2}
|
||||
lede="Where a commercial mediation usually turns on something technical."
|
||||
lede="Where a mediation usually turns on something technical."
|
||||
>
|
||||
<span slot="heading">Six areas.</span>
|
||||
</SectionHeading>
|
||||
@@ -272,9 +276,12 @@ const FORMATS = [
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>
|
||||
Half day and full day, with the preparation time bundled into each one
|
||||
stated rather than folded into the hours. Additional parties, overtime
|
||||
and the cancellation schedule are on the same page.
|
||||
Half day and full day, including up to {
|
||||
FEES.mediation.halfDay.prepIncluded
|
||||
} hours of preparation in a half day and up to {
|
||||
FEES.mediation.fullDay.prepIncluded
|
||||
} in a full day. Additional parties, overtime and the cancellation schedule
|
||||
are on the rate card.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Generates every Open Graph card at build. `AGENTS.md` R15's removal trigger.
|
||||
*
|
||||
* AN ENDPOINT RATHER THAN A SCRIPT, so it cannot be forgotten: `astro build`
|
||||
* runs it, and both deploy paths run `astro build`.
|
||||
*
|
||||
* THIS IS THE ONLY PLACE THAT KNOWS ABOUT BOTH SOURCES OF CARDS — the static
|
||||
* registry and the Insights collection — so the two cannot disagree about which
|
||||
* cards exist. `SEO.astro` derives a card's URL from the same `ogCardPath()`,
|
||||
* and drafts get no card because they get no page.
|
||||
*/
|
||||
import type { APIRoute, GetStaticPaths } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { OG_CARDS, articleCard, ogSlug } from '../../data/og-cards';
|
||||
import { renderOgCard, type OgCard } from '../../lib/og-card';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const articles = await getCollection('insights', ({ data }) => !data.draft);
|
||||
|
||||
const staticCards = Object.entries(OG_CARDS).map(([pathname, card]) => ({
|
||||
params: { slug: ogSlug(pathname) },
|
||||
props: { card },
|
||||
}));
|
||||
|
||||
// `articleCard` lives in `src/data/og-cards.ts` so this file holds no headline
|
||||
// literal — see that function for the defect that put it there.
|
||||
const articleCards = articles.map((entry) => ({
|
||||
params: { slug: ogSlug(`/insights/${entry.id}/`) },
|
||||
props: { card: articleCard(entry.data.title) satisfies OgCard },
|
||||
}));
|
||||
|
||||
return [...staticCards, ...articleCards];
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
export const GET: APIRoute = async ({ props }) => {
|
||||
const { card } = props as { card: OgCard };
|
||||
const body = await renderOgCard(card);
|
||||
return new Response(new Uint8Array(body), {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
// Dev-server only; production caching is set by `scripts/deploy-local.sh`.
|
||||
// ⚠️ It deliberately does NOT match production, and a previous comment
|
||||
// here claimed it did: the deploy script's pass 2 serves images at
|
||||
// `max-age=604800`, not `31536000, immutable`, and `immutable` would be
|
||||
// wrong for a filename that is not content-hashed — a card's path is
|
||||
// derived from its page, so replacing one reuses the URL.
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -37,7 +37,7 @@ import PracticeCard from '../components/PracticeCard.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { practiceIndexGraph } from '../data/schema';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { NEUTRAL_ROLE_LINE, PRACTICE_AREAS } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
@@ -46,7 +46,18 @@ const ldImage = await getImage({
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = practiceIndexGraph(new URL(ldImage.src, Astro.site).href);
|
||||
/* THE PERSON NODE AND NOTHING ELSE, and the reason is about this page rather
|
||||
than about graph shape — which is why it lives here now and not in
|
||||
`schema.ts`. NO `Service` NODES FOR THE SIX AREAS (each area page emits its
|
||||
own), NONE FOR THE "also offered" STRIP, AND NO `CollectionPage`. The strip
|
||||
is the constraint to keep: all three are PUBLISHABLE (§4, Q46(a)) but gate 1
|
||||
on each reads `[Pouya's stated basis]`, not `[verified]`, and ENE is §4's
|
||||
"offering nearest the NOT-NEGOTIABLE boundary" — a visible strip read in
|
||||
context is not the same claim as a machine-readable service type a crawler
|
||||
lifts alone. Adding any of them needs docs/04 changed first.
|
||||
|
||||
No `BreadcrumbList`: one hop from the root, no visible trail. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/* THREE, NOT FOUR. Each has a §4 Offerings row reading PUBLISHABLE (Q46(a),
|
||||
2026-08-28). Do not add a fourth without a row, and do not restore the one
|
||||
@@ -69,7 +80,7 @@ const ALSO_OFFERED = [
|
||||
|
||||
<BaseLayout
|
||||
title="Practice Areas · Pouya Lajevardi · Mediation and Arbitration"
|
||||
description="Six areas where a commercial dispute usually turns on something technical: construction, technology, energy, insurance, shareholder, and cross-border matters."
|
||||
description="Six areas where a dispute usually turns on something technical: construction, technology, energy, insurance, shareholder, and cross-border matters."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
@@ -78,10 +89,10 @@ const ALSO_OFFERED = [
|
||||
<Eyebrow dot>Practice</Eyebrow>
|
||||
<h1 class="display hero-h">Six areas, one reason.</h1>
|
||||
<p class="hero-lede">
|
||||
Each of these is a place where a commercial dispute turns on a document
|
||||
somebody has to actually read — a subcontract, a system impact
|
||||
assessment, a processing agreement, a benefits file. The process is
|
||||
mediation or arbitration. The area is the context it runs in.
|
||||
Each of these is a place where a dispute turns on a document somebody
|
||||
has to actually read — a subcontract, a system impact assessment, a
|
||||
processing agreement, a benefits file. The process is mediation or
|
||||
arbitration. The area is the context it runs in.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -181,7 +192,7 @@ const ALSO_OFFERED = [
|
||||
<div class="prose">
|
||||
<p>
|
||||
The area pages describe the disputes. These describe the process the
|
||||
parties are choosing between, in commercial matters.
|
||||
parties are choosing between.
|
||||
</p>
|
||||
<p class="onward">
|
||||
<a href="/mediation/">Mediation →</a>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
/**
|
||||
* `/process/` — build step 6. Spec: docs/01 §`/process/`, docs/03 §Process.
|
||||
*
|
||||
* ⚠️ `PROCESS_FRAMING` IS THE CONDITION ON WHICH THE FIVE TIMINGS MAY BE
|
||||
* PUBLISHED AT ALL — Q43, Pouya 2026-08-27 — and it renders ADJACENT TO THE
|
||||
* NUMBERS, never in the section lede above them: a reader who scans the strip
|
||||
* and skips the lede has read a commitment. `/` renders the same constant under
|
||||
* the same steps, so neither page can drift from the other.
|
||||
*
|
||||
* ⚠️ THE TWO UNDERTAKINGS HERE ARE RENDERED, NEVER RETYPED — §4's conduct
|
||||
* class, Q54. Softening one is a change to a published commitment, not a copy
|
||||
* edit, and nothing in a build fails when a promise gets smaller.
|
||||
*
|
||||
* ⚠️ AND NOTHING ON THIS PAGE MAY ADD A SEVENTH. §4's gate: an undertaking is
|
||||
* publishable only where Pouya has made it **in terms** — not "would obviously
|
||||
* agree to", not "follows from the process". That is why §Conflicts says when
|
||||
* the check happens and what it needs, and then stops — Q57, closed 2026-08-31.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import ProcessStep from '../components/ProcessStep.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import Undertaking from '../components/Undertaking.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import {
|
||||
CONDUCT_UNDERTAKINGS,
|
||||
MEMBERSHIP_ORGS,
|
||||
PROCESS,
|
||||
PROCESS_FRAMING,
|
||||
} from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="The Shape of an Engagement · Pouya Lajevardi · Toronto"
|
||||
description="The five steps from the intake call to minutes of settlement or an award: timing, conflicts, confidentiality, and what happens if a matter does not settle."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Process</Eyebrow>
|
||||
<h1 class="display hero-h">The shape of an engagement.</h1>
|
||||
<p class="hero-lede">
|
||||
Booking a neutral commits a client to a date, a cost and a process. This
|
||||
is the whole of it: the five steps, what each one needs from the
|
||||
parties, and what happens if the matter does not settle.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The five steps ---------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Step by step"
|
||||
level={2}
|
||||
lede="Intake to conclusion, with the timing each step usually takes."
|
||||
>
|
||||
<span slot="heading">Five steps.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
<ol class="steps" role="list">
|
||||
{
|
||||
PROCESS.map((step, i) => (
|
||||
<ProcessStep n={i + 1} title={step.title} timing={step.timing}>
|
||||
{step.body}
|
||||
</ProcessStep>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
|
||||
{
|
||||
/* Q43, and it is not a disclaimer: it is the condition on which the
|
||||
numbers above may appear. Directly under them, never in the lede. */
|
||||
}
|
||||
<p class="process-framing">{PROCESS_FRAMING}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. Conflicts --------------------------------------------------- */}
|
||||
{
|
||||
/* THE SECTION STOPS AT WHAT THE CHECK NEEDS, AND THE STOP IS THE ANSWER —
|
||||
Q57, closed 2026-08-31. A sentence naming the outcome would be a seventh
|
||||
conduct undertaking, which §4's gate does not permit this repository to
|
||||
infer. Do not add one. */
|
||||
}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Conflicts" level={2}>
|
||||
<span slot="heading">Checked at step one.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>
|
||||
Conflicts are run on the intake call, before anything is agreed. The
|
||||
check needs the full legal name of every party, the parent or
|
||||
affiliate actually behind the dispute where that is a different
|
||||
entity, and the names of counsel on each side.
|
||||
</p>
|
||||
<p>
|
||||
Send those with the request rather than at the call. An intake call
|
||||
that stops to collect names is an intake call that gets repeated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 4. Confidentiality --------------------------------------------- */}
|
||||
<section class="section section-inverse reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Confidentiality" level={2}>
|
||||
<span slot="heading">What stays in the room.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
{
|
||||
/* SOURCED, NOT ASSERTED. An earlier draft read "confidentiality is
|
||||
settled in the terms of appointment at step two" — a claim about
|
||||
what MY engagement documents contain, which §4 does not row and
|
||||
which no reader could check. What replaced it is a quotation from an
|
||||
artefact committed to this repository
|
||||
(`docs/reference/adr-institution-names.md`, retrieved 2026-08-29,
|
||||
with its URL) plus a membership §4 verifies.
|
||||
|
||||
AND THE SENTENCE AROUND THE QUOTE STAYS INSIDE IT — the gloss lens,
|
||||
which caught a second draft here. It read "on this it is ONE
|
||||
SENTENCE", a completeness claim about a third party's Code that the
|
||||
extract cannot support: the extract is a scrape of one web page
|
||||
carrying three quoted lines, and eighteen lines above it records a
|
||||
SEPARATE instrument — ADRIC's Code of Conduct for Mediators — that
|
||||
bears on the same subject. The artefact cited as the source supplied
|
||||
the counter-example. Same shape as the LAT ellipsis and the IESO
|
||||
"connection allocation". It now says only "it says".
|
||||
|
||||
The Code establishes an obligation on members. It does NOT establish
|
||||
what my appointment terms say, what any rule set provides, or that
|
||||
the undertaking below is required by it. That undertaking is
|
||||
introduced as mine, which is what it is.
|
||||
|
||||
THE MEMBERSHIP IS RENDERED FROM `MEMBERSHIP_ORGS`, not typed. R10's
|
||||
re-confirm trigger sweeps the constants; a hand-typed copy here
|
||||
would keep asserting the membership after a lapse, which is the OCNI
|
||||
shape R10 was written from. */
|
||||
}
|
||||
<p>
|
||||
I am a member of the {MEMBERSHIP_ORGS[0]}. Its <a
|
||||
href="https://adric.ca/rules-codes/code-of-ethics/"
|
||||
>Code of Ethics</a
|
||||
> applies to every member, and it says: <q
|
||||
>A Member shall be faithful to the relationship of trust and
|
||||
confidentiality inherent in the office of arbitrator or mediator.</q
|
||||
>
|
||||
</p>
|
||||
<p>That is the obligation. This is mine, in a mediation:</p>
|
||||
</div>
|
||||
<Undertaking>{CONDUCT_UNDERTAKINGS.mediationCaucus}</Undertaking>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 5. If it does not settle --------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="The other outcome"
|
||||
level={2}
|
||||
lede="The question counsel ask before committing a client's day."
|
||||
>
|
||||
<span slot="heading">If it does not settle.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>
|
||||
Then it does not settle, and that is an outcome the day is built to
|
||||
reach cleanly rather than a failure of it. What gets recorded is what
|
||||
the parties agree to record — the issues that closed and the ones that
|
||||
did not. A matter narrowed to two questions is worth having even when
|
||||
it is not what you came for.
|
||||
</p>
|
||||
<p>
|
||||
Where parties want a binding end if the mediation does not produce
|
||||
one, that is med-arb — and it is agreed at the start rather than
|
||||
reached for at the end:
|
||||
</p>
|
||||
</div>
|
||||
<Undertaking>{CONDUCT_UNDERTAKINGS.medArbSwitch}</Undertaking>
|
||||
<div class="cta">
|
||||
<Button href="/med-arb/" variant="gold">How med-arb runs →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ONE PER ROW, unlike `/`'s compressed strip. This page is the full account,
|
||||
so each step gets the width to be read rather than scanned. */
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Same treatment as `/`, deliberately: one condition, one look, so a reader
|
||||
who has seen it on the home page recognises it here. */
|
||||
.process-framing {
|
||||
margin-block-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin-block-start: var(--space-7);
|
||||
}
|
||||
</style>
|
||||
+55
-3
@@ -121,10 +121,33 @@ html {
|
||||
/* The header is sticky from 66rem up, and `scroll-padding-top` has to clear it
|
||||
or "Skip to content" drops the reader behind it — the one control that exists
|
||||
specifically for keyboard users, landing them on content they cannot see.
|
||||
--header-h is defined in tokens.css beside the value it has to match. */
|
||||
`--header-h` is a FLOOR at the default text size, so the `max()` ramp is what
|
||||
carries the cases where the masthead reflows taller (`AGENTS.md` Q61).
|
||||
|
||||
⚠️ THREE THINGS HERE ARE LOAD-BEARING AND EACH BREAKS SILENTLY.
|
||||
|
||||
1. `1lh` and not `1rem`/`1em`. Chrome's minimum-font-size setting enlarges
|
||||
text while `rem` keeps resolving at 16px; the font-metric units read the
|
||||
USED size and track it. `rem` here measures 97px against a 270.56px
|
||||
header — no error, no warning, focus behind the header.
|
||||
2. The plain declaration comes FIRST and is not redundant. An engine without
|
||||
`lh` discards the whole `max()` as invalid, and the property then falls
|
||||
back to `--space-4` = 16px, which is worse than no fix at all.
|
||||
3. `1lh` on `<html>` is immune to the `font-display: swap` window ONLY
|
||||
because `<html>` keeps the UA font family — `--font-sans` is set on
|
||||
`body`. Moving the family up to `html` makes this offset depend on whether
|
||||
a webfont has arrived. Do not.
|
||||
|
||||
One case is still short: fallback metrics with a seventh nav item, gated by a
|
||||
build failure in `SiteHeader` (`AGENTS.md` R20). `docs/02` §Reflow has the
|
||||
measurements. */
|
||||
@media (min-width: 66rem) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--header-h) + var(--space-4));
|
||||
scroll-padding-top: max(
|
||||
calc(var(--header-h) + var(--space-4)),
|
||||
calc(10lh - 83px)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,15 +223,17 @@ h4 {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The class is the type treatment; `Eyebrow.astro` is the label component. A real
|
||||
heading may carry the class — the footer's columns do — but an eyebrow above a
|
||||
heading is never itself an <h*>, which is what the component enforces. */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
/* An eyebrow is a label, never the page's heading element. */
|
||||
.eyebrow .dot {
|
||||
display: inline-block;
|
||||
inline-size: 6px;
|
||||
@@ -391,6 +416,23 @@ a:hover {
|
||||
.section-accent :focus-visible {
|
||||
outline-color: var(--gold-l);
|
||||
}
|
||||
/* AND THE LINK COLOUR, for the same reason and measured the same way. `--link`
|
||||
is --maroon-l, documented at 8.95:1 ON CREAM and never overridden for an
|
||||
inverse ground: on --ink it measures 1.88:1, and :hover (--maroon-d) 1.10:1.
|
||||
That is WORSE than the gold-on-cream 2.10:1 this project treats as the defect
|
||||
that must never ship. `SiteFooter` sets its own `.footer-col a` colour, which
|
||||
is the only reason this had not fired before — `/process/` shipped the site's
|
||||
first body link on a dark band. Values: gold-l 11.09:1 on ink, cream 16.81:1.
|
||||
Here rather than in the page, because the next inverse-ground link hits the
|
||||
same hole. Found by `adversarial-reviewer`, 2026-08-30. */
|
||||
.section-inverse a:not(.btn),
|
||||
.section-accent a:not(.btn) {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.section-inverse a:not(.btn):hover,
|
||||
.section-accent a:not(.btn):hover {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
/* The eyebrow's dot is a --accent (maroon) box, so recolouring only the TEXT
|
||||
for an inverse ground leaves the dot at 1.21:1 on ink and 1.00:1 on the
|
||||
accent band — present in the markup, invisible on the page. Colour never
|
||||
@@ -407,6 +449,16 @@ a:hover {
|
||||
.section-accent {
|
||||
--pill-border: var(--line-dark);
|
||||
--pill-fg: var(--text-inverse-2);
|
||||
/* `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);
|
||||
--btn-ghost-border-hover: var(--text-inverse-2);
|
||||
--btn-gold-border: var(--line-dark);
|
||||
/* `DefinitionGrid`'s <dt>. Added 2026-08-29: --text-meta is --muted, which
|
||||
tokens.css marks ON CREAM ONLY (3.07:1 on ink), and `/practice/` is the
|
||||
first page to put that component on an inverse ground. */
|
||||
|
||||
+37
-12
@@ -64,10 +64,23 @@
|
||||
|
||||
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
|
||||
end (1.25 → 1.333) so headlines scale harder than body copy.
|
||||
--text-2xs is the eyebrow floor docs/02 sets at 11px. Added 2026-08-27:
|
||||
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
|
||||
--text-2xs: 0.6875rem; /* 11 — eyebrow */
|
||||
--text-2xs HAS EXACTLY ONE CONSUMER: the header tagline in SiteHeader, held
|
||||
there for a measured layout reason recorded beside it. It is the smallest
|
||||
type on the site and nothing else may use it without a measurement — the
|
||||
three other places that did (the /contact/ labels, the /contact/ dl terms,
|
||||
the /bio/ sheet) all moved to --text-eyebrow on 2026-08-31 because none of
|
||||
them had a reason beyond having been written that way. Added 2026-08-27:
|
||||
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
|
||||
--text-2xs: 0.6875rem; /* 11 — label */
|
||||
--text-xs: 0.75rem; /* 12 — legal */
|
||||
/* THE SAME VALUE AS `--text-sm` BELOW, AND DELIBERATELY NOT AN ALIAS OF IT.
|
||||
Uppercase mono at 0.18em reads smaller than it measures, so the eyebrow
|
||||
needs the top of the small range rather than a rung of its own — but it and
|
||||
body-meta type move for different reasons, and aliasing would mean a future
|
||||
change to meta text silently moved every eyebrow on the site. Pouya raised
|
||||
it 12 -> 13 -> 14 px on 2026-08-31; 13 px was still getting lost against the
|
||||
display type. */
|
||||
--text-eyebrow: 0.875rem; /* 14 — eyebrow */
|
||||
--text-sm: 0.875rem; /* 14 — meta */
|
||||
--text-base: 1rem; /* 16 — body */
|
||||
--text-lg: clamp(1.0625rem, 0.99rem + 0.32vw, 1.1875rem); /* 17→19 */
|
||||
@@ -124,15 +137,27 @@
|
||||
|
||||
/* --- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Sticky-header height at >= 66rem, where the header IS sticky. global.css
|
||||
drives `scroll-padding-top` off this, so the skip link does not drop the
|
||||
reader behind the header. If SiteHeader's padding or nav sizing changes,
|
||||
re-measure and change this with it — one fact living in two files.
|
||||
[measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six
|
||||
nav items and with a seventh injected. 81px at every one: 32 padding + 48
|
||||
reserved brand block + the 1px bottom border, which is easy to forget and
|
||||
is why this is measured rather than added up. The brand reserves 48px so
|
||||
the height does not change when the tagline appears at 76rem] */
|
||||
/* ⚠️ A FLOOR, NOT A CONSTANT: the sticky header's height AT THE DEFAULT TEXT
|
||||
SIZE. Above the default the masthead reflows and is deliberately taller, which
|
||||
is why the nav no longer runs off-screen. `global.css` drives
|
||||
`scroll-padding-top` off it as the LOWER BOUND of a `max()` ramp — the ramp,
|
||||
not this token, is what covers the settings where the masthead reflows taller
|
||||
(`AGENTS.md` Q61, fixed 2026-09-01). One case remains short and is gated by a
|
||||
build failure rather than by this value: `AGENTS.md` R20. If SiteHeader's
|
||||
padding or nav sizing changes, re-measure and change this with it — one fact
|
||||
living in two files.
|
||||
⚠️ **AND THERE IS A THIRD FILE, WHICH IS THE ONE A READER MISSES.**
|
||||
`global.css`'s ramp is `max(calc(var(--header-h) + var(--space-4)),
|
||||
calc(10lh - 83px))`, and that `83px` is fitted so the second term equals the
|
||||
first at the default text size — 97 px both ways, measured. **Change this
|
||||
token and the 83 px moves with it**, or the ramp stops being a no-op at
|
||||
normal settings and starts changing the shipped rendering.
|
||||
[measured 2026-08-26, re-measured 2026-09-01 — headless Chrome, six nav items
|
||||
and a seventh injected. 81.00px at all EIGHT widths from 1056 to 1920px
|
||||
(1056/1100/1216/1240/1280/1440/1600/1920): 32 padding + 48 reserved brand
|
||||
block + the 1px bottom border, which is easy to forget and is why this is
|
||||
measured rather than added up. The brand reserves 48px so the height does not
|
||||
change when the tagline appears at 76rem] */
|
||||
--header-h: 5.0625rem; /* 81 — measured, not chosen */
|
||||
|
||||
--width-content: 80rem; /* 1280 */
|
||||
|
||||
Reference in New Issue
Block a user