Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f2d2eeb04 | ||
|
|
9f2d83c32f | ||
|
|
210bc25a26 |
@@ -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
|
||||
|
||||
@@ -140,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
|
||||
|
||||
@@ -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,54 @@ 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" 2>/dev/null || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
|
||||
echo "The site is deployed and the contact form is unverified."
|
||||
echo "See docs/06-deployment.md's cutover checklist."
|
||||
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||
echo "POST /api/intake -> 303 -> $location (routed, validating)"
|
||||
else
|
||||
echo "WARNING: POST /api/intake returned $code, expected 303 to"
|
||||
echo "/contact/could-not-send/; redirect was '${location:-none}'."
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can"
|
||||
echo "mean the same thing, or the handler refusing the Origin."
|
||||
echo "See docs/06-deployment.md's cutover checklist."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -703,9 +703,10 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Framework | **Astro 7.2.9**, `output: 'static'` `[verified 2026-08-27 — npm view astro version, D1 as amended]`. `@astrojs/mdx` 7.0.8, `@astrojs/sitemap` 3.7.3, `sharp` 0.35.4 — all three at `latest`. Bumped from 7.2.7 at the step 1 → step 2 phase boundary under R11: 7.2.8 published 2026-08-26 and 7.2.9 on 2026-08-27, i.e. **two patches appeared inside 48 hours**, which is the argument for checking at boundaries rather than on failure. `engines` unchanged at `node >=22.12.0`, `npm >=9.6.5` `[verified 2026-08-27 — npm view astro@7.2.9 engines]` |
|
||||
| **TypeScript — HELD at 6.x, and the hold is a peer constraint rather than a preference** | Pinned `^6.0.3`; `npm view typescript version` returns **7.0.2** `[verified 2026-08-31 — R11, step 6 → 7 phase boundary; 13 of 14 pins current, this the only hold]`. **The removal trigger was re-checked rather than recalled:** `npm view @astrojs/check@0.9.10 peerDependencies` still returns `{ typescript: '^5.0.0 || ^6.0.0' }`, so the hold stands. ⚠️ **AND THERE ARE TWO GATES, NOT ONE — corrected 2026-08-31, and this row named only the first for two days.** `npm view typescript-eslint peerDependencies` returns `{ typescript: '>=4.8.4 <6.1.0' }`, which is **tighter**: widening `@astrojs/check` alone would not lift the hold, so the trigger as recorded was unreachable. **A second consequence, live:** the pin is a caret, so if a 6.1.x ever ships, a plain `npm install` moves the tree into a peer-range breach with nothing in the repo saying so. Nothing is breached today — `npm ls typescript` resolves **6.0.3**, which is the highest stable 6.x on the registry. Found by running R11's own check rather than reading the row. **One major behind, which is the exact shape D1 was amended over — so the reason is recorded rather than the hold being silent.** `@astrojs/check@0.9.10` declares `peerDependencies: { typescript: '^5.0.0 \|\| ^6.0.0' }` `[verified 2026-08-29 — npm view @astrojs/check@0.9.10 peerDependencies]`, and `npm run check` is `astro check`, which is the type gate the deploy path runs before it builds. **Removal trigger: `@astrojs/check` AND `typescript-eslint` both widen to include 7** — either alone is not enough. Re-check at every phase boundary under R11 — this is a hold on a dependency's schedule, not on a judgement, so it lifts without a decision |
|
||||
| **TypeScript — HELD at 6.x, and the hold is a peer constraint rather than a preference** | Pinned `^6.0.3`; `npm view typescript version` returns **7.0.2** `[verified 2026-08-31 — R11, re-run at the step 10 → 11 boundary after the run added five dependencies: **18 of 19 pins current**, this the only hold, `npm audit` 0 vulnerabilities. The earlier run that day, at the step 6 → 7 boundary, read 13 of 14]`. **The removal trigger was re-checked rather than recalled:** `npm view @astrojs/check@0.9.10 peerDependencies` still returns `{ typescript: '^5.0.0 || ^6.0.0' }`, so the hold stands. ⚠️ **AND THERE ARE TWO GATES, NOT ONE — corrected 2026-08-31, and this row named only the first for two days.** `npm view typescript-eslint peerDependencies` returns `{ typescript: '>=4.8.4 <6.1.0' }`, which is **tighter**: widening `@astrojs/check` alone would not lift the hold, so the trigger as recorded was unreachable. **A second consequence, live:** the pin is a caret, so if a 6.1.x ever ships, a plain `npm install` moves the tree into a peer-range breach with nothing in the repo saying so. Nothing is breached today — `npm ls typescript` resolves **6.0.3**, which is the highest stable 6.x on the registry. Found by running R11's own check rather than reading the row. **One major behind, which is the exact shape D1 was amended over — so the reason is recorded rather than the hold being silent.** `@astrojs/check@0.9.10` declares `peerDependencies: { typescript: '^5.0.0 \|\| ^6.0.0' }` `[verified 2026-08-29 — npm view @astrojs/check@0.9.10 peerDependencies]`, and `npm run check` is `astro check`, which is the type gate the deploy path runs before it builds. **Removal trigger: `@astrojs/check` AND `typescript-eslint` both widen to include 7** — either alone is not enough. Re-check at every phase boundary under R11 — this is a hold on a dependency's schedule, not on a judgement, so it lifts without a decision |
|
||||
| Lint toolchain | ESLint **10.9.1**, `@eslint/js` 10.0.1, `globals` 17.11.0, `eslint-plugin-astro` **3.1.0**, `eslint-plugin-jsx-a11y` 6.10.2, `typescript-eslint` 8.68.0, `typescript` **6.0.3** — **every pin at the registry's `latest` except `typescript`** `[verified 2026-08-30 — npm view, all 14 pins, R11 at the step 5 → 6 boundary; thirteen current, one held]`. `@eslint/js` and `globals` are declared explicitly; before 2026-08-26 `eslint.config.js` imported them and they resolved only by npm hoisting accident. **Accessibility linting is on** — `flat/jsx-a11y-recommended`, 36 rules `[verified 2026-08-26 — 7 rules fired on a deliberately inaccessible .astro file]`. `eslint-plugin-jsx-a11y@6.10.2` declares a stale `eslint ^3..^9` peer range; a one-line `overrides` entry in `package.json` resolves it. ⚠️ **`eslint-plugin-astro@3.1.0` declares `node ^22.22.3 \|\| ^24.16.0 \|\| >=26.3.0`**, which excludes Pouya's Node 25.6.0 — `npm install` prints EBADENGINE there. Dev-time only, and `nvm use` (Node 22 LTS, per `.nvmrc`) clears it. **`typescript` — the hold, and it was too wide by a whole major until 2026-08-27.** `latest` is **7.0.2** and it is unusable here: `typescript-eslint@8.68.0` peers `typescript >=4.8.4 <6.1.0` and `@astrojs/check@0.9.10` peers `^5.0.0 \|\| ^6.0.0`, so taking 7 breaks **both** the linter and `astro check` `[verified 2026-08-27 — npm view <pkg> peerDependencies]`. **But this row previously read "held at 5.x", and 6.0.3 is a stable release both peers accept** — so the pin sat a full major behind for no reason the record could name, in a row whose whole job is to name the reason. Now at **6.0.3**, the newest version compatible with both peers; `npx tsc --version` reports 6.0.3 and the full gate is green `[verified 2026-08-27]`. The 6.x listing is mostly `-dev` and `-beta` tags; **6.0.2 and 6.0.3 are the only stable 6.x releases**, which is why `npm view typescript version` (7.0.2) is not the number to pin against here. Re-check at the next phase boundary: the hold ends when `typescript-eslint` widens its peer range past `<6.1.0` |
|
||||
| Lighthouse CI | **Not installed, and that is now stated wherever the check is claimed.** `@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`), 0.15.1 is `latest` so there was no clean upgrade, and it cannot run at all — no pages, no `lighthouserc`. Removed 2026-08-26; **`npm audit` now reports 0 vulnerabilities** `[verified 2026-08-26 — npm audit]`. Removing it left a **documented control that no longer exists** — `docs/04`, `CLAUDE.md` and `/build` Phase 5 all required a run that had silently become impossible, which is Q22's defect in a different costume. Fixed 2026-08-26: the unavailability is stated in `docs/04-seo-spec.md` (budget table, the callout under Performance, and the post-launch checklist), `CLAUDE.md` (performance budget and the definition of done), `.claude/commands/build.md` Phase 5, `docs/06-deployment.md` (PR checks and the cutover checklist), and `.claude/agents/adversarial-reviewer.md` §4 — which is also told **not** to raise the absence as a finding. **Re-add at build step 7** with a pin verified that day and a check for a patched release; do not assume 0.15.1 is still the ceiling (R11) |
|
||||
| OG card generator | **`satori` 0.33.4 + `sharp`** (already a dependency), plus **`@fontsource/geist` 5.3.0** and **`@fontsource/instrument-serif` 5.3.0** as devDependencies — all four at `latest`, `npm audit` **0 vulnerabilities** with them in the tree `[verified 2026-08-31 — npm view, npm audit]`. Added at build step 7b; discharges R15. Chosen over `astro-og-canvas` 0.13.0 (also 0 vulnerabilities): `sharp` was already here to rasterise satori's SVG, so it is one library rather than a CanvasKit wasm blob, and it renders the site's real fonts and tokens instead of approximating them. ⚠️ **THE FONT PACKAGES ARE A WORKAROUND AND THE REASON IS MEASURED, NOT ASSUMED.** satori parses TTF/OTF/WOFF and **not WOFF2**, and decompressing this site's own `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 the variable font's `fvar` table points at. `@fontsource/*` ships static 400 cuts as `.woff`, which satori reads directly: same typefaces, same upstream 5.3.0 that `docs/reference/fonts-provenance.md` records for the committed files, same weight. **Build-time only — no visitor fetches these**, and they are not covered by that file's provenance table because they are not served. ⚠️ **AND `src/lib/og-card.ts` RESOLVES ITS ASSETS FROM `process.cwd()`, NOT `import.meta.url`.** With `import.meta.url` the build fails on `ENOENT .../dist/.prerender/chunks/../styles/tokens.css`: Astro bundles that module into `dist/.prerender/chunks/`, so `import.meta.url` is the CHUNK's location. **It works under `astro dev` and dies at build** — the third defect of that shape in this run, alongside the `animation-timeline` minifier fold and Prettier's `{/* */}` → `{/_ _/}` rewrite. **The card palette is parsed out of `src/styles/tokens.css`** rather than duplicated, and a missing token throws rather than falling back to a wrong colour |
|
||||
| Lighthouse | **INSTALLED AND RUNNING AGAIN — `npm run lighthouse`, build step 7a, 2026-08-31. `lighthouse@13.4.1` + `chrome-launcher@1.2.1`, both `latest`, `npm audit` 0 vulnerabilities with them in the tree** `[verified 2026-08-31 — npm view, npm audit]`. ⚠️ **THIS ROW'S OWN ADVISORY ATTRIBUTION WAS WRONG, AND THE WRONG ATTRIBUTION IS WHY THE BUDGET WAS UNMEASURABLE FOR FIVE DAYS.** It read: *"`@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`)"*. The first clause is true; **the parenthesis is not**, and it is the parenthesis an implementer reads. Measured from two probe lockfiles rather than recalled: `@lhci/cli@0.15.1` carries 10 findings (7 high) and **pins `lighthouse` 12.6.1**; the two high carriers are **`tmp@0.1.0`, a DIRECT dependency of `@lhci/cli` itself**, and **`extract-zip@2.0.1` via `@puppeteer/browsers`**. In `lighthouse@13.4.1`'s 109-package tree **both are ABSENT** and `npm audit` returns **0**. **So Lighthouse was never the carrier.** `@lhci/cli` is still `latest` at 0.15.1 and still carries all ten `[verified 2026-08-31]` — the tool that was unusable stayed unusable, and the one the budget actually needs was clean the whole time. `npm view` alone would not have found this; it took the lockfiles. **DELIBERATE DEVIATION FROM R11's LITERAL TRIGGER, recorded as one.** R11 says *"at step 7, put `@lhci/cli` back"*. It is not back. **What that costs, stated rather than summarised away:** `lhci autorun`'s declarative assertion config, the LHCI server, and CI upload/history. `scripts/lighthouse.mjs` replaces the first with an inline threshold and does not replace the other two — there is no run history, so a slow regression across builds is invisible; only an absolute breach fires. **AND IT IS A LOCAL GATE, NOT A CI CHECK.** Standalone Lighthouse drives an installed browser; the `act_runner` is not registered at all (Q23) and would need Chrome on the host. So: `npm run lighthouse` at a keyboard, plus a blocking item on `docs/06`'s cutover checklist. It is **not** wired into `npm run build` or either deploy path, and `docs/06` now says why in those terms — a check described as running where it cannot is Q22's defect in a different costume, which is the same trap this row just came out of from the other side. **Pages are enumerated from `dist/`, never listed** — a hand-written page list stops covering the site the first time a page is added. ⚠️ **THE ACCESSIBILITY CATEGORY IS MEASURED WITH `prefers-reduced-motion` FORCED, and the qualification must travel with the number.** Measured twice per condition on `/process/`: motion on → **96**, `color-contrast` failing on **24 nodes**; motion off → **100**, 0 nodes. The 24 were the scroll-driven reveal caught mid-flight — axe reported foregrounds such as `#d0cbc4` on `#f8f4ed` and **neither value is in this palette**; they are the real colours blended toward the background by an in-progress `opacity` keyframe. A category that reports 24 known-false nodes on ten of fourteen pages cannot surface the twenty-fifth real one. The reduced-motion rendering is not synthetic: it is the branch `global.css` ships for a real user setting, and the one in which every element sits at its final colour. **Performance is measured on a default profile**, where the animation runs. ⚠️ **AND THE RUNNER'S FIRST VERSION SET `cache-control: no-store` ON ITS OWN SERVER, WHICH FAILED THE `bf-cache` AUDIT ON EVERY PAGE** — *"pages whose main resource has cache-control:no-store cannot enter back/forward cache"*. Lighthouse resets storage between runs by default, so the header bought nothing and cost an audit, in a report whose whole job is to find defects. Removed; verified by toggling the one header, twice each way. **Both of these are the same rule: a measurement is a claim about your instrument.** **Baseline, 14 pages, 2026-08-31:** performance 99–100, accessibility 100, best-practices 100, SEO 100, CLS 0.000 on every page. One budget note — **`/` LCP 2.03 s against `docs/04`'s < 2.0 s**, and the LCP element is `p.hero-lede`, **text**, not the portrait `[verified 2026-08-31 — lcp-breakdown-insight]`. So `fetchpriority="high"` on the hero image would misdirect priority away from the Geist face that actually gates it; the remaining lever is `BaseLayout`'s three-face preload, which is a documented measured trade. Left alone deliberately, not overlooked |
|
||||
| Node | **22 LTS line**, `.nvmrc` holds `22` so it tracks the latest 22.x rather than freezing at a floor. `package.json` declares **`engines.node` = `^22.13.0 || >=24`** (verbatim — it excludes Node 23, an EOL odd line, which a flat `>=22.13.0` would have admitted) and **`engines.npm >=9.6.5`** — the intersection of every dependency's own floor: Astro 7 and `@astrojs/mdx` 7 need `>=22.12.0`, ESLint 10 needs `^20.19.0 \|\| ^22.13.0 \|\| >=24` `[verified 2026-08-26 — npm view astro@7.2.7 engines, npm view eslint@10.9.1 engines]`. **Pouya's machine reports Node v25.6.0** `[verified 2026-08-26 — node -v]`, an odd-numbered non-LTS line; it satisfies the floor, but `nvm use` keeps local and CI on the same runtime |
|
||||
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
|
||||
| Known HTML-conformance error | **Astro's `<Picture>` + `densities` emits a duplicate-density `srcset`, and it is upstream of this repo.** The pattern is `url, url 1x, url 2x, url 3x` — the first candidate carries no descriptor and therefore defaults to `1x`, so two candidates claim the same density, which puts the document in error per the HTML spec. **Behaviour is benign** and selection is correct at DPR 1, 2 and 3 `[verified 2026-08-27 — measured in headless Chrome]`. It affects **4 of the 12 `srcset` attributes on `/`** — the `<source>` elements of every `<Picture>` using `densities`, i.e. the three `InfinityMark` instances. The hero portrait uses `widths` + `sizes` and emits clean `w` descriptors. **Consequence for the record: do not claim "HTML validator clean" for any page carrying a mark**, and do not re-open this as a defect in this repo — it is `<Picture>`'s output, not ours. It would go if `InfinityMark` moved to `widths` + `sizes`, which would mean every call site supplying a `sizes` string for a fixed-size decorative image; not worth it for a conformance nit. Recorded on `adversarial-reviewer`'s request, 2026-08-27; first raised as step-1 review finding #8 |
|
||||
@@ -771,7 +772,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
||||
|
||||
| # | Question | Blocks |
|
||||
|---|---|---|
|
||||
| **Q59** | **WHERE DOES THE OVERTIME HOUR START?** `docs/07`'s card carries *"Overtime, per hour — $500"* and has never said what it is overtime **to**. Q58's ruling settled the two allowances (up to 3 h / 6 h of session, up to 2 h / 3 h of preparation) and did not reach this, and Q15–Q17's answer records the rate with no trigger either. **The two candidates are the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository must not pick one** — it is a fee term, and `CLAUDE.md`'s rule for a fact we do not have is a question, not an inference. A first pass at `docs/07`'s Q58 note asserted the session cap as applied fact; `adversarial-reviewer` struck it in the same change set that wrote it. ⚠️ **AND THE ANSWER EXPOSES SOMETHING IN D14's FIGURES THAT IS WORTH SEEING BEFORE `/fees/` PUBLISHES THEM.** Under the session-cap trigger the half-day route costs `2000 + 500 × max(0, n − 3)` and the full-day route is **flat $4,000 until hour 6** — so **booking a half day and paying overtime is cheaper at EVERY length: by $2,000 at 3 h, $1,500 at 4 h, $1,000 at 5 h, and $500 from 6 h on. The full-day rate is never the cheaper choice.** The cause is the relationship between two of D14's own numbers rather than the trigger — the half-to-full step is $2,000 and three hours of overtime is $1,500 — and the envelope trigger trades bands rather than fixing it (worse at 6 h, closing to zero from 9 h). ⚠️ *This row first stated the spread as a flat $500 and generalised the full-day route as `500n + 1000` for all `n ≥ 3`, which is only valid from hour 6. The real spread is up to FOUR TIMES larger and is largest at 3–5 h, the band a half-day booking actually overruns into — so the wrong figure understated exactly the thing this row exists to escalate. Corrected 2026-08-31; found by `adversarial-reviewer` on round 2 of the change set that wrote it.* The table is in `docs/07` §The confirmed rate card and, for the rate decision, §Recorded dissent. **What is needed:** one line on where the hour starts, and a decision on whether the half/full step is a choice or an oversight (R5's 12-month review is the other place that belongs). Raised by `adversarial-reviewer`, 2026-08-31 | **`/for-parties/` NOW, and the overtime line on `/fees/` at step 9.** That page states an unambiguous session cap, so an overtime RATE printed on it defines the trigger **by adjacency** — there is no other quantity for the charge to attach to, and the answer travels alone into the `FAQPage` node. The rate is therefore off that page until this is answered; it names the charge without its rate or its trigger. `/mediation/` says only that overtime is on the rate card |
|
||||
| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling and `docs/07` carries it in full. ⚠️ **THIS ROW NAMED A CONSTANT THAT NO LONGER EXISTS** — `FEES.mediation.overtimeStartsAfterSessionHours` was deleted the same day as dead data: nothing read it, so reversing it would have changed nothing and failed nothing, which is Q22's shape at constant scope. **Where the ruling actually lives:** the trigger is rendered on `/fees/` from `halfDay.hours` / `fullDay.hours`, and `FEES.mediation.reservation` carries the half that publishes as prose. Found by `adversarial-reviewer` round 2 — §9 is what a later implementer reads to find where a ruling is recorded, so pointing it at a deleted identifier is the same defect one layer up. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
|
||||
| ~~Q58~~ | **RULED 2026-08-31 — `hours` IS THE SESSION, AND THE AMBIGUITY WAS IN `docs/07` RATHER THAN IN ANY COPY. Pouya owned it in terms:** *"the ambiguity is mine… My `docs/07` wording said "up to 3.5 h, including 2 h preparation", which is genuinely unclear: 3.5 was meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours in the room. Your arithmetic caught it: if prep sat inside, 3.5 and 7 wouldn't be exactly 2×, because preparation doesn't scale with session length. The intended reading is the market's, and my wording obscured it."* **THE CORRECTED CARD, in his words:** *"Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation. $2,000. Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation. $4,000."* His reason for 3 and 6: *"the market convention — Patey and Zuber both publish "all or part of 3 hours" and "all or part of 6 hours", and those were the comparables the rate was set against. Selling 1.5 hours of room time as a half day would be an outlier nobody would recognise."* ⚠️ **ONE PROVENANCE NOTE, and it is R14's rule rather than a doubt about the ruling:** `docs/07`'s committed extract records Patey and Zuber at **3 h** and **6 h** but **does not carry the phrase "all or part of"** — so `docs/07` cites the hours, not the phrase, and the phrase is not attributed to them anywhere in the repository. The hours corroborate the ruling on their own, and ADR Chambers' roster rate in the same table is the clearest corroboration of the *shape*: *"one half hour of preparation time per party **and** up to three hours of mediation"* — preparation counted separately from a three-hour session. **APPLIED:** `docs/07`'s two card rows and its §All parameters confirmed (which prescribed the flat *"including 2 hours"*, the form `/for-parties/` then shipped); `FEES.mediation.*.hours` 3.5 → 3 and 7 → 6 with the semantics in the constant's doc comment; `/for-parties/` now states the session length interpolated from the constant and the preparation allowance **as a cap**. **The preparation allowance is CAPPED and must be published as capped** — *"including **up to** 2 hours"*, never the flat form and never "preparation included". **`/fees/` is UNBLOCKED for build step 9.** **The question as raised is preserved below.** **DOES `hours` IN THE MEDIATION RATE CARD MEAN THE LENGTH OF THE DAY, OR THE BILLED ENVELOPE INCLUDING PREPARATION?** `docs/07-fees.md` reads *"Half day — **up to 3.5 h, including 2 h preparation**"* and *"Full day — up to 7 h, including 3 h preparation"*. Taken at face value, 3.5 is the whole billed envelope and the **time in the room is 1.5 h** for a half day and **4 h** for a full day. **Against that reading:** 3.5 and 7 are exactly 2×, which they would not be if preparation sat inside them (1.5 vs 4 is not 2×). So either the card's wording is wrong in the one document that is the authority on money, or `FEES.mediation.*.hours` in `src/data/site.ts` does not mean what a page would naturally publish it as. **This was one sentence from shipping.** A draft of `/for-parties/` answered *"What happens on the day?"* with *"A half day is about 3.5 hours"* — the envelope presented as the day, to the reader least able to check it. The sentence was removed rather than resolved by inference; the page now says only that you book a half day or a full day. **What is needed:** one line from Pouya saying which the 3.5 and 7 are. Then `docs/07`'s two rows or `site.ts`'s field gains the correction, and the semantics go in the constant's doc comment (a warning is there now). **`/fees/` at build step 9 publishes this table and cannot be built without the answer.** Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing.** No page stated a duration while the question was open — the one draft sentence that did was removed rather than reconciled, which is why the ruling had nothing to correct in public copy |
|
||||
| ~~Q57~~ | **CLOSED 2026-08-31 — NO SEVENTH UNDERTAKING, AND THE PAGE IS COMPLETE AS IT STANDS.** Pouya: *"`/process/` stating when conflicts are run and what the check needs is complete. A reader assumes the outcome, and the obvious undertaking ("if a conflict is found I decline") adds nothing a reader doesn't already infer. Your restraint was right — §4's gate held. Record it closed rather than open, so it stops appearing in the live list."* **So this is a closure, not a deferral:** the answer is that the page says nothing further, which was one of the two outcomes the question named. §4 gains no seventh conduct undertaking and `CONDUCT_UNDERTAKINGS` still holds six. **APPLIED:** the `TODO(pouya)` is removed from `src/pages/process.astro` §Conflicts and replaced with the ruling, so a later reader finds the decision where the question was rather than an open marker; the file header's *"see the TODO below"* is corrected to cite this closure. `src/` now carries **zero** live `TODO(pouya)` markers. **The question as raised is preserved below.** **WHAT HAPPENS WHEN A CONFLICTS CHECK TURNS SOMETHING UP?** `/process/` §Conflicts ships saying **when** the check runs (the intake call, before anything is agreed) and **what it needs** (full legal names of the parties, the parent or affiliate actually behind the dispute, counsel on each side). It stops there, and the stop is deliberate: **any sentence naming the outcome is a SEVENTH conduct undertaking**, and §4's gate for that class is one line — *"an undertaking may be published only where Pouya has made it in terms. Not 'would obviously agree to', not 'follows from the process' — said."* *"If a conflict appears I decline the appointment"* is exactly what that gate refuses to let this repository infer, however obvious it looks. **What is needed:** one sentence from Pouya, in his words, or a decision that the page says nothing further. `TODO(pouya)` sits at `src/pages/process.astro` §Conflicts. Raised at build step 6, 2026-08-30 | **Nothing.** The section shipped accurate and unchanged; what closed is whether anything more was owed |
|
||||
| ~~Q56~~ | **RULED 2026-08-30 — MEDIATION IS *NOT* SCOPED COMMERCIAL.** Pouya: *"Correct the four 'Commercial Mediation' surfaces to 'Mediation'; leave §4's mediation row unscoped."* **And the asymmetry with arbitration is DESIGNED, not an oversight — the reason is now recorded beside both §4 rows so nobody tidies them into a matching pair.** **Arbitration is scoped commercial because of a LEGAL GATE:** Q39 — family arbitration in Ontario requires prescribed training, and Pouya has excluded it by choice. **Mediation has no such gate**; he mediates commercial, construction, insurance/SABS, shareholder and cross-cultural matters, and the practice pages say so. So the site-wide "commercial" framing was **under-describing a wider offering**, which is why it is corrected rather than ratified as a positioning choice. `/practice/insurance/`'s SABS framing needs no reconciliation: it was never outside the offering. **The question as raised is preserved below.** ⚠️ **IS THE MEDIATION OFFERING SCOPED COMMERCIAL, OR NOT? THE SITE SAID BOTH.** §4 Offerings rows arbitration three times, each **scoped commercial**; the mediation row is `**Mediation** — sole mediator`, **unscoped**. Shipped output scopes it anyway: `/mediation/`'s `<title>` is *"Commercial Mediation"*, its `Service` node is `name: "Commercial mediation"`, and `/` and `/about/` both say *"I mediate commercial disputes"*. Meanwhile **`/practice/insurance/` offers mediation in matters that are not commercial on any ordinary reading** — *"Disputes between an insured person and an insurer under the Statutory Accident Benefits Schedule"*, with *"What I offer is private mediation"*. An individual claimant against their own insurer is not a commercial dispute. **Nothing here is a false claim** — scoping a page to commercial mediation asserts *less* than the unscoped §4 row grants, and narrower than the row is always safe. **The problem is that the two halves cannot both be the whole picture**: either the practice takes non-commercial mediations (and the site-wide "commercial" framing under-describes it, including on the page an appointing body reads), or it does not (and `/practice/insurance/` is offering something outside the offering). **What is needed:** either a §4 Offerings row scoping mediation, with `/practice/insurance/`'s SABS framing reconciled to it — or a decision that mediation is deliberately unscoped, in which case the site-wide "commercial" wording is a positioning choice and should be recorded as one rather than read as a limit. **Pre-existing, not created 2026-08-30** — but this change set newly wrote the claim onto `/med-arb/` and it has been removed again pending this answer. Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing — ruled the same day it was raised.** No page was wrong; the register was silent where the site was specific, and the ruling makes the site match the register rather than the other way round |
|
||||
@@ -781,7 +782,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
||||
| ~~Q3~~ | **ANSWERED 2026-08-26.** Email `info@smlcompany.ca`. No public phone — "By scheduled call". Location: Toronto · Ontario · By appointment | — |
|
||||
| ~~Q4 / Q14~~ | **ANSWERED 2026-08-26.** Rate card confirmed by Pouya — see D14 and `docs/07-fees.md` | — |
|
||||
| ~~Q13~~ | **ANSWERED 2026-08-26.** Self-hosted Gitea with Gitea Actions | — |
|
||||
| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only; where it begins is §9 Q59, open.** ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
|
||||
| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only. Where it begins was §9 Q59, now RULED AND CLOSED 2026-08-31: the session cap**, i.e. the fourth hour of a half day and the seventh of a full day. *(This clause read "where it begins is §9 Q59, open" for the rest of that day, eleven lines below the row recording the closure — found by `adversarial-reviewer` round 2.)* ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
|
||||
| ~~Q25~~ | **ANSWERED 2026-08-26. Struck.** The §4 row permitting the boutique to be named is removed; D16 governs alone | — |
|
||||
| ~~Q26~~ | **ANSWERED 2026-08-26.** §4 now carries an explicit **Licence status — NOT ESTABLISHED** row, and its opening no longer asserts that LSO rules apply. The register's rationale stands on the fabricated-credentials history alone | — |
|
||||
| ~~Q24~~ | **ANSWERED 2026-08-26.** `AWS-Hosting-Guide.md` copied into `docs/reference/` — it is the only record of how the hand-built infrastructure was created. Scanned for credentials before copying: no access keys, no secrets, no account ID | — |
|
||||
@@ -919,12 +920,12 @@ never being raised again.
|
||||
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
|
||||
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
|
||||
| R10 | ⚠️ **A THIRD SURFACE, 2026-08-30: `/process/` §Confidentiality renders `MEMBERSHIP_ORGS[0]` ("I am a member of the ADR Institute of Canada").** It is rendered from the constant rather than typed, so the sweep this reminder prescribes reaches it — that was `adversarial-reviewer`'s finding and the fix, in that order. **DISCHARGED AS WRITTEN 2026-08-28 — AND RE-ARMED WITH AN EVENT TRIGGER INSTEAD OF A DATE. STILL LIVE.** Pouya re-confirmed all four memberships as current (Q44), which discharges the prohibition this row carried, and `/about/` now publishes the Memberships group. **The row does not close, because he declined renewal-date tracking**, and that was his instruction for what to do about it: *"Without renewal months it cannot fire on a date, so make it fire on an event: re-confirm memberships before any cutover or major republish, and re-stamp §4 when confirmed."* **THE TRIGGER: re-confirm before any cutover, and before any major republish. Then re-stamp §4 the same day.** **His reason, kept verbatim because it is the general principle and not a membership detail:** *"§4 already carries OCNI as lapsed and unpublishable, and that was found roughly a year late. A stamp with no trigger behind it goes stale silently, which is exactly how OCNI got onto a list of things to feature."* **Two things the discharge did NOT license.** (1) **No currency warranty on the page** — list the memberships, promise nothing about their future state; the struck sentence stays struck and nothing replaces it. (2) ~~`memberOf` stays out of the JSON-LD~~ — **SUPERSEDED. Q53, ruled 2026-08-28: EMIT IT.** `/about/`'s Person node now carries the four memberships as `Organization` nodes. Pouya took `adversarial-reviewer`'s argument: they are already crawlable in `/about/`'s HTML, so withholding the triple reduced no exposure and only made the graph less complete than the page. **The consequence for THIS reminder is that it now covers two surfaces** — re-confirming before a cutover means `src/data/schema.ts` as well as the visible list, and they must not be allowed to diverge. **Renewal periods, stated once and not widened again:** the OBA sections and the CTF renew yearly; §4 records **nothing** about ADRIC's or ADRIO's period, and the widened form ("all four renew yearly") reached four files before it was swept. *Previous text described the prohibition and the withheld group; it held for one session and did its job.* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this. The group is on a public page now, which raises the cost of a lapse rather than lowering it — *(This rationale ended by pointing at **Q48** as a possible widening of the row. Q48 closed 2026-08-28 as not site-relevant — ADRIO retention governs whether Pouya keeps a designation, not what the site may say about holding one — so the clause is struck. §12 is read aloud every session; a live reminder pointing at a struck row produces a false surface every time.)*, not just a list |
|
||||
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7. **Carries one specific re-add trigger: at step 7, put `@lhci/cli` back.** Check for a patched release — do not assume `0.15.1` is still the ceiling just because it was on 2026-08-26 — pin it against `npm view` that day, write a `lighthouserc` with the budgets from `docs/04-seo-spec.md`, and delete the UNAVAILABLE notices this repo now carries in six places (§7 lists them). If the advisories are still unfixed, that is a decision to take deliberately, not a reason to leave the gap unstated | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
|
||||
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7. ✅ **THE STEP-7 RE-ADD TRIGGER IS DISCHARGED, 2026-08-31 — and NOT as written.** It said *"at step 7, put `@lhci/cli` back"*. `@lhci/cli` is still 0.15.1, still `latest`, and still carries 10 findings (7 high) `[verified 2026-08-31]`, so the literal instruction would have re-added a tool with seven high-severity advisories. What shipped is **`lighthouse@13.4.1` + `chrome-launcher@1.2.1`, 0 vulnerabilities**, as `npm run lighthouse`. **The reason is that §7's own advisory attribution was wrong** — it blamed `lighthouse → puppeteer-core → extract-zip`; the carriers were `@lhci/cli`'s own `tmp` and `@puppeteer/browsers`' `extract-zip`, and neither exists in Lighthouse's tree. **The last clause of this trigger is the one that earned its place:** *"if the advisories are still unfixed, that is a decision to take deliberately, not a reason to leave the gap unstated."* They are still unfixed; the decision was taken; §7 records what it costs (no `lhci` assertion config, no server, no run history) and that the gate is local rather than CI, because standalone Lighthouse needs an installed browser and the runner has none. **All six UNAVAILABLE notices are deleted** — `docs/04` (budget table, Performance callout, post-launch checklist), `CLAUDE.md` (performance budget, definition of done), `/build` Phase 5, `docs/06` (PR checks, cutover checklist), `.claude/agents/adversarial-reviewer.md` §4. The **general** half of R11 — re-check every pin at every phase boundary — is untouched and still fires | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
|
||||
| R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `<em>a</em><strong>b</strong>` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again |
|
||||
| R13 | **The infinity mark ships as a RASTER, and that is temporary. RAISED 2026-08-27; Pouya ruled the committed SVG does NOT close it** — *"Keep it committed, keep the AVIF render path. Your own measurement is the reason: 257 KB wrapping seven embedded base64 PNGs. It renders faithfully because it IS the raster."* So the exception stands and the reminder stays live. `InfinityMark.astro` renders an optimised AVIF/WebP from `src/assets/brand/sml-infinity-mark.png` — a deliberate, documented exception to `docs/02`'s "inline SVG, never a PNG", because the mark is gradient-mesh artwork and no true vector master exists yet (Q38). **Removal trigger: the commissioned vector master lands.** Then replace the `<Picture>` with inline SVG, regenerate `favicon.ico` and `apple-touch-icon.png` from it, and delete the exception from `docs/02`, from the component, and from Q38 | 2026-08-26 | Pouya flagged this himself when he made the ruling: *an interim raster is exactly the kind of temporary measure that becomes permanent by never being raised.* It costs ~8 KB and works, which is precisely why nobody will notice it again. There is no build error to prompt anyone — only this row |
|
||||
| R14 | **Anything a spec makes a claim about must be reachable from the repository.** If the artefact lives only in Drive, in a console, or on someone's disk, no reviewer can check the claim and the claim is unverifiable by construction — see the rule now in `CLAUDE.md` | 2026-08-26 | **This has now cost twice.** Q24 was `AWS-Hosting-Guide.md`, the only record of how the infrastructure was hand-built, living outside the repo. Q32 was the infinity mark: Claude Code traced it from the old site's *loading placeholder*, wrote a scrupulous provenance comment recording exactly that doubt — and **two adversarial review passes still could not catch it**, because the artwork they would have had to compare against was not in the repo. The doubt was correctly stated and structurally uncheckable. That is the failure mode: not a missing warning, a missing artefact |
|
||||
| R15 | **Every page shares ONE Open Graph image — the portrait — and that is an interim for seventeen of the nineteen. RULED, DEFERRED, AND TRACKED HERE ON POUYA'S INSTRUCTION:** *"Record the trigger so it cannot quietly become permanent."* Q40, 2026-08-27. The portrait is the **decided** card for `/` and `/about/` — a face is the strongest social preview for a personal brand. It is the wrong card for the other seventeen, where a typed card carrying the page title does the work. **Removal trigger: build step 7**, where Insights needs per-article cards anyway — one generator (`satori` or `astro-og-canvas`, pinned against `npm view` that day per R11), one dependency, one review. Then delete the interim note from `docs/04` and the cutover line from `docs/06`. **Blocks cutover; does not block steps 3–6** | 2026-08-27 | 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, and `docs/04` requires a **unique** title and description per page — nineteen unique pages that all preview identically is the failure, and it is invisible from inside the repo. It costs nothing and works, which is exactly the R13 shape |
|
||||
| R16 | **The one-page PDF bio does not exist, and it is deferred to build step 9 — not dropped. RULED AND TRACKED HERE ON POUYA'S INSTRUCTION:** *"Record the trigger so it doesn't become another quiet interim."* Q45, 2026-08-28. 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. **Removal trigger: build step 9, alongside `/fees/`.** Two decisions travel with it and are **not** settled by the deferral: (a) generated at build (a dependency and an `npm audit` surface, against R11) or authored once as a designed artefact; (b) whether it carries anything the site does not — a matter list, a fee summary, referees — **each of which is a §4 question of its own**, and the matter list would collide with §4 Forbidden directly. Then delete `docs/01` §`/about/` item 7's pending note | 2026-08-28 | **It is the one artefact class this project's review apparatus cannot reach.** A web page is re-reviewed by every audit and re-checked by every build; a PDF circulated with an appointment proposal is read once, by the reader who matters most, and never seen by a reviewer again. `/about/` currently ships with no link to it, which is correct — a dead link on the page an appointing body reads is worse than the absence — and that means **nothing on the site will ever prompt anyone about this.** Only this row will |
|
||||
| ~~R15~~ | ✅ **DISCHARGED 2026-08-31 — build step 7b. Seventeen pages and every future article now carry their own generated card; `/` and `/about/` keep the portrait, which Q40 called "not an interim" and which is unchanged.** `src/pages/og/[...slug].jpg.ts` renders them at build from `satori@0.33.4` + `sharp`; `src/data/og-cards.ts` is the registry; `SEO.astro` resolves which of the two kinds a page gets from its own pathname, so the decision lives in one data file rather than in nineteen call sites. `satori` over `astro-og-canvas@0.13.0` — both 0 vulnerabilities `[verified 2026-08-31]` — because `sharp` was already a dependency, so it is one library rather than a CanvasKit wasm blob, and it renders the site's real fonts and tokens instead of approximating them. **The interim note is deleted from `docs/04` and the cutover line from `docs/06`**, which is what this row's trigger asked for. ⚠️ **AND IT SURFACED A HOLE THAT IS WORTH MORE THAN THE ROW: 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 is the one surface on this site where new prose would face no mechanical check at all. So card copy is constrained structurally rather than editorially: **every headline is its page's own `<h1>`, character for character**, the kicker renders `CREDENTIALS.designations`, and **`npm run og:proof` enforces both against the built HTML** — it fails if the registry and the page disagree in either direction, and it also confirms every `og:image` resolves to a file that exists. That check found a straight-versus-typographic apostrophe on its first run. **A page with no registry 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, and a silent fallback recreates it exactly. **What is NOT discharged and is deliberately left open:** the check compares strings, so it cannot see a card that renders badly — a headline that overflows, a mark that clips. `npm run og:proof -- --sheet` writes a contact sheet of every card for that, and **a human still has to look**; `docs/06`'s cutover item says so. Two cards were read at step 7b (`/practice/energy/`, the longest headline, and `/practice/insurance/`) and rendered correctly | 2026-08-27 | 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, and `docs/04` requires a **unique** title and description per page — nineteen unique pages that all preview identically is the failure, and it is invisible from inside the repo. It cost nothing and worked, which is exactly the R13 shape |
|
||||
| ~~R16~~ | ✅ **DISCHARGED 2026-08-31 — build step 9, which is the trigger this row set. The one-page PDF exists, is committed at `public/pouya-lajevardi-bio.pdf`, and `/about/` links it.** Q45's two open sub-decisions are both taken, and the second one is what makes the first safe. **(a) Generated at build, or authored once? NEITHER — and the third option answers this row's actual objection.** The bio is a PAGE, `src/pages/bio.astro`, so every line of it is reviewed by the same apparatus as every other page: `astro check`, `npm run check:claims` on the built HTML, the per-step code review, and the cutover claims pass. `npm run bio:pdf` then RENDERS the PDF from the built page through Chrome, with **no new dependency** — `chrome-launcher` is already a devDependency because Lighthouse needs it. That is aimed squarely at what this row said the problem was: *"It is the one artefact class this project's review apparatus cannot reach… a PDF circulated with an appointment proposal is read once, by the reader who matters most, and never seen by a reviewer again."* A rendering of a reviewed page is back inside the apparatus. **It is deliberately NOT part of `astro build`** — CI has no Chrome, and a build step that cannot run in CI is Q22's shape again — so the PDF is committed, which R14 wants anyway. **(b) Does it carry anything the site does not? NO, and that is the answer that avoids the §4 question this row flagged.** Every line renders from `CREDENTIALS`, `ROLE`, `BOUTIQUE`, `PRACTICE_AREAS`, `FEES` and `CONTACT`. No matter list — which this row correctly said *"would collide with §4 Forbidden directly"* — no referees, and no figure that is not on `/fees/`. The fee summary IS there, because this row's own reasoning is that an appointment proposal needs the rate card as much as the bio. ⚠️ **`npm run bio:pdf` ASSERTS ONE PAGE and writes nothing if the count is wrong** — counted from the PDF's own page objects, cross-checked against the page tree's `/Count`. A one-page bio that silently becomes two is invisible from the source, because it depends on the print stylesheet, the paper size and how much §4 has grown. **And reading the rendered PDF caught a real breach the source review had not:** the opening sentence read *"I act as a neutral in commercial disputes — as a mediator, as an arbitrator in commercial matters…"*, whose leading clause scoped **mediation**, which Q56 leaves unscoped deliberately. `/bio/` is `noindex` and out of the sitemap: it is a condensed duplicate of `/about/` and `/fees/`. **What is NOT discharged:** nothing in the build regenerates the PDF. It must be re-rendered and re-committed whenever `/bio/`, §4, the rate card or the print styles change, and `docs/06`'s cutover checklist carries that | 2026-08-28 | It is the one artefact class this project's review apparatus cannot reach. A web page is re-reviewed by every audit and re-checked by every build; a PDF circulated with an appointment proposal is read once, by the reader who matters most, and never seen by a reviewer again |
|
||||
| R17 | **THE DEPLOY KEY'S FIRST ROTATION IS DUE 2026-11-26, AND THAT DATE IS THE WHOLE ROW.** Access key for `adr-sml-deploy` created **2026-08-26**; D3 commits to **quarterly** rotation. Pouya's instruction when Q22 closed: *"Put the DATE in the R-series reminder, not the intention."* **Rotation procedure is in `docs/06-deployment.md` §Key rotation** — create the second key, update the Gitea secrets, verify a deploy, **then** delete the first; never delete before verifying, or the next push fails with no way to authenticate the fix. **Then set the next date here (2027-02-26) rather than deleting the row.** Also re-run **all nine** `simulate-principal-policy` checks after any policy edit (they are listed and reproducible in `docs/reference/deploy-credential-verification.md` §5) — the narrowness is the control (§10), and a widened policy is invisible from inside the repo. **The key is currently UNUSED — `AccessKeyLastUsed` returns `ServiceName`/`Region` = `N/A` and no `LastUsedDate`** — because deploys still run locally via `npm run deploy`; that does not pause the clock, and an unused long-lived key is exactly the one nobody remembers to rotate | 2026-08-28 | This is the reminder D3 has been waiting for a subject since 2026-08-26. §10 records the reason it matters more here than on a normal project: the secret lives on **jointly-administered** infrastructure, and the account also holds another business's client-database backups. A static key on shared infrastructure is only as safe as its age and its scope, and **nothing in the repo, the build, or AWS will prompt anyone on either.** §7 carries the inventory; this row carries the date |
|
||||
| R18 | **THE SIX `docs/reference/` EXTRACTS BEHIND `/practice/*` ARE DATED 2026-08-29, AND SIX SHIPPED SENTENCES TURN ON FACTS THAT MOVE.** Build step 5 put statute, regulation, tribunal and bill status onto public pages — sourced, but **sourced as at one day**. The volatile ones, in order of how fast they move: **(a) federal Bill C-36** — `/practice/technology/` says it *"was introduced in June 2026 and was at second reading when this page was written"*; if it receives royal assent the page is wrong about the most load-bearing fact on it. **(b) the Ontario regulation under `Electricity Act` s. 28.1** — `/practice/energy/` says it *"had not been made as of August 2026"*. **(c) the ERO 026-0853 consultation**, comment period to **12 September 2026**. **(d) the Tribunals Ontario annual report** — `/practice/insurance/` publishes FY2024-25 figures and the extract records that a 2025-26 edition was never ruled out. **(e) the SABS**, amended with effect 1 July 2026. **(f) the ADRIC National Mediation Rules**, under review by ADRIC's own committee. **(g) ADRIC's Code of Ethics** — added 2026-08-30, build step 6. `/process/` §Confidentiality quotes it verbatim from `docs/reference/adr-institution-names.md` (retrieved 2026-08-29) **with a live link to ADRIC's page**, which is what makes it checkable and also what makes a stale quotation visible. It is the slowest-moving item here — a professional code, not a bill — so it does not change the cadence; it is listed because the trigger below says "all six" and there are now seven. **THE TRIGGER: re-check all seven extracts before any cutover, and before any republish that turns on one of them — the same event trigger R10 uses.** Then re-stamp the extract. **A page that was true when it was written and is false when it is read is still a false page**, and this is the first change set on the project to put that class of fact into public copy at volume | 2026-08-29 | Six sentences, six files, one retrieval date. Nothing here fires on its own; a fact with a shelf life and no owner is exactly what §12 exists for |
|
||||
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
|
||||
@@ -934,6 +935,506 @@ never being raised again.
|
||||
|
||||
# Change Log
|
||||
|
||||
## 2026-08-31 (ah) — Build steps 7a–10: the site is complete and reviewable. Lighthouse returns and its first run found three defects in itself; the OG generator discharges R15 and exposes a hole in `check:claims`; and an article auditor found a false assertion on a page that shipped two days ago
|
||||
|
||||
**Type:** build (four steps) + two rulings applied + two standing reminders discharged.
|
||||
|
||||
**Scope:** Pouya authorised steps 7a through 10 as one run, with standing authorisation to decide and record rather than stop and ask, and to batch every question to the end. Nothing was deployed. `claims-auditor` did not run — D20.
|
||||
|
||||
---
|
||||
|
||||
### What shipped
|
||||
|
||||
**22 built pages, up from 14.** New: `/insights/`, `/contact/`, `/contact/received/`, `/contact/could-not-send/`, `/fees/`, `/bio/`, `/legal/privacy/`, `/legal/terms/`. Plus `/insights/<slug>/` as a route with five drafted articles behind it, and 21 generated Open Graph cards.
|
||||
|
||||
**Step 7a — Lighthouse is installed and running again.** `lighthouse@13.4.1` + `chrome-launcher@1.2.1`, `npm run lighthouse`, `npm audit` 0 vulnerabilities.
|
||||
|
||||
**Step 7b — the OG card generator.** `satori@0.33.4` + `sharp`, `src/pages/og/[...slug].jpg.ts`, `src/data/og-cards.ts`, `src/lib/og-card.ts`. R15 discharged. Plus the Insights plumbing: `ArticleCard`, `Prose`, the index, the article route, `articleGraph`, and `/`'s section 7.
|
||||
|
||||
**Step 7c — five drafted articles**, `draft: true` / `reviewedByPouya: false`.
|
||||
|
||||
**Step 8 — `/contact/`, the intake form, and `backend/intake/`.** Undeployed.
|
||||
|
||||
**Step 9 — `/fees/` on Q59's ruling, and the one-page PDF bio.** R16 discharged.
|
||||
|
||||
**Step 10 — `/legal/privacy/` and `/legal/terms/`.**
|
||||
|
||||
**Plus `/about/`'s inverse credentials band**, approved at step 6 and outstanding since.
|
||||
|
||||
---
|
||||
|
||||
### The rulings applied
|
||||
|
||||
**Q59 — RULED AND CLOSED. Overtime runs from the session cap** (the fourth hour of a half day, the seventh of a full day), not the billed envelope.
|
||||
|
||||
**And the ruling's second half is what made `/fees/` publishable rather than merely unblocked.** *"A full day reserves the day. Half-day overtime is subject to availability."* The full-day fee buys the **day**. Without that sentence a reader adds `2000 + 500 × 3 = 3500` against `4000` and concludes the full-day rate is a $500 penalty for booking properly — which is how the R5 anomaly reads when the two options are treated as the same product. So the sentence publishes **adjacent to the overtime row**, from `FEES.mediation.reservation`, on the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43. **The anomaly is not closed** — the gap is in D14's figures and stays on R5.
|
||||
|
||||
---
|
||||
|
||||
### What the run found, in order of how much it cost
|
||||
|
||||
**1. `AGENTS.md` §7's Lighthouse advisory attribution was wrong, and the wrong attribution is why the budget was unmeasurable for five days.** §7 recorded the ten findings as arriving *"via `lighthouse → puppeteer-core → extract-zip`"*. Measured from two probe lockfiles: `@lhci/cli@0.15.1` carries them and pins **lighthouse 12.6.1**; the two high carriers are **`tmp@0.1.0`, its own direct dependency**, and `extract-zip@2.0.1` via `@puppeteer/browsers`. In `lighthouse@13.4.1` both are **absent** and `npm audit` returns **0**.
|
||||
|
||||
**Lighthouse was never the carrier.** `npm view` alone would not have found this — it took the lockfiles. Deliberate deviation from R11's literal *"put `@lhci/cli` back"*, recorded with what it costs (no `lhci` assertion config, no server, **no run history**, so only an absolute breach fires) and with the fact that it is a **local gate and cannot be CI**, because standalone Lighthouse drives an installed browser and the runner has none.
|
||||
|
||||
**2. Lighthouse's first run found three defects — all three in the instrument.**
|
||||
|
||||
- **Accessibility read 96 on ten of fourteen pages.** 24 `color-contrast` failures, foregrounds like `#d0cbc4` on `#f8f4ed` — **neither value is in this palette**. They were the scroll-driven reveal caught mid-animation. Forcing `prefers-reduced-motion`: 96 → **100**, 24 nodes → **0**, twice per condition. A category reporting 24 known-false nodes cannot surface the twenty-fifth real one.
|
||||
- **`bf-cache` failed on every page** — caused by the runner's own `cache-control: no-store`, which Lighthouse did not need (it resets storage itself). Verified by toggling the one header.
|
||||
- **SEO read 69 on the `noindex` pages**, and the only failing audit was `is-crawlable`, firing on the `noindex` `docs/04` requires. The budget as written is unsatisfiable for those pages. **Fixed by asserting them audit by audit** — every SEO audit must pass except that one — rather than by lowering a threshold or excepting the pages. **Probed:** removing a `<title>` from a `noindex` page exits 1 naming `document-title`; restored, exit 0. ⚠️ *One reading is unreproduced and is recorded rather than explained: the run that surfaced this listed **five** such pages and scored `/insights/` 100, while the final run lists **six** and scores it 69. `dist/insights/index.html` does carry `<meta name="robots" content="noindex,follow">`, and `/insights/` has emitted it since the page was written, so the earlier 100 should not have happened. It changes nothing — the page is correct and the gate now handles it — but I could not reconstruct the cause and am not going to guess one.*
|
||||
|
||||
**3. Text baked into a JPEG is unreachable by `check:claims`, which under D20 is the only per-step claims control there is.** An OG card would have been the one surface on this site with no mechanical check over its copy at all. So card copy is constrained structurally: **every headline is its page's own `<h1>`, character for character**, the kicker renders `CREDENTIALS.designations`, and **`npm run og:proof` enforces both against the built HTML**, in both directions, plus confirms every `og:image` resolves to a file that exists. A missing registry 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*.
|
||||
|
||||
**4. An article auditor found a false assertion on a page that shipped at step 5.** `/practice/energy/` said *"The regulation that would set them had not been made as of August 2026."* The extract's own adversarial check answers that exact question: **"NOT ESTABLISHED either way, and DO NOT ASSERT ITS ABSENCE"** — and supplies safe wording, which the page now uses. **A defect in an unpublished draft surfaced a shipped one**, and 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.
|
||||
|
||||
**5. Reading the rendered PDF caught a mediation-scoping breach the source review had not.** `/bio/` opened *"I act as a neutral in commercial disputes — as a mediator, as an arbitrator in commercial matters…"*, whose leading clause scoped **all three**, mediation included. Q56 leaves mediation unscoped deliberately. It is the `/practice/` shape exactly — the two words never appear in the same element, so no proximity grep reaches it.
|
||||
|
||||
**6. `.btn` is scoped to `Button.astro`, so a hand-written `<button class="btn btn-primary">` renders unstyled.** `/contact/`'s submit button was written that way. The parent-scope trap `CLAUDE.md` records, arrived at from the other direction.
|
||||
|
||||
**7. FOUR of my own verification commands were wrong, in four different ways, and all four are the same rule.**
|
||||
|
||||
- `grep -c 'acard' dist/index.html` returned **1**, which I read as one incidental mention. **`grep -c` counts LINES**, and minified CSS is one line — the `1` was the entire rule set. `ArticleCard`'s CSS does ship to `/` for a section that renders nothing: **10 rules, 1,496 bytes, 4.4% of the document**. Kept, because `npm run lighthouse -- /` returns **perf 99, LCP 2.03 s, CLS 0.000 — identical before and after**. A comment claiming the CSS was absent was corrected to the measurement.
|
||||
- A check for `<option value="">` on `/contact/`'s selects reported **0 of 4**. The *check* was wrong: Astro inserts `data-astro-cid` before the closing bracket. All four have it. **A grep that does not match is not an absence until you read the artefact** — the mirror of the rule already in `CLAUDE.md`.
|
||||
- The computed-contrast sweep written to VERIFY the ghost-button fix then reported **two new failures at 1.39:1 and 1.52:1** on `/for-parties/` and `/process/`. Both false: it measured each button's text against its **parent's** background, and a `.btn-gold` on a cream section carries its own ink background, so gold-l text was being compared to cream it never touches. The sweep's own failure list — which resolves the background from the element itself — reported **0**. Read before reporting; a sweep written to confirm a fix is still an instrument.
|
||||
|
||||
- And the worst of the four, because of what it was about. A check on the rendered med-arb article printed `undertakings render: False`, which reads as **a published conduct commitment failing to appear on a page that quotes it**. It was the test: one of its three search strings was `'stays in that caucus'`, which belongs to undertaking **(d)** — a `/mediation/` surface — and not to the three this article carries. Re-run with the right keys, all three of (a), (b) and (c) are **verbatim at 200, 162 and 161 characters**, and (d), (e), (f) are correctly absent. A false alarm on §4's most softening-prone class would have been the most expensive wrong report available in this run.
|
||||
|
||||
**The through-line is worth stating once, because it is four for four in one run:** each produced a number or a boolean, none was about what it appeared to be about, and in every case the disproof was reading the artefact rather than re-reading the command. Note the direction, too — **two of the four pointed at defects that did not exist.** `CLAUDE.md` already warns that a uniformly-good result ends a check instead of starting one; these are the mirror, and they cost differently: a false pass ships a defect, a false alarm ships a correction to something that was right.
|
||||
|
||||
---
|
||||
|
||||
### Decisions taken under standing authorisation
|
||||
|
||||
- **The intake form posts to a same-origin `/api/intake`**, not to the execute-api hostname: tighter CSP (`form-action 'self'`), no cross-origin POST, the endpoint id stays out of the HTML and the repo, and **submitting from a laptop does nothing**. Cost, stated: the form does not work until a CloudFront `/api/*` behaviour exists. Cutover item.
|
||||
- **POST-redirect-GET**, so the form works with zero JavaScript and cannot double-submit. Two `noindex` landing pages, excluded from the sitemap.
|
||||
- **The 3-second timing check is NOT implemented** — it is unimplementable on a CDN-cached static page and would be a control that does nothing, which is Q22's shape.
|
||||
- **CORS is not what protects the form; the `Origin` check is.** A form POST is a top-level navigation and is exempt from preflight.
|
||||
- **Two intake field tables, deliberately** — the server must not validate against a list the client shipped it — **cross-checked by `npm run check:intake`**, probed with three deliberate mismatches.
|
||||
- **`ANALYTICS.installed = false`**, and `/legal/privacy/` renders its analytics paragraph from it. D15 decided Plausible; deciding is not installing, and a policy naming a processor that processes nothing is a false disclosure.
|
||||
- **`/legal/privacy/` claims "encrypted at rest" and does NOT claim the customer-managed KMS key or PITR** — §7 verifies neither, and DynamoDB encrypts at rest unconditionally.
|
||||
- **`/legal/terms/` has no governing-law clause, no limitation of liability and no warranty disclaimer.** §4 bars this repository from concluding a proposition of law. Batched for Pouya.
|
||||
- **`/insights/` is `noindex` while empty**, derived from the collection so it self-clears. It stays in the sitemap because `astro.config.mjs`'s filter cannot see collection data — recorded rather than fixed with a frontmatter parser in build config.
|
||||
- **R16 (a):** the bio is a **page**, and the PDF is a rendering of it, so the artefact circulated with an appointment proposal is inside the review apparatus. **(b): it carries nothing the site does not.**
|
||||
- **The PDF asserts one page** and writes nothing if the count is wrong.
|
||||
|
||||
### The five drafted articles, and what auditing them cost
|
||||
|
||||
`docs/03`'s launch slate, drafted 1,498–1,795 words each, `draft: true` /
|
||||
`reviewedByPouya: false`. **They generate no page, appear in no index, no
|
||||
sitemap and no card set** — one predicate, `!data.draft`, is the filter every
|
||||
`getCollection('insights')` call on the site passes, so a draft is absent as a
|
||||
consequence of not existing rather than because five places each remembered.
|
||||
|
||||
**An independent compliance audit of each returned 76 findings — 12 blocking, 34
|
||||
should-fix, 30 consider — and 57 assertions the committed extracts do not
|
||||
establish.** Every blocking and should-fix finding was applied; the consider
|
||||
findings were applied except where doing so made the prose worse. Two spot-checks
|
||||
against the extracts confirmed the audits were reading them correctly rather than
|
||||
inventing objections.
|
||||
|
||||
**What the findings were mostly about is worth naming, because it is R14's own
|
||||
argument arriving from a new direction.** They were not voice or claim-register
|
||||
breaches. They were **facts about the world that the extract does not support** —
|
||||
a section number flattened, a statutory branch dropped, "about six weeks" for a
|
||||
statutory timetable that computes to five or seven, an IESO process described
|
||||
from four pages as though the four were exhaustive. The extracts in
|
||||
`docs/reference/` each carry an *"Adversarial check on this extract"* section
|
||||
recording what was searched and what was **NOT ESTABLISHED**, and that section is
|
||||
what caught almost all of it. **Sourced quotations were never the risk; the
|
||||
inferences drawn beside them were.**
|
||||
|
||||
⚠️ **AND THE ARTICLES ARE INVISIBLE TO `check:claims`, WHICH READS `dist/`.** A
|
||||
draft ships no HTML, so under D20 the five articles sit outside the only
|
||||
per-step claims control there is. They were checked once, deliberately: all five
|
||||
were temporarily set `draft: false` / `reviewedByPouya: true`, the site built to
|
||||
**27 pages**, `npm run check:claims` run over the article prose — **clean, 11
|
||||
patterns, 27 pages** — and then restored, with digests compared and **both files
|
||||
asserted to exist before comparing**. `docs/06` now carries the instruction to
|
||||
repeat that before any of them publishes.
|
||||
|
||||
**Three of the five undertaking quotations were hand-typed and are now rendered
|
||||
from `CONDUCT_UNDERTAKINGS`.** §4's third class says why in terms: the strings
|
||||
live in one constant so the diff that would soften one is visible on one line.
|
||||
A fourth hand-copy of a published commitment, in a file nobody re-reads against
|
||||
§4, is exactly the silent failure that class was flagged for. Verified by
|
||||
building with the articles published and matching all three strings byte for
|
||||
byte against the constant (200, 162 and 161 characters).
|
||||
|
||||
⚠️ **IF AN ARTICLE PUBLISHES, §4's ROWS (a), (b), (c), (d) AND (f) EACH GAIN A
|
||||
SURFACE** and their "where it ships" column has to say so. Not done, because
|
||||
nothing is published — flagged by two of the fix agents independently, and
|
||||
batched for Pouya.
|
||||
|
||||
### A process finding of my own, recorded because it cost real time
|
||||
|
||||
**I ran five article-fix agents concurrently with an adversarial reviewer that I
|
||||
had told to probe the check scripts.** Three of the five reported their file
|
||||
being reverted mid-edit, one reporting frontmatter reading `draft: false` /
|
||||
`reviewedByPouya: true` — which is a state that existed only inside my own
|
||||
temporary-publish verification, and which the reviewer would reproduce to probe
|
||||
the D9 gate. A fourth diagnosed a second, independent cause: **a batch of twelve
|
||||
concurrent `Edit` calls to one file lost four of them.**
|
||||
|
||||
Both causes are mine. **Do not run a probing reviewer against the same working
|
||||
tree as agents editing it**, and do not fan out concurrent edits to a single
|
||||
file. Ground truth was verified rather than assumed: all five files
|
||||
`draft: true` / `reviewedByPouya: false`, zero article pages in `dist/`, every
|
||||
fix marker present, `git stash` empty, and the reviewer's own probe — a
|
||||
`DELIBERATELY WRONG CARD TEXT` headline it had injected into the OG endpoint —
|
||||
restored to `entry.data.title`.
|
||||
|
||||
### Review — `adversarial-reviewer` alone, per D20. Two rounds, 25 findings, all acted on
|
||||
|
||||
**Round 1: 16 findings.**
|
||||
|
||||
**Two blocking, and both were on pages built in this run.**
|
||||
|
||||
**1. `/fees/` shipped an invisible navigation link — measured at 1.00:1.**
|
||||
`<Button variant="ghost">` sets `color: var(--text)` (ink) and a border of
|
||||
`--border` (ink at 10% alpha). On a `.section-inverse` ground both are the
|
||||
background colour: `{"t":"How an engagement runs →","color":"rgb(26, 22,
|
||||
20)","bg":"rgb(26, 22, 20)","ratio":1}`. Worse than the gold-on-cream 2.10:1 this
|
||||
project treats as unshippable, and a screenshot showed one button where the
|
||||
markup has two.
|
||||
|
||||
⚠️ **AND THE PAGE SCORED LIGHTHOUSE ACCESSIBILITY 100.** axe's `color-contrast`
|
||||
rule **skips a foreground that exactly equals its background** as "unable to
|
||||
determine". So the a11y category cannot be the only contrast control here, and a
|
||||
computed-contrast sweep is not redundant with it — which is the generalisable
|
||||
half of this finding.
|
||||
|
||||
Fixed with **inheriting custom properties on `Button.astro`, not a descendant
|
||||
rule in `global.css`**, and that choice is load-bearing: `.section-inverse
|
||||
.btn-ghost` would compile at (0,2,0), identical to `.btn-ghost[data-astro-cid]`,
|
||||
so which won would depend on injection order — a trap this record shows being hit
|
||||
once already on `.btn-gold`. Custom properties inherit, which is the one
|
||||
mechanism that legitimately crosses the boundary. A site-wide computed-contrast
|
||||
sweep over all 22 pages now returns **0 failures**.
|
||||
|
||||
**2. `/legal/privacy/` stated a falsehood about who receives the data.** It said
|
||||
*"Amazon Web Services is therefore a processor for this information. **No other
|
||||
third party receives it.**"* §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,
|
||||
which is the most sensitive thing the form collects. The page's own next section
|
||||
already admitted it: *"The notification sits in my mailbox."* That mailbox is
|
||||
Google's. A reader making a PIPEDA access request was told there was one
|
||||
processor when there are two. The page now names both, and names the inquirer's
|
||||
own provider as receiving the confirmation.
|
||||
|
||||
**The thirteen should-fix findings, and the two most instructive.**
|
||||
|
||||
⚠️ **`npm run og:proof` did not check article cards at all — the one surface
|
||||
`check:claims` cannot reach.** For an article path it matched the card FILENAME
|
||||
and `continue`d, skipping the headline comparison entirely, so its own header and
|
||||
`docs/04` both described a check it was not performing. Proved with a probe:
|
||||
`DELIBERATELY WRONG CARD TEXT` in the endpoint, one article published, and the
|
||||
script printed *"no card asserts anything its page does not"*, exit 0.
|
||||
`checkedHeadlines` stayed pinned at the registry size however many articles
|
||||
published — a coverage number that reads like completeness and falls further
|
||||
behind as the site grows.
|
||||
|
||||
⚠️ **AND THE FIRST REPAIR WAS WORSE: IT COMPARED THE ARTICLE'S `<h1>` AGAINST
|
||||
ITSELF.** A tautology. The same probe passed again. Caught by re-running the
|
||||
probe rather than by reading the patch — which is the entire argument for round 2
|
||||
and for probing a check instead of inspecting it.
|
||||
|
||||
**The working fix is not a cleverer comparison; it leaves nothing to compare.**
|
||||
The derivation moved into `articleCard()` in `src/data/og-cards.ts`, called by
|
||||
both the endpoint and the proof script, so the endpoint holds no headline literal
|
||||
that could disagree with anything. The script then checks the one thing still
|
||||
able to drift — whether the article's own frontmatter `title` is what the route
|
||||
renders as its `<h1>` — plus a coverage assertion that every non-portrait page
|
||||
was compared. Re-probed by making the route render `{data.title} — probe`: exit
|
||||
1, both sides printed, and the coverage assertion fired as an independent second
|
||||
signal.
|
||||
|
||||
The other eleven, each acted on: `SiteFooter` links `/insights/` from **all 22
|
||||
pages** while `insights/index.astro` claimed nothing linked into an empty section
|
||||
(the link stays — `docs/01` specifies the footer as the full sitemap — so the
|
||||
sentence changed); `/contact/received/` stated a confirmation *"has been sent"*
|
||||
on two paths that reach it having sent nothing (the honeypot, and
|
||||
`Promise.allSettled` on both emails rejecting); the committed PDF is
|
||||
**indexable**, re-creating the duplicate `/bio/` was made `noindex` to prevent —
|
||||
`X-Robots-Tag` is now a cutover item, because a `Disallow` will not de-index;
|
||||
the radio group's touch target measured **25.6 px** against `docs/02`'s 44 px
|
||||
floor; the consent checkbox's accessible name was a **250-character paragraph**
|
||||
with a link nested inside a label for another control; the handler **hand-typed**
|
||||
`NO_RETAINER_NOTICE` and **dropped its fourth clause** (*"and does not itself
|
||||
create a conflict check"*, which `docs/01` requires) three lines below a comment
|
||||
explaining why `RESPONSE_TIME` is injected rather than typed; the confirmation
|
||||
email printed **raw field names** to the inquirer (`practiceArea: Construction`);
|
||||
`overtimeStartsAfterSessionHours` was **dead data with a 21-line comment
|
||||
claiming a live constraint** — nothing read it, so reversing Q59 would have
|
||||
changed nothing and failed nothing, which is Q22's shape at constant scope;
|
||||
`priceRange` **mixed units and understated the floor** (see below); two comments
|
||||
asserted `priceRange` was absent in the file that set it; and the deploy guard
|
||||
blocked on `INTAKE_ENDPOINT`, which **nothing in `src/` reads** since step 8
|
||||
moved the form to a same-origin path, while the thing that decides whether the
|
||||
form works — the CloudFront `/api/*` behaviour — was guarded nowhere.
|
||||
|
||||
⚠️ **`priceRange` IS OUT, AND IT WENT IN AND OUT ON THE SAME DAY.** `docs/04`
|
||||
gates it on `/fees/` existing; the gate was met, so it shipped as `$500–$9,500`.
|
||||
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 fee. The
|
||||
floor also misinformed in the direction that matters: the least anyone pays for
|
||||
the headline service is **$2,000**, so the one machine-readable number on the
|
||||
site advertised a quarter of the real entry price. **Omitted rather than
|
||||
repaired** — `docs/04` gates the field, it does not require it, and `/fees/`
|
||||
publishes the conditions that make any single range meaningless.
|
||||
|
||||
**Declined: none.** All 16 acted on. One partially: the D19 comment-ratio
|
||||
"consider". The named outliers were trimmed — the parenthetical in `index.astro`
|
||||
narrating a previous version of itself, `insights.ts`, the OG endpoint,
|
||||
`ANALYTICS.installed`'s 17 lines for one boolean, and `/about/`'s contrast
|
||||
reasoning written twice — **and the ratio still rose, 0.40 → 0.44**, because
|
||||
sixteen fixes each carry their reason. Reported rather than churned; it is the
|
||||
one review item this entry does not claim to have satisfied.
|
||||
|
||||
**One false claim of my own, struck:** `scripts/bio-pdf.mjs` said the PDF renders
|
||||
*"deterministically"*. Two runs gave 89,496 bytes both times and **different
|
||||
digests** — Chrome stamps a `/CreationDate`. The consequence is real: every
|
||||
re-render is a binary diff, so a reviewer cannot tell a content change from a
|
||||
no-op. The comment now says so, and `docs/06` says re-commit it when something
|
||||
actually changed.
|
||||
|
||||
### ⚠️ `npm run format` BROKE THE BUILD, AND `*.mdx` IS NOW IN `.prettierignore`
|
||||
|
||||
Found at the end of the run, by running the check rather than assuming it.
|
||||
**Prettier 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 is fine in the source and dead at
|
||||
build**, which is the same shape as the `animation-timeline` minifier defect and
|
||||
the `import.meta.url` path bug in the card generator: three defects in one run
|
||||
that only exist in the built output.
|
||||
|
||||
`.prettierignore` already carried `*.md` and not `*.mdx`. It now carries both,
|
||||
and the entry records the second reason as well, which would matter even if
|
||||
Prettier fixed the first: **these five files are hand-audited prose.** They were
|
||||
read line by line against §4 and against the sourced extracts, and 76 findings
|
||||
were applied to them. Machine-reflowing audited copy means the committed bytes
|
||||
are no longer the bytes that were audited. The two articles Prettier had already
|
||||
touched were restored from the audited copies, digests compared, **both files
|
||||
asserted to exist before comparing**.
|
||||
|
||||
*(Prettier's other change was benign and is worth recording so the ignore does
|
||||
not read as broader than it is: on the second article it converted a YAML
|
||||
`'Ontario''s …'` to `"Ontario's …"`, which is the same string more clearly
|
||||
escaped. Restored anyway, so the committed articles are byte-identical to the
|
||||
ones `check:claims` passed over.)*
|
||||
|
||||
**One `eslint-disable` was added in the same pass, with its reason in the code:**
|
||||
`no-control-regex` on the handler's sanitiser. That rule exists to catch a control
|
||||
character that reached a pattern by accident; here the control range **is** what
|
||||
is being matched, and it is the part of the function that stops a submitted value
|
||||
forging an email header.
|
||||
|
||||
### Round 2: 9 findings, and EIGHT OF NINE were defects in round 1's own fixes
|
||||
|
||||
D19 caps the loop at two rounds and this is why the second one exists. The
|
||||
previous measurement behind that cap was *"most of round two's were defects in
|
||||
round one's fixes"*; this run reproduced it almost exactly, and the hit rate was
|
||||
predictable from inside round 1 — the `og:proof` repair had already been caught
|
||||
being a tautology and re-fixed before round 2 started.
|
||||
|
||||
**BLOCKING — the fix for finding 9 would have lost every inquiry.** Round 1
|
||||
converted the hand-typed no-retainer notice into `requireEnv('NO_RETAINER_NOTICE')`
|
||||
— a module-scope throw — **and added the variable to no document**, while the
|
||||
fix's own comment claimed *"`docs/06` names it."* It did not:
|
||||
`grep -c NO_RETAINER_NOTICE docs/06-deployment.md` returned **0**, and the
|
||||
deployment list named five variables for a handler that required six. An operator
|
||||
working the cutover checklist sets five, the Lambda throws at cold start on
|
||||
**every** invocation, API Gateway answers 5xx, and every inquiry from counsel is
|
||||
lost from the moment `/api/*` is wired — **loud in CloudWatch, silent to Pouya**,
|
||||
who has no reason to submit his own form. `docs/06` and `docs/05` now name all
|
||||
six; the comment that asserted the documentation existed is what made it
|
||||
invisible, and it is corrected rather than deleted.
|
||||
|
||||
**The intake route check I added to replace the stale guard could not fail.**
|
||||
`code=$(curl … -w '%{http_code}' … || echo 000)` — `curl -w` already prints `000`
|
||||
on a failed transfer, so the `||` double-appended and `$code` became `000000`,
|
||||
making the failure arm unreachable. And the pass arm accepted anything that was
|
||||
not literally `404`. The case that matters most was the one it waved through:
|
||||
**with the `/api/*` behaviour missing, CloudFront answers 403 for a method the
|
||||
default behaviour does not allow — indistinguishable from the handler's Origin
|
||||
refusal**, which is the single distinction the check exists to draw. It now sends
|
||||
the correct `Origin` and asserts a positive: `303` with a `Location` ending
|
||||
`/contact/could-not-send/`, which the handler returns **before any DynamoDB write
|
||||
and before any email**, and which is what makes the probe safe against
|
||||
production. Probed on four cases — refused connection, 501, 403, and the real
|
||||
303. **The old version passed the first three.** Fixed in both deploy paths.
|
||||
|
||||
**Removing `priceRange` left three live statements saying it was present or
|
||||
pending**, one of them the stated justification for `/fees/` emitting no `Offer`
|
||||
node — so that reasoning rested on a field that no longer existed, which is how
|
||||
the next reader re-adds one. **Deleting `overtimeStartsAfterSessionHours` left §9
|
||||
naming it** — Current Truth pointing at an identifier that does not exist, the
|
||||
same "flag that looks like a control" shape one layer up — and left the Q15–Q17
|
||||
row saying *"where it begins is §9 Q59, open"* eleven lines below the row
|
||||
recording it closed. **The Google fix was applied to §Where it is stored and not
|
||||
to §Who can see it**, so the page answered *"who can see the names of the
|
||||
opposing parties I gave you?"* with **"Nobody else has access"** under that
|
||||
heading and **"Google"** two sections earlier.
|
||||
|
||||
⚠️ **AND THE VARIABLE REMOVAL WAS JUSTIFIED WITH A PATH-SCOPED GREP, WHICH IS
|
||||
BOTH OF `CLAUDE.md`'s SWEEP RULES AT ONCE.** `git grep PUBLIC_INTAKE_ENDPOINT -- src/`
|
||||
was offered as proof of a repo-wide removal — and `git grep` additionally cannot
|
||||
see untracked files, which at that moment was most of the new work. The unscoped
|
||||
sweep found `docs/06`'s required-variables table still instructing an operator to
|
||||
set both, the OIDC example still setting them, and **`.env.example` still holding
|
||||
the full execute-api hostname — which falsifies a live claim in
|
||||
`src/data/intake.ts` that *"§7 remains the only place it lives"***. That claim is
|
||||
corrected, `docs/06`'s rows are gone, the OIDC example is marked superseded, and
|
||||
`deploy-local.sh`'s three-day-old *"Q22 records that it does NOT yet exist"* about
|
||||
the deploy credential is corrected against §7's PROVISIONED row.
|
||||
|
||||
⚠️ **`.env.example` ITSELF IS NOT EDITED, AND THAT IS A LIMIT RATHER THAN A
|
||||
CHOICE: this environment denies read access to it, and nothing here may edit a
|
||||
file it cannot read.** Batched for Pouya — delete `PUBLIC_INTAKE_ENDPOINT`,
|
||||
`PUBLIC_BOOKING_URL` and `PUBLIC_ANALYTICS_*`, none of which anything reads.
|
||||
|
||||
**Two smaller ones, both comments that pointed at nothing.** `og-card.ts` told a
|
||||
reader to run `npm run og:proof` to see every card in `dist/og-proof/` — wrong
|
||||
path, and the sheet requires `-- --sheet`, so the one documented mitigation for
|
||||
that file's own stated overflow hazard was wrong in both the path and the
|
||||
command. And **`og:image:alt` was the page `<title>`, not the card's headline**,
|
||||
on 20 pages: `/fees/` emitted *"Fees · Mediation and Arbitration Rates · Pouya
|
||||
Lajevardi"* against a card reading *"Published in full, including what overruns
|
||||
cost."* Now `OG_CARDS[path]?.headline ?? title`.
|
||||
|
||||
**What round 2 confirmed, which is the other half of its value.** All 56 `.btn`
|
||||
instances across all 22 pages measured with composited backgrounds: `/fees/`'s
|
||||
ghost button is **16.81:1**, every ghost and gold button ≥ 11.09:1, and the
|
||||
on-cream appearance is unchanged. The consent checkbox's accessible name computed
|
||||
from the AX tree is `CONSENT_TEXT` alone. Radio labels hit-test at **70.6 × 44**
|
||||
and **76.7 × 44**, clickable at all four corners. `og:proof` was exercised in a
|
||||
sandbox with five synthetic article pages: correct `<h1>`s gave *"25 card
|
||||
headlines matched (5 of them articles)"*, and one wrong `<h1>` produced both the
|
||||
mismatch and the coverage shortfall, exit 1. Zero text-contrast failures on all
|
||||
22 pages. All five MDX bodies compile against the installed `@mdx-js/mdx`.
|
||||
|
||||
**Declined: none of the nine.** One partially satisfied, again: the D19 comment
|
||||
ratio. The named blocks were trimmed — `Button.astro`'s Lighthouse anecdote,
|
||||
`global.css`'s duplicate of the same story, `schema.ts`'s 25 lines for an absent
|
||||
field, `Prose.astro`'s 15 for zero, `contact.astro`'s 22 for 16 — and **the ratio
|
||||
held at 0.44**, because round 2's own nine fixes each carry their reason. The
|
||||
trims removed roughly what the fixes added. Recorded as the one item this entry
|
||||
does not claim to have satisfied, for the second round running.
|
||||
|
||||
**There is no round 3 — D19.** Round 2's findings are fixed and this ships.
|
||||
|
||||
### `README.md`'s script table was missing a control, and had been for two days
|
||||
|
||||
It listed seven commands and **not `npm run check:claims`** — the one that runs on
|
||||
every deploy and is, under D20, the only per-step claims control there is. The
|
||||
four added in this run were missing too. **A table of the project's controls that
|
||||
omits a control is the shape those controls exist to catch**, and it is the first
|
||||
table a newcomer reads. Now eleven rows, with the two that cannot run in CI
|
||||
marked as such and the reason given, and with a parenthetical recording the
|
||||
omission rather than quietly closing it.
|
||||
|
||||
### Verification — run, with exit statuses read directly rather than through a pipe
|
||||
|
||||
```
|
||||
npm run check exit 0 0 errors, 0 warnings, 0 hints (56 files)
|
||||
npm run lint exit 0 eslint + prettier, after `*.mdx` was ignored
|
||||
npm run build exit 0 22 pages
|
||||
npm run check:claims exit 0 11 patterns, 22 pages · and 27 pages with the articles published
|
||||
npm run og:proof exit 0 21 og:image targets resolve; 20 card headlines == their page <h1>
|
||||
npm run check:intake exit 0 12 form fields vs 12 handler fields
|
||||
npm run lighthouse exit 0 22 pages — table below
|
||||
npm run bio:pdf exit 0 89,496 bytes, 1 page, Letter
|
||||
minifier tripwire exit 1 clean (no `animation:` beside a scroll timeline)
|
||||
```
|
||||
|
||||
**All 20 generated cards were LOOKED AT, not just string-compared.**
|
||||
`npm run og:proof -- --sheet` writes a contact sheet to `dist/og-proof.jpg`; the
|
||||
3 × 7 grid was read. Every card is legible, on the site's own type and palette,
|
||||
carries the designation line and the mark, and none overflows or clips — the
|
||||
longest headline (`/practice/energy/`, 63 characters) wraps to two lines and
|
||||
fits. **This is the step the R15 discharge says the check cannot do**: `og:proof`
|
||||
compares strings and cannot see a card that renders badly. The 21st `og:image`
|
||||
target is the portrait, which `/` and `/about/` share by decision.
|
||||
|
||||
**And the five drafted articles were link-checked separately, because the sweep
|
||||
over `dist/` cannot see them.** A draft ships no HTML, so its links are outside
|
||||
every check on this project until the day it publishes — which is the worst
|
||||
moment to discover one is broken. **29 internal links across the five, 0
|
||||
unresolved against the 22 built pages, and 0 external links in any of them** (so
|
||||
no link rot to inherit, and no URL that would need its own source under R14).
|
||||
|
||||
**Rendered with SCRIPT EXECUTION DISABLED at the CDP level** — not merely "no
|
||||
`<script>` tags present", which is the weaker claim:
|
||||
|
||||
```
|
||||
/ nodes= 315 text= 4761ch h1=1 links=50 forms=0 controls= 0
|
||||
/contact/ nodes= 295 text= 2972ch h1=1 links=40 forms=1 controls=16
|
||||
/fees/ nodes= 296 text= 3736ch h1=1 links=39 forms=0 controls= 0
|
||||
/insights/ nodes= 177 text= 1611ch h1=1 links=39 forms=0 controls= 0
|
||||
/legal/privacy/ nodes= 209 text= 5655ch h1=1 links=39 forms=0 controls= 0
|
||||
/bio/ nodes= 218 text= 3128ch h1=1 links=37 forms=0 controls= 0
|
||||
```
|
||||
|
||||
`/contact/` is the page that matters there: **the intake form and all sixteen of
|
||||
its controls render and are usable with scripting off**, which is the whole point
|
||||
of the POST-redirect-GET design rather than a happy consequence of it.
|
||||
|
||||
Measured on `dist/` as well: **0 `.js` files, 0 `client:*` directives**, one
|
||||
`<h1>` on every page, **22 unique titles all 50–60 characters and 22 unique
|
||||
descriptions all 140–160**, canonical + ≥10 OG + ≥5 Twitter tags on every page,
|
||||
every JSON-LD block parsing with a `@graph`, no heading-level jumps, **25 distinct
|
||||
internal links and 0 dangling**, and a sitemap of **17 URLs** — 22 pages less the
|
||||
five `noindex` ones, which is the arithmetic working.
|
||||
|
||||
**Lighthouse, 22 pages, mobile — re-run on the FINAL bytes after every round-1
|
||||
fix, because the earlier numbers described superseded output:**
|
||||
|
||||
| | worst | note |
|
||||
|---|---|---|
|
||||
| Performance | **99** | `/` only; every other page 100 |
|
||||
| Accessibility | **100** | every page. Measured with `prefers-reduced-motion` forced — see §7 |
|
||||
| Best practices | **100** | every page |
|
||||
| SEO | **100** | every *indexable* page. The five `noindex` pages are asserted audit by audit |
|
||||
| CLS | **0.000** | every page |
|
||||
| LCP | 1.50–2.03 s | one page at or over budget: `/` |
|
||||
|
||||
**`/` LCP is 2.03 s against `docs/04`'s < 2.0 s, and it is left alone
|
||||
deliberately.** The LCP element is **`p.hero-lede` — text, not the portrait**
|
||||
`[verified — lcp-breakdown-insight]`, so `fetchpriority="high"` on the hero image
|
||||
would misdirect priority away from the Geist face that actually gates it. The
|
||||
remaining lever is `BaseLayout`'s three-face preload, which is a documented trade
|
||||
set by measurement. Reported, not chased.
|
||||
|
||||
**`/about/`'s new inverse band was verified by measuring the rendered elements**,
|
||||
not by reading the CSS: background `rgb(26,22,20)`, 14 text elements, **worst
|
||||
ratio 11.09:1**. The one rule that had to change was `.cred-title`, which was
|
||||
`--text-secondary` — **1.43:1 on ink**, worse than the gold-on-cream 2.10:1 this
|
||||
project treats as the defect that must never ship.
|
||||
|
||||
### What is NOT done, and is not hidden
|
||||
|
||||
- **Nothing is deployed.** D11, and step 11 has not started.
|
||||
- **The intake form does not work.** The handler is undeployed and the CloudFront
|
||||
`/api/*` behaviour does not exist. Three cutover items.
|
||||
- **DynamoDB TTL is unverified and `/legal/privacy/` promises automatic
|
||||
deletion.** The handler writes `ttl`; TTL must also be *enabled on the table*
|
||||
and §7 does not record that it is. **A cutover blocker on a published promise.**
|
||||
- **API Gateway rate limiting is not configured**, and it is carrying load the
|
||||
unimplementable timing check was specified to carry.
|
||||
- **`claims-auditor` has not run.** D20 puts its single pass at cutover, over the
|
||||
whole finished site, and `docs/06` carries it as blocking.
|
||||
- **The five articles are unread by Pouya**, which is D9 and is the point.
|
||||
- **R6 booking stays parked**; `/contact/` mentions it nowhere.
|
||||
- **`struck-universal-q39`** still has no §4 row and no checklist line — carried,
|
||||
unresolved, and his call which side it is fixed on.
|
||||
- **`/legal/terms/` has no governing-law clause, no limitation of liability and no
|
||||
warranty disclaimer.** §4 bars this repository from concluding a proposition of
|
||||
law; batched for him.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-31 (ag) — Q58 and Q57 ruled and closed; §4's mediation row enumerates all six areas; `docs/03`'s compliance checklist stops paraphrasing §4 and cites it; and a derived fee term was struck from the one document that is the authority on money
|
||||
|
||||
**Four rulings from Pouya, plus what implementing them turned up.** Both of the
|
||||
|
||||
@@ -136,11 +136,32 @@ 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 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.
|
||||
|
||||
**`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
|
||||
|
||||
```
|
||||
@@ -456,13 +477,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
|
||||
|
||||
@@ -470,7 +508,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,117 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 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 NOT DEPLOYED. Written at build step 8; nothing on this project
|
||||
* deploys before cutover (D11). AGENTS.md §7 records that a hand-built
|
||||
* `adr-intake-handler` already exists in the console, created before this repo,
|
||||
* and this file REPLACES it rather than describing it. docs/06's cutover
|
||||
* checklist carries the deployment steps and the CloudFront `/api/*` behaviour
|
||||
* the form depends on. Until both are done the form on /contact/ posts into
|
||||
* nothing, which is why that page also publishes the email address.
|
||||
*
|
||||
* ── 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 ──────────────────────────────
|
||||
*
|
||||
* **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 honeypot, the
|
||||
* Origin check, the API Gateway rate limit and the validation below.
|
||||
*
|
||||
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
|
||||
*
|
||||
* - API Gateway throttling, 5 requests / 5 minutes per source IP (docs/05).
|
||||
* Not expressible in handler code.
|
||||
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
|
||||
* - A dead-letter queue on this function and a CloudWatch alarm on DLQ depth
|
||||
* >= 1 (docs/05). This handler writes to DynamoDB BEFORE sending mail so a
|
||||
* DLQ replay cannot lose a submission.
|
||||
* - The `ses-alerts` SNS email subscription is PENDING CONFIRMATION (§7, R9).
|
||||
* Until it is confirmed the bounce and complaint alarms fire into nothing.
|
||||
*/
|
||||
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 the honeypot name 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 { FIELDS, HONEYPOT } from './fields.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 — enforced by DynamoDB TTL, "not a policy
|
||||
* someone remembers". It must match /legal/privacy/ exactly. */
|
||||
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}`);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') {
|
||||
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();
|
||||
const ttl =
|
||||
Math.floor(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth() + RETENTION_MONTHS,
|
||||
now.getUTCDate(),
|
||||
now.getUTCHours(),
|
||||
now.getUTCMinutes(),
|
||||
now.getUTCSeconds(),
|
||||
) / 1000,
|
||||
) || 0;
|
||||
|
||||
/**
|
||||
* 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 the DLQ replay has something to replay; if the
|
||||
* write fails, nothing was accepted and the inquirer is told so.
|
||||
*/
|
||||
try {
|
||||
await ddb.send(
|
||||
new PutItemCommand({
|
||||
TableName: TABLE,
|
||||
Item: {
|
||||
pk: { S: `INTAKE#${id}` },
|
||||
sk: { 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.
|
||||
sourceIp: { S: event.requestContext?.http?.sourceIp ?? 'unknown' },
|
||||
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');
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` },
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Received ${now.toISOString()}\nRecord INTAKE#${id}\n\n${summaryLines}\n`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
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);
|
||||
}
|
||||
+65
-8
@@ -49,7 +49,26 @@ 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 three more added at build steps
|
||||
8 and 9, all three `noindex` and all three 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)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**So: 22 built pages plus one per published article.** `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.
|
||||
|
||||
### URL rules
|
||||
|
||||
@@ -191,8 +210,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
|
||||
@@ -620,8 +661,24 @@ 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; the pipe behind it is not.** The handler
|
||||
is written (`backend/intake/`) and undeployed, and the CloudFront `/api/*`
|
||||
behaviour it posts to does not exist yet. Both are cutover items, and `docs/05`
|
||||
§Build step 8 records three deliberate deviations from that spec
|
||||
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
|
||||
|
||||
+70
-22
@@ -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 |
|
||||
@@ -167,16 +189,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 +247,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
|
||||
|
||||
+121
-9
@@ -16,6 +16,98 @@ 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` — the handler that **replaces** the hand-built
|
||||
`adr-intake-handler` §7 records.
|
||||
|
||||
**What is NOT done, and the form does not work until it is.** Nothing on this
|
||||
project deploys before cutover (D11), so: the handler is not deployed, and the
|
||||
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
|
||||
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
|
||||
as the form for exactly this reason.
|
||||
|
||||
### 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
|
||||
API Gateway rate limit and server-side validation.
|
||||
|
||||
**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
|
||||
@@ -192,6 +284,16 @@ DLQ depth ≥ 1.
|
||||
|
||||
## 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 +333,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 +362,16 @@ 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] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
|
||||
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
|
||||
- [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. The handler writes the `ttl` attribute; TTL must also be **enabled on the table**, and §7 does not record that it is. Until this is verified the page promises a mechanism that may not run
|
||||
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
|
||||
- [ ] 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
|
||||
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
|
||||
- [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`
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **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
|
||||
|
||||
+125
-22
@@ -162,10 +162,31 @@ they are useful for debugging.
|
||||
| `AWS_REGION` | `AGENTS.md` §7 — Region |
|
||||
| `S3_BUCKET` | §7 — S3 bucket |
|
||||
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
|
||||
| `INTAKE_ENDPOINT` | §7 — Intake API |
|
||||
| `BOOKING_URL` | *(empty — parked, R6)* |
|
||||
|
||||
The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||
⚠️ **`INTAKE_ENDPOINT` AND `BOOKING_URL` WERE ROWS HERE AND ARE GONE, 2026-08-31.**
|
||||
Build step 8 moved the intake form to the same-origin path `/api/intake`, after
|
||||
which nothing in the build read either one — and both deploy paths were still
|
||||
refusing to run without `INTAKE_ENDPOINT`. **The removal was made in the two
|
||||
scripts and not here**, so this table went on telling an operator to set a
|
||||
variable no guard checked and no build consumed. Found by
|
||||
`adversarial-reviewer` round 2, whose sharper point is about the evidence: the
|
||||
removal was justified with `git grep PUBLIC_INTAKE_ENDPOINT -- src/` — a
|
||||
**path-scoped** grep offered as proof of a repo-wide removal, and `git grep`
|
||||
additionally cannot see untracked files, which at that moment was most of the new
|
||||
work. That is `CLAUDE.md`'s *a sweep is a command, not a claim* and *sweep the
|
||||
vocabulary, not only the subject*, both at once.
|
||||
|
||||
⚠️ **AND ONE PLACE STILL CARRIES IT: `.env.example` sets
|
||||
`PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname, and
|
||||
`PUBLIC_BOOKING_URL`.** That falsifies a live claim in `src/data/intake.ts` —
|
||||
which said the endpoint id *"is not restated in the repo either"* — and the claim
|
||||
has been corrected there rather than left standing. **The file itself was not
|
||||
edited: this environment denies read access to it, and nothing in this repo may
|
||||
edit a file it cannot read.** It is in the batched list for Pouya: delete both
|
||||
lines, and `PUBLIC_ANALYTICS_*` with them, since `ANALYTICS` is a literal in
|
||||
`src/data/site.ts`.
|
||||
|
||||
The three values above fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here** —
|
||||
§7 is the single source of truth for operational facts, and the copy that goes
|
||||
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
|
||||
@@ -321,11 +342,13 @@ link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
||||
(only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
|
||||
`npm run build`, `npm run check` and `npm run lint` all run clean locally.
|
||||
|
||||
**Lighthouse is not one of the checks that could be wired today.** `@lhci/cli`
|
||||
was removed on 2026-08-26 and there is no `npm run lighthouse` script any more —
|
||||
`AGENTS.md` §7 records why and what re-adding it at build step 7 requires. Wire
|
||||
the other four; do not write a workflow step that calls a script that does not
|
||||
exist.
|
||||
**Lighthouse is still not one of the checks that can be wired — and the reason
|
||||
changed on 2026-08-31.** `npm run lighthouse` exists again (step 7a, `AGENTS.md`
|
||||
§7), so the blocker is no longer a missing script: it is that standalone
|
||||
Lighthouse drives an **installed browser** and the runner has none. Wiring it
|
||||
would need Chrome on the `act_runner` host (Q23). Wire the other four; do not
|
||||
write a workflow step that shells out to a browser that is not there. The gate
|
||||
lives locally and on the cutover checklist below.
|
||||
|
||||
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
@@ -399,31 +422,111 @@ Then invalidate `/*`.
|
||||
expressed without naming the designation; that gap is `claims-auditor`'s to
|
||||
close, and it is stated in the pattern itself
|
||||
- [ ] **C.Med-Arb appears nowhere in `dist/`** — struck entirely 2026-08-29
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
||||
- [ ] Privacy policy matches the backend as actually built
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship. **And the reservation sentence sits adjacent to the overtime row** — Q59, ruled 2026-08-31: a reader who takes the $500 and skips *"a full day reserves the day; half-day overtime is subject to availability"* has read a different offer, and the arithmetic then makes the full-day rate look strictly worse
|
||||
- [ ] Privacy policy matches the backend as actually built. **Three of its statements are derived and cannot drift** — the collected-data list from `INTAKE_FIELDS`, the retention period from the handler's own figure, the analytics paragraph from `ANALYTICS.installed`. **If analytics are installed, that flag flips and the policy's last-updated date moves on the same build**; a policy naming a processor that processes nothing is a false disclosure
|
||||
- [ ] **`/insights/` — check which state it is in.** While no article is published the page emits `noindex` (derived from the collection) and `SiteHeader` keeps Insights out of the primary nav until **two** are live. Both clear themselves on publication. D9 means the flip is Pouya's: `src/content.config.ts` refuses `draft: false` without `reviewedByPouya: true`
|
||||
- [ ] **The five drafted articles are `draft: true` and therefore invisible to `check:claims`**, which reads `dist/`. They were checked once, on 2026-08-31, by temporarily publishing all five and running it — clean on 27 pages. **Re-run it that way before any of them publishes**, because a draft that changes in between is unchecked
|
||||
|
||||
**Technical**
|
||||
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
|
||||
verified against the registry that day, and a `lighthouserc` carrying the
|
||||
budgets from `04-seo-spec.md`. This box gates the next one
|
||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
||||
- [ ] **`npm run lighthouse` on the full built site**, from a machine with
|
||||
Chrome — it is not in CI and cannot be (see above). Read the exit status.
|
||||
Every category ≥ 95 on every page; the accessibility figure is measured
|
||||
with `prefers-reduced-motion` forced and that qualification is reported
|
||||
with it (`AGENTS.md` §7)
|
||||
- [ ] Every page renders fully with JavaScript disabled
|
||||
- [ ] `curl` of each URL returns real content, not a shell
|
||||
- [ ] All internal links resolve; no orphan pages
|
||||
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
||||
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
||||
- [ ] **OG cards are per-page, not one portrait on all nineteen** — `AGENTS.md`
|
||||
Q40 / **R15**. The portrait is the decided card for `/` and `/about/`; every
|
||||
other page needs the generated typed card, built at step 7 with Insights.
|
||||
**This blocks cutover.** A link preview is the surface a general counsel
|
||||
actually sees when a colleague pastes the URL into Teams, and the interim
|
||||
makes nineteen unique titles look identical
|
||||
- [ ] **`npm run og:proof`** — every page's `og:image` resolves to a file in
|
||||
`dist/`, and every generated card's headline is its page's own `<h1>`.
|
||||
R15 is discharged (step 7b) so this is a regression check rather than a
|
||||
blocker on the interim; it stays on the list because a broken preview image
|
||||
is invisible from inside the repo, and because card text cannot be reached
|
||||
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
|
||||
contact sheet** as well — the check compares strings, not rendering
|
||||
- [ ] 404 returns a 404 status
|
||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
||||
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [ ] Intake form tested end to end: DynamoDB record written to the intake table (`AGENTS.md` §7), both emails delivered to a real inbox, TTL set
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
|
||||
IT DOES — build step 8 shipped the page and not the pipe.**
|
||||
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
|
||||
`AGENTS.md` §7 records. The form posts to the same-origin path
|
||||
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
|
||||
so there is no cross-origin POST, so the endpoint id stays out of the
|
||||
HTML, and so submitting the form from a laptop does nothing. Without
|
||||
this behaviour the form 404s.
|
||||
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
|
||||
replacing the hand-built `adr-intake-handler` §7 records. It needs
|
||||
**SIX** variables — `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`,
|
||||
`MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`; it throws at cold
|
||||
start on any missing one, on purpose. ⚠️ **This list said five until
|
||||
2026-08-31 and the handler required six.** `NO_RETAINER_NOTICE` became a
|
||||
`requireEnv` in the same change set and was added to no document, so an
|
||||
operator working this list would have set five, and the Lambda would
|
||||
have thrown on **every** invocation — API Gateway answering 5xx and every
|
||||
inquiry from counsel lost from the moment `/api/*` was wired. Loud in
|
||||
CloudWatch, silent to Pouya, who has no reason to submit his own form.
|
||||
Found by `adversarial-reviewer` round 2.
|
||||
**Two of the six must be verbatim from `src/data/site.ts`:**
|
||||
`RESPONSE_TIME` from `CONTACT.responseTime` and `NO_RETAINER_NOTICE`
|
||||
from `NO_RETAINER_NOTICE`. Both are published commitments — the first is
|
||||
§4/Q27's two-business-day response, the second is the no-retainer notice
|
||||
`docs/01` §`/contact/` requires, **including its fourth clause about
|
||||
not itself creating a conflict check**, which a hand-typed copy in the
|
||||
handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
|
||||
in the confirmation email, and in the bio.
|
||||
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
|
||||
(`docs/05`). Not expressible in handler code, and it is carrying load
|
||||
the unimplementable 3-second timing check was specified to carry — see
|
||||
`docs/05` §Three deviations.
|
||||
|
||||
✅ **BOTH DEPLOY PATHS NOW WARN IF ITEM 1 IS MISSING**, so this does not
|
||||
rest on someone reading this list. `scripts/deploy-local.sh` and
|
||||
`.gitea/workflows/deploy.yml` each end with an **Intake route check**: a
|
||||
`POST` to `/api/intake` with no `Origin`. **404 means not routed. 403 means
|
||||
routed and refused**, which is the handler's `Origin` check doing its job
|
||||
and is therefore a pass — and is why the probe is safe against production:
|
||||
it is rejected before any DynamoDB write or any email. It warns rather than
|
||||
failing, because by that point the site is already deployed and failing the
|
||||
job would not un-deploy it. This replaced an `INTAKE_ENDPOINT` guard that
|
||||
blocked deploys on a variable nothing read.
|
||||
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
|
||||
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
|
||||
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
|
||||
by the database rather than by someone remembering to do it"* after 24
|
||||
months. The handler writes the `ttl` attribute; **TTL must also be enabled
|
||||
on the table**, and §7 does not record that it is. Until this is verified,
|
||||
that page promises a mechanism that may not run — which on a privacy policy
|
||||
is a false statement to the public. `aws dynamodb describe-time-to-live
|
||||
--table-name <the table §7 records>` and confirm
|
||||
`TimeToLiveStatus: ENABLED`, then write a record with a near-future `ttl`
|
||||
and confirm it disappears.
|
||||
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
|
||||
on names, requiredness, caps and option sets. They are two independent
|
||||
tables on purpose (`docs/05`); this is what stops them drifting
|
||||
- [ ] **Both POST-redirect-GET landing pages reachable and `noindex`** —
|
||||
`/contact/received/` and `/contact/could-not-send/`. Both are excluded from
|
||||
the sitemap in `astro.config.mjs`
|
||||
- [ ] **Regenerate and re-commit the bio PDF** — `npm run build && npm run bio:pdf`.
|
||||
Nothing in the build does this, so a change to `/bio/`, to §4, to the rate
|
||||
card or to the print styles leaves the committed PDF stale. It asserts one
|
||||
page and writes nothing if the count is wrong (R16). ⚠️ **It is not
|
||||
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
|
||||
identical content differ in digest and every re-render is a binary diff.
|
||||
Re-commit it when something actually changed, and say what in the message
|
||||
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**, via a CloudFront response-headers
|
||||
policy. **This is the PDF half of a decision already taken for the page.**
|
||||
`/bio/` is `noindex` and excluded from the sitemap because 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."* The committed
|
||||
PDF is a rendering of that same page, it is linked from `/about/` which is
|
||||
`index,follow`, `robots.txt` allows everything, and **Google indexes PDFs** —
|
||||
so the duplicate the page-level `noindex` prevents arrives as a file
|
||||
instead. A `Disallow` will not do it: a blocked URL can still be listed.
|
||||
Found by `adversarial-reviewer`, 2026-08-31
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
- [ ] Tested at 320 px and at 200% zoom
|
||||
|
||||
+43
-10
@@ -141,13 +141,37 @@ sells an uncapped one. See §All parameters confirmed below.
|
||||
`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.
|
||||
|
||||
⚠️ **BUT WHERE OVERTIME BEGINS IS STILL NOT STATED, AND IT IS NOT SAFE TO INFER
|
||||
— §9 Q59, OPEN.** A first pass at this paragraph asserted that *"the overtime
|
||||
rate begins after 3 h and 6 h of session respectively"*. **That is a derived fee
|
||||
term, not part of the ruling**, and `adversarial-reviewer` was right to strike
|
||||
it: this file is the authority on money and `/fees/` is now cleared to publish
|
||||
from it. Nothing in the card, in Q58's ruling, or in Q15–Q17's answer says when
|
||||
the hourly rate starts.
|
||||
✅ **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
|
||||
@@ -182,9 +206,18 @@ not uniformly better either** — the gap stays at $2,000 through five hours and
|
||||
$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
|
||||
is not a defect this file can fix by picking a trigger, which is why the trigger
|
||||
goes to Pouya and the step goes to R5** — see §Recorded dissent below, where it is
|
||||
written out for the 12-month review rather than left in this footnote.
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+1352
-15
File diff suppressed because it is too large
Load Diff
+11
-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,11 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^7.0.8",
|
||||
@@ -27,12 +31,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.
@@ -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.',
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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 } from '../src/data/intake.ts';
|
||||
import {
|
||||
FIELDS as SERVER_FIELDS,
|
||||
HONEYPOT,
|
||||
} 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.',
|
||||
);
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
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.');
|
||||
+83
-9
@@ -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
|
||||
|
||||
@@ -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,61 @@ 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" 2>/dev/null || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
||||
echo "The contact form posts there. The site is deployed and the form is" >&2
|
||||
echo "unverified — see docs/06-deployment.md's cutover checklist." >&2
|
||||
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||
echo " POST /api/intake -> 303 -> $location (routed, validating, rejecting an empty probe)"
|
||||
else
|
||||
echo >&2
|
||||
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
||||
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can mean the" >&2
|
||||
echo "same thing — CloudFront rejecting a method the default behaviour does" >&2
|
||||
echo "not allow — or the handler refusing the Origin. Either way the form is" >&2
|
||||
echo "not verified working. See docs/06-deployment.md's cutover checklist." >&2
|
||||
fi
|
||||
|
||||
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/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}/` : '/');
|
||||
}
|
||||
}
|
||||
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,325 @@
|
||||
#!/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 });
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+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 umbrella name is connection assessment and approval. 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.
|
||||
|
||||
## What the connection assessment and approval process is
|
||||
|
||||
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. The umbrella is connection assessment and approval, or CAA. 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 published 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. The umbrella name is the 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 six 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,231 @@
|
||||
/**
|
||||
* 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, and on every length cap — and fails the build
|
||||
* script if they do not. 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.
|
||||
*/
|
||||
export const CONSENT_TEXT =
|
||||
'I consent to Pouya Lajevardi 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';
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* 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, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT
|
||||
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.**
|
||||
* Neither has been done — nothing on this project deploys before cutover (D11),
|
||||
* and both are checklist items in `docs/06`. Until then the page is complete and
|
||||
* the pipe behind it is not, which is why `/contact/` also publishes the email
|
||||
* address rather than treating the form as the only way in.
|
||||
*/
|
||||
export const INTAKE_ACTION = '/api/intake';
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
'/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`;
|
||||
}
|
||||
@@ -365,7 +365,24 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
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 and creates a connection-approval requirement for a "specified load facility", a category 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.",
|
||||
|
||||
+90
-4
@@ -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,
|
||||
@@ -217,6 +222,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 +292,14 @@ export function aboutGraph(imageUrl?: string) {
|
||||
* emitting one would assert navigation the page does not show. Breadcrumbs
|
||||
* begin at `/practice/<area>/` and `/insights/<slug>/`.
|
||||
*
|
||||
* NO `offers` AND NO `priceRange` until `/fees/` exists (build step 9) — same
|
||||
* gate docs/04 puts on `ProfessionalService`.
|
||||
* NO `offers` AND NO `priceRange` — A DECISION, NOT A GATE. This read "until
|
||||
* `/fees/` exists (build step 9)", which shipped, so it had become an
|
||||
* instruction to add them — against the decision recorded on
|
||||
* `professionalServiceNode` above, where `priceRange` went in at step 9 and came
|
||||
* out the same day. `/fees/` publishes the conditions — session length, party
|
||||
* count, format — that make any single machine-readable figure misleading, and
|
||||
* schema.org's `Offer` models one price for one item. Found by
|
||||
* `adversarial-reviewer` round 2.
|
||||
*
|
||||
* NO `availableLanguage` EITHER, AND THAT IS NOT AN OVERSIGHT. schema.org's
|
||||
* `domainIncludes` for it is `ContactPoint`, `Course`, `LodgingBusiness`,
|
||||
@@ -496,3 +516,69 @@ export function medArbGraph(opts: {
|
||||
'@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': [
|
||||
{
|
||||
'@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 },
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+37
-3
@@ -437,6 +437,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 = {
|
||||
@@ -488,14 +498,38 @@ export const FEES = {
|
||||
* *"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.
|
||||
*
|
||||
* ⚠️ WHERE OVERTIME STARTS IS NOT SETTLED — §9 Q59, OPEN. `overtimePerHour`
|
||||
* may be published; the trigger may not.
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+68
-2
@@ -588,8 +588,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}>
|
||||
@@ -835,6 +875,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 {
|
||||
@@ -860,7 +918,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,395 @@
|
||||
---
|
||||
/**
|
||||
* `/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="sheet-strap">{SITE.tagline}</p>
|
||||
</header>
|
||||
|
||||
<div class="sheet-grid">
|
||||
<section class="block block-wide">
|
||||
<h2>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. */
|
||||
}
|
||||
I act as a neutral — 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>Designations</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.designations.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Education</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.education.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Certifications</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.certifications.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Memberships</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.memberships.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Languages</h2>
|
||||
<ul role="list">
|
||||
<li>
|
||||
{CREDENTIALS.languages.join(' and ')}, without an interpreter
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Processes</h2>
|
||||
<ul role="list">
|
||||
{PROCESSES.map((p) => <li>{p}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>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>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>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);
|
||||
}
|
||||
.sheet-strap {
|
||||
margin-block-start: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.block h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
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);
|
||||
}
|
||||
.rates-list {
|
||||
max-inline-size: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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,498 @@
|
||||
---
|
||||
/**
|
||||
* `/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 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 { CONTACT, NO_RETAINER_NOTICE } from '../data/site';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
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 because I cannot accept
|
||||
an appointment before conflicts are checked, and that check needs
|
||||
names. 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. */
|
||||
}
|
||||
<div class="honeypot" 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>
|
||||
|
||||
{
|
||||
/* ⚠️ `<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 also what lets the labels stay legible at
|
||||
small sizes without a heading level. */
|
||||
.direct {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
.direct dt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
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 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);
|
||||
}
|
||||
|
||||
label,
|
||||
legend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* 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 {
|
||||
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,476 @@
|
||||
---
|
||||
/**
|
||||
* `/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.
|
||||
*
|
||||
* ⚠️ **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, agreed in the first procedural order.',
|
||||
fee: money(FEES.arbitration.documentsOnlySimple),
|
||||
},
|
||||
{
|
||||
item: 'Documents-only or expedited — complex',
|
||||
detail: 'Flat fee. Which band applies is settled before the appointment.',
|
||||
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. Other services ---------------------------------------------- */}
|
||||
<section class="section section-alt 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>
|
||||
|
||||
{/* ---- 5. Cancellation ------------------------------------------------ */}
|
||||
<section class="section 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>
|
||||
|
||||
{/* ---- 6. 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>
|
||||
+75
-10
@@ -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,6 +95,14 @@ 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
|
||||
@@ -588,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 ---------------------------------------------- */}
|
||||
{
|
||||
@@ -602,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 {
|
||||
|
||||
@@ -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,360 @@
|
||||
---
|
||||
/**
|
||||
* `/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 verifies the table's name and region and
|
||||
* **does not verify any of those three as enabled**. 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 is the one promise on this page
|
||||
* that depends on a control nobody has verified.** The handler writes the
|
||||
* `ttl` attribute; TTL must also be ENABLED on the table, which §7 does not
|
||||
* record. docs/05's definition of done carries "TTL set and verified by test
|
||||
* record" and `docs/06`'s cutover checklist now names this page as what that
|
||||
* item is protecting. It must be verified before this page is public.
|
||||
*
|
||||
* ⚠️ **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 { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { ANALYTICS, 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 = '31 August 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>
|
||||
<p>
|
||||
Submitting the form also records the date and time, your IP address
|
||||
and your browser's user-agent string. Those three are kept for
|
||||
investigating abuse of the form and are not used for anything else.
|
||||
</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: I cannot accept
|
||||
an appointment before conflicts are checked, and the check needs
|
||||
names.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</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 me and a confirmation to you — using
|
||||
Amazon Simple Email Service, also in the same Canadian region.
|
||||
</p>
|
||||
{
|
||||
/* ⚠️ THIS PARAGRAPH REPLACED A FALSE ONE, AND IT IS THE MOST SERIOUS
|
||||
THING FOUND IN THE STEP 7–10 REVIEW. It read: *"Amazon Web Services
|
||||
is therefore a processor for this information. **No other third party
|
||||
receives it.**"*
|
||||
|
||||
`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 names of opposing parties and
|
||||
their counsel, which is the most sensitive thing this form collects —
|
||||
as a mail processor. The page's own next section already admitted it:
|
||||
*"The notification sits in my mailbox."* That mailbox is Google's.
|
||||
|
||||
A reader making a PIPEDA access request was being told there was one
|
||||
processor when there are two. This page's header comment sets the
|
||||
standard the sentence failed: a statement that describes an intended
|
||||
control rather than a real one is a false statement to the public in
|
||||
a legal document, and it fails silently, because nothing breaks and
|
||||
the sentence reads correctly.
|
||||
|
||||
Found by `adversarial-reviewer`, 2026-08-31. §7 is cited rather than
|
||||
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 my own 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 receives it. There is no CRM, no mailing list, no
|
||||
analytics on the submission, and no assistant or outside
|
||||
administrator.
|
||||
</p>
|
||||
|
||||
<h2>How long it is kept</h2>
|
||||
<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 my mailbox and
|
||||
the confirmation sits in yours, and neither is deleted by that
|
||||
mechanism.
|
||||
</p>
|
||||
|
||||
<h2>Who can see it</h2>
|
||||
{
|
||||
/* ⚠️ THIS SAID "Nobody else has access" AND THE SECTION TWO ABOVE HAD
|
||||
JUST NAMED GOOGLE. The Google correction was applied to §Where it is
|
||||
stored and not swept into the section actually headed with the
|
||||
question a reader asks — so the page answered "who can see the names
|
||||
of the opposing parties I gave you?" with *nobody else* under that
|
||||
heading and *Google* under a different one. Fixing one section and
|
||||
not the section that answers the same question is the sweep failure
|
||||
`CLAUDE.md` describes. Found by `adversarial-reviewer` round 2. */
|
||||
}
|
||||
<p>
|
||||
I can. The table is reachable by the function that writes to it and by
|
||||
one administrative account, which is mine — nobody else has access to
|
||||
the table. There is no team, no assistant and no external
|
||||
administrator.
|
||||
</p>
|
||||
<p>
|
||||
The one other place a copy exists is the notification email, which
|
||||
sits in the Google Workspace mailbox named above. So the honest answer
|
||||
to "who can see this" is: me, and Google as the company that runs my
|
||||
mail.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and analytics</h2>
|
||||
{
|
||||
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 nothing is stored on your device.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>This site sets no cookies and runs no analytics.</strong>
|
||||
There is no tracking script on any page, nothing is stored on your
|
||||
device, and there is therefore nothing to consent to and no
|
||||
banner. If that changes, this page changes on the same day and its
|
||||
last updated date moves with it.
|
||||
</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>
|
||||
<p>
|
||||
Deletion removes the record. It does not retract the emails already
|
||||
sent, and if a conflicts check has already been run I will tell you
|
||||
what its outcome was rather than pretending the inquiry did not
|
||||
happen.
|
||||
</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 = '31 August 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 sources — statutes, regulators, tribunals and
|
||||
institutions. 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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -424,6 +424,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. */
|
||||
|
||||
Reference in New Issue
Block a user