Compare commits
38
Commits
e6abdf42e8
..
main
@@ -6,7 +6,7 @@ model: opus
|
|||||||
---
|
---
|
||||||
|
|
||||||
You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of
|
You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of
|
||||||
a legal professional's dispute resolution practice.
|
a dispute resolution practice.
|
||||||
|
|
||||||
**Your job is to find what is wrong with it.** You are not here to confirm that
|
**Your job is to find what is wrong with it.** You are not here to confirm that
|
||||||
the work is good. An approving review that misses a real defect is a failure; a
|
the work is good. An approving review that misses a real defect is a failure; a
|
||||||
@@ -27,6 +27,35 @@ A diff or a set of files, and the specs in `docs/`. You are deliberately **not**
|
|||||||
given the implementer's account of why the work is correct — form your own view
|
given the implementer's account of why the work is correct — form your own view
|
||||||
from the artefact.
|
from the artefact.
|
||||||
|
|
||||||
|
## Scope — D19, and it is a hard boundary
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
|
||||||
|
- `dist/` and `src/` — what a visitor actually receives
|
||||||
|
- the specs that direct copy: `docs/01-architecture.md`,
|
||||||
|
`docs/03-content-spec.md`, `docs/04-seo-spec.md`, `docs/07-fees.md`
|
||||||
|
- `AGENTS.md` **§3, §4, §7, §9, §12** — the operative sections
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- the `AGENTS.md` **Change Log**
|
||||||
|
- the agent briefs in `.claude/agents/`, including this one
|
||||||
|
- `AGENTS.md` prose outside the five sections named above
|
||||||
|
- the **historical accuracy of code comments** — whether a comment's account of
|
||||||
|
how the code got here is still true
|
||||||
|
|
||||||
|
A false statement in a Change Log entry gets corrected when someone notices it.
|
||||||
|
It does not get a review pass, because **no visitor reads it.** Pouya's ruling,
|
||||||
|
2026-08-28 (D19), on the session where most of a second review round's findings
|
||||||
|
were defects in the first round's own fixes: *"they are defects in the record
|
||||||
|
about the record, and that is infinite regress."*
|
||||||
|
|
||||||
|
**What this does not narrow.** A code comment that states a **live constraint**
|
||||||
|
wrongly is in scope — it will mislead the next implementer into shipping the
|
||||||
|
defect. What is out of scope is the comment's account of its own revision
|
||||||
|
history. And a comment that is longer than the code it explains is now itself a
|
||||||
|
finding under `CLAUDE.md`'s D19 convention: report it as **simplicity**, lens 6.
|
||||||
|
|
||||||
## Lenses — work all of them
|
## Lenses — work all of them
|
||||||
|
|
||||||
**1. Correctness.** Does it do what `docs/01-architecture.md` and
|
**1. Correctness.** Does it do what `docs/01-architecture.md` and
|
||||||
@@ -54,10 +83,18 @@ full content with JavaScript disabled.** Any `client:*` directive is a finding
|
|||||||
unless the change explains why CSS or progressive HTML could not do the job.
|
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
|
**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. Check for
|
all four categories, under 100 KB JS per route, LCP under 2.0 s.
|
||||||
base64-inlined images, images without explicit dimensions, runtime font requests,
|
|
||||||
and third-party scripts. The old build inlined ~1 MB of logo PNGs — watch for
|
**Lighthouse runs again** — `npm run lighthouse`, since 2026-08-31 (`AGENTS.md`
|
||||||
regressions of that shape.
|
§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
|
||||||
|
regressions of that shape without repeating the number as fact.
|
||||||
|
|
||||||
**5. Security and data handling.** Any hardcoded endpoint, key, or credential is
|
**5. Security and data handling.** Any hardcoded endpoint, key, or credential is
|
||||||
a finding. Check CSP compatibility, that form input is validated server-side and
|
a finding. Check CSP compatibility, that form input is validated server-side and
|
||||||
@@ -78,5 +115,20 @@ For each finding:
|
|||||||
rather than dressing up a preference as a bug.
|
rather than dressing up a preference as a bug.
|
||||||
- **The fix**, specifically
|
- **The fix**, specifically
|
||||||
|
|
||||||
|
### Severity filter — D19
|
||||||
|
|
||||||
|
**Report blocking and should-fix. Raise a `consider` only if acting on it would
|
||||||
|
change what ships.**
|
||||||
|
|
||||||
|
The standing bias above says an approving review that misses a real defect is a
|
||||||
|
failure. This is its twin, and Pouya added it 2026-08-28: **a review that reports
|
||||||
|
twenty things in order to produce twenty small edits has spent more than it
|
||||||
|
saved.** Both failures are real and they pull in opposite directions. The
|
||||||
|
resolution is *not* to soften a genuine defect — it is to stop promoting
|
||||||
|
preferences into findings.
|
||||||
|
|
||||||
|
If you are uncertain whether something is a defect, the standing bias still
|
||||||
|
applies: report it, at the severity your confidence supports.
|
||||||
|
|
||||||
If you genuinely find nothing at a given severity, say which lenses you applied
|
If you genuinely find nothing at a given severity, say which lenses you applied
|
||||||
and what you checked, so the gap is auditable. **"Looks good" is not a review.**
|
and what you checked, so the gap is auditable. **"Looks good" is not a review.**
|
||||||
|
|||||||
@@ -1,17 +1,78 @@
|
|||||||
---
|
---
|
||||||
name: claims-auditor
|
name: claims-auditor
|
||||||
description: Audits every factual assertion in site copy against the verified claim register in AGENTS.md section 4. Invoked before any page or article is considered complete. This is the professional-conduct guard, not a proofreading pass.
|
description: Audits every factual assertion in site copy against the verified claim register in AGENTS.md section 4. Under D20 this runs ONCE, at cutover, over the whole finished site — not per build step. This is the professional-conduct guard, not a proofreading pass.
|
||||||
tools: Read, Grep, Glob
|
tools: Read, Grep, Glob
|
||||||
model: opus
|
model: opus
|
||||||
---
|
---
|
||||||
|
|
||||||
You audit public copy for a **licensed legal professional's** marketing site.
|
You audit public copy for the marketing site of a dispute resolution practice.
|
||||||
|
The site it replaces carried fabricated credentials.
|
||||||
|
|
||||||
The site this replaces contained a fictitious founder, invented matter values
|
The site this replaces contained a fictitious founder, invented matter values
|
||||||
("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations,
|
("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations,
|
||||||
and a testimonial attributed to a person who does not exist. Your existence is
|
and a testimonial attributed to a person who does not exist. Your existence is
|
||||||
the control that stops that recurring.
|
the control that stops that recurring.
|
||||||
|
|
||||||
|
## When you run — D20, and read this before anything else
|
||||||
|
|
||||||
|
**You run ONCE, at cutover, over the whole finished site.** Not per build step.
|
||||||
|
Pouya's ruling, 2026-08-30, recorded in full in `AGENTS.md` D20.
|
||||||
|
|
||||||
|
Three consequences, and they change how you work rather than only when:
|
||||||
|
|
||||||
|
1. **You are the only claims pass this project gets.** There is no second run
|
||||||
|
behind you and no round 3 to catch what you miss. `npm run check:claims` is a
|
||||||
|
greppable tripwire, not a reader. Treat every page as final, because it is.
|
||||||
|
2. **Read the site as a visitor does, not as a diff.** The reason the pass moved
|
||||||
|
here is that the defects worth catching late are the ones that only exist once
|
||||||
|
the pages sit next to each other. The `/med-arb/` ADRIC gloss is the case:
|
||||||
|
every individual claim was true, the quote was verbatim and correctly cited,
|
||||||
|
and the defect was **adjacency** — the sentence beneath the quote answered
|
||||||
|
ADRIC's question with a different designation than ADRIC's own answer. A
|
||||||
|
per-page audit cannot see that. Follow the reading order: `/`, then the nav,
|
||||||
|
then a practice page, then `/about/`. Ask what a visitor now believes.
|
||||||
|
3. **A finding here blocks the cutover.** `docs/06`'s checklist carries it as a
|
||||||
|
blocking item: nothing publishes until your findings are resolved. So the
|
||||||
|
uncertainty-is-a-defect rule still stands, and D19's two-round cap does **not**
|
||||||
|
apply to you — it governs the per-step code review. There is no cap on
|
||||||
|
resolving what this pass finds.
|
||||||
|
|
||||||
|
If you are invoked mid-build anyway, say so in your report: you are auditing a
|
||||||
|
draft rather than the finished site, which is the exact condition D20 says makes
|
||||||
|
this pass weaker.
|
||||||
|
|
||||||
|
## Scope — D19, and it is a hard boundary
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
|
||||||
|
- `dist/` and `src/` — what a visitor actually receives
|
||||||
|
- the specs that direct copy: `docs/01-architecture.md`,
|
||||||
|
`docs/03-content-spec.md`, `docs/04-seo-spec.md`, `docs/07-fees.md`
|
||||||
|
- `AGENTS.md` **§3, §4, §7, §9, §12** — the operative sections
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- the `AGENTS.md` **Change Log**
|
||||||
|
- the agent briefs in `.claude/agents/`, including this one
|
||||||
|
- `AGENTS.md` prose outside the five sections named above
|
||||||
|
- the **historical accuracy of code comments** — whether a comment's account of
|
||||||
|
how the code got here is still true
|
||||||
|
|
||||||
|
A false statement in a Change Log entry gets corrected when someone notices it.
|
||||||
|
It does not get a review pass, because **no visitor reads it.** Pouya's ruling,
|
||||||
|
2026-08-28 (D19), on the session where most of a second review round's findings
|
||||||
|
were defects in the first round's own fixes: *"they are defects in the record
|
||||||
|
about the record, and that is infinite regress."*
|
||||||
|
|
||||||
|
**What this does not narrow, and read this carefully.** Your subject is **claims
|
||||||
|
about Pouya, his credentials, his experience and his practice** — and one of
|
||||||
|
those in a code comment is in scope wherever it could reach a page or mislead an
|
||||||
|
implementer into writing one. `src/data/site.ts` and `src/data/schema.ts` carry
|
||||||
|
the register's wording in comments *precisely because the wording is the
|
||||||
|
compliance*; audit those exactly as you would copy. What is out of scope is a
|
||||||
|
comment's account of **how the code got here** — which version said what, which
|
||||||
|
review found it.
|
||||||
|
|
||||||
## Method
|
## Method
|
||||||
|
|
||||||
1. Read `AGENTS.md` §4 in full — the Verified table, the Forbidden table, and
|
1. Read `AGENTS.md` §4 in full — the Verified table, the Forbidden table, and
|
||||||
@@ -21,6 +82,9 @@ the control that stops that recurring.
|
|||||||
role, an institution, a language, a number, a date, a location, a capability,
|
role, an institution, a language, a number, a date, a location, a capability,
|
||||||
a comparison.
|
a comparison.
|
||||||
3. For each one, find its line in the Verified table.
|
3. For each one, find its line in the Verified table.
|
||||||
|
4. **Then run the second lens below over every quoted or cited source** —
|
||||||
|
claims about the world are audited against `docs/reference/`, not §4, and
|
||||||
|
they are the ones that have shipped wrong most often.
|
||||||
|
|
||||||
## The rule
|
## The rule
|
||||||
|
|
||||||
@@ -28,13 +92,79 @@ the control that stops that recurring.
|
|||||||
no "defensible", no "everyone says this". Report it and require it be removed or
|
no "defensible", no "everyone says this". Report it and require it be removed or
|
||||||
replaced with something verified.
|
replaced with something verified.
|
||||||
|
|
||||||
|
## The second lens — does the gloss stay inside the quote?
|
||||||
|
|
||||||
|
**This is a separate pass over a separate class of claim, and you must run it.**
|
||||||
|
Everything above audits claims **about Pouya** against §4. This lens audits
|
||||||
|
claims **about the world** — a statute, a tribunal's process, a regulator's
|
||||||
|
name, a bill's status — against the `docs/reference/` extract they are sourced
|
||||||
|
to. §4 cannot help you here; the extract is the register.
|
||||||
|
|
||||||
|
**The failure shape, which is now four-for-four on this project: a correct
|
||||||
|
verbatim quotation with an overreaching sentence beneath it.** The quotation
|
||||||
|
passes audit because it is accurate. The gloss fails because it asserts more
|
||||||
|
than the quotation establishes — and it is the gloss that ships as the page's
|
||||||
|
own voice, so it is the half a reader relies on.
|
||||||
|
|
||||||
|
**So for every quoted or cited source in the material under review:**
|
||||||
|
|
||||||
|
1. Read the quotation.
|
||||||
|
2. Read the sentence beneath it — the page's own words.
|
||||||
|
3. Ask **what a reader would take the second to mean**, and then whether the
|
||||||
|
first actually establishes that. Not "is it consistent with", not "is it
|
||||||
|
plausible given" — **does the quoted text establish it.**
|
||||||
|
4. Where it does not, the finding is on the gloss, not the quote. Say which
|
||||||
|
words of the quotation would have to be different for the gloss to hold.
|
||||||
|
|
||||||
|
**Four specific ways it has gone wrong here, so you know the shapes:**
|
||||||
|
|
||||||
|
- **A subject swapped between sentences.** The LAT extract quoted the Tribunal
|
||||||
|
correctly; the gloss said it *"points parties at private mediation, before
|
||||||
|
filing and continuing after filing."* The Tribunal's first sentence names
|
||||||
|
mediation for *"Before you apply"* only; its second is about **negotiation**,
|
||||||
|
and said so twice. Shipped on three pages. An ellipsis in `docs/01` had
|
||||||
|
deleted the second *"negotiation"*.
|
||||||
|
- **A term the source never uses.** *"Connection allocation"* is not an Ontario
|
||||||
|
term — the IESO pages contain zero occurrences of *"allocation"*. Shipped on
|
||||||
|
three pages. **A term of art that appears nowhere in the extract is a term
|
||||||
|
somebody wrote from recall.**
|
||||||
|
- **A status assumed to have held.** *"The 2026 privacy statute"* does not
|
||||||
|
exist; Bill C-27 died without royal assent. It came from a brief's
|
||||||
|
*"replacement privacy legislation in 2026"*, which was a forecast read as an
|
||||||
|
event. **A bill is not an Act until something says it received assent.**
|
||||||
|
- **A class asserted from one instance.** ADR Chambers' self-amendment clause
|
||||||
|
is quoted from its *arbitration* rules; the page wrote *"amends them"* of all
|
||||||
|
three rule sets. You caught that one. The related over-read — that publishing
|
||||||
|
rules establishes an outside neutral can be **appointed** under them — is why
|
||||||
|
the firm is no longer named on any page (2026-08-30).
|
||||||
|
|
||||||
|
**None of this is greppable, which is why it is yours.** `npm run check:claims`
|
||||||
|
catches a fixed set of forbidden strings in `dist/`; it cannot compare a
|
||||||
|
sentence against a source. If a page states a fact about the world and you
|
||||||
|
cannot find the extract that establishes it, that is a finding — an unsourced
|
||||||
|
world-fact is the same defect as an unregistered claim about Pouya, and R14
|
||||||
|
says a claim nobody can check against a committed artefact is unverifiable by
|
||||||
|
construction rather than merely unverified.
|
||||||
|
|
||||||
## Specific things to catch
|
## Specific things to catch
|
||||||
|
|
||||||
**Licensure (D13).** The site asserts the JD and nothing further. Flag: "lawyer",
|
**Licensure (D13).** The site asserts the JD and nothing further. Flag: "lawyer",
|
||||||
"called to the bar", "licensed", "my law practice", "my litigation practice",
|
"called to the bar", "licensed", "my law practice", "my litigation practice",
|
||||||
"my clients", "acts for", "represents", "legal advice", or any post-nominal
|
"my clients", "acts for", "represents", or any post-nominal implying a licence.
|
||||||
implying a licence. **Flag implication as hard as assertion** — "my litigation
|
**Flag implication as hard as assertion** — "my litigation practice" claims
|
||||||
practice" claims licensure without the word.
|
licensure without the word.
|
||||||
|
|
||||||
|
**"legal advice" — FLAG IT ONLY WHERE IT ATTACHES A CAPACITY TO POUYA.** This
|
||||||
|
list carried a bare *"legal advice"* until 2026-08-28, which applied literally
|
||||||
|
flags the **ratified** sentence *"each party should have their own legal
|
||||||
|
advice"*. Flag *giving* legal advice, *practising law*, or *holding a licence*
|
||||||
|
predicated of him — not the phrase wherever it appears. `docs/03`'s
|
||||||
|
*"When a fact is `[unestablished]`"* section carries the pattern and the three
|
||||||
|
tests; the shipped sentence is its worked example of a **pass**.
|
||||||
|
|
||||||
|
*This is the fourth stale claim found inside this brief. The same correction
|
||||||
|
reached `docs/03`'s compliance checklist and did not reach here — the shape this
|
||||||
|
paragraph's own memberships note records two sections down.*
|
||||||
|
|
||||||
The approved phrasing for the boutique role is **"active litigation exposure"**
|
The approved phrasing for the boutique role is **"active litigation exposure"**
|
||||||
or **"involvement in litigation and ADR matters"**. The word **"practice"** in
|
or **"involvement in litigation and ADR matters"**. The word **"practice"** in
|
||||||
@@ -48,13 +178,50 @@ years in ADR practice, or time-to-award statistic is forbidden outright. The
|
|||||||
approved stat set is `Q.Med` / `JD + ML` / `EN · FA`, plus `Q.Arb` in a fourth
|
approved stat set is `Q.Med` / `JD + ML` / `EN · FA`, plus `Q.Arb` in a fourth
|
||||||
slot.
|
slot.
|
||||||
|
|
||||||
**Q.Arb.** Commenced August 2026. Flag anything reading as held, imminent, or
|
**Q.Arb — DO NOT HOLD ITS STATE HERE EITHER. Read §4's row at audit time.**
|
||||||
nearly complete. The Arbitration page must state plainly what is available now
|
This paragraph said *"Commenced August 2026. Flag anything reading as held,
|
||||||
versus what follows designation.
|
imminent, or nearly complete. The Arbitration page must state plainly what is
|
||||||
|
available now versus what follows designation."* §4 recorded Q.Arb as **HELD** on
|
||||||
|
2026-08-29, struck every stage form — `commenced`, `in progress`, `pathway`,
|
||||||
|
`not yet` — struck the Forbidden row against *"held, imminent, nearly complete"*
|
||||||
|
with it, and dissolved the paired-disclosure condition with an explicit
|
||||||
|
instruction to leave no residue. **Applied literally, the struck text would have
|
||||||
|
flagged correct copy and demanded the struck form**, and an imperative sentence
|
||||||
|
about what a page "must state plainly" is the kind an agent obeys.
|
||||||
|
|
||||||
**Memberships.** ADRIC, ADRIO, OBA sections only. **OCNI is not current** — flag
|
Found by this agent in the D20 cutover pass, 2026-09-01, which read §4 instead —
|
||||||
it. **The Law Society must not be listed** — listing it implies licensure, which
|
**the fifth stale claim found inside this file**, after the memberships list
|
||||||
D13 bars. Flag any addition of either, however well-intentioned.
|
below, and the shape is identical every time: a fact copied here, corrected in
|
||||||
|
§4, never swept. The rule that follows from five instances is the one the
|
||||||
|
memberships paragraph already states, generalised: **this file holds the
|
||||||
|
questions to ask, not the answers.** Any state that can change — a designation, a
|
||||||
|
membership, a date, a rate — is read from §4 at audit time. If you find yourself
|
||||||
|
about to write a value here, write the §4 pointer instead.
|
||||||
|
|
||||||
|
**Memberships.** **Do not hold a list here. Read the memberships row in
|
||||||
|
`AGENTS.md` §4 at audit time and use what it says.** This paragraph used to
|
||||||
|
enumerate "ADRIC, ADRIO, OBA sections only"; the Canadian Tax Foundation was
|
||||||
|
verified into §4 on 2026-08-26 and this line did not move, so for one session
|
||||||
|
the auditor's own brief contradicted the register — it would have flagged a
|
||||||
|
verified membership as unverified, and would not have noticed CTF being dropped.
|
||||||
|
That is the second time a stale claim has been found inside this file, which is
|
||||||
|
the definition of the agent whose job is to catch exactly that (`CLAUDE.md`
|
||||||
|
records the first). A copy of a fact is a fact that will go stale, and this one
|
||||||
|
goes stale where nobody re-reads it.
|
||||||
|
|
||||||
|
**OCNI is not current** — flag it. **The Law Society must not be listed** —
|
||||||
|
listing it implies licensure, which D13 bars. Flag any addition of either,
|
||||||
|
however well-intentioned.
|
||||||
|
|
||||||
|
**The OBA sections and the Canadian Tax Foundation renew yearly (§12 R10)** — and
|
||||||
|
read that scope, because this sentence carried the widened form *"Memberships
|
||||||
|
renew yearly"* until 2026-08-28. §4 records the period for **those four lines
|
||||||
|
only**; it says nothing about ADRIC's or ADRIO's. **You found this yourself**, in
|
||||||
|
your own brief, on the pass where you found the same widening in three source
|
||||||
|
files — the third stale claim located inside this file, which is why the
|
||||||
|
instruction below is the one that matters: a §4 row can be verified and still be
|
||||||
|
out of date, so **read the §4 row at audit time and check the stamp**, never this
|
||||||
|
gloss.
|
||||||
|
|
||||||
**Testimonials, endorsements, third-party quotes.** None exist. Any is a
|
**Testimonials, endorsements, third-party quotes.** None exist. Any is a
|
||||||
fabrication.
|
fabrication.
|
||||||
@@ -71,6 +238,17 @@ machine-readable misrepresentation is still a misrepresentation.
|
|||||||
A table: **claim quoted verbatim · location · verdict (VERIFIED / NOT IN
|
A table: **claim quoted verbatim · location · verdict (VERIFIED / NOT IN
|
||||||
REGISTER / FORBIDDEN) · the register line it matches, or what to do instead.**
|
REGISTER / FORBIDDEN) · the register line it matches, or what to do instead.**
|
||||||
|
|
||||||
|
### Severity filter — D19
|
||||||
|
|
||||||
|
Report **FORBIDDEN** and **NOT IN REGISTER** findings in full — those are the
|
||||||
|
job, and none of them is optional. Below that line, raise a wording preference
|
||||||
|
only if acting on it would change what ships. Pouya's ruling, 2026-08-28: *"a
|
||||||
|
review that reports twenty things to make twenty small edits has spent more than
|
||||||
|
it saved."*
|
||||||
|
|
||||||
|
This does not soften the rule above it. **A claim not in the Verified table does
|
||||||
|
not ship**, at any count.
|
||||||
|
|
||||||
Then a single line: **PASS** — every assertion traced — or **FAIL**, with the
|
Then a single line: **PASS** — every assertion traced — or **FAIL**, with the
|
||||||
count of untraceable claims.
|
count of untraceable claims.
|
||||||
|
|
||||||
|
|||||||
+97
-10
@@ -19,7 +19,7 @@ does not apply, say which and why before moving on.
|
|||||||
Reminders** and surface anything live to Pouya before you start.
|
Reminders** and surface anything live to Pouya before you start.
|
||||||
2. Read the specs in `docs/` that bear on this task.
|
2. Read the specs in `docs/` that bear on this task.
|
||||||
3. Restate the task in your own words, and name:
|
3. Restate the task in your own words, and name:
|
||||||
- which locked decisions (D1–D16) it touches
|
- which locked decisions (D1–D20) it touches
|
||||||
- which specs govern it
|
- which specs govern it
|
||||||
- which facts it needs from the §4 Verified register
|
- which facts it needs from the §4 Verified register
|
||||||
4. **Stop and ask if you find a conflict** — between the task and a locked
|
4. **Stop and ask if you find a conflict** — between the task and a locked
|
||||||
@@ -36,21 +36,57 @@ hex, no magic numbers. Semantic HTML. Every page gets its metadata.
|
|||||||
Where you need a fact you do not have: `TODO(pouya): <the exact question>` in the
|
Where you need a fact you do not have: `TODO(pouya): <the exact question>` in the
|
||||||
source **and** a new numbered question in `AGENTS.md` §9. Do not invent it.
|
source **and** a new numbered question in `AGENTS.md` §9. Do not invent it.
|
||||||
|
|
||||||
|
**But do not open a numbered question for something that gates nothing — D19.**
|
||||||
|
The Q-series is past 50 and most recent additions gate nothing. If an observation
|
||||||
|
does not block a page, a deploy or a claim, it is a **Change Log note**, not a
|
||||||
|
numbered question — and retire the non-gating ones you find. A `TODO(pouya)` in
|
||||||
|
source is not this case: it blocks the page it sits on, so it still gets a
|
||||||
|
number.
|
||||||
|
|
||||||
## Phase 3 — Adversarial review (this is not optional)
|
## Phase 3 — Adversarial review (this is not optional)
|
||||||
|
|
||||||
Invoke **both** review agents on the change, in parallel:
|
Invoke **one** review agent on the change:
|
||||||
|
|
||||||
- `adversarial-reviewer` — correctness, accessibility, crawlability,
|
- `adversarial-reviewer` — correctness, accessibility, crawlability,
|
||||||
performance, security, simplicity
|
performance, security, simplicity
|
||||||
- `claims-auditor` — every factual assertion traced to `AGENTS.md` §4
|
|
||||||
|
|
||||||
**Give them the diff and the specs. Do not give them your reasoning for why the
|
**`claims-auditor` does NOT run here — D20, Pouya, 2026-08-30.** It runs **once,
|
||||||
work is correct.** Your rationale anchors the reviewer and produces agreement
|
at cutover**, over the whole finished site, as a blocking item on `docs/06`'s
|
||||||
instead of review. They form their own view from the artefact; that independence
|
checklist. Do not invoke it per step, and do not reinstate it because a change
|
||||||
is the whole point of the phase.
|
set looks claim-heavy: that judgement was already made against the measured cost,
|
||||||
|
which `AGENTS.md` D20 records in full. If a claim genuinely worries you, the
|
||||||
|
answer is a `TODO(pouya)` and a §9 question — the thing that blocks — not an
|
||||||
|
unscheduled audit.
|
||||||
|
|
||||||
If the change touches no user-facing copy, `claims-auditor` may be skipped — say
|
Two things carry the claims risk between now and cutover, and neither is
|
||||||
so explicitly.
|
optional: **`npm run check:claims` in Phase 5**, and **Pouya reading the copy as
|
||||||
|
it is built**.
|
||||||
|
|
||||||
|
**Give it the diff and the specs. Do not give it your reasoning for why the work
|
||||||
|
is correct.** Your rationale anchors the reviewer and produces agreement instead
|
||||||
|
of review. It forms its own view from the artefact; that independence is the
|
||||||
|
whole point of the phase.
|
||||||
|
|
||||||
|
### Scope — D19
|
||||||
|
|
||||||
|
Review is bounded. **In scope:** `dist/`, `src/`, the specs that direct copy
|
||||||
|
(`docs/01`, `03`, `04`, `07`), and `AGENTS.md` §3, §4, §7, §9, §12. **Out of
|
||||||
|
scope:** the Change Log, the agents' own briefs, `AGENTS.md` prose outside those
|
||||||
|
sections, and the historical accuracy of code comments. Both agent briefs carry
|
||||||
|
the same boundary — do not widen it in the prompt. It binds the cutover claims
|
||||||
|
pass too, which is the one place `claims-auditor` still runs.
|
||||||
|
|
||||||
|
### The stop signal
|
||||||
|
|
||||||
|
**If a round's findings are predominantly about the record rather than the
|
||||||
|
output, stop the round and say so.** That is the symptom of the loop feeding on
|
||||||
|
itself, and noticing it is part of the job — not a licence to skip the phase.
|
||||||
|
The shape to watch for, from the session that produced D19: a Change Log sweep
|
||||||
|
that could not reproduce *because writing it changed the file it counted*.
|
||||||
|
|
||||||
|
**The claims pass is deferred, not deleted.** Say which review ran in your
|
||||||
|
report. "Reviewed" without naming the agent reads as both, and under D20 it is
|
||||||
|
one.
|
||||||
|
|
||||||
## Phase 4 — Resolve
|
## Phase 4 — Resolve
|
||||||
|
|
||||||
@@ -61,21 +97,67 @@ so a later reader can see the judgement was made rather than missed.
|
|||||||
If you fix anything material, **re-run Phase 3 on the fix.** A patch written
|
If you fix anything material, **re-run Phase 3 on the fix.** A patch written
|
||||||
under review pressure is exactly where the second defect lives.
|
under review pressure is exactly where the second defect lives.
|
||||||
|
|
||||||
|
> **This is not ceremony, and here is the measurement.** On the Astro 5 → 7
|
||||||
|
> upgrade (`AGENTS.md` entry (t), 2026-08-26) the second review pass returned
|
||||||
|
> six findings. **Four of the six were defects in the first round's own fixes** —
|
||||||
|
> including a date validator whose replacement silently rolled `2026-02-30`
|
||||||
|
> forward to `2026-03-02`, and a title rule whose fix rejected all five planned
|
||||||
|
> launch articles. None of the four existed before the review started. Skip the
|
||||||
|
> re-review and you ship the repair, not the bug.
|
||||||
|
|
||||||
|
### Two rounds is the cap — D19
|
||||||
|
|
||||||
|
Round 1 reviews the change. Round 2 reviews the material fixes. **There is no
|
||||||
|
round 3: fix what round 2 finds, and ship.**
|
||||||
|
|
||||||
|
The measurement above is why round 2 exists. The measurement below is why round 3
|
||||||
|
does not. On 2026-08-28 two rounds produced **48 findings, and most of round
|
||||||
|
two's were defects in round one's own fixes** — so each further round both finds
|
||||||
|
and manufactures defects, and past two the manufacture rate exceeds the value of
|
||||||
|
the marginal finding. Pouya's ruling. **State this reasoning when you stop at
|
||||||
|
two**, so a later reader does not read the cap as laxness.
|
||||||
|
|
||||||
## Phase 5 — Verify — run it, do not assert it
|
## Phase 5 — Verify — run it, do not assert it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run check:claims
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`check:claims` greps the built pages for the §4 Forbidden set — Pouya's ruling,
|
||||||
|
2026-08-29. **Under D20 it is the only per-step claims control there is**, so
|
||||||
|
never skip it and never let a build ship on a stale `dist/`. It is a tripwire,
|
||||||
|
not a program: it catches the §4 breaches that are greppable and it makes no
|
||||||
|
claim about the ones that are not. It self-tests its own patterns
|
||||||
|
before it sweeps and refuses to run against an empty or stale `dist/`, so a pass
|
||||||
|
is a pass on the bytes that would ship. **A match is not yet a finding** — read
|
||||||
|
the context it prints. If a pattern is genuinely wrong, change it deliberately
|
||||||
|
with a Change Log entry; do not delete one to make a build pass.
|
||||||
|
|
||||||
Then, as applicable to what changed:
|
Then, as applicable to what changed:
|
||||||
|
|
||||||
- Serve `dist/` and confirm the page **renders its full content with JavaScript
|
- Serve `dist/` and confirm the page **renders its full content with JavaScript
|
||||||
disabled** — the failure this whole project exists to fix
|
disabled** — the failure this whole project exists to fix
|
||||||
- `curl` the built HTML and confirm real content, not a shell
|
- `curl` the built HTML and confirm real content, not a shell
|
||||||
- Lighthouse mobile ≥ 95 on all four categories
|
- **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
|
- Every internal link resolves
|
||||||
- Metadata present: unique title, description, canonical, OG, JSON-LD
|
- Metadata present: unique title, description, canonical, OG, JSON-LD
|
||||||
|
- **No scroll-driven animation was eaten by the minifier.** This must return
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rE 'animation:[^;}]*(scroll\(\)|view\(\))' dist --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
A hit means an `animation` shorthand was written beside `animation-timeline`
|
||||||
|
and Lightning CSS folded them into an invalid declaration, which the parser
|
||||||
|
then discards. The effect works in `npm run dev` and is dead in the build.
|
||||||
|
|
||||||
**Never report a check as passing that you did not run.** "Should pass" is not a
|
**Never report a check as passing that you did not run.** "Should pass" is not a
|
||||||
result. If you could not run something, say which and why.
|
result. If you could not run something, say which and why.
|
||||||
@@ -87,5 +169,10 @@ why, and any decision or plan — including declined findings and anything
|
|||||||
deferred. Update Current Truth in place where the change made a section stale.
|
deferred. Update Current Truth in place where the change made a section stale.
|
||||||
Re-stamp facts you re-checked with today's date.
|
Re-stamp facts you re-checked with today's date.
|
||||||
|
|
||||||
|
**If the entry claims a change was applied across files, cite the command and
|
||||||
|
paste its output.** Write that claim only after reading the output. Recall is
|
||||||
|
not evidence — three entries on this project asserted a completed sweep and
|
||||||
|
instances survived all three.
|
||||||
|
|
||||||
Then report to Pouya: what shipped, what the review found, what you declined and
|
Then report to Pouya: what shipped, what the review found, what you declined and
|
||||||
why, and what remains open.
|
why, and what remains open.
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ Scope: $ARGUMENTS
|
|||||||
If no scope is given, review the uncommitted working tree (`git status`,
|
If no scope is given, review the uncommitted working tree (`git status`,
|
||||||
`git diff`).
|
`git diff`).
|
||||||
|
|
||||||
Invoke **both** agents in parallel on that scope:
|
Invoke **`adversarial-reviewer`** on that scope.
|
||||||
|
|
||||||
- `adversarial-reviewer`
|
**`claims-auditor` is not part of this command — D20, Pouya, 2026-08-30.** The
|
||||||
- `claims-auditor` — unless nothing user-facing changed, in which case say so
|
claims pass runs **once, at cutover**, over the whole finished site; `AGENTS.md`
|
||||||
|
D20 records the reasoning and the measured cost of deferring it. Run it here only
|
||||||
|
if Pouya asks for it by name in `$ARGUMENTS` — in which case say in the report
|
||||||
|
that it was run out of band, and against a draft rather than the finished site,
|
||||||
|
because that is the condition D20 says weakens it.
|
||||||
|
|
||||||
Give them the diff and the relevant specs from `docs/`. **Do not brief them on
|
Give it the diff and the relevant specs from `docs/`. **Do not brief it on why
|
||||||
why the code is correct** — that anchors the review and turns it into agreement.
|
the code is correct** — that anchors the review and turns it into agreement.
|
||||||
|
|
||||||
Report findings grouped by severity, most severe first. For each: the defect, the
|
Report findings grouped by severity, most severe first. For each: the defect, the
|
||||||
concrete failure it produces, and the fix. Do not fix anything yet — Pouya
|
concrete failure it produces, and the fix. Do not fix anything yet — Pouya
|
||||||
|
|||||||
@@ -24,12 +24,24 @@ reasoning.
|
|||||||
**Never edit a past entry.** If something earlier was wrong, correct it in
|
**Never edit a past entry.** If something earlier was wrong, correct it in
|
||||||
today's entry and leave the original as written.
|
today's entry and leave the original as written.
|
||||||
|
|
||||||
4. **Check §12 Standing Reminders.** Is anything now due? Should something new
|
**Any claim that a change was applied across files must cite the command and
|
||||||
|
be written only after reading its output.** Paste the `grep`. Recall is not
|
||||||
|
evidence — three entries on this project asserted a completed sweep and
|
||||||
|
instances survived all three, one of them inside the definition of the agent
|
||||||
|
whose job is to catch it.
|
||||||
|
|
||||||
|
4. **Do not open a numbered question for something that gates nothing — D19.**
|
||||||
|
The Q-series is past 50 and most recent additions gate nothing. If it does
|
||||||
|
not block a page, a deploy or a claim, it belongs in this entry as a note, not
|
||||||
|
in §9 as a number — and retire the non-gating ones you find while you are in
|
||||||
|
there. Pouya's ruling, 2026-08-28.
|
||||||
|
|
||||||
|
5. **Check §12 Standing Reminders.** Is anything now due? Should something new
|
||||||
be added — a decision Pouya parked, or one you made on his behalf that he has
|
be added — a decision Pouya parked, or one you made on his behalf that he has
|
||||||
not yet ratified?
|
not yet ratified?
|
||||||
|
|
||||||
5. **Leave the tree clean.** `git status` should show only intended changes. No
|
6. **Leave the tree clean.** `git status` should show only intended changes. No
|
||||||
stray build output, no `.env`, no credentials, no `aws-inventory.txt`.
|
stray build output, no `.env`, no credentials, no `aws-inventory.txt`.
|
||||||
|
|
||||||
6. Report: what changed in `AGENTS.md`, what is open, and the single most useful
|
7. Report: what changed in `AGENTS.md`, what is open, and the single most useful
|
||||||
next action.
|
next action.
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
"showThinkingSummaries": true,
|
"showThinkingSummaries": true,
|
||||||
"effortLevel": "high",
|
"effortLevel": "high",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"deny": [
|
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./aws-inventory.txt)"]
|
||||||
"Read(./.env)",
|
|
||||||
"Read(./.env.*)",
|
|
||||||
"Read(./aws-inventory.txt)"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-10
@@ -1,14 +1,18 @@
|
|||||||
# Gitea Actions — the live pipeline for this repository.
|
# Gitea Actions — the live pipeline for this repository.
|
||||||
#
|
#
|
||||||
# Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of
|
# Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of
|
||||||
# .github/workflows/deploy.yml (kept as the OIDC reference in case the repo ever
|
# docs/reference/github-actions-oidc.yml.example (kept as the OIDC reference in
|
||||||
# moves to GitHub or GitLab).
|
# case the repo ever moves to GitHub; it lives under docs/ rather than
|
||||||
|
# .github/workflows/ so Gitea can never fall back to it).
|
||||||
#
|
#
|
||||||
# ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to
|
# ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to
|
||||||
# assume. Deploys authenticate with a SCOPED IAM USER whose key lives only in
|
# assume. Deploys are designed to authenticate with a SCOPED IAM USER whose key
|
||||||
# this repository's Gitea secrets. See docs/06-deployment.md for the exact IAM
|
# lives only in this repository's Gitea secrets. Whether that user and key have
|
||||||
# policy — it grants four actions on one bucket and one distribution, nothing
|
# actually been created is AGENTS.md Q22 — unanswered as of 2026-08-26.
|
||||||
# more. Rotate the key quarterly; OIDC would have made that unnecessary.
|
#
|
||||||
|
# See docs/06-deployment.md for the exact IAM policy — it grants four actions on
|
||||||
|
# one bucket and one distribution, nothing more. Rotate the key quarterly; OIDC
|
||||||
|
# would have made that unnecessary.
|
||||||
#
|
#
|
||||||
# Requires a Gitea Actions runner registered to this repo or its organisation.
|
# Requires a Gitea Actions runner registered to this repo or its organisation.
|
||||||
|
|
||||||
@@ -33,8 +37,55 @@ jobs:
|
|||||||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
|
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
||||||
|
# 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:
|
steps:
|
||||||
|
# Runs first, before checkout and before any AWS call, so a
|
||||||
|
# misconfiguration costs one second instead of a full build.
|
||||||
|
#
|
||||||
|
# Repository variables live at Settings -> Actions -> Variables. Gitea
|
||||||
|
# only added the `vars` context in 1.21; this instance reports 1.27.2
|
||||||
|
# [verified 2026-08-26 - /api/v1/version, AGENTS.md §7], so the guard is
|
||||||
|
# belt-and-braces rather than load-bearing. It stays because an unset or
|
||||||
|
# mistyped variable degrades the sync target to "s3://" and the run dies
|
||||||
|
# obscurely somewhere in the middle, whatever the Gitea version.
|
||||||
|
#
|
||||||
|
# Covers the deploy-target variables, the intake endpoint, AND the two
|
||||||
|
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
|
||||||
|
# confirmed the IAM user or its key exists, so an unset key is the single
|
||||||
|
# likeliest first-run failure - and without this it would burn a whole
|
||||||
|
# build before dying at `aws sts get-caller-identity`.
|
||||||
|
#
|
||||||
|
# Only emptiness is ever tested. No value is echoed, so nothing here can
|
||||||
|
# leak a secret into the run log.
|
||||||
|
- name: Guard - required variables and secrets are set
|
||||||
|
run: |
|
||||||
|
missing=''
|
||||||
|
[ -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 "$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
|
||||||
|
echo "Not set:$missing"
|
||||||
|
echo
|
||||||
|
echo 'Variables: Settings -> Actions -> Variables.'
|
||||||
|
echo 'Secrets: Settings -> Actions -> Secrets.'
|
||||||
|
echo 'See docs/06-deployment.md.'
|
||||||
|
echo 'If the variables ARE set, this Gitea predates the vars context (1.21+).'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'All required variables and secrets are set.'
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -51,9 +102,19 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
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
|
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
||||||
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
|
||||||
|
# reads dist/, and it refuses a stale or empty dist for the same reason
|
||||||
|
# this workflow guards its variables: an empty sweep reads exactly like a
|
||||||
|
# clean one. Mirrored in scripts/deploy-local.sh.
|
||||||
|
- name: Claim check
|
||||||
|
run: npm run check:claims
|
||||||
|
|
||||||
# Some Gitea runner images ship without the AWS CLI. Install if missing.
|
# Some Gitea runner images ship without the AWS CLI. Install if missing.
|
||||||
- name: Ensure AWS CLI
|
- name: Ensure AWS CLI
|
||||||
@@ -68,8 +129,8 @@ jobs:
|
|||||||
- name: Verify credentials
|
- name: Verify credentials
|
||||||
run: aws sts get-caller-identity
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
# Two passes: hashed immutable assets first, HTML last. A visitor must
|
# Three passes: hashed immutable assets first, then images, HTML last.
|
||||||
# never fetch a new page whose assets have not landed yet.
|
# A visitor must never fetch a new page whose assets have not landed yet.
|
||||||
- name: Sync hashed assets
|
- name: Sync hashed assets
|
||||||
run: |
|
run: |
|
||||||
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
@@ -100,5 +161,69 @@ jobs:
|
|||||||
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||||
--paths "/*"
|
--paths "/*"
|
||||||
|
|
||||||
|
# Mirrors the same step in scripts/deploy-local.sh, because that script's
|
||||||
|
# header requires the two paths to match on everything that determines
|
||||||
|
# what gets published - and this replaced the INTAKE_ENDPOINT guard.
|
||||||
|
#
|
||||||
|
# It ASSERTS A POSITIVE. The first version excluded one status code and
|
||||||
|
# passed on everything else; `adversarial-reviewer` round 2 measured it
|
||||||
|
# passing on a refused connection (curl -w already prints 000, so the
|
||||||
|
# `|| echo 000` double-appended and made $code "000000") and on a real 501.
|
||||||
|
# It would also have passed the case that matters most: with the /api/*
|
||||||
|
# behaviour MISSING, the POST falls to the S3 default behaviour and
|
||||||
|
# CloudFront answers 403 for a disallowed method - indistinguishable from
|
||||||
|
# the handler's Origin refusal, which is the one distinction this check
|
||||||
|
# exists to draw.
|
||||||
|
#
|
||||||
|
# With the correct Origin and an empty submission the handler validates,
|
||||||
|
# rejects, and redirects 303 to /contact/could-not-send/ - BEFORE any
|
||||||
|
# DynamoDB write and before any email, which is what makes it safe against
|
||||||
|
# production. Probed on four cases: refused, 501, 403, and the real 303.
|
||||||
|
#
|
||||||
|
# It warns rather than failing: the site is already deployed by this point,
|
||||||
|
# and failing the job would not un-deploy it.
|
||||||
|
- name: Intake route check
|
||||||
|
run: |
|
||||||
|
url="https://adr.smlcompany.ca/api/intake"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
--max-time 15 \
|
||||||
|
-H "Origin: https://adr.smlcompany.ca" \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data 'deploy-route-probe=1' "$url")
|
||||||
|
rc=$?
|
||||||
|
location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||||
|
--max-time 15 \
|
||||||
|
-H "Origin: https://adr.smlcompany.ca" \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data 'deploy-route-probe=1' "$url" || true)
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
|
||||||
|
echo "The site is deployed and the contact form is unverified."
|
||||||
|
echo "See docs/06-deployment.md's cutover checklist."
|
||||||
|
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||||
|
echo "POST /api/intake -> 303 -> $location (routed, validating)"
|
||||||
|
else
|
||||||
|
echo "WARNING: POST /api/intake returned $code, expected 303 to"
|
||||||
|
echo "/contact/could-not-send/; redirect was '${location:-none}'."
|
||||||
|
# Kept in step with scripts/deploy-local.sh — the two are one
|
||||||
|
# artefact in two places. 404 is ambiguous between three causes and
|
||||||
|
# the distribution's 404 mapping hides API Gateway's own body.
|
||||||
|
echo "404: /api/* behaviour missing (docs/09 Part 3), OR the POST"
|
||||||
|
echo "/api/intake route missing (Part 6.2), OR the route exists and"
|
||||||
|
echo "the 404 mapping replaced the API's body. Separate them with"
|
||||||
|
echo "aws apigatewayv2 get-routes --api-id <id> --query"
|
||||||
|
echo "'Items[].RouteKey' — the --api-id is required; without it the"
|
||||||
|
echo "CLI exits 252 on ParamValidation."
|
||||||
|
echo "403: method rejected, or the handler refused the Origin —"
|
||||||
|
echo "read which origin request policy /api/* carries. Since"
|
||||||
|
echo "2026-09-04 it may be the custom whitelist"
|
||||||
|
echo "adr-sml-api-viewer-address rather than the managed"
|
||||||
|
echo "AllViewerExceptHostHeader; a policy that does not forward"
|
||||||
|
echo "Origin 403s every real submission. Rollback id:"
|
||||||
|
echo "b689b0a8-53d0-40ab-baf2-68738e2966ac."
|
||||||
|
echo "500: the invoke permission for this route is missing (6.1)."
|
||||||
|
echo "See docs/09-cutover-runbook.md Part 7.1."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
|
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ test-results/
|
|||||||
|
|
||||||
# generated inventory — safe to share, but not tracked
|
# generated inventory — safe to share, but not tracked
|
||||||
aws-inventory.txt
|
aws-inventory.txt
|
||||||
|
|
||||||
|
# The pre-cutover archive of the old single-file build (docs/09 Part 8.1).
|
||||||
|
# NOT committed: it is ~3.3 MB of the page that carried the fabricated founder
|
||||||
|
# and the invented matter values, and Part 8.3 runs `git tag` eleven lines later.
|
||||||
|
# `docs/06` says to keep it, not to version it.
|
||||||
|
_archive/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.astro/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Frozen historical record — reformatting would obscure what it originally said.
|
||||||
|
docs/reference/
|
||||||
|
|
||||||
|
# Markdown here is hand-maintained to an 80-column convention, and AGENTS.md is
|
||||||
|
# an append-only history whose tables Prettier would rewrite wholesale (an
|
||||||
|
# 892-line diff for no reading benefit). Prose wrapping is checked by eye.
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# tokens.css aligns every custom property's comment into a column so the
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["prettier-plugin-astro"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.astro",
|
||||||
|
"options": { "parser": "astro" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.md",
|
||||||
|
"options": { "proseWrap": "preserve" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,9 +22,14 @@
|
|||||||
|
|
||||||
## The one rule that matters more than the code
|
## The one rule that matters more than the code
|
||||||
|
|
||||||
Pouya is a licensed legal professional. **No factual claim about him, his
|
This is Pouya's public marketing surface, and the site it replaces carried
|
||||||
credentials, his experience, or his practice may appear on a public page unless
|
fabricated credentials. **No factual claim about him, his credentials, his
|
||||||
it is in the Verified table in `AGENTS.md` §4.**
|
experience, or his practice may appear on a public page unless it is in the
|
||||||
|
Verified table in `AGENTS.md` §4.**
|
||||||
|
|
||||||
|
(§4 does not verify licensure either way — so do not describe him as
|
||||||
|
"licensed", or as a "legal professional", anywhere, this file included. State
|
||||||
|
the reason for the rule, not a credential the register cannot vouch for.)
|
||||||
|
|
||||||
If a page needs a fact you do not have:
|
If a page needs a fact you do not have:
|
||||||
|
|
||||||
@@ -52,8 +57,9 @@ being restated in the prompt.
|
|||||||
anything live), read the governing specs, name the decisions the task touches,
|
anything live), read the governing specs, name the decisions the task touches,
|
||||||
and **stop and ask on any conflict**. A blocked build is a correct build.
|
and **stop and ask on any conflict**. A blocked build is a correct build.
|
||||||
2. **Implement** — following the conventions below.
|
2. **Implement** — following the conventions below.
|
||||||
3. **Adversarial review** — invoke `adversarial-reviewer` and `claims-auditor` in
|
3. **Adversarial review** — invoke `adversarial-reviewer` on the diff. **D20:
|
||||||
parallel on the diff.
|
`claims-auditor` does NOT run per step.** It runs once, at cutover, over the
|
||||||
|
whole finished site.
|
||||||
4. **Resolve** — fix each finding or decline it with a stated reason. Re-review
|
4. **Resolve** — fix each finding or decline it with a stated reason. Re-review
|
||||||
material fixes.
|
material fixes.
|
||||||
5. **Verify** — run the checks. Never report a check as passing that you did not
|
5. **Verify** — run the checks. Never report a check as passing that you did not
|
||||||
@@ -62,6 +68,14 @@ being restated in the prompt.
|
|||||||
|
|
||||||
`/review` runs phase 3 alone. `/wrap` runs phase 6 at session end.
|
`/review` runs phase 3 alone. `/wrap` runs phase 6 at session end.
|
||||||
|
|
||||||
|
**Agent definitions load at session start.** An edit to `.claude/agents/*.md`
|
||||||
|
does not reach the session you made it in — the brief in force is the one that
|
||||||
|
was on disk when the session began. After committing a change to one, **restart
|
||||||
|
before relying on it, and say in the report which version actually ran.** Found
|
||||||
|
2026-08-30: the gloss lens was added to `claims-auditor` and the agent then
|
||||||
|
reconstructed it from the `AGENTS.md` Change Log rather than having it in its
|
||||||
|
brief, which is luck, not process.
|
||||||
|
|
||||||
**Think deeply before acting.** Extended thinking is on by default for this
|
**Think deeply before acting.** Extended thinking is on by default for this
|
||||||
project (`.claude/settings.json`), and `/build` and `/review` request it
|
project (`.claude/settings.json`), and `/build` and `/review` request it
|
||||||
explicitly. The planning and review phases are where it earns its cost — a defect
|
explicitly. The planning and review phases are where it earns its cost — a defect
|
||||||
@@ -78,16 +92,40 @@ view from the artefact — that independence *is* the mechanism.
|
|||||||
|
|
||||||
**The reviewers are instructed to treat uncertainty as a defect.** They will
|
**The reviewers are instructed to treat uncertainty as a defect.** They will
|
||||||
sometimes be wrong, and that is the intended trade. Explaining why a finding is
|
sometimes be wrong, and that is the intended trade. Explaining why a finding is
|
||||||
mistaken costs minutes; a missed defect on a licensed professional's public
|
mistaken costs minutes; a missed defect on this project's public marketing
|
||||||
marketing page costs considerably more. Do not read a finding as an accusation,
|
pages costs considerably more — the site this replaces carried fabricated
|
||||||
and do not argue a reviewer down — either fix it, or record the reason you
|
credentials, and that is the standard being corrected. Do not read a finding as
|
||||||
declined it so a later reader can see the judgement was made rather than missed.
|
an accusation, and do not argue a reviewer down — either fix it, or record the
|
||||||
|
reason you declined it so a later reader can see the judgement was made rather
|
||||||
|
than missed.
|
||||||
|
|
||||||
**Two reviewers, because they catch different things.** `adversarial-reviewer`
|
**Two reviewers, because they catch different things — but they no longer run at
|
||||||
reads the code. `claims-auditor` reads the copy against the §4 register and knows
|
the same time.** `adversarial-reviewer` reads the code. `claims-auditor` reads the
|
||||||
nothing about whether the code is elegant. A generic reviewer consistently
|
copy against the §4 register and knows nothing about whether the code is elegant.
|
||||||
under-weights the professional-conduct check, which is the highest-stakes failure
|
A generic reviewer consistently under-weights the professional-conduct check,
|
||||||
mode on this project — so it gets its own pass.
|
which is the highest-stakes failure mode on this project — so it keeps its own
|
||||||
|
pass rather than being folded into the code review.
|
||||||
|
|
||||||
|
**D20, 2026-08-30 — the claims pass moved to cutover.** Per step it is
|
||||||
|
`adversarial-reviewer` alone. `claims-auditor` runs **once, over the whole
|
||||||
|
finished site**, as a blocking item on `docs/06`'s cutover checklist. Pouya's
|
||||||
|
reasoning, and it is a calibration and not an erosion: nothing has shipped, so
|
||||||
|
every claims finding so far has been about a page no visitor can reach — the risk
|
||||||
|
is deferred to cutover anyway, and one pass over twenty finished pages catches
|
||||||
|
**more** than nine passes over drafts, because it sees the site as a reader does.
|
||||||
|
The `/med-arb/` ADRIC gloss is the proof: no individual claim was false, the
|
||||||
|
defect was **adjacency**, and adjacency does not exist until the pages sit next to
|
||||||
|
each other. The code reviewer stays per step because what it catches **compounds**
|
||||||
|
— an accessibility or crawlability defect propagates into the next page built on
|
||||||
|
it, and a claims defect does not; it sits there until someone reads it.
|
||||||
|
|
||||||
|
**What it costs is recorded in `AGENTS.md` D20, not summarised away here.** Read
|
||||||
|
it before proposing any further relaxation: `claims-auditor` has caught defects
|
||||||
|
that would have been serious on a live page, and D20 accepts that such a defect
|
||||||
|
may now live in an unpublished draft for weeks. Two things carry that risk in the
|
||||||
|
meantime — **`npm run check:claims`, which is unchanged and runs on every build
|
||||||
|
and both deploy paths**, and **Pouya reading the copy as it is built**. Neither is
|
||||||
|
optional, and neither is a substitute for the cutover pass.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -97,9 +135,45 @@ npm run dev # local dev server
|
|||||||
npm run build # static build to ./dist
|
npm run build # static build to ./dist
|
||||||
npm run preview # serve ./dist locally
|
npm run preview # serve ./dist locally
|
||||||
npm run check # astro check — type and template errors
|
npm run check # astro check — type and template errors
|
||||||
|
npm run check:claims # §4 Forbidden, enforced on dist/ — run it after a build
|
||||||
|
npm run check:intake # the form's field table vs the Lambda's — they are two on purpose
|
||||||
|
npm run og:proof # every og:image resolves; every card headline IS its page's <h1>
|
||||||
|
npm run lighthouse # the performance budget. LOCAL ONLY — needs Chrome, not in CI
|
||||||
|
npm run bio:pdf # re-renders the committed one-page PDF from /bio/. LOCAL ONLY
|
||||||
|
npm run icons # re-derives public/favicon.ico from the brand master. REGENERATOR
|
||||||
npm run lint # eslint + prettier check
|
npm run lint # eslint + prettier check
|
||||||
|
npm run format # prettier — rewrite files in place
|
||||||
|
npm run deploy # build + deploy from this machine (see docs/06)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Four of those are gates and two of them cannot run in CI.** `check`,
|
||||||
|
`check:claims`, `check:intake` and `og:proof` are pure Node and run anywhere.
|
||||||
|
`lighthouse` and `bio:pdf` drive an installed browser, and the Gitea runner has
|
||||||
|
none — so they are keyboard gates plus blocking items on `docs/06`'s cutover
|
||||||
|
checklist, and **they are deliberately not wired into `npm run build` or either
|
||||||
|
deploy path.** Do not describe either as gating a deploy: a check described as
|
||||||
|
running where it cannot is the defect `AGENTS.md` Q22 turned out to be.
|
||||||
|
|
||||||
|
**`bio:pdf` and `icons` are REGENERATORS, not gates** — they rewrite committed
|
||||||
|
artefacts (`public/pouya-lajevardi-bio.pdf`, `public/favicon.ico`) rather than
|
||||||
|
checking anything, so they are run deliberately and their output is committed.
|
||||||
|
⚠️ **`LOCAL ONLY` above means a different thing for each, so do not read the two
|
||||||
|
labels as one.** `bio:pdf` **cannot** run in CI — it drives a browser. `icons`
|
||||||
|
is pure Node and **could**; it is out of the build because a build should not
|
||||||
|
silently rewrite an artefact a human approved, which is why it is labelled
|
||||||
|
`REGENERATOR` rather than `LOCAL ONLY`. It regenerates the favicon **only** — the touch icon
|
||||||
|
is hand-made and must stay opaque cream (`docs/reference/brand-assets.md`
|
||||||
|
§The icon set).
|
||||||
|
|
||||||
|
**`og:proof` and `check:intake` exist because two facts in this repo are
|
||||||
|
deliberately duplicated**, and a duplicated fact needs a mechanism rather than a
|
||||||
|
comment. `og:proof` compares each generated OG card's headline against its page's
|
||||||
|
own `<h1>` — **text baked into a JPEG cannot be grepped by `check:claims`**, so
|
||||||
|
that comparison is the only thing keeping card copy inside the claim register.
|
||||||
|
`check:intake` compares the form's field table against the Lambda's, which are
|
||||||
|
independent because a server that validates against a list the client shipped it
|
||||||
|
is not validating.
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -110,22 +184,24 @@ docs/ the specs you build from
|
|||||||
03-content-spec.md voice, copy rules, per-page copy deck
|
03-content-spec.md voice, copy rules, per-page copy deck
|
||||||
04-seo-spec.md metadata, structured data, sitemap, crawlability
|
04-seo-spec.md metadata, structured data, sitemap, crawlability
|
||||||
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
||||||
06-deployment.md S3/CloudFront, GitHub Actions OIDC, cutover checklist
|
06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist
|
||||||
src/
|
src/
|
||||||
|
content.config.ts content collections — Content Layer API, NOT content/config.ts
|
||||||
styles/tokens.css design tokens — the single source of colour and scale
|
styles/tokens.css design tokens — the single source of colour and scale
|
||||||
styles/global.css reset, base type, utilities
|
styles/global.css reset, base type, utilities
|
||||||
layouts/ page shells
|
layouts/ page shells
|
||||||
components/ UI components
|
components/ UI components
|
||||||
pages/ routes (file-based)
|
pages/ routes (file-based)
|
||||||
content/ content collections; Insights MDX lives here
|
content/insights/ Insights MDX only; the config sits above, not in here
|
||||||
data/site.ts site-wide constants, nav, contact details
|
data/site.ts site-wide constants, nav, contact details
|
||||||
public/ static assets served as-is
|
public/ static assets served as-is
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
**Framework.** Astro, `output: 'static'`. Never introduce a server runtime
|
**Framework.** Astro **7.x**, `output: 'static'` (D1 as amended). Never introduce
|
||||||
without a Change Log entry recording why.
|
a server runtime without a Change Log entry recording why. The major is pinned
|
||||||
|
deliberately — check `npm view astro version` before changing it.
|
||||||
|
|
||||||
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
|
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
|
||||||
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
|
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
|
||||||
@@ -141,9 +217,130 @@ interactive elements reachable by keyboard, `prefers-reduced-motion` honoured on
|
|||||||
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
|
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
|
||||||
2.10:1. See `docs/02-design-system.md`.
|
2.10:1. See `docs/02-design-system.md`.
|
||||||
|
|
||||||
|
**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 laptop, no
|
||||||
|
reviewer can compare the claim against it and the claim is **unverifiable by
|
||||||
|
construction** — not merely unverified. Commit the artefact, or commit a faithful
|
||||||
|
extract with its provenance and the command that produced it.
|
||||||
|
|
||||||
|
This has cost twice. `AGENTS.md` Q24 was the AWS hosting guide, the only record
|
||||||
|
of how the infrastructure was hand-built, living outside the repo. Q32 was the
|
||||||
|
infinity mark: it was traced from the old site's *loading placeholder*, the
|
||||||
|
source comment said so in as many words — and **two adversarial review passes
|
||||||
|
still could not catch that the shape was wrong**, because the real artwork was
|
||||||
|
not in the repo to compare against. Stating a doubt is not enough when the thing
|
||||||
|
that would resolve it is unreachable. Tracked as R14.
|
||||||
|
|
||||||
|
**A command that did not run is not evidence of absence.** Check that a tool
|
||||||
|
exists before trusting its silence, and read exit status, not just stdout. This
|
||||||
|
project ran `timeout 60 ls "$DRIVE"` four times, got empty output each time, and
|
||||||
|
reported the brand assets unreachable — `timeout` is not installed on macOS, so
|
||||||
|
the command had never executed and the directory was fully readable all along.
|
||||||
|
Empty output from a command that failed to start looks exactly like empty output
|
||||||
|
from a command that found nothing. Same family as *a sweep is a command, not a
|
||||||
|
claim*: the claim must rest on output you actually read, from a command that
|
||||||
|
actually ran.
|
||||||
|
|
||||||
|
**And never suppress stderr in a verification script.** *Pouya's convention,
|
||||||
|
2026-08-28, from verifying the deploy credential (`AGENTS.md` Q22).* This is the
|
||||||
|
rule above from the other direction, and it is the more dangerous direction:
|
||||||
|
**`2>/dev/null` converts "it failed" into "it found nothing", and those are
|
||||||
|
opposite results.** His first pass at eight `simulate-principal-policy` checks
|
||||||
|
returned empty for all eight; the suppression was hiding an `InvalidInput` error,
|
||||||
|
and the empty output was then explained with a guess — *"probably lacks the
|
||||||
|
permission"* — which is the answer the check was supposed to produce, arrived at
|
||||||
|
without the check running. The actual cause was a **zsh parameter-expansion bug**:
|
||||||
|
`$ACCT:user/` parses `:u` as a history modifier and yields `327082975128ser/`.
|
||||||
|
Braces fixed it.
|
||||||
|
|
||||||
|
So: no `2>/dev/null` in anything whose output you intend to believe, read the
|
||||||
|
exit status, and when a result is empty **remove the suppression and look before
|
||||||
|
proposing a cause.** A guessed explanation for an empty result is worse than no
|
||||||
|
result, because it closes the question.
|
||||||
|
|
||||||
|
**And never TRUNCATE the output of a check you intend to believe.** *Added
|
||||||
|
2026-08-29, from build step 5.* This is the stderr rule's twin and it is easier
|
||||||
|
to commit, because the command runs and the pipe looks harmless. `npm run check`
|
||||||
|
prints its verdict as three lines — `- N errors`, `- N warnings`, `- N hints` —
|
||||||
|
followed by a blank line. **`npm run check 2>&1 | tail -3` therefore returns
|
||||||
|
warnings, hints and the blank line, and silently drops the errors line.** It was
|
||||||
|
run four times that way and reported as passing each time; `astro check` was
|
||||||
|
exiting **1 with 10 type errors**, and both deploy paths run it before the build,
|
||||||
|
so nothing could have shipped. `adversarial-reviewer` found it.
|
||||||
|
|
||||||
|
The fix is not a bigger `tail`. **Read the exit status** — `cmd; echo "exit=$?"`
|
||||||
|
or `cmd || echo FAILED` — because it is the one signal a pipe cannot silently
|
||||||
|
reshape. `head`, `tail`, `grep -c` and `| grep -i error` all have the same
|
||||||
|
failure mode: they turn a verdict you did not read into a verdict you assert.
|
||||||
|
Same family as *a sweep is a command, not a claim*, and note the asymmetry that
|
||||||
|
makes it dangerous — the truncation only ever hides the bad news, because the
|
||||||
|
error line comes first.
|
||||||
|
|
||||||
|
*Corroborated the same day, twice, in the same session and both in zsh:*
|
||||||
|
`grep -rn $EX 'Mediator-Arbitrator'` printed an option error and no matches —
|
||||||
|
which reads as clean — because zsh does not word-split unquoted variables; and a
|
||||||
|
digest-comparison loop using `set -- $pair` printed **`DIFFER` on all five rows**,
|
||||||
|
which reads as "the source changed under me", because the loop body received one
|
||||||
|
argument and the comparison never ran. Prefer `git grep`, quote or array-expand
|
||||||
|
anything you pass as flags, and re-check any result whose shape is "uniformly
|
||||||
|
bad".
|
||||||
|
|
||||||
|
**zsh does not word-split parameter expansions; a loop over `$VAR` runs ONCE —
|
||||||
|
use command substitution or arrays, and assert the iteration count.** *Pouya's
|
||||||
|
convention, 2026-09-01.* `for p in $PAGES` iterates one item, not twenty-two,
|
||||||
|
and `node probe.mjs 320,360 $P` measures one page — both of which then report
|
||||||
|
"max=0, nonzero=0" and read as a clean sweep. `$(cat file)` and `${=VAR}` do
|
||||||
|
split; `"${(@f)VAR}"` splits on newlines. **The fix is not remembering which:
|
||||||
|
assert the count before reading the result** — a probe that says how many rows it
|
||||||
|
measured cannot silently measure one.
|
||||||
|
|
||||||
|
**And re-check "uniformly GOOD" too — that is the dangerous half.** *Added
|
||||||
|
2026-08-30; sharpened on Pouya's instruction 2026-08-31, as "the sharpest
|
||||||
|
instrument finding yet".* The same `set -- $pair` loop recurred while confirming
|
||||||
|
nine restored files matched a saved copy, and this time it printed **`same` on
|
||||||
|
all nine**: `shasum` was handed both filenames as one argument, errored, and left
|
||||||
|
both variables empty, so `"" = ""` passed.
|
||||||
|
|
||||||
|
**The distinguishing property, and it is the whole rule: a broken verification
|
||||||
|
that fails loudly is safe; one that passes uniformly is not.** `DIFFER` on every
|
||||||
|
row announces itself — it is alarming, so it starts an investigation, and the
|
||||||
|
investigation finds the broken loop. A uniform pass is **the result you were
|
||||||
|
hoping for, so it ends the check** instead of starting one. The two failures come
|
||||||
|
from the identical bug and only one of them is survivable.
|
||||||
|
|
||||||
|
So a comparison must **assert that both things it compares exist** before
|
||||||
|
comparing them — that is the assertion the shell loop skipped, and it is what
|
||||||
|
turns this class of bug back into the loud kind. Note the same hole in `git grep`:
|
||||||
|
it silently misses untracked files, so a clean sweep across new work means
|
||||||
|
nothing until the files are staged.
|
||||||
|
|
||||||
|
**A parent cannot style a child component's root element.** Astro does not pass
|
||||||
|
a parent's scope attribute down, so `<Button class="header-cta" />` compiles the
|
||||||
|
parent's rule to `.header-cta[data-astro-cid-<parent>]` while the rendered `<a>`
|
||||||
|
carries only `<Button>`'s own cid. **The rule silently never matches** — no
|
||||||
|
error, no warning, and the CSS looks correct in the source. Wrap the child in an
|
||||||
|
element the parent owns (`<div class="header-cta"><Button …/></div>`), or reach
|
||||||
|
it deliberately with `:global()` from a parent-scoped ancestor. Inherited
|
||||||
|
properties (`white-space`, `color`, `font-*`) do cross the boundary and are the
|
||||||
|
exception. This cost a header CTA that was documented as hidden on mobile,
|
||||||
|
was not hidden, and sat 75 px short of the right edge on desktop — both found by
|
||||||
|
measurement, neither by reading. It will recur with `PracticeCard`,
|
||||||
|
`ArticleCard`, and `Pill`.
|
||||||
|
|
||||||
|
**Never write the `animation` shorthand beside `animation-timeline`.** Longhands
|
||||||
|
only — `animation-name`, `animation-duration`, `animation-timing-function`,
|
||||||
|
`animation-fill-mode`, then `animation-timeline` and `animation-range`.
|
||||||
|
`scroll()` and `view()` are not legal components of the shorthand, and Lightning
|
||||||
|
CSS folds the two declarations together on minify into something invalid, which
|
||||||
|
is then discarded whole. **It works in `npm run dev` and is dead in
|
||||||
|
`npm run build`** — the worst shape a defect can take. It happened twice in one
|
||||||
|
session, the second time inside the fix for the first. `/build` Phase 5 greps
|
||||||
|
`dist` for it; do not remove that check.
|
||||||
|
|
||||||
**Images.** Astro `<Image>` with explicit width and height. AVIF/WebP with
|
**Images.** Astro `<Image>` with explicit width and height. AVIF/WebP with
|
||||||
fallback. Never base64-inline an image into HTML — the old site did this with
|
fallback. Never base64-inline an image into HTML — the old site did this with
|
||||||
~1 MB of logo PNGs.
|
~1 MB of logo PNGs — a figure `AGENTS.md` Q34 is now open against, so treat the
|
||||||
|
rule as standing on its own merits rather than on that number.
|
||||||
|
|
||||||
**Fonts.** Self-hosted, subset, `font-display: swap`, preloaded. No Google Fonts
|
**Fonts.** Self-hosted, subset, `font-display: swap`, preloaded. No Google Fonts
|
||||||
request at runtime — it costs a round trip and adds a third-party call to a
|
request at runtime — it costs a round trip and adds a third-party call to a
|
||||||
@@ -153,20 +350,260 @@ page that collects legal inquiries.
|
|||||||
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
||||||
`docs/04-seo-spec.md`. A page without these is not finished.
|
`docs/04-seo-spec.md`. A page without these is not finished.
|
||||||
|
|
||||||
|
**A version pin is verified against the registry, never recalled.** Before you
|
||||||
|
write or change any dependency version, run `npm view <pkg> version` and pin
|
||||||
|
against what it returns. One second of checking; a stale pin costs a migration.
|
||||||
|
This rule exists because `astro: "^5.0.0"` was written from memory and was
|
||||||
|
**two majors stale on the day it was written** — which meant shipping a
|
||||||
|
framework carrying high-severity XSS advisories. The same check applies to
|
||||||
|
every pin in `package.json`, not just the framework.
|
||||||
|
|
||||||
|
Re-check currency at each phase boundary in the build order (`AGENTS.md` R11),
|
||||||
|
not only when something breaks.
|
||||||
|
|
||||||
|
**`AGENTS.md` §7 is the single source of truth for operational facts.** Resource
|
||||||
|
IDs, regions, DNS records, credential state, service status — these live in §7
|
||||||
|
and nowhere else. Specs in `docs/` **cite** §7; they do not restate it. Write
|
||||||
|
"the region `AGENTS.md` §7 records", not the region. Same for bucket names,
|
||||||
|
distribution IDs, DKIM tokens, endpoints, and account identifiers.
|
||||||
|
|
||||||
|
A duplicated fact is a fact that will eventually be wrong in one place, and the
|
||||||
|
copy that goes stale is the one nobody re-reads. This rule exists because
|
||||||
|
`docs/05-backend-spec.md` carried its own copy of the SES DKIM table, a
|
||||||
|
correction reached §7 and never reached it, and the stale copy ended up telling
|
||||||
|
an operator to delete the three records that authenticate outbound mail —
|
||||||
|
under the heading "Never delete".
|
||||||
|
|
||||||
|
**A measurement is a claim about your instrument until you check the
|
||||||
|
instrument.** This has now cost nine times, and the shape is identical every
|
||||||
|
time: a number that looks like a finding, from a probe nobody validated.
|
||||||
|
|
||||||
|
- `timeout 60 ls "$DRIVE"` — **the command never ran.** `timeout` is not
|
||||||
|
installed on macOS. Empty output from a command that failed to start looks
|
||||||
|
exactly like empty output from a command that found nothing, and it produced a
|
||||||
|
report that the brand assets were unreachable when the directory was fully
|
||||||
|
readable.
|
||||||
|
- **`1.23:1` for the traced mark** — the bounding box of the path's *coordinate
|
||||||
|
hull*, not of the curve. A cubic's control points sit outside it, so the box
|
||||||
|
was 33% too tall while giving the *correct* width — which means the obvious
|
||||||
|
sanity check, "does the width look right?", passes.
|
||||||
|
- **"the mark renders at 24px"** — the harness reported the worst-deviating
|
||||||
|
instance on the page, not the instance under discussion, which was exact.
|
||||||
|
- **"0 overflow at every width"** — true, and it measured the *document*. A flex
|
||||||
|
child was absorbing the deficit by being crushed to aspect 0.891. **Measure
|
||||||
|
the elements, not only the page.**
|
||||||
|
- **`img.naturalWidth` = 64 at DPR 1, 2 and 3** — which reads as *the density
|
||||||
|
ladder is not being generated at all*, a shipped defect on every page. It is
|
||||||
|
**density-corrected by spec**: a 192px file selected at `3x` correctly reports
|
||||||
|
64. The files on disk were 64 / 128 / 192 all along.
|
||||||
|
- **"10 distinct contexts" from `grep -roh '.\{50\}X.\{50\}' dist/ | sort -u`**
|
||||||
|
— **`grep -o` takes NON-OVERLAPPING matches.** On minified HTML a page is a
|
||||||
|
handful of very long lines, so an early window eats the characters a later one
|
||||||
|
needs and occurrences vanish silently. A whole shipped sentence was missing
|
||||||
|
from the list. **A `grep -o` window count is not an enumeration** — to count
|
||||||
|
occurrences of a string, iterate every match position, or `grep -o` the bare
|
||||||
|
string with no context window.
|
||||||
|
- **An ink bounding box of `48x48` filling the whole 48 px favicon** — which
|
||||||
|
reads as *the regeneration blew the mark up to the full square*. The probe
|
||||||
|
identified ink as "differs from cream", which is **correct on a matted icon and
|
||||||
|
meaningless on a transparent one**, because `alpha = 0` pixels still carry RGB
|
||||||
|
`(0,0,0)`. **Nothing about the probe changed — the class of input did**, and a
|
||||||
|
probe cannot tell you that. Composite over a known ground first, so both things
|
||||||
|
compared are the kind of thing the instrument was built for.
|
||||||
|
- **A contrast ratio of `14.02:1` for the mark's darkest ink** — computed from a
|
||||||
|
raw channel value **without asking what alpha it is painted at**. Exactly one
|
||||||
|
pixel carried that colour and its alpha was 251; a `>= 250` filter let it
|
||||||
|
through and the ratio was then taken as if it were opaque. **It is a number
|
||||||
|
about a colour that is never painted.** Composite against the actual ground
|
||||||
|
before measuring contrast, and take "the ink colour" only from fully opaque
|
||||||
|
pixels.
|
||||||
|
- **`POST /api/intake` returning 403 — read as "the route does not exist", on a
|
||||||
|
LIVE site.** ⚠️ **A bare POST to `/api/intake` returns 403 BY DESIGN.** The
|
||||||
|
handler rejects a request with no `Origin` header, and **`docs/09` §7.1 says so
|
||||||
|
in as many words** — *"403 means the `Origin` header did not arrive"* — three
|
||||||
|
lines below the probe it prescribes. **The only valid route probe is `docs/09`
|
||||||
|
§7.1 verbatim, `Origin` header included; a 403 without that header is not
|
||||||
|
evidence about the route.** Run correctly it returns **303** to
|
||||||
|
`/contact/could-not-send/`, which is the handler answering as designed. This
|
||||||
|
fired **twice on this project in two days** — Pouya's own probe tripped it
|
||||||
|
2026-09-02, and it then reached a Change Log entry, a `docs/06` blocker and a
|
||||||
|
report to him as *"the intake form is live and broken"*. **The status code was
|
||||||
|
read without reading the document that defines what that status code means on
|
||||||
|
that route**, and the document was in the repo the whole time. Generalised:
|
||||||
|
**before interpreting a response, check whether the endpoint documents its own
|
||||||
|
failure modes** — an API that rejects by design looks exactly like an API that
|
||||||
|
is missing.
|
||||||
|
|
||||||
|
So before acting on a number: say what it is a number *of*; confirm the command
|
||||||
|
actually ran and read its exit status; and check it against a second method that
|
||||||
|
cannot fail the same way — the bytes on disk, a screenshot, a hit test.
|
||||||
|
|
||||||
|
**Two simulations of 200% text are not equivalent: media-query `rem` resolves
|
||||||
|
against the browser DEFAULT font size, not the root element — measure under both
|
||||||
|
methods before declaring a reflow result.** *Pouya's convention, 2026-09-01.*
|
||||||
|
Raising the default moves the breakpoints along with the type, so the desktop
|
||||||
|
layout is never reached and nothing overflows; setting `documentElement.style
|
||||||
|
.fontSize` doubles the type and leaves the breakpoints where they were, which is
|
||||||
|
the layout the desktop nav was measured in. One of those reported **0** while the
|
||||||
|
other reported **944 px** on the same 22 pages, and the prose generalised the
|
||||||
|
zero.
|
||||||
|
|
||||||
|
⚠️ **AND THERE ARE THREE MECHANISMS, NOT TWO — the third defeats the `rem`/`em`
|
||||||
|
FAMILY, which is not the same as defeating CSS.** Measured 2026-09-01: Chrome's
|
||||||
|
**"Minimum font size"** *floors* computed font sizes instead of scaling them, so
|
||||||
|
text enlarges while `rem` keeps resolving at 16 px. A media query in `rem` does
|
||||||
|
not see it, and neither does a container query — `@container` `rem`/`em` DO track
|
||||||
|
the root element (that is the one real difference from `@media`, and it is
|
||||||
|
measured), but under a minimum-font-size setting they still resolve at 16 px while
|
||||||
|
`getComputedStyle` reports 32 px.
|
||||||
|
|
||||||
|
⚠️ **BUT "NO CSS CONDITIONAL CAN SEE IT" IS FALSE, AND ASSERTING IT COST A
|
||||||
|
CONFORMANCE FAILURE.** *Corrected 2026-09-01, same day, by `adversarial-reviewer`.*
|
||||||
|
The **font-metric** units read the *used* font size and therefore double:
|
||||||
|
**`ch`, `ex`, `cap`, `lh`, `rlh`** all respond — in property values, in `@media`
|
||||||
|
**and** in `@container` (`ch` 10.608 → 21.216 px; `@media (min-width: 100ch)`
|
||||||
|
flips). Only `rem`, `em`, `ic` and `px` are blind. The false generalisation was
|
||||||
|
written into `docs/02`, `docs/06`, `global.css` and `tokens.css`, and it was then
|
||||||
|
used as the premise for accepting a **WCAG 2.2 SC 2.4.11 (AA)** failure as
|
||||||
|
unfixable — *"the only fix is JS"*. **The lesson is the shape, not the units: "no
|
||||||
|
mechanism can X" is a claim about every mechanism, including the ones you did not
|
||||||
|
enumerate.** Test the family you did not think of before writing "none", and
|
||||||
|
prefer "every construct I measured is blind, and here is the list" — which is
|
||||||
|
falsifiable and was what the measurement actually supported.
|
||||||
|
|
||||||
|
For *reflow* the conclusion is unchanged: **used-value layout — wrapping — is
|
||||||
|
still the right mechanism**, because it needs no threshold and no fitted constant.
|
||||||
|
That path
|
||||||
|
was the worst of the three: on one grid of 22 pages × 16 widths, **219 of 352
|
||||||
|
page-widths overflowed** against root-style's **175**, and it was the only one
|
||||||
|
failing at 320 px and 1024 px. **Always state the grid with the count** — two
|
||||||
|
sweeps in that session quoted totals of 220, 330 and 352 for the same claim, and
|
||||||
|
side by side they read as contradictions rather than as different width lists.
|
||||||
|
Measure all three mechanisms; treat a clean result from one as evidence about
|
||||||
|
that one.
|
||||||
|
|
||||||
|
**And a grep that matches is not a finding until you read what it matched.**
|
||||||
|
A case-insensitive sweep for `LSO` hit `I aLSO practise`; a superlative sweep for
|
||||||
|
`leading` hit `the pLEADINGs`. Both on the same page on the same day. Print the
|
||||||
|
match with context before you believe it.
|
||||||
|
|
||||||
|
**Never name an Astro prop `as`.** `const { as = 'p' } = Astro.props` detaches
|
||||||
|
the `Props` interface from the component, and **every call site silently stops
|
||||||
|
being type-checked.** `astro check` reports it only as `ts(6196) 'Props' is
|
||||||
|
declared but never used`, which reads like lint noise. Measured: with the prop
|
||||||
|
named `as`, `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**;
|
||||||
|
renaming the one identifier to `tag` made the same probe fail correctly. **Do not
|
||||||
|
silence a `ts(6196)` with `Astro.props as Props`** — that hides the warning and
|
||||||
|
leaves the call sites unchecked, which is strictly worse. If that hint appears on
|
||||||
|
any component, pass it a bogus prop before believing its props are checked.
|
||||||
|
|
||||||
|
**A sweep is a command, not a claim.** Any statement that a change was applied
|
||||||
|
across files — a phrase removed everywhere, a path updated everywhere, a
|
||||||
|
decision swept through the docs — must cite the command that proves it, and be
|
||||||
|
written only after reading that command's output. Paste the `grep` into the
|
||||||
|
Change Log entry. Three consecutive entries on this project asserted a completed
|
||||||
|
sweep; instances survived all three, and one of them was inside
|
||||||
|
`.claude/agents/claims-auditor.md` — the definition of the agent whose job is to
|
||||||
|
catch exactly that. Recall is not evidence.
|
||||||
|
|
||||||
|
**And sweep the VOCABULARY, not only the subject.** *Added 2026-08-30, from the
|
||||||
|
Q.Arb amendment.* `git grep 'Q.Arb'` is line-anchored, so it could not find **ten
|
||||||
|
lines in `docs/03` that were entirely about Q.Arb and never named it** — an
|
||||||
|
unstruck, imperative block still instructing the struck form, eleven lines below
|
||||||
|
that change set's own strike notice on the same bullet. The sweep was a real
|
||||||
|
command and its output was read honestly. It was still the wrong command.
|
||||||
|
|
||||||
|
So after sweeping the term, sweep the words its claims are **made of** — here,
|
||||||
|
the stage vocabulary (`commenced`, `in progress`, `pathway`, `not yet`) with no
|
||||||
|
mention of the designation. This is R8's sharpest edge, and it is the one that
|
||||||
|
survives an honest reader: a sweep can pass every test in the rule above and
|
||||||
|
still miss everything, because the anchor you chose is not the anchor the text
|
||||||
|
uses. The same session also excluded `docs/reference/` as "sourced extracts" —
|
||||||
|
half right. The quotations there are evidence; **the commentary around them is
|
||||||
|
this repository's voice**, and three lines of it still asserted the struck row.
|
||||||
|
|
||||||
|
|
||||||
|
**`check:claims` IS FROZEN. It is a tripwire, not a program.** *Pouya's ruling,
|
||||||
|
2026-08-30.* Round 2 of the Q.Arb amendment found **five defects in round 1's own
|
||||||
|
fixes to that script, two of which made it worse than before the pattern
|
||||||
|
existed** — a dedup key that reported two breaches of the same string as one (the
|
||||||
|
check truncating its own output), and a collapsed-text view whose window leapt
|
||||||
|
paragraph boundaries onto approved copy while its comment claimed it could not.
|
||||||
|
At that point it was generating defects at roughly the rate it caught them.
|
||||||
|
|
||||||
|
The rule, and it has no exceptions:
|
||||||
|
|
||||||
|
- **A pattern is added only after a real breach has reached `dist/`.** Never
|
||||||
|
speculatively, never to close a gap you can imagine.
|
||||||
|
- **Each addition ships with a probe** — an injected page proving it catches the
|
||||||
|
actual breach — **and a negative fixture** proving it stays silent on the
|
||||||
|
approved copy nearest to it.
|
||||||
|
- **No refactors. No coverage improvements. No tidying.** If a pattern is wrong,
|
||||||
|
change that pattern deliberately, with a Change Log entry. Do not rewrite the
|
||||||
|
scanner around it.
|
||||||
|
|
||||||
|
Under D20 this script is the only per-step claims control, which is an argument
|
||||||
|
for keeping it **correct**, not for growing it. It catches the §4 breaches that
|
||||||
|
are greppable and makes no claim about the ones that are not.
|
||||||
|
|
||||||
|
**Comments record decisions, not history — D19.** *"X because D13"* stays.
|
||||||
|
*"This was Y, then flagged, then became X"* belongs in the `AGENTS.md` Change
|
||||||
|
Log, which is where a reader looks for how something got here. **A comment
|
||||||
|
longer than the code it explains must justify itself. Trim on sight.**
|
||||||
|
|
||||||
|
Pouya's ruling, 2026-08-28, on his own measurement: **342 lines added to `src/`
|
||||||
|
in one session for 8 functional lines**, and four of that session's review
|
||||||
|
findings were stale statements living inside those comments. A comment that
|
||||||
|
narrates its own revision history becomes a second record to keep true, and then
|
||||||
|
a source of defects about the record rather than about the site.
|
||||||
|
|
||||||
|
This does **not** license deleting a comment that carries a live constraint. The
|
||||||
|
parent-scope trap, the `animation-timeline` minifier defect and the `as`-prop
|
||||||
|
hazard above are load-bearing and stay. The test is whether a future reader needs
|
||||||
|
it **to avoid breaking something** — not whether it is interesting.
|
||||||
|
|
||||||
**Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`).
|
**Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`).
|
||||||
One logical change per commit. Never commit secrets, `.env` files, or AWS
|
One logical change per commit. Never commit secrets, `.env` files, or AWS
|
||||||
credentials — deploys use OIDC role assumption.
|
credentials. Gitea is not an AWS OIDC provider, so the deploy key is designed as
|
||||||
|
a static IAM access key to be held in Gitea Actions secrets — whether it has
|
||||||
|
actually been provisioned is `AGENTS.md` Q22. It must never reach the repo.
|
||||||
|
|
||||||
**Performance budget.** Lighthouse ≥ 95 on all four categories, on mobile, for
|
**Performance budget.** Lighthouse ≥ 95 on all four categories, on mobile, for
|
||||||
every page. Under 100 KB of JS on any route. LCP under 2.0 s on a simulated
|
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.
|
Slow 4G connection. Treat a budget breach as a failing build.
|
||||||
|
|
||||||
|
**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
|
## What "done" means for a page
|
||||||
|
|
||||||
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
|
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
|
||||||
- [ ] No `TODO(pouya)` left unlogged in §9
|
- [ ] No `TODO(pouya)` left unlogged in §9
|
||||||
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
|
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
|
||||||
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
|
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
|
||||||
- [ ] Lighthouse ≥ 95 mobile, all four categories
|
- [ ] 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
|
- [ ] Renders correctly with JavaScript disabled
|
||||||
- [ ] `AGENTS.md` Change Log entry appended
|
- [ ] `AGENTS.md` Change Log entry appended
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
The dispute resolution practice of Pouya Lajevardi — Toronto.
|
The dispute resolution practice of Pouya Lajevardi — Toronto.
|
||||||
|
|
||||||
A static site built with [Astro](https://astro.build), deployed to Amazon S3
|
A static site built with [Astro](https://astro.build), built for deployment to
|
||||||
behind CloudFront by GitHub Actions.
|
Amazon S3 behind CloudFront by Gitea Actions — see Deployment; the pipeline is
|
||||||
|
not yet proven.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvm use # Node 22
|
nvm use # Node 22 LTS — the floor is in package.json engines
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:4321
|
npm run dev # http://localhost:4321
|
||||||
```
|
```
|
||||||
@@ -18,10 +19,36 @@ npm run dev # http://localhost:4321
|
|||||||
| Command | Does |
|
| Command | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `npm run dev` | Development server with hot reload |
|
| `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 preview` | Serve the built site locally |
|
||||||
| `npm run check` | `astro check` — type and template errors |
|
| `npm run check` | `astro check` — type and template errors |
|
||||||
| `npm run lint` | ESLint + Prettier |
|
| `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
|
## Before you contribute
|
||||||
|
|
||||||
@@ -29,23 +56,80 @@ Read **`AGENTS.md`** first, and maintain it as you work — it is the living
|
|||||||
record of what this project is, what was decided, and why. Then read
|
record of what this project is, what was decided, and why. Then read
|
||||||
**`CLAUDE.md`** for the working rules, and the specs in `docs/`.
|
**`CLAUDE.md`** for the working rules, and the specs in `docs/`.
|
||||||
|
|
||||||
The single hardest rule: **no factual claim about the practice ships unless it
|
The single hardest rule: **no factual claim about Pouya, his credentials, his
|
||||||
appears in the verified register in `AGENTS.md` §4.** This is a licensed
|
experience, or his practice ships unless it appears in the verified register in
|
||||||
professional's public marketing surface, and the site this replaces contained
|
`AGENTS.md` §4.** This is a public marketing surface, and the site it replaces
|
||||||
fabricated credentials.
|
contained fabricated credentials.
|
||||||
|
|
||||||
## How work is done here
|
## How work is done here
|
||||||
|
|
||||||
Pouya decides; Claude Code implements and then adversarially reviews its own
|
Pouya decides; Claude Code implements and then adversarially reviews its own
|
||||||
work. Run **`/build <task>`** for any substantive change — it plans, implements,
|
work. Run **`/build <task>`** for any substantive change — it plans, implements,
|
||||||
runs two independent review agents on the diff, resolves the findings, verifies
|
runs two independent review agents on the diff (the claims audit wherever copy
|
||||||
the build, and records the session in `AGENTS.md`. `/review` runs the review pass
|
changed), resolves the findings, verifies the build, and records the session in
|
||||||
alone; `/wrap` closes a session.
|
`AGENTS.md`. `/review` runs the review pass alone; `/wrap` closes a session.
|
||||||
|
|
||||||
Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
|
Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Pushes to `main` build and deploy automatically via
|
**Today, deploys run locally: `npm run deploy`** (`scripts/deploy-local.sh`).
|
||||||
`.github/workflows/deploy.yml`, using OIDC role assumption — there are no
|
It runs the same guard, the same three sync passes with the same cache headers,
|
||||||
long-lived AWS credentials in this repository. See `docs/06-deployment.md`.
|
and the same invalidation as the CI workflow — at this scale the pipeline
|
||||||
|
changes only *how* a deploy is triggered, not what it does. The script and
|
||||||
|
`.gitea/workflows/deploy.yml` are one artefact in two places: change one, change
|
||||||
|
both.
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` is the CI pipeline — **Gitea Actions**, not GitHub
|
||||||
|
Actions. **It has never run**, for two reasons that are not oversights:
|
||||||
|
|
||||||
|
- Actions are not enabled and no runner is registered. The Gitea instance is
|
||||||
|
jointly administered, so both need its second administrator (Q23).
|
||||||
|
|
||||||
|
*(It previously listed a second reason — that the scoped IAM user did not exist.
|
||||||
|
**It exists**: `adr-sml-deploy`, created 2026-08-26, Q22 closed 2026-08-28. See
|
||||||
|
`AGENTS.md` §7 for the inventory and the least-privilege verification.)*
|
||||||
|
|
||||||
|
Its **first** step is a guard: the run aborts, naming what is missing, if any
|
||||||
|
required variable or either AWS secret is empty. Only emptiness is tested and no
|
||||||
|
value is echoed.
|
||||||
|
|
||||||
|
The GitHub Actions equivalent, which uses OIDC role assumption, is kept as
|
||||||
|
`docs/reference/github-actions-oidc.yml.example` in case the project ever moves
|
||||||
|
to a forge that supports it. It sits outside `.github/workflows/` on purpose:
|
||||||
|
Gitea falls back to that directory when `.gitea/workflows` is absent, so a
|
||||||
|
workflow file left there with a `push` trigger would be only conditionally
|
||||||
|
inert. As an `.example` under `docs/` it cannot be picked up at all.
|
||||||
|
|
||||||
|
**The pipeline is designed around a long-lived AWS credential, and that
|
||||||
|
credential now exists.** Gitea is not an AWS OIDC provider, so there is no role
|
||||||
|
to assume: deploys authenticate as a scoped IAM user, `adr-sml-deploy`, with its
|
||||||
|
access key in the repository's Gitea Actions secrets. **The user was created
|
||||||
|
2026-08-26 and verified least-privilege by execution — `AGENTS.md` §7, Q22 closed
|
||||||
|
2026-08-28.** Its access key has **never been used** (`LastUsed` null), because
|
||||||
|
deploys still run locally. *(This paragraph asserted the user "has not been
|
||||||
|
created" until 2026-08-28.)* In the meantime the local script
|
||||||
|
**refuses to run as `user/pouya`**, the broadly-permissioned personal user —
|
||||||
|
see `AGENTS.md` §10. Two things are meant to bound the risk, and neither is in
|
||||||
|
place yet:
|
||||||
|
|
||||||
|
- **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket,
|
||||||
|
`s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and
|
||||||
|
`cloudfront:CreateInvalidation` on one distribution. No `Action: "*"`, no
|
||||||
|
`Resource: "*"`, nothing outside that one bucket and that one distribution.
|
||||||
|
The AWS account is shared with unrelated projects, including a bucket whose
|
||||||
|
name indicates another business's production database backups — that
|
||||||
|
narrowness is what keeps a compromised runner away from it, and it is
|
||||||
|
load-bearing rather than hygiene. See `AGENTS.md` §10. If a deploy step needs
|
||||||
|
a permission the policy lacks, question the step; do not widen the policy.
|
||||||
|
- **The key must be rotated quarterly. It now has an owner and a date: first
|
||||||
|
rotation due 2026-11-26** (key created 2026-08-26) — `AGENTS.md` §12 **R17**.
|
||||||
|
Create a second access key, update the Gitea secrets, confirm a deploy
|
||||||
|
succeeds, **then** delete the old one — rotation that leaves the old key active
|
||||||
|
is not rotation, and deleting before verifying leaves no way to authenticate
|
||||||
|
the fix. OIDC would have removed the obligation entirely; it is unavailable, so
|
||||||
|
this is a standing calendar task. *(It read "nobody owns that yet" until
|
||||||
|
2026-08-28; R17 is the owner, and the date is the whole point of the row.)*
|
||||||
|
|
||||||
|
Full procedure, IAM policy, runner setup, and cutover checklist:
|
||||||
|
`docs/06-deployment.md`.
|
||||||
|
|||||||
+56
-7
@@ -17,19 +17,68 @@ export default defineConfig({
|
|||||||
trailingSlash: 'always',
|
trailingSlash: 'always',
|
||||||
build: { format: 'directory', inlineStylesheets: 'auto' },
|
build: { format: 'directory', inlineStylesheets: 'auto' },
|
||||||
|
|
||||||
|
// Astro 7 changed this default from `true` to `'jsx'`. Measured, not assumed
|
||||||
|
// (AGENTS.md entry (t)): in an .astro template, an inline pair split across
|
||||||
|
// two lines renders as `<em>inline</em><strong>pair</strong>` under 'jsx' and
|
||||||
|
// `<em>inline</em> <strong>pair</strong>` under `true`. The space is silently
|
||||||
|
// deleted — no error, no warning; you find out by reading the page.
|
||||||
|
//
|
||||||
|
// MDX prose is NOT affected either way; the hazard is .astro markup only.
|
||||||
|
// Held at the HTML-aware behaviour because hand-written templates on a
|
||||||
|
// text-heavy site are exactly where a wrapped line meets an inline tag.
|
||||||
|
// Revisit only with a measurement, not a preference. See R12.
|
||||||
|
compressHTML: true,
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
mdx(),
|
mdx(),
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => !page.includes('/legal/'),
|
// 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/') &&
|
||||||
|
!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',
|
changefreq: 'monthly',
|
||||||
lastmod: new Date(),
|
// No `lastmod`. It was `new Date()`, which stamped every URL with the
|
||||||
|
// build time — telling crawlers all 17 pages changed whenever one did.
|
||||||
|
// Google discounts lastmod it judges unreliable, so that spent the signal
|
||||||
|
// docs/04-seo-spec.md wants rather than sending it. Step 7 can reinstate
|
||||||
|
// it per-entry from an article's `updatedDate` via `serialize`.
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
image: {
|
// No `image` block: `astro/assets/services/sharp` is already Astro's default
|
||||||
// Explicit dimensions everywhere; never base64-inline an image.
|
// service, so setting it was dead configuration. The conventions it used to
|
||||||
service: { entrypoint: 'astro/assets/services/sharp' },
|
// sit under — explicit width/height on every image, never base64-inline —
|
||||||
},
|
// live in CLAUDE.md, which is where they are actually enforced (by review).
|
||||||
|
|
||||||
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
|
// No `prefetch` block at all — removed 2026-08-26, see AGENTS.md entry (r).
|
||||||
|
// Any prefetch setting ships Astro's prefetch script to every page, against
|
||||||
|
// CLAUDE.md's "default to zero JS", for a marginal gain on a small static site
|
||||||
|
// already served from CloudFront. Revisit only against real Lighthouse numbers.
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* The intake handler's OWN field table. Spec: docs/05-backend-spec.md §Form fields.
|
||||||
|
*
|
||||||
|
* ⚠️ THIS IS A SECOND, INDEPENDENT COPY OF THE FORM'S FIELD LIST, AND THE
|
||||||
|
* DUPLICATION IS ARCHITECTURAL RATHER THAN AN OVERSIGHT.
|
||||||
|
*
|
||||||
|
* docs/05: "Client-side validation is a convenience. **The Lambda re-validates
|
||||||
|
* everything.**" A server that validates against a list the client shipped it is
|
||||||
|
* not validating — it is asking the caller what the rules are. And this file is
|
||||||
|
* deployed inside the Lambda zip, which cannot import from `src/` at all.
|
||||||
|
*
|
||||||
|
* WHAT KEEPS THE TWO HONEST IS A CHECK, NOT A SHARED IMPORT.
|
||||||
|
* `npm run check:intake` imports this module and `src/data/intake.ts` and
|
||||||
|
* asserts they agree on every field name, on which are required, on every length
|
||||||
|
* cap, and on every closed option set. A disagreement means either the form
|
||||||
|
* offers something the handler rejects — a lost inquiry that looks like a
|
||||||
|
* browser bug — or the handler accepts something no form ever shows.
|
||||||
|
*
|
||||||
|
* It lives in its own file rather than inside `handler.mjs` so the check can
|
||||||
|
* import it. `handler.mjs` calls `requireEnv()` at module scope and throws
|
||||||
|
* without a configured environment, so importing THAT would mean inventing
|
||||||
|
* fixture credentials to run a check that has nothing to do with them.
|
||||||
|
* (The first version of the check scraped this table out of the handler as text
|
||||||
|
* and evaluated it. Its "refuse anything executable" guard then rejected the
|
||||||
|
* table on the word `process` — which is a FIELD NAME. A guard that fires on the
|
||||||
|
* data it exists to protect is worse than no guard, and the fix was to stop
|
||||||
|
* scraping.)
|
||||||
|
*
|
||||||
|
* `select` and `radio` fields carry their option list, and a value outside it is
|
||||||
|
* REJECTED rather than coerced — a select is a closed set, and a request that
|
||||||
|
* sends something else is not a browser.
|
||||||
|
*
|
||||||
|
* ⚠️ **`label` IS HERE BECAUSE THE CONFIRMATION EMAIL PRINTED FIELD NAMES.**
|
||||||
|
* `summaryLines` was `${f.name}: ${value}`, so the inquirer's receipt read
|
||||||
|
* `practiceArea: Construction`, `otherParties: …`, `opposingCounsel: …`. That
|
||||||
|
* email is the one artefact an inquirer keeps from this practice, and it is also
|
||||||
|
* the artefact that quotes third-party names back at them, so its legibility is
|
||||||
|
* not cosmetic. Found by `adversarial-reviewer`, 2026-08-31.
|
||||||
|
* `npm run check:intake` compares labels as well as names, requiredness, caps
|
||||||
|
* and option sets — so the receipt cannot drift from the form's own wording.
|
||||||
|
*/
|
||||||
|
export const FIELDS = [
|
||||||
|
{ name: 'name', label: 'Your name', required: true, max: 120 },
|
||||||
|
{ name: 'email', label: 'Email', required: true, max: 254 },
|
||||||
|
{ name: 'phone', label: 'Phone', required: false, max: 40 },
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
label: 'Your role',
|
||||||
|
required: true,
|
||||||
|
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organisation',
|
||||||
|
label: 'Firm or organisation',
|
||||||
|
required: false,
|
||||||
|
max: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process',
|
||||||
|
label: 'Process sought',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
'Mediation',
|
||||||
|
'Arbitration',
|
||||||
|
'Med-Arb',
|
||||||
|
'Early neutral evaluation',
|
||||||
|
'Not sure',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'practiceArea',
|
||||||
|
label: 'Subject matter',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
'Construction',
|
||||||
|
'Technology',
|
||||||
|
'Energy',
|
||||||
|
'Insurance',
|
||||||
|
'Shareholder',
|
||||||
|
'Cross-border',
|
||||||
|
'Other',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'otherParties', label: 'Other parties', required: false, max: 300 },
|
||||||
|
{
|
||||||
|
name: 'opposingCounsel',
|
||||||
|
label: 'Opposing counsel',
|
||||||
|
required: false,
|
||||||
|
max: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'summary',
|
||||||
|
label: 'What the dispute is about',
|
||||||
|
required: true,
|
||||||
|
max: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timing',
|
||||||
|
label: 'Timing',
|
||||||
|
required: false,
|
||||||
|
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preferredContact',
|
||||||
|
label: 'Preferred reply',
|
||||||
|
required: false,
|
||||||
|
options: ['Email', 'Phone'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The honeypot field name. NOT in `FIELDS`, and that is load-bearing: it is
|
||||||
|
* checked before validation and a non-empty value gets the SUCCESS page, not a
|
||||||
|
* rejection. Telling a bot it was detected is how the next version of the bot
|
||||||
|
* stops filling the field. `check:intake` asserts it is absent from `FIELDS`.
|
||||||
|
*/
|
||||||
|
export const HONEYPOT = 'company_website';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SECOND honeypot — a decoy checkbox that must arrive ABSENT. Also not in
|
||||||
|
* `FIELDS`, for the same reason, and `check:intake` asserts that too.
|
||||||
|
*
|
||||||
|
* ⚠️ **DIFFERENT TRAP, NOT A SECOND COPY.** `HONEYPOT` catches a bot that fills
|
||||||
|
* every text input; this catches one that sets every control it enumerates.
|
||||||
|
*
|
||||||
|
* ⚠️ **IT IS PROBABLY INERT AGAINST THE 2026-09-04 PAIR, AND THE COMMENT HERE
|
||||||
|
* SAID THE OPPOSITE FOR ONE ROUND.** They left `HONEYPOT` empty, so they skip
|
||||||
|
* hidden fields — and a bot that skips a hidden text input skips a hidden
|
||||||
|
* checkbox. `src/data/intake.ts` carries the full argument; this is defence in
|
||||||
|
* depth against a different class, not a counter to the observed one.
|
||||||
|
*
|
||||||
|
* ⚠️ **UNCHECKED SENDS NOTHING, so absence is the pass — and so is an empty
|
||||||
|
* value, because the handler tests for a non-empty one rather than for mere
|
||||||
|
* presence.** See
|
||||||
|
* `src/data/intake.ts` for the full reasoning; the two files state it separately
|
||||||
|
* because they are separately deployed and `check:intake` is what keeps the
|
||||||
|
* NAMES in step, not the comments.
|
||||||
|
*/
|
||||||
|
export const DECOY_CHECKBOX = 'updates_optin';
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
/**
|
||||||
|
* The intake handler. Spec: docs/05-backend-spec.md. Resource names, region,
|
||||||
|
* table and SES state: AGENTS.md §7 — this file reads them from the environment
|
||||||
|
* and does not restate them.
|
||||||
|
*
|
||||||
|
* ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and
|
||||||
|
* `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built
|
||||||
|
* `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT
|
||||||
|
* DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on
|
||||||
|
* this file can say: an edit made in that belief ships to a form real inquirers
|
||||||
|
* are using. Changes here reach production on the next `docs/09` Part 5 run.
|
||||||
|
*
|
||||||
|
* ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check
|
||||||
|
* below. `docs/09` §7.1 is the only valid route probe; a 403 without that header
|
||||||
|
* is not evidence about the route. It has been misread as one twice.
|
||||||
|
*
|
||||||
|
* ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ─────────────────────────────
|
||||||
|
*
|
||||||
|
* The site ships ZERO JavaScript (AGENTS.md §7, and it is not "minimal" — none).
|
||||||
|
* So the form is a plain HTML POST, and this handler answers with **303 See
|
||||||
|
* Other** and a `Location` on the site. That gives, with no script anywhere:
|
||||||
|
*
|
||||||
|
* - a working form with JavaScript disabled, which is the failure this whole
|
||||||
|
* project exists to fix;
|
||||||
|
* - no JSON response rendered as a raw page, which is what a plain POST to an
|
||||||
|
* API Gateway JSON endpoint shows the user;
|
||||||
|
* - no double submission on refresh, because the browser lands on a GET.
|
||||||
|
*
|
||||||
|
* docs/05's definition of done asks that the form "degrades to a mailto:
|
||||||
|
* fallback with JavaScript disabled". It does not need to: there is nothing to
|
||||||
|
* degrade FROM, because the form never used script. The email address is
|
||||||
|
* published on /contact/ regardless.
|
||||||
|
*
|
||||||
|
* ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ──────────────────────────────
|
||||||
|
*
|
||||||
|
* ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled
|
||||||
|
* *"raise the timing floor"* after the first real spam. There is no floor to
|
||||||
|
* raise — the check has never existed — and the reason below is unchanged by
|
||||||
|
* the spam arriving: it is a property of a CDN-cached static page, not of how
|
||||||
|
* hard anyone has tried. What CAN carry a per-visitor clock is named in
|
||||||
|
* `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66.
|
||||||
|
*
|
||||||
|
* **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.**
|
||||||
|
* docs/05 asks to "reject submissions completed in under 3 seconds". It cannot
|
||||||
|
* be done here and implementing it would produce a control that does nothing:
|
||||||
|
* the check needs to know when the form was SERVED to that visitor, and
|
||||||
|
* /contact/ is a static file cached at the CloudFront edge. A build-time
|
||||||
|
* timestamp is the same value for every visitor and is hours or days old, so
|
||||||
|
* `now - served` is always large — the check would pass for a bot exactly as it
|
||||||
|
* passes for a human. A per-visitor token needs either a dynamic origin or
|
||||||
|
* client-side script, and the site has neither by design.
|
||||||
|
*
|
||||||
|
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
|
||||||
|
* records of what a control that exists on paper and not in fact costs here. So
|
||||||
|
* it is omitted, said out loud, and the load is carried by the TWO honeypots,
|
||||||
|
* the Origin check, the aggregate API Gateway route throttle and the validation
|
||||||
|
* below — plus, since 2026-09-04, a score that LABELS and never rejects.
|
||||||
|
* (Aggregate, not per-IP — see above; the earlier wording here said "rate
|
||||||
|
* limit" and let the reader supply the stronger meaning.)
|
||||||
|
*
|
||||||
|
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
|
||||||
|
*
|
||||||
|
* - An AGGREGATE API Gateway route throttle. NOT per source IP: API Gateway
|
||||||
|
* throttling is per route and per stage across all callers, so docs/05's
|
||||||
|
* "5 requests / 5 minutes per source IP" is struck — per-IP needs AWS WAF.
|
||||||
|
* Never describe what ships as per-IP. docs/09 Part 6.3.
|
||||||
|
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
|
||||||
|
* - CloudWatch alarms on Lambda `Errors` and on API Gateway 5xx for this
|
||||||
|
* route. NOT a dead-letter queue: `DeadLetterConfig` is used only for
|
||||||
|
* ASYNCHRONOUS invocations, API Gateway invokes synchronously, so a DLQ here
|
||||||
|
* would sit at depth 0 for ever and an alarm on it would be a permanently
|
||||||
|
* green light. docs/05 §Notification carries the replacement.
|
||||||
|
* - The `ses-alerts` SNS email subscription is CONFIRMED (§7) — R9 closed
|
||||||
|
* 2026-09-01, so the bounce and complaint alarms reach someone.
|
||||||
|
*/
|
||||||
|
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
/* The field table and BOTH honeypot names live in their own module so that
|
||||||
|
`npm run check:intake` can import them without this file's module-scope
|
||||||
|
`requireEnv()` calls running. See fields.mjs for why there are two tables. */
|
||||||
|
import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs';
|
||||||
|
/* Scoring lives in its own module so it can be unit-tested — this file throws at
|
||||||
|
import without a configured environment, so it cannot be. `node
|
||||||
|
backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP:
|
||||||
|
`docs/09` Part 5.1 packages it explicitly, and a cold start would fail with
|
||||||
|
ERR_MODULE_NOT_FOUND if it were left out. */
|
||||||
|
import {
|
||||||
|
isPossibleSpam,
|
||||||
|
scoreSubmission,
|
||||||
|
SPAM_THRESHOLD,
|
||||||
|
} from './spam-score.mjs';
|
||||||
|
|
||||||
|
/* Region comes from the Lambda runtime, which sets AWS_REGION to the function's
|
||||||
|
own region — the one §7 records. Not hardcoded: a second copy of a fact §7
|
||||||
|
owns is the copy that goes stale. */
|
||||||
|
const ddb = new DynamoDBClient({});
|
||||||
|
const ses = new SESv2Client({});
|
||||||
|
|
||||||
|
const TABLE = requireEnv('INTAKE_TABLE');
|
||||||
|
const SITE_ORIGIN = requireEnv('SITE_ORIGIN');
|
||||||
|
const NOTIFY_TO = requireEnv('NOTIFY_TO');
|
||||||
|
const MAIL_FROM = requireEnv('MAIL_FROM');
|
||||||
|
|
||||||
|
/** 24 months, docs/05 §Retention. It must match /legal/privacy/ exactly.
|
||||||
|
* ⚠️ Writing this attribute is NOT the mechanism — TTL must be enabled on the
|
||||||
|
* table, and AGENTS.md §7 records whether it is. */
|
||||||
|
const RETENTION_MONTHS = 24;
|
||||||
|
|
||||||
|
/** The public commitment, §4 and Q27. It must read identically here, on
|
||||||
|
* /contact/, and in any bio. Injected rather than typed so one edit moves all
|
||||||
|
* three; the deploy step sets it from `CONTACT.responseTime`. */
|
||||||
|
const RESPONSE_TIME = requireEnv('RESPONSE_TIME');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ INJECTED FOR EXACTLY THE REASON ABOVE, AND IT WAS HAND-TYPED UNTIL
|
||||||
|
* 2026-08-31. The confirmation email spelled the no-retainer notice out in
|
||||||
|
* prose, which made it a **fourth** hand-copy of `NO_RETAINER_NOTICE` — and the
|
||||||
|
* copy **dropped the fourth clause the constant carries**, *"and does not itself
|
||||||
|
* create a conflict check"*, which `docs/01` §`/contact/` requires. It also used
|
||||||
|
* a hyphen where the constant uses an en dash.
|
||||||
|
*
|
||||||
|
* The reasoning three lines above applied to it identically and was not applied.
|
||||||
|
* `npm run check:intake` compares field tables only, so a future softening of
|
||||||
|
* the constant would never have reached this email and nothing would have
|
||||||
|
* failed — the silent-drift shape §4 flags for the whole commitment class.
|
||||||
|
* Found by `adversarial-reviewer`. The deploy step sets it from
|
||||||
|
* `NO_RETAINER_NOTICE` in `src/data/site.ts`.
|
||||||
|
*
|
||||||
|
* ⚠️ AND THAT SENTENCE USED TO END "`docs/06` names it", WHICH IT DID NOT.
|
||||||
|
* This variable became a `requireEnv` and reached no document — so the
|
||||||
|
* deployment list said five variables while this file required six, and the
|
||||||
|
* function would have thrown at cold start on every invocation. **The comment
|
||||||
|
* asserting the documentation existed is what made it invisible.** `docs/06` and
|
||||||
|
* `docs/05` now name all six. Found by `adversarial-reviewer` round 2.
|
||||||
|
*/
|
||||||
|
const NO_RETAINER_NOTICE = requireEnv('NO_RETAINER_NOTICE');
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
// Fail at cold start, not per request: a function missing configuration
|
||||||
|
// should not accept a submission it cannot store.
|
||||||
|
throw new Error(`intake handler: ${name} is not set`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML before storage and before anything enters an email body (docs/05).
|
||||||
|
*
|
||||||
|
* NOT AN HTML SANITISER, AND IT DOES NOT NEED TO BE — every field is stored and
|
||||||
|
* rendered as PLAIN TEXT, never as markup, so the job is to make a value
|
||||||
|
* incapable of becoming markup later, not to allow safe markup now.
|
||||||
|
*
|
||||||
|
* ⚠️ AND FOR THAT REASON IT STRIPS ANGLE BRACKETS RATHER THAN ENTITY-ENCODING
|
||||||
|
* THEM. The first version of this function escaped `&` to `&`, which is
|
||||||
|
* correct only when the sink is HTML: both sinks here are plain text, so the
|
||||||
|
* reader of the confirmation email would have received the five literal
|
||||||
|
* characters `&` wherever they had typed an ampersand. Encoding for the
|
||||||
|
* wrong sink is a defect wearing the costume of a protection.
|
||||||
|
*/
|
||||||
|
function toPlainText(value) {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
// Control characters, including the CR/LF that would let a value forge a
|
||||||
|
// header line in an email, and the C1 range.
|
||||||
|
//
|
||||||
|
// `no-control-regex` is disabled ON PURPOSE and with the reason: that rule
|
||||||
|
// exists to catch a control character that reached a pattern by ACCIDENT,
|
||||||
|
// usually a mis-escaped literal. Here the control range IS the thing being
|
||||||
|
// matched, and it is the one part of this function that stops a submitted
|
||||||
|
// value from forging an email header. Rewriting it as a charCodeAt filter
|
||||||
|
// to satisfy the linter would make the intent less legible, not more.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||||
|
// Angle brackets removed rather than entity-encoded. The destination is
|
||||||
|
// plain text — a DynamoDB string attribute and a text/plain email body —
|
||||||
|
// so `&` would REACH THE READER as the five characters "&", which
|
||||||
|
// is a defect rather than a protection. Encoding is right when the sink is
|
||||||
|
// HTML; here the requirement is only that the value can never become
|
||||||
|
// markup if it is later put into one, and no `<` satisfies that
|
||||||
|
// permanently. Nothing else in the value is altered.
|
||||||
|
.replace(/[<>]/g, '')
|
||||||
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email validation, server side. Deliberately structural rather than clever:
|
||||||
|
* one @, something either side, a dot in the domain, no whitespace, no angle
|
||||||
|
* brackets, within the RFC 5321 length. A regex that tries to implement RFC 5322
|
||||||
|
* rejects real addresses, and the confirmation email in D18 is the real check —
|
||||||
|
* if it does not arrive, the address was wrong whatever a regex said.
|
||||||
|
*/
|
||||||
|
function looksLikeEmail(value) {
|
||||||
|
return (
|
||||||
|
value.length <= 254 &&
|
||||||
|
/^[^\s@<>]+@[^\s@<>.]+(\.[^\s@<>.]+)+$/.test(value) &&
|
||||||
|
!value.includes('..')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(event) {
|
||||||
|
const raw = event.isBase64Encoded
|
||||||
|
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
|
||||||
|
: (event.body ?? '');
|
||||||
|
const type = headerOf(event, 'content-type') ?? '';
|
||||||
|
if (type.includes('application/x-www-form-urlencoded')) {
|
||||||
|
return Object.fromEntries(new URLSearchParams(raw));
|
||||||
|
}
|
||||||
|
// JSON is accepted so the endpoint stays testable with curl, and because a
|
||||||
|
// future island could post JSON without changing this handler.
|
||||||
|
if (type.includes('application/json')) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
parsed === null ||
|
||||||
|
typeof parsed !== 'object' ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
throw new Error('body is not an object');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported content-type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ THE UNFORGEABLE VALUE, AND NOT THE USEFUL ONE. `requestContext.http
|
||||||
|
* .sourceIp` is the TCP peer, which behind the CloudFront behaviour that routes
|
||||||
|
* /api/* is a CloudFront EDGE — so this records AWS rather than the inquirer.
|
||||||
|
*
|
||||||
|
* IT READ `x-forwarded-for` FOR ONE REVISION AND THAT WAS WORSE. CloudFront
|
||||||
|
* APPENDS the viewer address to a client-supplied XFF rather than replacing it,
|
||||||
|
* so the leftmost entry is whatever the client sent: a submission with
|
||||||
|
* `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. That turns a field held for abuse
|
||||||
|
* investigation into one that can be made to name an uninvolved third party, and
|
||||||
|
* /legal/privacy/ promises the record holds "your IP address". A forgeable value
|
||||||
|
* presented as an identification is worse than an honest useless one.
|
||||||
|
*
|
||||||
|
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which
|
||||||
|
* CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request
|
||||||
|
* policy on the /api/* behaviour — the managed AllViewerAndCloudFrontHeaders
|
||||||
|
* forwards Host, which 403s every request at API Gateway.
|
||||||
|
*
|
||||||
|
* ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5,
|
||||||
|
* Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL
|
||||||
|
* DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet
|
||||||
|
* acted on*. What the record holds is published field by field on
|
||||||
|
* /legal/privacy/, so storing a different address is a DISCLOSURE change
|
||||||
|
* governed by `docs/09` §7.2's decision table — an infrastructure change
|
||||||
|
* forwards a header; only a privacy-policy change may store one. Do not "fix"
|
||||||
|
* this from any header without that measurement and that edit.
|
||||||
|
*/
|
||||||
|
function viewerIp(event) {
|
||||||
|
return event.requestContext?.http?.sourceIp ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerOf(event, name) {
|
||||||
|
const headers = event.headers ?? {};
|
||||||
|
// API Gateway HTTP API lowercases header keys; a direct invoke or a test
|
||||||
|
// harness may not, so this does not assume it.
|
||||||
|
const hit = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
||||||
|
return hit ? headers[hit] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = (path) => ({
|
||||||
|
statusCode: 303,
|
||||||
|
headers: {
|
||||||
|
Location: `${SITE_ORIGIN}${path}`,
|
||||||
|
// A redirect that a CDN or a browser caches would send the next visitor
|
||||||
|
// straight to the confirmation page without submitting anything.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
body: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUCCESS = '/contact/received/';
|
||||||
|
const FAILURE = '/contact/could-not-send/';
|
||||||
|
|
||||||
|
export async function handler(event) {
|
||||||
|
/**
|
||||||
|
* ORIGIN CHECK, AND IT IS THE CONTROL CORS IS USUALLY MISTAKEN FOR. A form
|
||||||
|
* POST is a top-level navigation: it is exempt from CORS preflight, so an
|
||||||
|
* `Access-Control-Allow-Origin` setting on the endpoint does not stop another
|
||||||
|
* site from posting a form here. Checking the header does.
|
||||||
|
*
|
||||||
|
* Firefox omits `Origin` on some same-origin form navigations, so `Referer` is
|
||||||
|
* accepted as a fallback — both must MATCH the site origin when present, and
|
||||||
|
* a request with neither is refused.
|
||||||
|
*/
|
||||||
|
const origin = headerOf(event, 'origin');
|
||||||
|
const referer = headerOf(event, 'referer');
|
||||||
|
const originOk = origin
|
||||||
|
? origin === SITE_ORIGIN
|
||||||
|
: referer
|
||||||
|
? referer.startsWith(`${SITE_ORIGIN}/`)
|
||||||
|
: false;
|
||||||
|
if (!originOk) {
|
||||||
|
return {
|
||||||
|
statusCode: 403,
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = parseBody(event);
|
||||||
|
} catch {
|
||||||
|
return redirect(FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE HONEYPOT GETS THE SUCCESS PAGE, NOT AN ERROR. Telling a bot it was
|
||||||
|
* detected is how the next version of the bot stops filling the field. A
|
||||||
|
* human cannot reach this field — it is `display: none`, `tabindex="-1"` and
|
||||||
|
* `aria-hidden` — so a non-empty value is not a mistake anyone made.
|
||||||
|
*/
|
||||||
|
/* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can
|
||||||
|
arrive as `true` or `1` rather than a string — and `typeof === 'string'`
|
||||||
|
let exactly that through both traps for one round. `String(v).trim()`
|
||||||
|
catches every non-empty shape and still treats absence as a pass. */
|
||||||
|
if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') {
|
||||||
|
/* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION
|
||||||
|
AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing
|
||||||
|
on real visitors — a stylesheet that 404s, an autofiller, a template edit
|
||||||
|
that unhides the wrapper — is indistinguishable from quiet weeks, and the
|
||||||
|
only signal is inquiries that were never mentioned again. The FIELD NAME
|
||||||
|
only: the value is whatever a bot chose and nothing about the submission
|
||||||
|
is kept, which is what makes this safe to log at all. */
|
||||||
|
console.warn('intake: discarded by honeypot', { field: HONEYPOT });
|
||||||
|
return redirect(SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no
|
||||||
|
* person can see; an unchecked box sends nothing at all, so a VALUE arrives
|
||||||
|
* only because something ticked it. The value itself is not compared —
|
||||||
|
* `=1`, `=yes` and `=on` are all a tick — only that there is one.
|
||||||
|
*
|
||||||
|
* Same silent SUCCESS as above, and for the same reason.
|
||||||
|
*
|
||||||
|
* ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and
|
||||||
|
* they fail differently:
|
||||||
|
*
|
||||||
|
* - Requiring the field to ARRIVE would turn every dropped-field path — an
|
||||||
|
* extension, a proxy, a template edit — into a lost inquiry reported as
|
||||||
|
* sent.
|
||||||
|
* - Trapping on mere PRESENCE (`!== undefined`) would catch a form
|
||||||
|
* serialiser that emits `updates_optin=` for a hidden checkbox without
|
||||||
|
* reading its checked state. That is rare and it is not impossible, and
|
||||||
|
* the cost of being wrong is a real legal inquiry discarded in silence.
|
||||||
|
*
|
||||||
|
* So the test is the same shape as the honeypot above — a non-empty value —
|
||||||
|
* while the BEHAVIOUR it catches is the opposite one. That is the distinction
|
||||||
|
* that matters: filling text fields versus ticking boxes, not `undefined`
|
||||||
|
* versus `''`.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
body[DECOY_CHECKBOX] !== undefined &&
|
||||||
|
String(body[DECOY_CHECKBOX]).trim() !== ''
|
||||||
|
) {
|
||||||
|
console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX });
|
||||||
|
return redirect(SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = {};
|
||||||
|
const errors = [];
|
||||||
|
for (const field of FIELDS) {
|
||||||
|
const rawValue = body[field.name];
|
||||||
|
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
||||||
|
if (value === '') {
|
||||||
|
if (field.required) errors.push(`${field.name} is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// REJECT over the cap rather than truncating (docs/05). A silently
|
||||||
|
// truncated matter summary is a file read wrongly.
|
||||||
|
if (field.max && value.length > field.max) {
|
||||||
|
errors.push(`${field.name} exceeds ${field.max} characters`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.options && !field.options.includes(value)) {
|
||||||
|
errors.push(`${field.name} is not one of the offered values`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.name === 'email' && !looksLikeEmail(value)) {
|
||||||
|
errors.push('email is not a well-formed address');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clean[field.name] = toPlainText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit, unchecked by default, and required (docs/05). An unchecked box
|
||||||
|
// sends no value at all, so absence is the failure case.
|
||||||
|
if (body.consent !== 'on' && body.consent !== 'true') {
|
||||||
|
errors.push('consent was not given');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
// Logged for the operator, never returned to the caller: an error list is a
|
||||||
|
// description of the validation rules, which is a gift to whoever is
|
||||||
|
// probing them.
|
||||||
|
console.warn('intake rejected', { errors });
|
||||||
|
return redirect(FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const id = randomUUID();
|
||||||
|
/* NO `|| 0` FALLBACK (removed 2026-08-31): DynamoDB will not expire an item
|
||||||
|
whose TTL is more than five years past, so `ttl: 0` means RETAINED FOREVER
|
||||||
|
while /legal/privacy/ promises deletion. Let a bad value fail the write. */
|
||||||
|
const ttl = Math.floor(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth() + RETENTION_MONTHS,
|
||||||
|
now.getUTCDate(),
|
||||||
|
now.getUTCHours(),
|
||||||
|
now.getUTCMinutes(),
|
||||||
|
now.getUTCSeconds(),
|
||||||
|
) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DYNAMODB FIRST, THEN MAIL — docs/05: "SES failure must never lose the
|
||||||
|
* submission." The order is the whole guarantee. If SES fails after this
|
||||||
|
* write, the record exists and a resend has something to resend; if the write
|
||||||
|
* fails, nothing was accepted and the inquirer is told so. (This said "the DLQ
|
||||||
|
* replay" — there is no DLQ and there cannot usefully be one on a
|
||||||
|
* synchronously invoked function; see the note at the top of this file.)
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await ddb.send(
|
||||||
|
new PutItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Item: {
|
||||||
|
/* ⚠️ `submissionId` IS THE TABLE'S PARTITION KEY AND THERE IS NO SORT
|
||||||
|
KEY. A DynamoDB key schema cannot be altered after creation, so this
|
||||||
|
attribute name is fixed by the table `AGENTS.md` §7 names, not
|
||||||
|
chosen here — and an item missing it fails the whole write with
|
||||||
|
`ValidationException`, which this function converts into the failure
|
||||||
|
page. Verify against `describe-table` before changing either name;
|
||||||
|
`submittedAt` is an ordinary attribute and is free. */
|
||||||
|
submissionId: { S: id },
|
||||||
|
submittedAt: { S: now.toISOString() },
|
||||||
|
ttl: { N: String(ttl) },
|
||||||
|
// Abuse investigation only (docs/05). Named so a later reader does not
|
||||||
|
// repurpose them: they are not analytics and not part of the reply.
|
||||||
|
/* Behind CloudFront this is the EDGE address, not the inquirer's.
|
||||||
|
See `viewerIp()` — and read it before changing this. */
|
||||||
|
sourceIp: { S: viewerIp(event) },
|
||||||
|
userAgent: {
|
||||||
|
S: (headerOf(event, 'user-agent') ?? 'unknown').slice(0, 400),
|
||||||
|
},
|
||||||
|
consentAt: { S: now.toISOString() },
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(clean).map(([k, v]) => [k, { S: v }]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('intake: DynamoDB write failed', error);
|
||||||
|
return redirect(FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `f.label`, not `f.name` — see the note on `label` in fields.mjs. The
|
||||||
|
// notification to the operator gets the same rendering: one shape, so the two
|
||||||
|
// messages cannot describe the same submission differently.
|
||||||
|
const summaryLines = FIELDS.filter((f) => clean[f.name] !== undefined)
|
||||||
|
.map((f) => `${f.label}: ${clean[f.name]}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04.
|
||||||
|
*
|
||||||
|
* ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF
|
||||||
|
* ORDERING. Nothing below can decline a submission: by the time it runs, the
|
||||||
|
* write has already succeeded and the only remaining question is what the
|
||||||
|
* OPERATOR's subject line says. There is deliberately no branch here that can
|
||||||
|
* reach `redirect(FAILURE)`.
|
||||||
|
*
|
||||||
|
* ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is
|
||||||
|
* unchanged. A real inquirer wrongly scored must never be told that a machine
|
||||||
|
* thought they were a bot.
|
||||||
|
*/
|
||||||
|
/* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception
|
||||||
|
here would escape `handler`, API Gateway would answer 500, and the inquirer
|
||||||
|
would see a failure for a submission ALREADY WRITTEN to the table — a path
|
||||||
|
that costs an inquiry, decided by a labelling function. Pouya's constraint
|
||||||
|
is that nothing but a honeypot may cost one, so the scorer is allowed to
|
||||||
|
fail and the submission is not. Unlabelled is the safe default. */
|
||||||
|
let spam = { score: 0, signals: [] };
|
||||||
|
try {
|
||||||
|
spam = scoreSubmission(clean);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('intake: spam scoring failed; sending unlabelled', {
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const flagged = isPossibleSpam(spam);
|
||||||
|
const notificationBody = [
|
||||||
|
`Received ${now.toISOString()}`,
|
||||||
|
`submissionId ${id}`,
|
||||||
|
...(flagged
|
||||||
|
? [
|
||||||
|
'',
|
||||||
|
`Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` +
|
||||||
|
`Signals: ${spam.signals.join('; ')}.`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
'',
|
||||||
|
summaryLines,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TWO EMAILS — D18, and the second one is why the form beats a mailto: link.
|
||||||
|
* `Promise.allSettled`, not `Promise.all`: the record is already stored, so a
|
||||||
|
* failure on either message must be logged rather than lost, and one failing
|
||||||
|
* must not prevent the other from being attempted.
|
||||||
|
*/
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
ses.send(
|
||||||
|
new SendEmailCommand({
|
||||||
|
FromEmailAddress: MAIL_FROM,
|
||||||
|
Destination: { ToAddresses: [NOTIFY_TO] },
|
||||||
|
// Replyable to the inquirer (docs/05), which is what makes the
|
||||||
|
// notification usable without copying an address out of it.
|
||||||
|
ReplyToAddresses: [clean.email],
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
/* The prefix is what Pouya filters on in Gmail, so it is the
|
||||||
|
FIRST thing in the subject and it is a fixed string. Do not make
|
||||||
|
it conditional on anything else, and do not vary its wording. */
|
||||||
|
Subject: {
|
||||||
|
Data:
|
||||||
|
`${flagged ? '[Possible spam] ' : ''}` +
|
||||||
|
`Intake — ${clean.name} (${clean.practiceArea})`,
|
||||||
|
},
|
||||||
|
Body: {
|
||||||
|
// The body carries the bare submissionId, because it is the
|
||||||
|
// partition key: that line gets pasted into the console to find
|
||||||
|
// the record, so it must be the key and not a rendering of it.
|
||||||
|
Text: { Data: notificationBody },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ses.send(
|
||||||
|
new SendEmailCommand({
|
||||||
|
FromEmailAddress: MAIL_FROM,
|
||||||
|
Destination: { ToAddresses: [clean.email] },
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
Subject: { Data: 'Your inquiry has been received' },
|
||||||
|
Body: {
|
||||||
|
Text: {
|
||||||
|
Data: [
|
||||||
|
`Thank you — your inquiry has been received.`,
|
||||||
|
``,
|
||||||
|
RESPONSE_TIME,
|
||||||
|
``,
|
||||||
|
NO_RETAINER_NOTICE,
|
||||||
|
``,
|
||||||
|
`What you sent:`,
|
||||||
|
``,
|
||||||
|
summaryLines,
|
||||||
|
``,
|
||||||
|
`How this information is handled, and how to ask for it to be`,
|
||||||
|
`deleted: ${SITE_ORIGIN}/legal/privacy/`,
|
||||||
|
``,
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.error(
|
||||||
|
`intake: SES send ${i === 0 ? 'notification' : 'confirmation'} failed`,
|
||||||
|
{ id, reason: result.reason },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The submission is stored. Mail failures are an operator problem, not the
|
||||||
|
// inquirer's, and telling them it failed would invite a second submission of
|
||||||
|
// a record that already exists.
|
||||||
|
return redirect(SUCCESS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Spam SCORING for the intake handler. Pouya's ruling, 2026-09-04.
|
||||||
|
*
|
||||||
|
* ⚠️ **THIS MODULE NEVER REJECTS ANYTHING, AND THAT IS THE WHOLE DESIGN.** It
|
||||||
|
* returns a score and a list of signal names. The handler stores the record and
|
||||||
|
* sends both emails either way; above the threshold it prefixes the OPERATOR
|
||||||
|
* notification's subject with `[Possible spam] ` and adds one line naming the
|
||||||
|
* signals. Pouya filters in Gmail. His words: *"Nothing is dropped; a false
|
||||||
|
* positive costs him one glance."*
|
||||||
|
*
|
||||||
|
* That asymmetry is why the thresholds below can be tuned aggressively. The cost
|
||||||
|
* of a false positive is a subject-line prefix; the cost of a false negative is
|
||||||
|
* one unlabelled email. Neither loses an inquiry — which a filter that rejected
|
||||||
|
* would, and a legal inquiry lost silently is the one outcome this form must not
|
||||||
|
* produce.
|
||||||
|
*
|
||||||
|
* ⚠️ **NOTHING HERE IS STORED.** The score and the signals do not enter the
|
||||||
|
* DynamoDB item. `/legal/privacy/` publishes what the record holds, field by
|
||||||
|
* field, and adding an attribute would make that list wrong — a disclosure
|
||||||
|
* defect, not a schema change. The label lives only in the operator
|
||||||
|
* notification. ⚠️ **THAT MAILBOX IS DELEGATED, NOT PERSONAL** — §9 Q63 and
|
||||||
|
* `/legal/privacy/` §Who can see it both say so, and an earlier draft of this
|
||||||
|
* comment said the label "lives in an email that only Pouya reads", which is the
|
||||||
|
* exclusivity Q63 struck. It reaches whoever reads `info@smlcompany.ca`. If a
|
||||||
|
* stored score is ever wanted, the page changes first.
|
||||||
|
*
|
||||||
|
* ⚠️ **AND THE INQUIRER NEVER SEES ANY OF THIS.** The confirmation email is
|
||||||
|
* untouched. A person wrongly scored must not be told a machine thought they
|
||||||
|
* were a bot.
|
||||||
|
*
|
||||||
|
* WHY SCORING RATHER THAN MORE REJECTION. The two submissions of 2026-09-04
|
||||||
|
* (`docs/05` §Observed abuse) passed the honeypot. Every rule that would have
|
||||||
|
* caught them — a foreign phone, a link in the summary, a disposable-looking
|
||||||
|
* address — is a rule some real inquirer also trips: this practice takes
|
||||||
|
* cross-border commercial work, so a `+44` number is a client, not a bot. A
|
||||||
|
* rejecting rule set built from those signals would eventually discard a real
|
||||||
|
* dispute and report success while doing it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ **NOT MEASURED FROM A CORPUS — THERE IS NO CORPUS.** No genuine inquiry has
|
||||||
|
* arrived through this form yet, so there is nothing to measure a normal summary
|
||||||
|
* length against, and a number presented as measured when it is not is the
|
||||||
|
* defect `AGENTS.md` keeps paying for.
|
||||||
|
*
|
||||||
|
* It is DERIVED, and the derivation is the form's own instruction: the `summary`
|
||||||
|
* field's hint reads *"A few sentences is enough."* This floor sits **below** what
|
||||||
|
* that invites, so it fires on a summary that does not attempt the question
|
||||||
|
* rather than on one that answers it briefly. `[assumed 2026-09-04]`
|
||||||
|
*
|
||||||
|
* ⚠️ **TUNE IT DOWN WHEN IN DOUBT, NEVER UP.** An unlabelled spam costs nothing
|
||||||
|
* that matters; a labelled real inquiry spends the reader's trust in the label.
|
||||||
|
* *"Shareholder dispute, two directors, Ontario CBCA company."* is 57 characters
|
||||||
|
* and is exactly what the hint asks for — a floor above that scores the form's
|
||||||
|
* own instruction as a spam signal.
|
||||||
|
*
|
||||||
|
* **Pouya can replace this with a measurement whenever he likes** — the two spam
|
||||||
|
* records of 2026-09-04 are still in the table, and their `summary` lengths are
|
||||||
|
* the first real data this number could rest on. §9 Q65 records that.
|
||||||
|
*/
|
||||||
|
export const SHORT_SUMMARY_CHARS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this, the notification is labelled. Weights below are 1 for a signal a
|
||||||
|
* real inquirer plausibly trips and 2 for one they rarely do, so the threshold
|
||||||
|
* of 2 means: **one strong signal, or two weak ones.**
|
||||||
|
*
|
||||||
|
* Worked, because a threshold nobody has worked through is a guess with a number
|
||||||
|
* on it:
|
||||||
|
* - Ontario counsel, local number, three-line summary → 0, clean
|
||||||
|
* - Cross-border counsel, `+44` number, three-line summary → 1, clean
|
||||||
|
* - Cross-border counsel, `+44` number, one-line summary → 2, LABELLED
|
||||||
|
* - A four-part real name at gmail.com → 1, clean
|
||||||
|
* - Anyone pasting a link to a public tender document → 2, LABELLED
|
||||||
|
* - foreign number + a short scraped summary carrying a link → 4, LABELLED
|
||||||
|
*
|
||||||
|
* The third and fourth rows are the accepted false positives. Both are real
|
||||||
|
* shapes, both cost one glance, and both were preferred to missing the fifth.
|
||||||
|
*
|
||||||
|
* ⚠️ **THE LAST ROW IS A SHAPE, NOT A MEASUREMENT OF THE TWO 2026-09-04
|
||||||
|
* SUBMISSIONS. THOSE RECORDS WERE NEVER READ.** What the attested signature
|
||||||
|
* guarantees is a non-NANP phone — **one weak signal** — and whether either is
|
||||||
|
* labelled turns on facts only the two rows in the table hold. §9 Q65 records
|
||||||
|
* that they are still there and are the only real data any of these numbers
|
||||||
|
* could rest on.
|
||||||
|
*/
|
||||||
|
export const SPAM_THRESHOLD = 2;
|
||||||
|
|
||||||
|
/** `https://…` or `www.…` only. A bare `acme.com` is NOT matched: an inquirer
|
||||||
|
* writing "the dispute concerns acme.com's supply contract" is describing a
|
||||||
|
* party, and matching that would label ordinary commercial prose. */
|
||||||
|
const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)\S/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NANP: an explicit `+<cc>` settles it; otherwise ten digits, or eleven
|
||||||
|
* beginning with 1, after the tail is dropped.
|
||||||
|
*
|
||||||
|
* ⚠️ **A DIGIT COUNT ALONE CANNOT DO THIS.** `416-555-0123 ext 22` is twelve
|
||||||
|
* digits, `416-555-0123 or 416-555-0124` is twenty, and both are ordinary
|
||||||
|
* Toronto numbers that a bare count calls foreign — a signal saying the opposite
|
||||||
|
* of the truth. The tail is dropped at the first extension marker or
|
||||||
|
* second-number separator, and the marker list is deliberately generous.
|
||||||
|
*
|
||||||
|
* **The country code is read FIRST because it is the only unambiguous thing in
|
||||||
|
* the field.** `+44 …` and `+7 …` are settled without counting anything, which
|
||||||
|
* is what a pure shape test cannot do: `+7 912 345 6789` is grouped 3-3-4
|
||||||
|
* exactly like a NANP number, so matching the shape would call it Canadian.
|
||||||
|
* Only when there is no explicit country code does the digit count run, and then
|
||||||
|
* the tail is dropped at the first extension marker or second-number separator.
|
||||||
|
*/
|
||||||
|
function looksNorthAmerican(phone) {
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
/* An explicit international prefix is decisive in both directions. */
|
||||||
|
const cc = trimmed.match(/^\+\s*(\d{1,3})/);
|
||||||
|
if (cc) return cc[1] === '1';
|
||||||
|
/* Longest alternative FIRST: regex alternation is leftmost-first, so `ext`
|
||||||
|
placed before `extension` matches the first three letters and then relies on
|
||||||
|
backtracking. Ordering it correctly is cheaper than depending on that.
|
||||||
|
`\bx\b` would NOT match the `x` in `x22` — the digit after it is a word
|
||||||
|
character, so there is no boundary — which is how `(416) 555-0123 x22` scored
|
||||||
|
foreign for one round. The marker is matched by what FOLLOWS it. */
|
||||||
|
const digits = trimmed
|
||||||
|
.split(/\s*(?:extension|extn|ext|x)[.:-]?\s*\d|[#,;]|\bor\b/i)[0]
|
||||||
|
.replace(/\D/g, '');
|
||||||
|
return digits.length === 10 || (digits.length === 11 && digits[0] === '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gmail dot trick: one mailbox, unlimited distinct-looking addresses,
|
||||||
|
* because Gmail ignores dots in the local part.
|
||||||
|
*
|
||||||
|
* ⚠️ **A DOT IS NOT THE SIGNAL, AND TREATING IT AS ONE WOULD LABEL MOST REAL
|
||||||
|
* GMAIL USERS.** `first.last@gmail.com` is the single most ordinary form a Gmail
|
||||||
|
* address takes. What distinguishes the trick is dot DENSITY: **three or more
|
||||||
|
* dots**, and nothing else.
|
||||||
|
*
|
||||||
|
* ⚠️ **AND THE WEIGHT IS 1, NOT 2, WHICH MATTERS MORE THAN THE BOUNDARY DOES.**
|
||||||
|
* `mary.jane.o.brien@gmail.com` and `maria.de.la.cruz@gmail.com` are three-dot
|
||||||
|
* REAL names — compound surnames and middle initials are ordinary, not rare —
|
||||||
|
* and weight 2 is defined here as what a real inquirer rarely trips. At weight 1
|
||||||
|
* nothing can be labelled on the shape of its owner's name alone; a genuine
|
||||||
|
* dot-trick address reaches the threshold as soon as it trips anything else,
|
||||||
|
* which spam reliably does. **Do not raise it back.**
|
||||||
|
*/
|
||||||
|
function looksLikeGmailDotTrick(email) {
|
||||||
|
const at = email.lastIndexOf('@');
|
||||||
|
if (at < 1) return false;
|
||||||
|
const local = email.slice(0, at);
|
||||||
|
const domain = email.slice(at + 1).toLowerCase();
|
||||||
|
if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false;
|
||||||
|
const dots = local.split('.').length - 1;
|
||||||
|
return dots >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string>} fields the handler's `clean` map — validated,
|
||||||
|
* plain-texted values, keyed by field name. Absent fields are simply absent.
|
||||||
|
* @returns {{score: number, signals: string[]}} `signals` are written for a
|
||||||
|
* human reading one line of an email, not for a machine.
|
||||||
|
*/
|
||||||
|
export function scoreSubmission(fields) {
|
||||||
|
const signals = [];
|
||||||
|
let score = 0;
|
||||||
|
const add = (weight, label) => {
|
||||||
|
score += weight;
|
||||||
|
signals.push(label);
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = fields.summary ?? '';
|
||||||
|
const phone = fields.phone ?? '';
|
||||||
|
const email = fields.email ?? '';
|
||||||
|
|
||||||
|
/* Only when a summary exists. An absent one is a validation failure the
|
||||||
|
handler has already turned into the failure page, so scoring an empty
|
||||||
|
string here would be scoring a submission that never got this far. */
|
||||||
|
if (summary !== '' && summary.length < SHORT_SUMMARY_CHARS) {
|
||||||
|
add(1, `summary under ${SHORT_SUMMARY_CHARS} characters`);
|
||||||
|
}
|
||||||
|
/* `phone` is OPTIONAL. Not giving one is not a signal — most inquirers will
|
||||||
|
not — so this fires only on a number that is present and not North
|
||||||
|
American. Treating absence as suspicious would label the quiet majority. */
|
||||||
|
if (phone !== '' && !looksNorthAmerican(phone)) {
|
||||||
|
add(1, 'phone is not a Canadian or US number');
|
||||||
|
}
|
||||||
|
if (URL_IN_TEXT.test(summary)) {
|
||||||
|
add(2, 'link in the dispute summary');
|
||||||
|
}
|
||||||
|
if (looksLikeGmailDotTrick(email)) {
|
||||||
|
add(1, 'Gmail address using the dot trick');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score, signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the operator notification should carry the label. */
|
||||||
|
export const isPossibleSpam = ({ score }) => score >= SPAM_THRESHOLD;
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Unit test for the intake spam scorer. `node backend/intake/spam-score.test.mjs`.
|
||||||
|
*
|
||||||
|
* Same shape and same reasoning as `infra/cloudfront/router.test.mjs`: the real
|
||||||
|
* check is a real submission, this one runs in a second and catches the branch
|
||||||
|
* mistakes that a regex change makes silently.
|
||||||
|
*
|
||||||
|
* ⚠️ **EVERY SIGNAL SHIPS WITH A NEGATIVE FIXTURE**, which is the discipline
|
||||||
|
* `CLAUDE.md` imposes on `check:claims` and applies here for the same reason:
|
||||||
|
* this scorer's failure mode is not missing spam, it is labelling a real
|
||||||
|
* inquiry. The pairs below are the nearest legitimate submission to each trap —
|
||||||
|
* `j.k.smith@gmail.com` beside the dot trick, an extension-carrying Toronto
|
||||||
|
* number beside a Russian one, ordinary commercial prose naming a company
|
||||||
|
* beside a pasted link.
|
||||||
|
*
|
||||||
|
* ⚠️ **EACH CASE ASSERTS THE SIGNAL NAMES, NOT ONLY THE SCORE.** Asserting the
|
||||||
|
* total alone lets two rules swap weights, or one rule fire in place of
|
||||||
|
* another, with every case still passing — the suite would then be checking
|
||||||
|
* arithmetic rather than behaviour. `expected` is the exact signal set.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
scoreSubmission,
|
||||||
|
isPossibleSpam,
|
||||||
|
SPAM_THRESHOLD,
|
||||||
|
SHORT_SUMMARY_CHARS,
|
||||||
|
} from './spam-score.mjs';
|
||||||
|
|
||||||
|
const SHORT = `summary under ${SHORT_SUMMARY_CHARS} characters`;
|
||||||
|
const PHONE = 'phone is not a Canadian or US number';
|
||||||
|
const LINK = 'link in the dispute summary';
|
||||||
|
const GMAIL = 'Gmail address using the dot trick';
|
||||||
|
|
||||||
|
const MID =
|
||||||
|
'A construction lien dispute over a delayed fit-out. Counsel are engaged ' +
|
||||||
|
'on both sides and we want a mediator.';
|
||||||
|
|
||||||
|
const LONG =
|
||||||
|
'The parties are in dispute over a delayed fit-out on a Toronto office ' +
|
||||||
|
'tower. The subcontract was terminated in June and the holdback has not ' +
|
||||||
|
'been released. Counsel are engaged on both sides and we are looking for a ' +
|
||||||
|
'mediator with construction experience.';
|
||||||
|
|
||||||
|
/* [label, fields, expected signals] — score and labelled are DERIVED from the
|
||||||
|
weights below, so a weight change fails every affected case by name rather
|
||||||
|
than silently re-balancing the totals. */
|
||||||
|
const WEIGHTS = { [SHORT]: 1, [PHONE]: 1, [LINK]: 2, [GMAIL]: 1 };
|
||||||
|
|
||||||
|
const CASES = [
|
||||||
|
// ---- clean submissions, which is the half that matters most -------------
|
||||||
|
[
|
||||||
|
'ordinary Ontario inquiry',
|
||||||
|
{ summary: LONG, phone: '416-555-0123', email: 'a.counsel@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
['no phone given at all', { summary: LONG, email: 'counsel@firm.ca' }, []],
|
||||||
|
[
|
||||||
|
'+1 with punctuation',
|
||||||
|
{ summary: LONG, phone: '+1 (647) 555-0188', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ten digits, no punctuation',
|
||||||
|
{ summary: LONG, phone: '6475550188', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Toronto number with an extension',
|
||||||
|
{ summary: LONG, phone: '416-555-0123 ext 22', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extension written x22',
|
||||||
|
{ summary: LONG, phone: '(416) 555-0123 x22', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extension written Ext:',
|
||||||
|
{ summary: LONG, phone: '416-555-0123 Ext: 4501', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extension spelled out',
|
||||||
|
{ summary: LONG, phone: '416-555-0123 extension 22', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extension hyphenated',
|
||||||
|
{ summary: LONG, phone: '416-555-0123 ext-22', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'two numbers in one field',
|
||||||
|
{
|
||||||
|
summary: LONG,
|
||||||
|
phone: '416-555-0123 or 416-555-0124',
|
||||||
|
email: 'c@firm.ca',
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ordinary gmail, one dot',
|
||||||
|
{ summary: LONG, phone: '416-555-0123', email: 'first.last@gmail.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'gmail, single initial',
|
||||||
|
{ summary: LONG, phone: '416-555-0123', email: 'j.smith@gmail.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'gmail, TWO initials and a surname',
|
||||||
|
{ summary: LONG, email: 'j.k.smith@gmail.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
/* ⚠️ NON-GMAIL, THREE DOTS — this pins the DOMAIN GUARD, which nothing did.
|
||||||
|
Deleting `if (domain !== 'gmail.com' && …) return false` left all 30 cases
|
||||||
|
passing: the nearest legitimate submission to a three-dot trap is a
|
||||||
|
three-dot address at a firm domain, and it was the one fixture missing. */
|
||||||
|
[
|
||||||
|
'law-firm address, three dots',
|
||||||
|
{ summary: LONG, email: 'j.p.van.dam@blakes.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'four-part real name at gmail',
|
||||||
|
{
|
||||||
|
summary: LONG,
|
||||||
|
phone: '416-555-0123',
|
||||||
|
email: 'mary.jane.o.brien@gmail.com',
|
||||||
|
},
|
||||||
|
[GMAIL],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'company named in prose, no link',
|
||||||
|
{ summary: `${LONG} The respondent is acme.com Ltd.`, email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'googlemail, one dot',
|
||||||
|
{ summary: LONG, email: 'first.last@googlemail.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- one weak signal: still clean ---------------------------------------
|
||||||
|
[
|
||||||
|
'cross-border counsel, UK number',
|
||||||
|
{ summary: LONG, phone: '+44 20 7946 0958', email: 'c@firm.co.uk' },
|
||||||
|
[PHONE],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'the concise summary the hint invites',
|
||||||
|
{
|
||||||
|
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
|
||||||
|
phone: '416-555-0123',
|
||||||
|
email: 'c@firm.ca',
|
||||||
|
},
|
||||||
|
[SHORT],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- boundaries ----------------------------------------------------------
|
||||||
|
/* PINS THE FLOOR'S VALUE, which the two boundary cases below cannot: they
|
||||||
|
derive their lengths from `SHORT_SUMMARY_CHARS`, so they move with it and
|
||||||
|
a floor raised back to 140 passed them silently. This one is a literal
|
||||||
|
109-character summary of the kind the form's hint invites, and it fails the
|
||||||
|
moment the floor rises above it. */
|
||||||
|
[
|
||||||
|
'a realistic 109-character summary',
|
||||||
|
{ summary: MID, email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary exactly at the floor',
|
||||||
|
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS), email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary one under the floor',
|
||||||
|
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS - 1), email: 'c@firm.ca' },
|
||||||
|
[SHORT],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'eleven digits not starting 1',
|
||||||
|
{ summary: LONG, phone: '+7 912 345 6789', email: 'c@firm.ca' },
|
||||||
|
[PHONE],
|
||||||
|
],
|
||||||
|
/* ⚠️ TEN DIGITS IN TOTAL, AND FOREIGN — Iceland writes +354 followed by seven.
|
||||||
|
This is the ONE case that pins the country-code branch: without it the
|
||||||
|
digit count reads 10 and calls this a NANP number. Every other foreign
|
||||||
|
fixture here has 11+ digits, so the count agrees by accident and the
|
||||||
|
branch could be deleted with the whole suite still green. */
|
||||||
|
[
|
||||||
|
'ten-digit international number',
|
||||||
|
{ summary: LONG, phone: '+354 555 1234', email: 'c@firm.is' },
|
||||||
|
[PHONE],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'gmail, exactly two dots',
|
||||||
|
{ summary: LONG, email: 'a.b.smith@gmail.com' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'gmail, exactly three dots',
|
||||||
|
{ summary: LONG, email: 'a.b.c.smith@gmail.com' },
|
||||||
|
[GMAIL],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- two weak signals: labelled ------------------------------------------
|
||||||
|
[
|
||||||
|
'foreign number and terse summary',
|
||||||
|
{
|
||||||
|
summary: 'Need a mediator.',
|
||||||
|
phone: '+7 912 345 6789',
|
||||||
|
email: 'c@firm.ru',
|
||||||
|
},
|
||||||
|
[SHORT, PHONE],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- one strong signal: labelled -----------------------------------------
|
||||||
|
[
|
||||||
|
'link in the summary',
|
||||||
|
{ summary: `${LONG} See https://example.com/tender`, email: 'c@firm.ca' },
|
||||||
|
[LINK],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'www link in the summary',
|
||||||
|
{ summary: `${LONG} See www.example.com/tender`, email: 'c@firm.ca' },
|
||||||
|
[LINK],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dot trick, four dots',
|
||||||
|
{ summary: LONG, email: 'j.o.h.nsmith@gmail.com' },
|
||||||
|
[GMAIL],
|
||||||
|
],
|
||||||
|
/* ⚠️ WEIGHT 1, SO IT DOES NOT LABEL ALONE. That is the whole point of the
|
||||||
|
weight change, and this is the case that fails if it goes back to 2. */
|
||||||
|
[
|
||||||
|
'dot trick alone does not label',
|
||||||
|
{ summary: LONG, email: 'r.a.n.d.om@gmail.com' },
|
||||||
|
[GMAIL],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- the shape the 2026-09-04 pair is described as ------------------------
|
||||||
|
// NOT a measurement of those records: their `summary` values were never read.
|
||||||
|
[
|
||||||
|
'scraped text, foreign number, link',
|
||||||
|
{
|
||||||
|
summary: 'Buy now at https://spam.example/offer',
|
||||||
|
phone: '+7 912 345 6789',
|
||||||
|
email: 'r.a.n.d.om@gmail.com',
|
||||||
|
},
|
||||||
|
[SHORT, PHONE, LINK, GMAIL],
|
||||||
|
],
|
||||||
|
/* The module's own worked example of a legitimate concise summary, beside a
|
||||||
|
Toronto direct line. It scored 2 and shipped `[Possible spam]` while the
|
||||||
|
extension strip was incomplete. */
|
||||||
|
[
|
||||||
|
'concise summary + Toronto extension',
|
||||||
|
{
|
||||||
|
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
|
||||||
|
phone: '416-555-0123 ext: 4501',
|
||||||
|
email: 'c@firm.ca',
|
||||||
|
},
|
||||||
|
[SHORT],
|
||||||
|
],
|
||||||
|
// The attested signature ALONE — a non-NANP phone and nothing else known —
|
||||||
|
// is one weak signal and is NOT labelled. Kept as a case so the limit of what
|
||||||
|
// the observed evidence supports is asserted rather than described.
|
||||||
|
[
|
||||||
|
'attested signature alone',
|
||||||
|
{ summary: LONG, phone: '+7 912 345 6789', email: 'random@gmail.com' },
|
||||||
|
[PHONE],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ---- absent fields must not throw or score -------------------------------
|
||||||
|
['empty object', {}, []],
|
||||||
|
[
|
||||||
|
'summary absent, phone local',
|
||||||
|
{ phone: '416-555-0123', email: 'c@firm.ca' },
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
['email absent', { summary: LONG }, []],
|
||||||
|
['malformed email, no @', { summary: LONG, email: 'not-an-address' }, []],
|
||||||
|
['gmail with no local part', { summary: LONG, email: '@gmail.com' }, []],
|
||||||
|
];
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
const failures = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [label, fields, expected] of CASES) {
|
||||||
|
const result = scoreSubmission(fields);
|
||||||
|
expected.forEach((sig) => seen.add(sig));
|
||||||
|
const wantScore = expected.reduce((n, sig) => n + WEIGHTS[sig], 0);
|
||||||
|
const wantLabelled = wantScore >= SPAM_THRESHOLD;
|
||||||
|
const gotSignals = [...result.signals].sort();
|
||||||
|
const wantSignals = [...expected].sort();
|
||||||
|
const ok =
|
||||||
|
result.score === wantScore &&
|
||||||
|
isPossibleSpam(result) === wantLabelled &&
|
||||||
|
JSON.stringify(gotSignals) === JSON.stringify(wantSignals);
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
} else {
|
||||||
|
failures.push(
|
||||||
|
` ${label}\n` +
|
||||||
|
` expected score ${wantScore}, labelled ${wantLabelled}, signals ${JSON.stringify(wantSignals)}\n` +
|
||||||
|
` got score ${result.score}, labelled ${isPossibleSpam(result)}, signals ${JSON.stringify(gotSignals)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* COVERAGE, ASSERTED RATHER THAN ASSUMED. A rule with no positive case is a rule
|
||||||
|
nobody has run, and it would still show a green suite. */
|
||||||
|
for (const sig of Object.keys(WEIGHTS)) {
|
||||||
|
if (!seen.has(sig)) {
|
||||||
|
failures.push(` no case exercises the "${sig}" signal — it is untested.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The threshold is part of the contract the cases above were written against.
|
||||||
|
Changing it without re-deriving them would leave every expectation a
|
||||||
|
statement about a threshold that no longer exists. */
|
||||||
|
if (SPAM_THRESHOLD !== 2) {
|
||||||
|
failures.push(
|
||||||
|
` SPAM_THRESHOLD is ${SPAM_THRESHOLD}, not 2 — the weights and expectations ` +
|
||||||
|
'above were written against 2. Re-derive them before changing it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`spam-score: ${failures.length} FAILED of ${CASES.length}`);
|
||||||
|
console.error(failures.join('\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`spam-score: ${pass} of ${CASES.length} cases pass; all ${Object.keys(WEIGHTS).length} signals exercised`,
|
||||||
|
);
|
||||||
+441
-47
@@ -49,7 +49,49 @@ decision, not an aesthetic one.
|
|||||||
/legal/terms/ Terms of use
|
/legal/terms/ Terms of use
|
||||||
```
|
```
|
||||||
|
|
||||||
Nineteen fixed URLs plus one per article.
|
Nineteen fixed URLs plus one per article — **and four more, all `noindex` and all
|
||||||
|
excluded from the sitemap in `astro.config.mjs`.** They are utilities rather than
|
||||||
|
pages in the IA above, which is why they are listed here rather than in it:
|
||||||
|
|
||||||
|
```
|
||||||
|
/contact/received/ The intake form's success landing page
|
||||||
|
/contact/could-not-send/ Its failure landing page
|
||||||
|
/bio/ Source of the one-page PDF (R16)
|
||||||
|
/404/ Emitted as dist/404.html — see below
|
||||||
|
```
|
||||||
|
|
||||||
|
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
|
||||||
|
the form is a plain POST and the handler answers `303 See Other` to a real URL —
|
||||||
|
`docs/05` §Build step 8 has the reasoning. `/bio/` exists so the PDF circulated
|
||||||
|
with an appointment proposal is a *rendering of a reviewed page* rather than a
|
||||||
|
document outside this project's review apparatus.
|
||||||
|
|
||||||
|
⚠️ **`/404/` IS THE ONE ROUTE THAT BREAKS THIS DOCUMENT'S OWN URL RULES, AND IT
|
||||||
|
HAS TO.** The rules above are lower-case, hyphenated, trailing slash, no file
|
||||||
|
extensions. Astro emits `src/pages/404.astro` as **`dist/404.html`** — a file at
|
||||||
|
the root, outside `build.format: 'directory'` — because that is the object name a
|
||||||
|
CDN custom error response can point at. `SEO.astro` still sees the path as
|
||||||
|
`/404/`, which is why its `OG_CARDS` key is `/404/` while the URL a tool fetches
|
||||||
|
is `/404.html`. Added 2026-09-01; `docs/04` had asked for the page since before
|
||||||
|
build step 1 and it did not exist.
|
||||||
|
|
||||||
|
**So: 23 built pages plus one per published article.**
|
||||||
|
|
||||||
|
⚠️ **AND THIS SENTENCE USED TO END WITH A REASSURANCE THAT WAS DISPROVEN THE DAY
|
||||||
|
THE 404 PAGE WAS ADDED.** It read: *"`npm run lighthouse` enumerates them from
|
||||||
|
`dist/` rather than from this list, which is why this count being stale could never
|
||||||
|
make the gate miss a page."* Both `scripts/lighthouse.mjs` and
|
||||||
|
`scripts/og-proof.mjs` enumerated **`index.html` under `dist/`**, not every page —
|
||||||
|
so both missed `/404/`, and `og:proof` reported it backwards, as an orphaned card
|
||||||
|
rather than an unchecked page. The count being stale was not the failure mode; the
|
||||||
|
**definition of "a page"** was. Both now take any `.html` at the root as well, and
|
||||||
|
`check:claims` always did, which is why the new page's copy was inside the claim
|
||||||
|
register from its first build.
|
||||||
|
|
||||||
|
**The rule that replaces the reassurance:** a route that does not live at
|
||||||
|
`<dir>/index.html` is invisible to anything that looks for `index.html`. If a
|
||||||
|
future page is emitted outside the directory convention, grep the three scripts
|
||||||
|
for `index.html` before trusting any of them.
|
||||||
|
|
||||||
### URL rules
|
### URL rules
|
||||||
|
|
||||||
@@ -81,8 +123,15 @@ the ceiling before a nav stops being scannable.
|
|||||||
## Deliberate omission: Indigenous engagement
|
## Deliberate omission: Indigenous engagement
|
||||||
|
|
||||||
The strategy brief (§III.4) rates Indigenous engagement, IBA, and consultation-
|
The strategy brief (§III.4) rates Indigenous engagement, IBA, and consultation-
|
||||||
breakdown mediation as *"strategically the most valuable single niche"* for a
|
breakdown mediation as *"strategically the most valuable single niche"*.
|
||||||
Q.Med on the C.Med-Arb pathway.
|
|
||||||
|
⚠️ **THAT SENTENCE USED TO CONTINUE "for a Q.Med on the C.Med-Arb pathway", AND
|
||||||
|
BOTH HALVES WERE DEFECTIVE.** *"pathway"* is a struck stage form (§4, 2026-08-29),
|
||||||
|
and the clause sat **outside** the quotation marks in `docs/01`'s own voice while
|
||||||
|
a parenthetical called the whole thing *"the brief's framing, quoted"* — a
|
||||||
|
quotation boundary the marks do not draw, which is the gloss defect inverted.
|
||||||
|
Only the four quoted words come from the brief, and the brief is not in this
|
||||||
|
repository (R14). The reasoning below never turned on either half.
|
||||||
|
|
||||||
There is no page for it at launch, on the following reasoning:
|
There is no page for it at launch, on the following reasoning:
|
||||||
|
|
||||||
@@ -97,6 +146,23 @@ seat. The cost of getting this wrong is much higher than the cost of waiting.
|
|||||||
Revisit at month 12–18, once there is relationship history to point to.
|
Revisit at month 12–18, once there is relationship history to point to.
|
||||||
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
|
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
|
||||||
|
|
||||||
|
## Not a practice area yet: tax-adjacent disputes
|
||||||
|
|
||||||
|
**Canadian Tax Foundation membership is verified** (`AGENTS.md` §4 — read the stamp there; a second copy of a currency stamp goes stale where nobody re-reads it, and this one had)
|
||||||
|
and it is the one credential none of the six areas above touch. Tax-adjacent
|
||||||
|
disputes are genuinely ADR territory — valuation and purchase-price disputes on
|
||||||
|
a share sale, indemnity and earn-out fights that turn on a tax position,
|
||||||
|
shareholder splits where the assessment is the thing actually in dispute.
|
||||||
|
|
||||||
|
**There is no seventh practice page at launch,** for the same reason as the
|
||||||
|
section above and not a weaker one: a practice page is a claim of present
|
||||||
|
capability, and there is no track record to point at. A membership is a
|
||||||
|
credential, not a caseload.
|
||||||
|
|
||||||
|
It belongs on `/about/` with the other memberships. Revisit at the **month
|
||||||
|
12–18 review, alongside the Indigenous engagement decision** — one review, two
|
||||||
|
candidates. Tracked as `AGENTS.md` R3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page specifications
|
## Page specifications
|
||||||
@@ -117,14 +183,29 @@ four audiences to its surface.
|
|||||||
2. **Credential row.** Three slots: `Q.Med` · `JD + ML` · `EN · FA`. Never
|
2. **Credential row.** Three slots: `Q.Med` · `JD + ML` · `EN · FA`. Never
|
||||||
matter counts — `AGENTS.md` §4.
|
matter counts — `AGENTS.md` §4.
|
||||||
3. **The approach.** The "two directions at once" argument — law and engineering
|
3. **The approach.** The "two directions at once" argument — law and engineering
|
||||||
converging on the same dispute. Infinity mark as the visual anchor.
|
converging on the same dispute. ⚠️ **The noun pair "law and engineering" is
|
||||||
|
the construction Q37 struck and Q41(a) extended to prose. The argument
|
||||||
|
stands; do not lift the phrase into copy** — it reached `/` once already.
|
||||||
|
State the asymmetry instead: `docs/03` §The credential row. Infinity mark as the visual anchor.
|
||||||
4. **Two practices.** Mediation and Arbitration cards → `/mediation/`, `/arbitration/`.
|
4. **Two practices.** Mediation and Arbitration cards → `/mediation/`, `/arbitration/`.
|
||||||
Med-Arb named here as the long-term arc, linking to `/med-arb/`.
|
Med-Arb named here, linking to `/med-arb/`. ⚠️ **This item read "named here as
|
||||||
|
the long-term arc" until 2026-08-29.** There is no arc — C.Med-Arb is struck
|
||||||
|
(§4) — and Med-Arb has had its own §4 Offerings row since 2026-08-27 (Q35),
|
||||||
|
so it is named as a **present offering**. `index.astro` cited this item as
|
||||||
|
amended before it was; both are correct now.
|
||||||
5. **Practice areas.** Six-card grid → `/practice/*`. This is the most important
|
5. **Practice areas.** Six-card grid → `/practice/*`. This is the most important
|
||||||
block on the page for search, because it distributes authority to the pages
|
block on the page for search, because it distributes authority to the pages
|
||||||
that can actually rank.
|
that can actually rank.
|
||||||
6. **Process preview.** Compressed five-step strip → `/process/`.
|
6. **Process preview.** Compressed five-step strip → `/process/`.
|
||||||
7. **Latest insights.** Three most recent → `/insights/`.
|
7. **Latest insights.** Three most recent → `/insights/`. **NOT BUILT AT STEP
|
||||||
|
2, and it arrives at STEP 7 with the collection it lists.** `ArticleCard` and
|
||||||
|
the drafted slate (D9) land in the same step; rendering the section against an
|
||||||
|
empty collection means shipping a component's scoped CSS to every visitor for
|
||||||
|
a block with nothing in it, plus a props surface with no call site.
|
||||||
|
`SiteHeader` already gates the Insights **nav** item on the same collection,
|
||||||
|
so the page section and the nav item appear together. Recorded here as well
|
||||||
|
as in the page source, because "eight sections specified, seven built" should
|
||||||
|
not be discoverable only by reading the file that deviates.
|
||||||
8. **Contact band.** Intake CTA and booking link.
|
8. **Contact band.** Intake CTA and booking link.
|
||||||
|
|
||||||
### `/about/` — Biography and credentials
|
### `/about/` — Biography and credentials
|
||||||
@@ -139,68 +220,241 @@ to an appointment. This page carries the verifiable record.
|
|||||||
engineering, operating a company — told as one arc rather than three lists.
|
engineering, operating a company — told as one arc rather than three lists.
|
||||||
3. **Credentials**, structured and scannable: designations, education,
|
3. **Credentials**, structured and scannable: designations, education,
|
||||||
certifications, memberships. Every line from `AGENTS.md` §4 Verified.
|
certifications, memberships. Every line from `AGENTS.md` §4 Verified.
|
||||||
4. **The credentialing arc.** Q.Med held → Q.Arb in progress → C.Med-Arb as the
|
4. ~~**The credentialing arc.**~~ ⚠️ **STRUCK 2026-08-29 — Pouya, and the
|
||||||
endpoint. The brief (§V) treats the arc itself as part of the story; say so
|
section is deleted from the page, not rewritten.** *"Two held designations,
|
||||||
openly rather than implying a finished state.
|
no journey… An arc invites 'where are you on it'; two designations don't."*
|
||||||
|
Q.Arb is held and C.Med-Arb is off the site entirely, so the item had no
|
||||||
|
subject. **Do not restore it from this outline or from the brief (§V), which
|
||||||
|
treated the arc as part of the story.** What it carried belongs in item 3,
|
||||||
|
which is a list of held things.
|
||||||
5. **Languages and cross-cultural practice.**
|
5. **Languages and cross-cultural practice.**
|
||||||
6. **Speaking and publications.** Omit the section entirely until there is
|
6. **Speaking and publications.** Omit the section entirely until there is
|
||||||
something in it. An empty "Speaking" heading is worse than no heading.
|
something in it. An empty "Speaking" heading is worse than no heading.
|
||||||
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
||||||
an asset for circulation with appointment proposals.
|
an asset for circulation with appointment proposals.
|
||||||
|
|
||||||
|
> ✅ **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
|
||||||
|
> with R16. Original note follows, and its reasoning is why this is R16 rather
|
||||||
|
> than a to-do. **The PDF bio does NOT ship with build step 3, and the omission
|
||||||
|
> is stated rather than silent** — `AGENTS.md` Q45, opened 2026-08-28. No such file exists
|
||||||
|
> in the repo, and a link to one that does not exist is a broken link on the
|
||||||
|
> page an appointing body reads. It is also not a formatting job: a one-page bio
|
||||||
|
> is a **credential document** whose every line has to trace to §4 exactly as a
|
||||||
|
> web page does, and it will be circulated detached from the site, where no
|
||||||
|
> reviewer sees it again. Two decisions are Pouya's — whether it is generated at
|
||||||
|
> build (a dependency, against R11) or authored once as a designed artefact, and
|
||||||
|
> whether it carries anything the site does not. Everything else on this page
|
||||||
|
> ships.
|
||||||
|
|
||||||
### `/mediation/`
|
### `/mediation/`
|
||||||
|
|
||||||
**Job:** convert counsel who have already decided on mediation and are choosing a
|
**Job:** convert counsel who have already decided on mediation and are choosing a
|
||||||
neutral.
|
neutral.
|
||||||
**Search intent:** `commercial mediator Toronto`, `ADRIC mediation rules`,
|
**Search intent:** `commercial mediator Toronto`, `ADRIC mediation rules`,
|
||||||
`what happens at mediation Ontario`.
|
`what happens at mediation Ontario`. **These are queries, not the page's scope —
|
||||||
|
Q56, 2026-08-30.** The mediation offering is **not** scoped commercial (§4's row
|
||||||
|
is unscoped; the arbitration scope is a legal gate and does not transfer). Do not
|
||||||
|
read the first query back into the `<title>` or the copy, which is where
|
||||||
|
*"Commercial Mediation"* came from in the first place.
|
||||||
|
|
||||||
1. What the service is; the neutral's role stated plainly.
|
1. What the service is; the neutral's role stated plainly.
|
||||||
2. **Formats:** full-day, half-day, shuttle, remote, hybrid.
|
2. **Formats:** full-day, half-day, shuttle, remote, hybrid.
|
||||||
3. **Rules:** ADRIC Model Mediation Rules, or a bespoke protocol agreed by the
|
3. **Rules:** the **ADRIC National Mediation Rules**, or a bespoke protocol
|
||||||
parties.
|
agreed by the parties.
|
||||||
|
⚠️ *This item read "ADRIC Model Mediation Rules" until 2026-08-28 and that is
|
||||||
|
not the name of anything ADRIC publishes* — **0 occurrences** across all four
|
||||||
|
of its rules pages, against **10** of "National Mediation Rules" on the
|
||||||
|
document's own page. "Model" belongs to the **Model Dispute Resolution
|
||||||
|
Clause**, a contract clause inside the rules. Sourced and reproducible:
|
||||||
|
`docs/reference/adric-rules.md` Finding 1. The spec would have put a wrong
|
||||||
|
institutional name on a public page, which is the `Chartered
|
||||||
|
Mediator-Arbitrator` shape a second time — caught here only because R14 sent
|
||||||
|
the fetch out before the copy was written.
|
||||||
4. **What parties should bring** — briefs, documents, authority to settle.
|
4. **What parties should bring** — briefs, documents, authority to settle.
|
||||||
5. **Confidentiality and without-prejudice framing.**
|
5. **Confidentiality and without-prejudice framing.**
|
||||||
6. Practice areas → `/practice/*`.
|
6. Practice areas → `/practice/*`.
|
||||||
7. Fees → `/fees/`. Booking → `/contact/`.
|
7. Fees → `/fees/`. Booking → `/contact/`.
|
||||||
|
|
||||||
|
⚠️ **This page carries no first-person conduct commitment, and that is
|
||||||
|
deliberate.** How Pouya handles caucus material, and what he undertakes about a
|
||||||
|
bespoke protocol, are claims about his practice with no §4 row. Two of them
|
||||||
|
shipped here for one pass and were removed. Drafted for his ruling as **Q54(d)**
|
||||||
|
and **(e)**; until he rules, the Confidentiality and Rules sections describe what
|
||||||
|
an *agreement* settles, not what he promises.
|
||||||
|
|
||||||
### `/arbitration/`
|
### `/arbitration/`
|
||||||
|
|
||||||
**Job:** the same, for arbitration — and to state the Q.Arb position honestly.
|
**Job:** the same, for arbitration. *(This read "and to state the Q.Arb
|
||||||
|
position honestly" until 2026-08-29; Q.Arb is held and there is no position to
|
||||||
|
state.)*
|
||||||
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
|
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
|
||||||
`documents-only arbitration`.
|
`documents-only arbitration`.
|
||||||
|
|
||||||
1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary
|
1. What the service is; sole-arbitrator and party-appointed
|
||||||
appointments.
|
appointments.
|
||||||
2. **Tracks:** documents-only, expedited, full hearing.
|
2. **Tracks:** documents-only, expedited, full hearing.
|
||||||
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
|
3. **Rules:** ADRIC, ad hoc. ✅ **Both are named on the page.**
|
||||||
|
⚠️ **ADR CHAMBERS WAS STRUCK FROM THIS ITEM AND FROM THE PAGE ON 2026-08-30
|
||||||
|
— Pouya's ruling, and do not reinstate it from an earlier reading of this
|
||||||
|
spec.** This item listed it as a third rule option for most of the project.
|
||||||
|
`docs/reference/adr-institution-names.md` sources what the firm *publishes*;
|
||||||
|
it does not source that an outside neutral can be appointed under those
|
||||||
|
rules, and the firm's own model clause reads *"at ADR Chambers"* — so naming
|
||||||
|
it here implied a relationship the repository does not establish. The ADRIC
|
||||||
|
edition date (1 March 2025) is published on the page because ADRIC publishes
|
||||||
|
one.
|
||||||
4. Awards — form, reasoning, timing.
|
4. Awards — form, reasoning, timing.
|
||||||
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
|
5. ~~**Credentialing status, stated plainly.**~~ ⚠️ **STRUCK 2026-08-29 —
|
||||||
the page says so and describes what is available now (co-arbitration,
|
Pouya. Q.Arb is HELD, and §4's paired-disclosure condition dissolved with the
|
||||||
tribunal secretary) versus what follows designation. Honesty here is a
|
stage it required.** *"It existed only because Q.Arb was in progress. There
|
||||||
differentiator, not a weakness — and misstating it is a conduct problem.
|
is no stage left to disclose."* The page's whole credentialing-stage section
|
||||||
|
is deleted, its `<h1>` no longer reads *"Available now, and open about the
|
||||||
|
stage"*, and **no residue of the condition may be reintroduced** — a page
|
||||||
|
that offers arbitration and then reaches for something qualifying to say is
|
||||||
|
reproducing it from memory. **What survives:** all three forms — sole,
|
||||||
|
party-appointed and co-arbitration — are offered now, with an `AGENTS.md` §4
|
||||||
|
Offerings row for each `[verified 2026-08-26 — Pouya]`, and the scope is
|
||||||
|
**commercial** (Q39, Pouya's own choice, never part of this condition).
|
||||||
|
*(This paragraph read "(co-arbitration, co-arbitration)" until 2026-08-26 —
|
||||||
|
edited without being re-read — and then carried a caveat against Q36 for
|
||||||
|
several hours after Q36 closed. Both are recorded because the pattern is the
|
||||||
|
same one: an edit that was not re-read against the register.)*
|
||||||
6. Fees, booking.
|
6. Fees, booking.
|
||||||
|
|
||||||
|
⚠️ **No first-person conduct commitment on this page either.** The Rules and
|
||||||
|
Awards sections say what a process and an award *should* settle and contain, not
|
||||||
|
what Pouya undertakes to do — **Q54(e)** and **(f)** are drafted and unruled, and
|
||||||
|
both shipped here in the first person for one pass. The `<title>` names a
|
||||||
|
**service**, never "Sole Arbitrator": §4 grants exactly one practised role and it
|
||||||
|
is "Mediator".
|
||||||
|
|
||||||
### `/med-arb/`
|
### `/med-arb/`
|
||||||
|
|
||||||
**Job:** own a term few Canadian neutrals explain well, and frame the C.Med-Arb
|
**Job:** own a term few Canadian neutrals explain well. *(This read "and frame
|
||||||
endpoint.
|
the C.Med-Arb endpoint" until 2026-08-29; C.Med-Arb is off the site.)*
|
||||||
**Search intent:** `med-arb Canada`, `what is med-arb`, `arb-med`.
|
**Search intent:** `med-arb Canada`, `what is med-arb`, `arb-med`.
|
||||||
|
|
||||||
1. What Med-Arb is; how it differs from Arb-Med.
|
1. What Med-Arb is; how it differs from Arb-Med.
|
||||||
|
⚠️ *As built, the page flags the confusion and does **not define arb-med***.
|
||||||
|
No source for a definition of arb-med is committed, and this repository does
|
||||||
|
not publish a definition of a third party's process from recall (R14). The
|
||||||
|
page says only that the two are one syllable apart, that the processes are
|
||||||
|
not interchangeable, and that a reader should check which one their contract
|
||||||
|
names — against the rule set the contract adopts, not against this page. **It
|
||||||
|
states no differentia at all**, because on a page that defines med-arb as
|
||||||
|
mediation→arbitration, "the phases run in a different order" *is* a
|
||||||
|
definition of arb-med by inversion. That wording shipped for one pass and
|
||||||
|
`claims-auditor` caught it. Define arb-med when a source is committed, or add
|
||||||
|
an **Arb-Med** row to `AGENTS.md` §11 — not before.
|
||||||
2. The procedural fairness objection, addressed head-on rather than elided.
|
2. The procedural fairness objection, addressed head-on rather than elided.
|
||||||
|
⚠️ *The page answers it at the level of **process design** — what a med-arb
|
||||||
|
agreement has to settle before the mediation phase begins. It does **not**
|
||||||
|
carry Pouya's own protocol commitments, which are claims about his practice
|
||||||
|
with no §4 row.* Three are drafted for his approval in **Q54**; the section is
|
||||||
|
incomplete until he rules.
|
||||||
3. When it fits and when it does not.
|
3. When it fits and when it does not.
|
||||||
4. The C.Med-Arb designation and why it is the practice's stated endpoint.
|
4. ~~The C.Med-Arb designation and why it is the practice's stated endpoint.~~
|
||||||
|
⚠️ **STRUCK 2026-08-29 — Pouya. C.Med-Arb is out entirely, and this is a
|
||||||
|
deliberate deviation from the strategy brief**, which made it *"the explicit
|
||||||
|
long-term professional narrative"*. His reasoning: *"Pouya holds Q.Med and
|
||||||
|
Q.Arb, which is what med-arb requires. The page says he conducts med-arb and
|
||||||
|
stops."* The section is **rewritten, not edited** — it earned its credibility
|
||||||
|
from an incomplete credential and now says what med-arb demands of one
|
||||||
|
neutral. ⚠️ **AND IT NO LONGER RESTATES THE DESIGNATIONS EITHER —
|
||||||
|
2026-09-02, `claims-auditor` D20 pass, finding F.** §Why this practice held a
|
||||||
|
bare `DESIGNATIONS_HELD_LINE` sentence sitting directly beneath §Rules'
|
||||||
|
quotation of ADRIC requiring *"a high level of practitioner competence"*, so
|
||||||
|
the designations read as meeting a bar ADRIC answers with the Chartered
|
||||||
|
Med-Arb — struck from this site entirely. The section now makes **no
|
||||||
|
credential claim at all**; `/about/` publishes them and the JSON-LD carries
|
||||||
|
them. Do not reinstate one here.
|
||||||
|
5. **The ADRIC Med-Arb Rules**, which this spec did not mention and which exist:
|
||||||
|
~~a published rule set, presented to ADRIC's membership~~ — **CORRECTED
|
||||||
|
2026-09-02, `claims-auditor` D20 pass, finding 6.** The source says a
|
||||||
|
**discussion draft** was presented, not the rules; `adric-rules.md` Finding 2
|
||||||
|
records no adoption and no effective date, in deliberate contrast to the
|
||||||
|
Arbitration Rules ("effective March 1, 2025") and the Mediation Rules ("the
|
||||||
|
existing rules remain in effect"). The page was corrected on 2026-09-01 and
|
||||||
|
**this line still instructed the struck form for a day** — the Q.Arb lesson
|
||||||
|
exactly: page fixed, spec still telling the next implementer to write the
|
||||||
|
false version. Write it as **presented to the membership as a discussion
|
||||||
|
draft at ADRIC's 2019 annual conference** — drafted for domestic commercial
|
||||||
|
disputes and designed to work with its Mediation and Arbitration Rules.
|
||||||
|
Sourced: `docs/reference/adric-rules.md` Finding 2. Quote ADRIC's own
|
||||||
|
framing rather than paraphrasing it, and **keep its superlatives inside the
|
||||||
|
quotation marks** — Finding 5 lists the two not to lift.
|
||||||
|
|
||||||
This page is a strong candidate for the best-performing page on the site.
|
This page is a strong candidate for the best-performing page on the site.
|
||||||
Search demand exists, competition is thin, and it maps exactly to the brand's
|
Search demand exists and competition is thin. *(This closed "and it maps exactly
|
||||||
long-term narrative.
|
to the brand's long-term narrative" — the C.Med-Arb narrative struck thirteen
|
||||||
|
lines above on 2026-08-29. The two reasons that survive are the two that were
|
||||||
|
ever measurable.)*
|
||||||
|
|
||||||
### `/practice/` — index
|
### `/practice/` — index
|
||||||
|
|
||||||
Six cards, one paragraph each, linking onward. Also the natural home for the
|
Six cards, one paragraph each, linking onward. Also the natural home for the
|
||||||
"also offered" strip: early neutral evaluation, settlement counsel, dispute-
|
"also offered" strip: **early neutral evaluation, dispute-system design, and
|
||||||
system design, and pre-dispute technical advisory.
|
pre-dispute technical advisory** — three, and each has an `AGENTS.md` §4
|
||||||
|
Offerings row.
|
||||||
|
|
||||||
|
> ✅ **THE STRIP MAY SHIP — Q46(a) closed 2026-08-28, on Pouya's second
|
||||||
|
> ruling.** All three §4 Offerings rows now read **PUBLISHABLE**. Read the ENE
|
||||||
|
> row before writing the strip: gate 1 (*is the activity gated?*) is closed as
|
||||||
|
> **`[Pouya's stated basis 2026-08-28]`** and **not** as `[verified]`, because no
|
||||||
|
> source for any of the three exists in `docs/reference/`. That is a deliberate,
|
||||||
|
> attributed position of the architect's, not a sourced fact — do not describe it
|
||||||
|
> on the page or in a comment as settled law.
|
||||||
|
>
|
||||||
|
> **Two framing constraints survive the ruling and are not discretionary:**
|
||||||
|
>
|
||||||
|
> 1. **ENE is an assessment delivered to BOTH parties**, never advice to one. It
|
||||||
|
> is the offering nearest §4's NOT-NEGOTIABLE boundary — a neutral assessment
|
||||||
|
> of the *merits* sits closest to *"providing legal services"*.
|
||||||
|
> 2. **Pre-dispute advisory carries a conflict caution**, and it is practice
|
||||||
|
> management rather than a publication gate: advisory work for one
|
||||||
|
> organisation can conflict against a later appointment in the same matter.
|
||||||
|
> **No copy may imply the offering is free of that tension** — this strip is
|
||||||
|
> where the temptation to imply it will arise.
|
||||||
|
|
||||||
|
> **`settlement counsel` IS STRUCK FROM THIS STRIP AND MUST NOT BE RESTORED.**
|
||||||
|
> `AGENTS.md` Q42, 2026-08-27. Pouya struck it as his own error in this document:
|
||||||
|
>
|
||||||
|
> > "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
|
||||||
|
> > role, and putting it on a site that (a) sells neutrality and (b) asserts no
|
||||||
|
> > licensure under D13 is **wrong twice over**: it undercuts the brand's
|
||||||
|
> > central claim and it edges into acting for a client."
|
||||||
|
>
|
||||||
|
> Note which objection comes first. This is not primarily a compliance problem —
|
||||||
|
> it is a **positioning** problem, and it would have been wrong on a site with
|
||||||
|
> no licensure question at all. The compliance half is the aggravation, not the
|
||||||
|
> reason. Never priced, never offered, never listed: it is a struck row in §4
|
||||||
|
> Offerings so that a later reader finds the decision rather than the gap.
|
||||||
|
|
||||||
### `/practice/construction/`
|
### `/practice/construction/`
|
||||||
|
|
||||||
@@ -208,7 +462,7 @@ system design, and pre-dispute technical advisory.
|
|||||||
`subcontract dispute arbitration Toronto`.
|
`subcontract dispute arbitration Toronto`.
|
||||||
|
|
||||||
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
|
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
|
||||||
what an active litigation practice in the same matters brings to the room; the
|
what active litigation exposure in the same matters brings to the room; the
|
||||||
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
|
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
|
||||||
transit; typical process shape. Strongest immediate fit per brief §III.1.
|
transit; typical process shape. Strongest immediate fit per brief §III.1.
|
||||||
|
|
||||||
@@ -232,18 +486,93 @@ practice.
|
|||||||
**Search intent:** `Bill 40 dispute`, `IESO dispute resolution`,
|
**Search intent:** `Bill 40 dispute`, `IESO dispute resolution`,
|
||||||
`OEB leave to construct dispute`, `grid connection dispute Ontario`.
|
`OEB leave to construct dispute`, `grid connection dispute Ontario`.
|
||||||
|
|
||||||
Grid connection and allocation, leave-to-construct, proponent–municipality
|
Connection assessment and approval, leave to construct, proponent–municipality
|
||||||
disputes, IESO market participation, data-centre connection allocation. Brief
|
disputes, IESO market participation, and the large-load / data-centre connection
|
||||||
§III.2 frames this as a 24–36 month build. **Write it as a genuine position, not
|
regime. Brief §III.2 frames this as a 24–36 month build. **Write it as a genuine
|
||||||
a claim of existing volume.**
|
position, not a claim of existing volume.**
|
||||||
|
|
||||||
|
> ⚠️ **"CONNECTION ALLOCATION" WAS THIS SECTION'S WORDING AND IT IS NOT AN
|
||||||
|
> ONTARIO TERM. Corrected 2026-08-29**, against the IESO's own pages
|
||||||
|
> (`docs/reference/ontario-energy-regulatory.md`), which contain **zero**
|
||||||
|
> occurrences of "allocation" of any kind across the connection process. It also
|
||||||
|
> reached `src/data/site.ts` and shipped in the six-card grid on three pages.
|
||||||
|
>
|
||||||
|
> **Use the terms these bodies use:** the IESO's own words are *"the IESO's and
|
||||||
|
> transmitter's connection assessment and approval (CAA) process"*, within which
|
||||||
|
> the IESO performs a *System Impact Assessment (SIA)* and the transmitter a
|
||||||
|
> *Customer Impact Assessment (CIA)*. ⚠️ **This read "CAA is the umbrella" until
|
||||||
|
> 2026-09-03** — which is the extract's own COMMENTARY, not the IESO's, and
|
||||||
|
> `CLAUDE.md` is explicit that commentary around a quotation is this
|
||||||
|
> repository's voice. The pages took the attribution from here and gave the
|
||||||
|
> process to the IESO alone. **And it is the CONNECTION PROCESS that runs to
|
||||||
|
> *up to* six stages, not the CAA** — the source scopes the count by connection
|
||||||
|
> type, and CAA is stage 2 of that process rather than a name for it. Naming the
|
||||||
|
> wrong subject here is how the conflation reaches a page. **Ontario has no
|
||||||
|
> interconnection queue** — the IESO says so in terms and works from "committed
|
||||||
|
> projects" instead, so "our place in the queue" describes nothing. The
|
||||||
|
> genuinely adjacent term, the OEB's *Capacity Allocation Model* in the
|
||||||
|
> Distribution System Code, is about housing-development connections and is a
|
||||||
|
> different thing.
|
||||||
|
>
|
||||||
|
> **Bill 40 is identified**, and the identification was not safe to assume: it
|
||||||
|
> is Bill 40 of the **44th Parliament, 1st Session — the Protect Ontario by
|
||||||
|
> Securing Affordable Energy for Generations Act, 2025**, Royal Assent
|
||||||
|
> 11 December 2025. Bill numbers are reused every parliament and most Ontario
|
||||||
|
> Bill 40s are unrelated to energy (43-1 is a highway-traffic bill). Cite the
|
||||||
|
> parliament and session, never the bare number.
|
||||||
|
|
||||||
### `/practice/insurance/`
|
### `/practice/insurance/`
|
||||||
|
|
||||||
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
|
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
|
||||||
`accident benefits mediator Ontario`, `MIG dispute`.
|
`accident benefits mediator Ontario`, `MIG dispute`.
|
||||||
|
|
||||||
|
> ⚠️ **`LAT pre-hearing mediation` IS A SEARCH INTENT AND NOTHING ELSE. It must
|
||||||
|
> never be published as an offering** — `AGENTS.md` Q41(c), closed 2026-08-27,
|
||||||
|
> verified 2026-08-28 against the Tribunal's own materials and extracted into
|
||||||
|
> **`docs/reference/lat-case-conference.md`**. It reached `src/data/site.ts` as a
|
||||||
|
> service blurb once already; this note exists because a search-intent list is
|
||||||
|
> where that lift starts.
|
||||||
|
>
|
||||||
|
> What the verification found, in one line each:
|
||||||
|
>
|
||||||
|
> - **LAT Rule 2.4:** *"'Case Conference' has the same meaning as 'Pre-Hearing
|
||||||
|
> Conference' as defined in the SPPA."* **"Pre-hearing" is the Tribunal's own
|
||||||
|
> label**, and what it labels is a case conference.
|
||||||
|
> - **Rule 14.3:** a **Member** presides and does not then sit on the hearing
|
||||||
|
> panel except with the consent of the parties; **Rule 14.6:** parties must
|
||||||
|
> attend. The neutral is the Tribunal's. A privately retained one is not
|
||||||
|
> appointed to it and cannot be.
|
||||||
|
> - The LAT Rules contain **zero** occurrences of `mediat` or `arbitrat` —
|
||||||
|
> 0 in 66,593 characters. The concept is not in them.
|
||||||
|
> - The LAT-AABS page itself, though, says: *"Before you apply to the LAT-AABS,
|
||||||
|
> you may want to consider negotiation or mediation services."*
|
||||||
|
> **That is the affirmative basis for the offering, in the Tribunal's words** —
|
||||||
|
> and it is the whole of it.
|
||||||
|
>
|
||||||
|
> ⚠️ **THE QUOTATION ABOVE WAS ELLIPSED, AND THE ELLIPSIS REMOVED THE WORD THAT
|
||||||
|
> SCOPED IT. Corrected 2026-08-29.** It read *"…consider negotiation or
|
||||||
|
> mediation services… including before filing at the LAT-AABS, and continuing…
|
||||||
|
> after a claim has been filed."* The Tribunal's second sentence is
|
||||||
|
> *"Parties are encouraged to attempt to **negotiate** the claim at all times,
|
||||||
|
> including before filing at the LAT-AABS, and continuing **negotiation**
|
||||||
|
> discussions after a claim has been filed."* — **negotiation, twice.** The
|
||||||
|
> second ellipsis deleted the second "negotiation" and made a sentence about
|
||||||
|
> negotiation read as one about mediation. The full passage is quoted verbatim
|
||||||
|
> in `docs/reference/lat-case-conference.md`, which now carries the correction
|
||||||
|
> and the reason it survived three checks.
|
||||||
|
>
|
||||||
|
> **The page must state that the mediation offered is PRIVATE, retained by the
|
||||||
|
> parties, and is not the Tribunal's case conference.** Published blurb:
|
||||||
|
> *"Accident benefits and SABS entitlement, MIG disputes, and private mediation
|
||||||
|
> retained by the parties, not the Tribunal's case conference."* **Do not
|
||||||
|
> restore the "before filing or after" form** — it rested on the elided half.
|
||||||
|
> The page may quote the Tribunal's actual sentence, which supports mediation
|
||||||
|
> **before an application**; it may not attribute the after-filing frame to the
|
||||||
|
> Tribunal. If Pouya holds a roster position that makes more than that true, it
|
||||||
|
> is a §4 addition — absent a row, it is not.
|
||||||
|
|
||||||
Highest realistic near-term volume — it flows directly from the existing
|
Highest realistic near-term volume — it flows directly from the existing
|
||||||
personal-injury and SABS practice, and brief §IV.7 notes the segment is
|
personal-injury and SABS work, and brief §IV.7 notes the segment is
|
||||||
underserved by senior mediators. Unglamorous and worth doing well.
|
underserved by senior mediators. Unglamorous and worth doing well.
|
||||||
|
|
||||||
### `/practice/shareholder/`
|
### `/practice/shareholder/`
|
||||||
@@ -255,6 +584,20 @@ Shareholder and partnership disputes, co-founder breakdowns, family-business
|
|||||||
succession, SME exits. The operator angle — running SML Company Ltd. alongside
|
succession, SME exits. The operator angle — running SML Company Ltd. alongside
|
||||||
the practice — is the differentiator here.
|
the practice — is the differentiator here.
|
||||||
|
|
||||||
|
**"Family Business" means COMMERCIAL disputes among family shareholders, and the
|
||||||
|
page must say so.** Pouya's ruling of 2026-08-27 (`AGENTS.md` Q39): the label
|
||||||
|
covers shareholder and partnership disputes, co-founder breakdowns and business
|
||||||
|
succession — **not** family law. **Family arbitration under the *Family Law Act*
|
||||||
|
is not offered**, and that activity is separately gated by prescribed training
|
||||||
|
(`docs/reference/ontario-family-arbitration-training.md`), so the exclusion has
|
||||||
|
to be legible rather than left to be inferred from the surrounding nouns.
|
||||||
|
|
||||||
|
**One sentence, not a section.** His instruction, and the reason is also the test
|
||||||
|
for whether it belongs at all: *"The page should say plainly that family law
|
||||||
|
matters are not accepted. One sentence, not a section: it saves a wasted intake
|
||||||
|
call, which is the only reason it earns its place."* A disclaimer that grows into
|
||||||
|
a paragraph reads as defensive, which is the opposite of the point.
|
||||||
|
|
||||||
### `/practice/cross-cultural/`
|
### `/practice/cross-cultural/`
|
||||||
|
|
||||||
**Search intent:** `Farsi speaking mediator Toronto`,
|
**Search intent:** `Farsi speaking mediator Toronto`,
|
||||||
@@ -272,13 +615,23 @@ and framing (1–7) · pre-session exchange (7–21) · the session (21–30) ·
|
|||||||
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
|
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
|
||||||
a matter does not settle.
|
a matter does not settle.
|
||||||
|
|
||||||
|
**The timings are published as the TYPICAL shape of an engagement, explicitly
|
||||||
|
not a guarantee** — `AGENTS.md` Q43, Pouya 2026-08-27. Render `PROCESS_FRAMING`
|
||||||
|
(`src/data/site.ts`) **adjacent to the steps**, on this page and on `/`. The
|
||||||
|
numbers above are unchanged; what is required is that they never appear
|
||||||
|
unframed. *"Published as typical, they are honest and useful; published as
|
||||||
|
commitments, the first matter that slips makes the page false."*
|
||||||
|
|
||||||
### `/fees/`
|
### `/fees/`
|
||||||
|
|
||||||
**Blocked on `AGENTS.md` Q4 — do not invent numbers.**
|
**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
|
||||||
|
in `docs/07-fees.md`; still do not invent numbers.**
|
||||||
|
|
||||||
Hourly rate; half-day and full-day mediation; preparation time policy;
|
Hourly rate; half-day and full-day mediation; **med-arb, billed by phase and
|
||||||
cancellation terms; administrative fee; HST treatment; who pays and how costs
|
carrying no figure of its own** (added 2026-09-03, `docs/07` §Med-arb, INTERIM
|
||||||
are shared between parties; payment terms. A real page with real numbers, or a
|
against R5 — the page shipped it and this outline did not name it); preparation
|
||||||
|
time policy; cancellation terms; administrative fee; HST treatment; who pays and
|
||||||
|
how costs are shared between parties; payment terms. A real page with real numbers, or a
|
||||||
`TODO(pouya)` — nothing in between.
|
`TODO(pouya)` — nothing in between.
|
||||||
|
|
||||||
### `/for-parties/`
|
### `/for-parties/`
|
||||||
@@ -286,8 +639,15 @@ are shared between parties; payment terms. A real page with real numbers, or a
|
|||||||
**Job:** serve the self-represented tier without diluting the counsel-facing
|
**Job:** serve the self-represented tier without diluting the counsel-facing
|
||||||
pages. Plain language, short sentences, no jargon.
|
pages. Plain language, short sentences, no jargon.
|
||||||
|
|
||||||
What mediation is · what it is not · who the mediator is and is not (not your
|
What mediation is · what it is not · who the mediator is and is not —
|
||||||
lawyer, not a judge) · what happens on the day · what it costs · what happens if
|
**render `NEUTRAL_ROLE_LINE`, and read `docs/03` §For parties before writing a
|
||||||
|
word of it.** ⚠️ **THIS LINE READ *"(not your lawyer, not a judge)"* UNTIL
|
||||||
|
2026-08-30 AND IT INSTRUCTED A FORM STRUCK ON 2026-08-28.** *"Not your lawyer"*
|
||||||
|
**presupposes lawyer status**, which §4 Forbidden bars and D13 treats as hard as
|
||||||
|
assertion — a negation still carries the presupposition. `docs/03` struck it
|
||||||
|
with that reasoning two days earlier and the sweep never reached this file;
|
||||||
|
build step 6 is the step that consumes this line, and it was found here by
|
||||||
|
`adversarial-reviewer` while the page itself avoided the trap · what happens on the day · what it costs · what happens if
|
||||||
you do not settle · how to prepare.
|
you do not settle · how to prepare.
|
||||||
|
|
||||||
### `/insights/` and `/insights/[slug]/`
|
### `/insights/` and `/insights/[slug]/`
|
||||||
@@ -295,8 +655,21 @@ you do not settle · how to prepare.
|
|||||||
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
||||||
by practice area.
|
by practice area.
|
||||||
|
|
||||||
Article frontmatter: `title`, `description`, `publishDate`, `updatedDate`,
|
Article frontmatter: `title`, `seoTitle` (optional), `description`,
|
||||||
`topics[]`, `practiceAreas[]`, `readingTime`, `draft`.
|
`publishDate`, `updatedDate`, `topics[]`, `practiceAreas[]`, `readingTime`,
|
||||||
|
`image` and `imageAlt` (both optional, but `imageAlt` is **required whenever
|
||||||
|
`image` is set**), `draft`, `reviewedByPouya`.
|
||||||
|
|
||||||
|
`title` is the headline and, for articles, the `<title>` — they carry no
|
||||||
|
` · Pouya Lajevardi` suffix; see `04-seo-spec.md` for why. `seoTitle` replaces
|
||||||
|
it when a headline that reads well falls outside 50–60. `src/content.config.ts`
|
||||||
|
enforces the rendered length and names the offending string in the error.
|
||||||
|
|
||||||
|
Dates are date-only ISO (`2026-08-01`), parsed as UTC and round-tripped, so a
|
||||||
|
typo fails the build rather than shipping as 1970 or as the wrong day.
|
||||||
|
|
||||||
|
`reviewedByPouya` carries D9: the schema refuses to build an entry with
|
||||||
|
`draft: false` and `reviewedByPouya: false`.
|
||||||
|
|
||||||
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
||||||
industry-specific dispute commentary · anonymised reflections · technical
|
industry-specific dispute commentary · anonymised reflections · technical
|
||||||
@@ -312,8 +685,9 @@ anything.
|
|||||||
|
|
||||||
### `/contact/`
|
### `/contact/`
|
||||||
|
|
||||||
Intake form (`05-backend-spec.md`), booking embed, direct email and phone
|
Intake form (`05-backend-spec.md`), booking embed, direct email
|
||||||
(Q3), Toronto by-appointment line, response-time expectation, and an explicit
|
(Q3 — **there is no public phone number**; render `CONTACT.phoneFallback`,
|
||||||
|
"By scheduled call", wherever a number would go), Toronto by-appointment line, response-time expectation, and an explicit
|
||||||
note that submitting the form does not create a retainer or a mediator–party
|
note that submitting the form does not create a retainer or a mediator–party
|
||||||
relationship and does not itself create a conflict check.
|
relationship and does not itself create a conflict check.
|
||||||
|
|
||||||
@@ -337,8 +711,28 @@ Dependency-ordered, so nothing is blocked mid-stream:
|
|||||||
4. `/mediation/`, `/arbitration/`, `/med-arb/`
|
4. `/mediation/`, `/arbitration/`, `/med-arb/`
|
||||||
5. `/practice/` and the six area pages
|
5. `/practice/` and the six area pages
|
||||||
6. `/process/`, `/for-parties/`
|
6. `/process/`, `/for-parties/`
|
||||||
7. `/insights/` plumbing, then the drafted articles
|
7. ✅ `/insights/` plumbing, then the drafted articles — **built 2026-08-31.**
|
||||||
8. `/contact/` and the intake backend
|
Step 7a returned Lighthouse (`AGENTS.md` §7, R11); 7b built the OG card
|
||||||
9. `/fees/` — last, since it is blocked on Q4
|
generator (R15) and the Insights routes; 7c drafted the five launch articles.
|
||||||
10. `/legal/*` — written to match the backend as actually built
|
**The section is not live and cannot be**: D9 and `src/content.config.ts`
|
||||||
11. Audit and cutover (`06-deployment.md`)
|
between them mean an article publishes only when Pouya sets both flags, and
|
||||||
|
`SiteHeader` keeps Insights out of the nav until two are live
|
||||||
|
8. ✅ `/contact/` — **the page is built and the pipe behind it is LIVE as of
|
||||||
|
2026-09-02.** The handler is deployed and the CloudFront `/api/*` behaviour is
|
||||||
|
in place; `AGENTS.md` §7 holds the state and this list does not restate it.
|
||||||
|
`docs/05` §Build step 8 records three deliberate deviations from that spec, and
|
||||||
|
§Observed abuse records the first real spam and what was added for it.
|
||||||
|
⚠️ **This item read *"the pipe behind it is not"* until 2026-09-04** — written
|
||||||
|
under D11, true then, and left asserting an undeployed backend for two days
|
||||||
|
after cutover
|
||||||
|
9. ✅ `/fees/` — **built 2026-08-31 on Q59's ruling**, which settled where the
|
||||||
|
overtime hour starts (the session cap) and supplied the reservation point that
|
||||||
|
answers the rate card's arithmetic anomaly. The PDF bio shipped with it (R16)
|
||||||
|
10. ✅ `/legal/privacy/` and `/legal/terms/` — **built 2026-08-31, written to the
|
||||||
|
backend as actually built.** Three of the privacy policy's statements are
|
||||||
|
DERIVED — the collected-data list from `INTAKE_FIELDS`, the retention period
|
||||||
|
from the handler's own figure, the analytics paragraph from
|
||||||
|
`ANALYTICS.installed` — so they cannot drift from the implementation
|
||||||
|
11. Audit and cutover (`06-deployment.md`) — **not started. Nothing is deployed.**
|
||||||
|
`claims-auditor`'s single pass over the whole finished site (D20) is a blocking
|
||||||
|
item there and has not run
|
||||||
|
|||||||
+1043
-13
File diff suppressed because it is too large
Load Diff
+400
-33
@@ -13,6 +13,47 @@ table before writing any number, statistic, or superlative. If you need a fact
|
|||||||
you do not have, write `TODO(pouya): <exact question>` and log it in §9. Do not
|
you do not have, write `TODO(pouya): <exact question>` and log it in §9. Do not
|
||||||
infer, do not soften, do not carry anything over from the old site.
|
infer, do not soften, do not carry anything over from the old site.
|
||||||
|
|
||||||
|
### When a fact is `[unestablished]`, write around the capacity question
|
||||||
|
|
||||||
|
**Ratified by Pouya 2026-08-28 as a reusable pattern, with the instruction that
|
||||||
|
it will recur.** §4 records licence status as `[unestablished]` and says: *do not
|
||||||
|
assert it, do not deny it.* Copy that addresses the question at all must
|
||||||
|
therefore pick neither side — and the trap is that **both obvious phrasings pick
|
||||||
|
one.**
|
||||||
|
|
||||||
|
The worked example, which took three attempts and two audits:
|
||||||
|
|
||||||
|
| Attempt | Copy | Why it failed |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | "I **do not** give legal advice" | *"Do not"* is an **election** — it implies entitlement withheld by choice. Flagged by audit 1 |
|
||||||
|
| 2 | "I **cannot** give legal advice" | *"Cannot"* is a **denial** of capacity. Flagged by audit 2 |
|
||||||
|
| 3 ✅ | "I act as a neutral. I do not act for a party in a matter I take, and each party should have their own legal advice." | Makes **no capacity claim at all** |
|
||||||
|
|
||||||
|
**Both audits were right, and that is why the third version works.** Attempts 1
|
||||||
|
and 2 are opposite answers to the same question, so flagging both is not
|
||||||
|
inconsistency — it is the correct finding twice. Pouya: *"'do not' elects,
|
||||||
|
'cannot' denies, and §4 says do neither. The shipped sentence makes no capacity
|
||||||
|
claim at all — it describes the role and points the reader somewhere useful."*
|
||||||
|
|
||||||
|
**The pattern, stated for reuse:**
|
||||||
|
|
||||||
|
> Do not look for a *softer* way to answer the capacity question. Any word that
|
||||||
|
> answers it — *cannot, do not, am not, is not permitted, is not qualified* —
|
||||||
|
> takes a side of a fact this register does not hold. Instead write the sentence
|
||||||
|
> so the question **does not arise**: state the **role**, state the
|
||||||
|
> **consequence** for the reader, and stop.
|
||||||
|
|
||||||
|
Three tests before shipping such a sentence. It fails if any is yes:
|
||||||
|
|
||||||
|
1. Could a reader infer from it that he **is** entitled to do the thing?
|
||||||
|
2. Could a reader infer from it that he is **not**?
|
||||||
|
3. Does it contain a verb of capacity or permission attached to him at all?
|
||||||
|
|
||||||
|
**Where this will come up next:** `/practice/` (step 5) and `/for-parties/`, both
|
||||||
|
of which have to tell an unrepresented party what the neutral will and will not
|
||||||
|
do for them — the exact place the "cannot" phrasing feels most natural and is
|
||||||
|
most wrong.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voice
|
## Voice
|
||||||
@@ -28,9 +69,83 @@ detect padding instantly and discount everything after it.
|
|||||||
practice". The old site's "we" implied a firm that does not exist.
|
practice". The old site's "we" implied a firm that does not exist.
|
||||||
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
|
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
|
||||||
card. Minutes of settlement.* Specificity is the credential.
|
card. Minutes of settlement.* Specificity is the credential.
|
||||||
- Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation;
|
- Name the limits — but name the *right* ones. This bullet carried the model
|
||||||
co-arbitration and tribunal-secretary work is available now." Precision about
|
sentence *"Sole-arbitrator appointments follow the Q.Arb designation;
|
||||||
what you cannot yet do makes the rest believable.
|
co-arbitration work is available now"* until 2026-08-26. **Both halves were
|
||||||
|
wrong and they were wrong in opposite directions**, which is why it survived
|
||||||
|
two audits: the first half understated (sole-arbitrator appointments are
|
||||||
|
offered **now** and are not gated by Q.Arb — §4 Offerings), and the second was
|
||||||
|
unsourced when written. §4 now carries rows for all three forms.
|
||||||
|
|
||||||
|
The shape of the bullet still stands, so here is a sentence that fits it and
|
||||||
|
clears the register: *"I accept sole, party-appointed and co-arbitration
|
||||||
|
appointments **in commercial matters**."* ⚠️ **THE SECOND SENTENCE IS STRUCK
|
||||||
|
(2026-08-29).** It read *"The Q.Arb **pathway** commenced in August 2026;
|
||||||
|
C.Med-Arb is the endpoint"*; Q.Arb is held, C.Med-Arb is off the site, and §4's
|
||||||
|
paired-disclosure condition that required a second sentence at all is
|
||||||
|
dissolved. **The limit this bullet names is now the SCOPE, not the stage** —
|
||||||
|
which is the durable half. **"in commercial matters" is not
|
||||||
|
optional** — every §4 Offerings arbitration row is scoped commercial, family
|
||||||
|
arbitration has its own NOT OFFERED row, and §4's NOT-NEGOTIABLE boundary
|
||||||
|
records the unscoped class form as the struck universal (Q39). This model
|
||||||
|
sentence was unscoped until 2026-08-28 while the shipped page it feeds was
|
||||||
|
scoped; found by `claims-auditor` on re-audit, one file over from the same
|
||||||
|
defect fixed in `docs/04` earlier the same day.
|
||||||
|
|
||||||
|
⚠️ **AND THE MEDIATION HALF IS THE OPPOSITE — Q56, ruled by Pouya 2026-08-30.
|
||||||
|
DO NOT MIRROR THIS SCOPE ONTO MEDIATION.** The scope above exists because of a
|
||||||
|
**legal gate**: family arbitration in Ontario requires prescribed training, so
|
||||||
|
"in commercial matters" is load-bearing there. **Mediation has no such gate,
|
||||||
|
§4's mediation row is unscoped on purpose.** **He mediates in all six published
|
||||||
|
practice areas** — construction, technology, energy, insurance, shareholder,
|
||||||
|
cross-cultural — and §4's row now enumerates them (`PRACTICE_AREAS` in
|
||||||
|
`src/data/site.ts`), each named under **Q35(c)'s subject-matter publication
|
||||||
|
gate**. ⚠️ **The six are the PUBLISHED areas, not the authorised
|
||||||
|
subject-matter list, and Q35(c)'s gate is not spent by having been used six
|
||||||
|
times.** A seventh may be named where it clears that gate.
|
||||||
|
|
||||||
|
*(Q56's ruling had named five, which was four of the six areas plus the word
|
||||||
|
"commercial", and this block called the list "illustrative of breadth, not a
|
||||||
|
closed set" for one day. Pouya struck the hedge on 2026-08-31: "the register
|
||||||
|
should match the site; 'illustrative' is a hedge covering a gap that has a
|
||||||
|
correct value." Technology and energy are the two that were missing. The
|
||||||
|
non-exhaustiveness clause was struck with the hedge in the same pass and
|
||||||
|
restored the same day — his ruling supplied a correct value, it did not close
|
||||||
|
the class.)*
|
||||||
|
|
||||||
|
The site-wide *"Commercial Mediation"* framing was **under-describing the
|
||||||
|
offering**, and it was corrected rather than ratified as positioning. The
|
||||||
|
asymmetry between the two halves is designed; a later editor tidying them into
|
||||||
|
a matching pair would be reintroducing the defect.
|
||||||
|
|
||||||
|
⚠️ **AND DO NOT TREAT ANY LIST OF THE CORRECTED SURFACES AS COMPLETE.** Q56
|
||||||
|
named four. The sweep that implemented it changed **thirteen shipped strings
|
||||||
|
across five files**, and `adversarial-reviewer` then found **three more it had
|
||||||
|
missed** — the worst on `/practice/`, where *"These describe the process the
|
||||||
|
parties are choosing between, in commercial matters"* scoped mediation with
|
||||||
|
the two words never appearing in the same element, so no proximity grep could
|
||||||
|
reach it. The command, and its limit:
|
||||||
|
|
||||||
|
```
|
||||||
|
git grep -nEi 'commercial[^.]{0,60}mediat|mediat[^.]{0,60}commercial' -- src/
|
||||||
|
```
|
||||||
|
|
||||||
|
That finds the **adjacent** form only. For the split form there is no pattern —
|
||||||
|
read every occurrence of `commercial` in `src/` and in `dist/`, and ask what
|
||||||
|
each one is scoping.
|
||||||
|
|
||||||
|
⚠️ **TEN LINES WERE DELETED HERE ON 2026-08-30, AND THE DELETION IS THE
|
||||||
|
POINT.** They read *"'Pathway', never 'designation'"*, cited the **struck**
|
||||||
|
Forbidden row as live, instructed that *"the limit being named is the stage of
|
||||||
|
the arc, stated plainly"*, and closed *"Precision about what you cannot yet do
|
||||||
|
makes the rest believable."* Every one of those directs an implementer to
|
||||||
|
write the form §4 now bars — **eleven lines below this bullet's own strike
|
||||||
|
notice**, so one bullet said both things. The Q25 shape, in the copy deck an
|
||||||
|
implementer is told to lift verbatim. **The 2026-08-29 sweep missed it because
|
||||||
|
`git grep -nE 'Q\.?Arb'` is line-anchored and not one of those ten lines
|
||||||
|
contained the string.** Found by `claims-auditor` on the round-2 audit.
|
||||||
|
(**No tribunal-secretary work** — D14 removed the rate and bars offering it;
|
||||||
|
see `docs/07-fees.md`.)
|
||||||
- Plain words over Latin. "Without prejudice" survives because it is a term of
|
- Plain words over Latin. "Without prejudice" survives because it is a term of
|
||||||
art; *inter alia* does not.
|
art; *inter alia* does not.
|
||||||
|
|
||||||
@@ -44,13 +159,18 @@ detect padding instantly and discount everything after it.
|
|||||||
**The approved phrasing is "active litigation exposure" or "involvement in
|
**The approved phrasing is "active litigation exposure" or "involvement in
|
||||||
litigation and ADR matters" — never "practice" in that context.** Pouya's
|
litigation and ADR matters" — never "practice" in that context.** Pouya's
|
||||||
wording, 2026-08-26. So: *Director of Firm Operations at a Toronto litigation
|
wording, 2026-08-26. So: *Director of Firm Operations at a Toronto litigation
|
||||||
and ADR boutique, with active exposure to construction, personal injury, POA,
|
and ADR boutique, with **active litigation exposure** across construction,
|
||||||
and SABS matters.* Accurate, specific, and it claims nothing it should not.
|
personal injury, POA, and SABS matters.* Accurate, specific, and it claims
|
||||||
|
nothing it should not. *(This example read "with active exposure to" until
|
||||||
|
2026-08-28 — a **third** variant of the phrasing, three lines below the
|
||||||
|
sentence naming the only two approved forms, in the one place where the wording
|
||||||
|
IS the compliance. Found by `claims-auditor` on re-audit.)*
|
||||||
|
|
||||||
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
|
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
|
||||||
rather than letting it settle in by default.
|
rather than letting it settle in by default.
|
||||||
- Superlatives. No "leading", "premier", "top-rated", "best". LSO marketing rules,
|
- Superlatives. No "leading", "premier", "top-rated", "best". They are
|
||||||
and they read as insecure.
|
unverifiable, they read as insecure, and marketing rules for regulated
|
||||||
|
professions treat them as suspect.
|
||||||
- Outcome language that could be read as a guarantee.
|
- Outcome language that could be read as a guarantee.
|
||||||
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
|
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
|
||||||
"leverage", "synergy", "solutions".
|
"leverage", "synergy", "solutions".
|
||||||
@@ -68,15 +188,34 @@ detect padding instantly and discount everything after it.
|
|||||||
Reused, adapted, across the hero, the About page, and the PDF bio:
|
Reused, adapted, across the hero, the About page, and the PDF bio:
|
||||||
|
|
||||||
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
|
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
|
||||||
> who is also a working litigator and a practising machine-learning and
|
> with active litigation exposure and a practising machine-learning and
|
||||||
> infrastructure engineer. Built for commercial, construction, energy,
|
> infrastructure engineer. Built for commercial, construction, energy,
|
||||||
> technology, and cross-cultural disputes that turn on facts most neutrals take
|
> technology, and cross-cultural disputes that turn on the contract, the code,
|
||||||
> on faith: the contract, the code, the engineering documents, and the
|
> the engineering documents, and the regulatory overlay around them.
|
||||||
> regulatory overlay around them.
|
|
||||||
|
|
||||||
Every version of this must survive the §4 check. It does: each element is
|
Every version of this must survive the §4 check. It does: each element is
|
||||||
verified.
|
verified.
|
||||||
|
|
||||||
|
**AMENDED 2026-08-27 — `AGENTS.md` Q41(b). The statement read *"disputes that
|
||||||
|
turn on facts most neutrals take on faith: the contract…"* and the comparative
|
||||||
|
is struck.** It was not restored, and Pouya gave two reasons, the second of
|
||||||
|
which is the one to remember:
|
||||||
|
|
||||||
|
> "That is an unverifiable empirical claim about other practitioners, and
|
||||||
|
> comparative claims must be factual and verifiable. **It is also weaker copy:
|
||||||
|
> assert his capability, not the field's incapability.** Rewrite to claim only
|
||||||
|
> about himself — 'built for disputes that turn on the contract, the code, and
|
||||||
|
> the engineering documents'. Same force, nothing to defend."
|
||||||
|
|
||||||
|
So the compliance objection and the editorial objection point the same way.
|
||||||
|
The checklist item below — *"any comparative claim is factual and verifiable"* —
|
||||||
|
had been overridden in practice by the fact that this paragraph was **approved
|
||||||
|
copy**, which is how an unverifiable claim ends up inside the document that
|
||||||
|
forbids it. The approved copy is what changed.
|
||||||
|
|
||||||
|
**This is now the ONLY sanctioned form of the statement.** Any earlier draft
|
||||||
|
carrying the comparative is superseded, wherever it is quoted.
|
||||||
|
|
||||||
## Approved headline options
|
## Approved headline options
|
||||||
|
|
||||||
From the content brief; all three sit honestly with the practice.
|
From the content brief; all three sit honestly with the practice.
|
||||||
@@ -84,7 +223,12 @@ From the content brief; all three sit honestly with the practice.
|
|||||||
1. *A mediator who reads the contract, the code, and the room.* — **recommended.**
|
1. *A mediator who reads the contract, the code, and the room.* — **recommended.**
|
||||||
The cleanest one-sentence statement of the moat, and rare because it is rare.
|
The cleanest one-sentence statement of the moat, and rare because it is rare.
|
||||||
2. *Engineered for the cases that don't fit a courtroom.*
|
2. *Engineered for the cases that don't fit a courtroom.*
|
||||||
3. *Disputes resolved by someone who has been on every side of one.*
|
3. ~~*Disputes resolved by someone who has been on every side of one.*~~
|
||||||
|
**Does not clear §4 as written** (flagged 2026-08-26). "Every side" asserts
|
||||||
|
having acted as party, as counsel, and as neutral; §4 verifies the neutral
|
||||||
|
role and *active litigation exposure*, not the other two. Left in place so
|
||||||
|
the option is not silently re-invented — but it cannot be chosen without a §4
|
||||||
|
row to choose it from.
|
||||||
|
|
||||||
## The credential row
|
## The credential row
|
||||||
|
|
||||||
@@ -93,10 +237,60 @@ Three slots, never counts:
|
|||||||
| Slot | Value | Label |
|
| Slot | Value | Label |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **Q.Med** | ADRIC / ADRIO designation |
|
| 1 | **Q.Med** | ADRIC / ADRIO designation |
|
||||||
| 2 | **JD + ML** | Law and engineering |
|
| 2 | **JD + ML** | Legal training and engineering practice |
|
||||||
| 3 | **EN · FA** | Bilingual practice |
|
| 3 | **EN · FA** | Bilingual practice |
|
||||||
|
|
||||||
Fourth slot where the layout has one: **Q.Arb** — in progress.
|
**Slot 2's label changed on 2026-08-27 (Pouya, `AGENTS.md` Q37).** It read
|
||||||
|
*"Law and engineering"*. His reasoning: *"The parallel was doing the implying — a
|
||||||
|
degree and a practice under one noun. The asymmetry is the honest part."* A JD is
|
||||||
|
a degree; engineering is a practice, and a verified one. Setting them in parallel
|
||||||
|
invited the reader to supply the symmetry, and for "Law" the missing half is a
|
||||||
|
licence — which D13 bars by implication as hard as by assertion. The replacement
|
||||||
|
is longer and deliberately lopsided. Do not tidy it back into a parallel.
|
||||||
|
|
||||||
|
**AND THE RULE IS NOT SCOPED TO THIS LABEL — `AGENTS.md` Q41(a), Pouya
|
||||||
|
2026-08-27.** Q37 was asked about a credential label; the reasoning applies to
|
||||||
|
every surface, prose included:
|
||||||
|
|
||||||
|
> "Yes, Q37's reasoning reaches prose. The implication test applies everywhere,
|
||||||
|
> not just to labels. **Prose has more room, so it is easier to satisfy: state
|
||||||
|
> the asymmetry explicitly rather than relying on a parallel construction to
|
||||||
|
> carry it.**"
|
||||||
|
|
||||||
|
Read the second half carefully, because it sets a **higher** bar for prose, not
|
||||||
|
a looser one. Deleting the parallel is only half the job — a reader can still
|
||||||
|
supply the missing symmetry from silence. Prose has room to say which half is
|
||||||
|
which, so it must. Naming the legal half as **training** is what makes the licence
|
||||||
|
implication impossible rather than merely absent.
|
||||||
|
|
||||||
|
**THE SENTENCE IS A CONSTANT, NOT COPY TO BE RETYPED** — `ASYMMETRY_LINE` in
|
||||||
|
`src/data/site.ts`:
|
||||||
|
|
||||||
|
> "The two halves are not the same kind of thing, and the asymmetry is the honest
|
||||||
|
> part. A law degree on one side. A working engineering practice on the other.
|
||||||
|
> One is training I hold. The other is work I still do."
|
||||||
|
|
||||||
|
It lives beside `ROLE` for the reason that file gives: *"these are the two where
|
||||||
|
the wording IS the compliance."* This paragraph previously quoted it inline and
|
||||||
|
cited it as living at "`/`, §The approach" — and by then it had been typed into
|
||||||
|
`/about/` too, so there were **three copies and two of them had already
|
||||||
|
diverged**: a comma here and on `/`, full stops on `/about/`, all inside the
|
||||||
|
session that wrote them. Consume the constant on any page that needs the
|
||||||
|
sentence. Do not retype it, do not paraphrase it, and do not quote a variant of
|
||||||
|
it in a spec.
|
||||||
|
|
||||||
|
Fourth slot where the layout has one: **`Q.Arb` over `ADRIC / ADRIO designation`**
|
||||||
|
— the label is byte-identical to the Q.Med slot's, because the two render side by
|
||||||
|
side and any difference reads as a distinction being drawn. ⚠️ **AMENDED 2026-08-29.** It read *"Q.Arb — commenced August
|
||||||
|
2026"*, and every stage form — "commenced", "in progress", "pathway", "not yet" —
|
||||||
|
is now barred: Q.Arb is **held** (`AGENTS.md` §4), so a stage word understates a
|
||||||
|
held credential. **The acquisition date is recorded in §4 and is not published.**
|
||||||
|
|
||||||
|
**The fourth slot is no longer MANDATORY anywhere.** It was, on `/`, under §4's
|
||||||
|
paired-disclosure condition — that page says *arbitrator* in its opening
|
||||||
|
sentence and the stage had to appear beside the offering. The condition is
|
||||||
|
dissolved. `/` keeps the slot because §4's substitution principle wants a fourth
|
||||||
|
credential where the layout has one, not because anything requires it.
|
||||||
|
|
||||||
The substitution principle (`AGENTS.md` §4): wherever the design wants a "how
|
The substitution principle (`AGENTS.md` §4): wherever the design wants a "how
|
||||||
many", substitute a longer-arc credential. These are all true at launch and stay
|
many", substitute a longer-arc credential. These are all true at launch and stay
|
||||||
@@ -113,21 +307,44 @@ Hero headline from the approved list. Positioning paragraph above. CTAs:
|
|||||||
dispute — and keeps the infinity metaphor: *disputes are loops; the work is
|
dispute — and keeps the infinity metaphor: *disputes are loops; the work is
|
||||||
redrawing the loop into a line.* First person: "my mark", not "our mark".
|
redrawing the loop into a line.* First person: "my mark", not "our mark".
|
||||||
|
|
||||||
|
> ⚠️ **"law and engineering" IS THE STRUCK CONSTRUCTION. Do not lift this
|
||||||
|
> sentence into copy.** The *argument* it names is Pouya's and stands; the noun
|
||||||
|
> pair carrying it is what Q37 struck and Q41(a) extended to prose. It reached
|
||||||
|
> the page once already, as *"Law and engineering are not blended here"* — the
|
||||||
|
> struck parallel relocated from the credential label into body copy, one day
|
||||||
|
> after it was struck, and strengthened by attributing both halves to him
|
||||||
|
> personally. A spec phrase describing an argument is not approved copy.
|
||||||
|
|
||||||
### About
|
### About
|
||||||
400–600 words of narrative, then structured credentials. Tell the three tracks
|
400–600 words of narrative, then structured credentials. Tell the three tracks
|
||||||
as one arc, not three lists: a JD and an active litigation practice; a parallel
|
as one arc, not three lists: a JD and active litigation exposure; a parallel
|
||||||
career in machine learning and infrastructure engineering; a company run
|
career in machine learning and infrastructure engineering; a company run
|
||||||
alongside both. The arc is the point — the credentialing pathway from Q.Med
|
alongside the practice — §4's wording; "alongside both" was a concurrency claim
|
||||||
through Q.Arb to C.Med-Arb is stated openly as in progress. The brief treats
|
the register does not make.
|
||||||
that arc as part of the story rather than something to obscure.
|
|
||||||
|
⚠️ **THE CREDENTIALING ARC IS STRUCK (2026-08-29, Pouya), AND SO IS `/about/`'s
|
||||||
|
ARC SECTION.** This paragraph required *"the credentialing pathway from Q.Med
|
||||||
|
through Q.Arb to C.Med-Arb… stated openly, with Q.Arb described as commenced
|
||||||
|
August 2026"*. Q.Arb is held; C.Med-Arb is off the site; there is no pathway.
|
||||||
|
**The three-track story stays** — law, engineering, a company — because that arc
|
||||||
|
is about his working life, not about a credential. Only the credentialing arc
|
||||||
|
goes.
|
||||||
|
|
||||||
|
The designation names expand as **Qualified Mediator** and **Qualified
|
||||||
|
Arbitrator** — ADRIO's own forms, sourced in
|
||||||
|
`docs/reference/adrio-designations.md`. *(**Chartered Med-Arbitrator** was here
|
||||||
|
too; the designation is real and stays in `AGENTS.md` §11 as a definition, but
|
||||||
|
nothing on the site names it.)* Never "Chartered Mediator-Arbitrator".
|
||||||
|
|
||||||
Omit any section that would be empty. No "Speaking" heading until there is a
|
Omit any section that would be empty. No "Speaking" heading until there is a
|
||||||
talk to list.
|
talk to list.
|
||||||
|
|
||||||
### Mediation / Arbitration / Med-Arb
|
### Mediation / Arbitration / Med-Arb
|
||||||
Procedural, specific, unembellished. Name the rules. Describe the formats. State
|
Procedural, specific, unembellished. Name the rules. Describe the formats. State
|
||||||
what a party should expect to do and when. On `/arbitration/`, state the Q.Arb
|
what a party should expect to do and when. *(This closed with "On
|
||||||
position in plain terms — what is available now versus what follows designation.
|
`/arbitration/`, state the Q.Arb position in plain terms — what is available now
|
||||||
|
versus what follows designation." Struck 2026-08-29: Q.Arb is held, and nothing
|
||||||
|
follows designation.)*
|
||||||
|
|
||||||
`/med-arb/` addresses the procedural-fairness objection directly: the same
|
`/med-arb/` addresses the procedural-fairness objection directly: the same
|
||||||
neutral who heard a party's confidential caucus later decides the matter. Do not
|
neutral who heard a party's confidential caucus later decides the matter. Do not
|
||||||
@@ -137,8 +354,38 @@ Meeting the strongest objection is what makes the page worth reading.
|
|||||||
### Practice areas
|
### Practice areas
|
||||||
Each page: dispute types, why this practice fits, what the process looks like,
|
Each page: dispute types, why this practice fits, what the process looks like,
|
||||||
and the market context that makes the area live. Context comes from the strategy
|
and the market context that makes the area live. Context comes from the strategy
|
||||||
brief §III–IV — Ontario's megaproject pipeline, Bill 40 and grid connection, the
|
brief §III–IV — Ontario's megaproject pipeline, Bill 40 and grid connection,
|
||||||
2026 privacy statute, LAT volumes.
|
~~the 2026 privacy statute~~, LAT volumes.
|
||||||
|
|
||||||
|
> ⚠️ **"THE 2026 PRIVACY STATUTE" DOES NOT EXIST. Struck rather than corrected
|
||||||
|
> in place, so the phrase is not re-invented. Checked 2026-08-29; sourced in
|
||||||
|
> `docs/reference/canada-privacy-technology.md`.**
|
||||||
|
>
|
||||||
|
> **Nothing enacted federally or in Ontario in 2025 or 2026 is a privacy
|
||||||
|
> statute.** Bill C-27 — which would have enacted the Consumer Privacy
|
||||||
|
> Protection Act and the Artificial Intelligence and Data Act — **died without
|
||||||
|
> royal assent** when the 44th Parliament's first session ended, and was never
|
||||||
|
> reinstated. **PIPEDA remains the operative federal private-sector statute**,
|
||||||
|
> and **Canada has no federal AI statute.** The nearest real thing is federal
|
||||||
|
> **Bill C-36 (45-1)**, introduced 15 June 2026, which *would* enact the
|
||||||
|
> Protecting Privacy and Consumer Data Act — a bill, at second reading, not law.
|
||||||
|
>
|
||||||
|
> **Caught before it reached a page, and only because the phrase was checked
|
||||||
|
> rather than trusted.** Same failure mode as `docs/01`'s "Model Mediation
|
||||||
|
> Rules" and the LAT gloss corrected the same day: a spec naming an instrument
|
||||||
|
> from recall, and an implementer with no reason to doubt it. **Bill 40 in the
|
||||||
|
> same sentence turned out to be real** — Bill 40 of the 44th Parliament, 1st
|
||||||
|
> Session, the *Protect Ontario by Securing Affordable Energy for Generations
|
||||||
|
> Act, 2025* — but the number alone did not establish that, because bill numbers
|
||||||
|
> are reused every parliament. Cite the parliament and session.
|
||||||
|
>
|
||||||
|
> **What `/practice/technology/` publishes instead** is the real position, and
|
||||||
|
> it is better copy than the invented one: what is in force, what died, what is
|
||||||
|
> only a bill — and the genuinely useful part for a commercial audience, that
|
||||||
|
> **no Canadian statute requires personal data to be stored in Canada.** That is
|
||||||
|
> the assumption a great many data-residency clauses are drafted against, and it
|
||||||
|
> is quoted from the Privacy Commissioner's own guidance rather than concluded
|
||||||
|
> here.
|
||||||
|
|
||||||
**Frame as positioning, not as history.** "Built to facilitate procurement and
|
**Frame as positioning, not as history.** "Built to facilitate procurement and
|
||||||
subcontract disputes on Ontario's megaproject pipeline" — not "extensive
|
subcontract disputes on Ontario's megaproject pipeline" — not "extensive
|
||||||
@@ -149,15 +396,53 @@ neither.
|
|||||||
Five steps with real timing. Say what happens if the matter does not settle —
|
Five steps with real timing. Say what happens if the matter does not settle —
|
||||||
counsel want to know the downside shape before they commit a client's day.
|
counsel want to know the downside shape before they commit a client's day.
|
||||||
|
|
||||||
|
**AMENDED 2026-08-27 — `AGENTS.md` Q43, and it overrides this section's previous
|
||||||
|
reading.** "Real timing" was being read as *barring* the word "typical", which
|
||||||
|
is why the step-2 build shipped the five timings as bare numbers and escalated
|
||||||
|
the question instead of framing them. Pouya ruled the other way:
|
||||||
|
|
||||||
|
> "The five process timings are **service commitments, same class as Q27's
|
||||||
|
> response time** — not facts about Pouya, so they need framing, not a Verified
|
||||||
|
> row. Present them as the TYPICAL shape of an engagement, explicitly not a
|
||||||
|
> guarantee: mediation timing depends on party and counsel availability, which
|
||||||
|
> he does not control. **Published as typical, they are honest and useful;
|
||||||
|
> published as commitments, the first matter that slips makes the page false.**"
|
||||||
|
|
||||||
|
So: the **numbers do not change** — softening them was never the fix and
|
||||||
|
inventing them was never on. What "real timing" bars is a *vague* timing
|
||||||
|
("promptly", "in a matter of weeks"), not an honest statement of what the
|
||||||
|
numbers are. The framing is `PROCESS_FRAMING` in `src/data/site.ts` and it is
|
||||||
|
**not optional**: every page that renders the steps renders it, adjacent to the
|
||||||
|
numbers rather than in a section lede above them. A reader who scans the strip
|
||||||
|
and skips the lede has read a commitment.
|
||||||
|
|
||||||
### Fees
|
### Fees
|
||||||
**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from"
|
**Unblocked — Q4/Q14 answered, D14.** Build from the confirmed card in
|
||||||
|
`docs/07-fees.md`. Plain table, no "starting from"
|
||||||
evasions, no "contact for pricing" after promising a rate card.
|
evasions, no "contact for pricing" after promising a rate card.
|
||||||
|
|
||||||
### For parties
|
### For parties
|
||||||
The one page in second person. Grade-9 reading level. Short sentences. Says
|
The one page in second person. Grade-9 reading level. Short sentences. Says
|
||||||
explicitly: the mediator is not your lawyer and cannot give you legal advice; the
|
explicitly: **the mediator is a neutral and does not act for either party; each
|
||||||
mediator does not decide who is right. Answers what it costs and what happens if
|
party should have their own legal advice;** and the mediator does not decide who
|
||||||
you do not settle.
|
is right. Answers what it costs and what happens if you do not settle.
|
||||||
|
|
||||||
|
> ⚠️ **THIS DIRECTIVE READ *"the mediator is not your lawyer and cannot give you
|
||||||
|
> legal advice"* UNTIL 2026-08-28, AND IT BREACHED D13 TWICE** — in the file that
|
||||||
|
> warns, fifty lines above, that `/for-parties/` is *"the exact place the
|
||||||
|
> 'cannot' phrasing feels most natural and is most wrong"*.
|
||||||
|
>
|
||||||
|
> 1. **"not your lawyer" presupposes lawyer status.** §4 Forbidden bars *the word
|
||||||
|
> "lawyer" used of Pouya*, and D13 treats implication as hard as assertion. A
|
||||||
|
> negation still carries the presupposition.
|
||||||
|
> 2. **"cannot give you legal advice" is attempt 2** in this file's own ratified
|
||||||
|
> table — *"a **denial** of capacity"* — and it fails test 3 and the
|
||||||
|
> compliance-checklist item, both added to this file in the same change set
|
||||||
|
> that left this line untouched.
|
||||||
|
>
|
||||||
|
> The replacement is the ratified pattern applied verbatim: **role, then
|
||||||
|
> consequence for the reader, and no verb of capacity.** Found by
|
||||||
|
> `claims-auditor` on re-audit; the original sweep did not reach this section.
|
||||||
|
|
||||||
### Insights
|
### Insights
|
||||||
1,200–1,800 words, monthly cadence (brief §VIII). Territories from §VII:
|
1,200–1,800 words, monthly cadence (brief §VIII). Territories from §VII:
|
||||||
@@ -186,13 +471,95 @@ Drafted by Claude, **every word reviewed by Pouya before publication**:
|
|||||||
|
|
||||||
## Compliance checklist — before any page ships
|
## Compliance checklist — before any page ships
|
||||||
|
|
||||||
|
⚠️ **EVERY ITEM HERE THAT BARS SOMETHING CITES ITS `AGENTS.md` §4 ROW. IT DOES
|
||||||
|
NOT RESTATE IT — STRUCTURAL FIX, Pouya, 2026-08-31, after the third instance.**
|
||||||
|
|
||||||
|
His ruling: *"the checklist must CITE the §4 row, not restate it. Same
|
||||||
|
single-source rule as §7 and operational facts."* **This file is what gets
|
||||||
|
grepped; §4 is what is correct.** Three times a line here paraphrased a §4 row,
|
||||||
|
dropped a qualifier, and then flagged the copy a spec **requires** — *"no dollar
|
||||||
|
figures"* for §4's *"attached to **past matters**"*, which forbade the rate card
|
||||||
|
D8 commits to; *"no testimonials"* for §4's bar on endorsements **of the practice
|
||||||
|
or of Pouya**, which forbade the institutional quotations `docs/01` directs; and
|
||||||
|
the licence-capacity item, which rejected the exact sentence the section above
|
||||||
|
exists to bless. Each was caught by review, never by the checklist, and the row
|
||||||
|
was never wrong.
|
||||||
|
|
||||||
|
**So an item below names what to look for on the page, and which row decides it.
|
||||||
|
Not both halves of the rule.**
|
||||||
|
|
||||||
- [ ] Every factual claim appears in `AGENTS.md` §4 Verified
|
- [ ] Every factual claim appears in `AGENTS.md` §4 Verified
|
||||||
- [ ] No matter counts, settlement rates, dollar figures, or time-to-award stats
|
- [ ] **Numbers that describe the practice** — any count, rate, percentage, time
|
||||||
- [ ] No testimonials, endorsements, or third-party quotes
|
or dollar figure about matters, hours, years, or outcomes. Decided by §4
|
||||||
- [ ] No superlatives and no guarantee language
|
Forbidden's *counts of matters closed / hours mediated / years in ADR
|
||||||
- [ ] No claim or implication of legal licensure anywhere (D13)
|
practice*, *settlement rates, resolution percentages, median time to
|
||||||
- [ ] Q.Arb described as commenced August 2026, never as held or nearly complete
|
award*, *aggregate value resolved; any dollar figure attached to past
|
||||||
- [ ] Nothing implies a firm, a team, or offices that do not exist
|
matters*, and *named or describable past matters*. `npm run check:claims`
|
||||||
|
`counts-and-tenure` sweeps `dist/`. ⚠️ **The rate card is a different
|
||||||
|
thing and no row reaches it:** D8 commits to publishing it in full, D14
|
||||||
|
confirms the figures `[verified 2026-08-26 — Pouya]`, and `docs/01`
|
||||||
|
requires `/for-parties/` to answer *"what it costs"*. A published **price**
|
||||||
|
is an offer; a published **statistic about past matters** is what the old
|
||||||
|
site fabricated
|
||||||
|
- [ ] **Third-party voices** — decided by §4 Forbidden's *testimonials,
|
||||||
|
endorsements, quotes from counterparties*. ⚠️ **Scope is the whole
|
||||||
|
question:** that row reaches a third party praising the practice or Pouya.
|
||||||
|
It does not reach an institution quoted **about its own rules**, from a
|
||||||
|
committed source — which `docs/01` §`/med-arb/` **directs**, and which
|
||||||
|
`/mediation/`, `/arbitration/` and `/process/` all do. **Keep the source's
|
||||||
|
superlatives inside the quotation marks**
|
||||||
|
- [ ] **Superlatives and guarantee language** — decided by §4 Forbidden's
|
||||||
|
*guarantees of outcome, or superlatives*. `check:claims` `superlatives`
|
||||||
|
sweeps `dist/`
|
||||||
|
- [ ] **Licensure, asserted or implied** — decided by §4 Forbidden's two
|
||||||
|
licensure rows and D13. The second of the two is the one that catches
|
||||||
|
copy: it reaches phrasing that *implies* entitlement without saying so.
|
||||||
|
`check:claims` `licensure-of-pouya` and `acting-for-a-party` sweep `dist/`
|
||||||
|
- [ ] **The licence-capacity question answered in EITHER direction** — see *When
|
||||||
|
a fact is `[unestablished]`* above, and §4's own note that its two
|
||||||
|
licensure rows are verified **directives not to publish**, not a verified
|
||||||
|
status. Check for *cannot*, *do not*, *am not*, *not permitted*, *not
|
||||||
|
qualified* **attached to giving legal advice, practising law, or holding a
|
||||||
|
licence.** ⚠️ **The objection is to answering the capacity question, not
|
||||||
|
to the words themselves** — the approved sentence contains "do not" and
|
||||||
|
passes: *"I act as a neutral. I do not act for a party in a matter I take,
|
||||||
|
and each party should have their own legal advice."* That is **role
|
||||||
|
conduct**, which he may state freely. `check:claims` `capacity-phrasing`
|
||||||
|
sweeps `dist/`
|
||||||
|
- [ ] **Q.Arb** — decided by §4's Q.Arb Verified row and its Forbidden row. The
|
||||||
|
publishable form is `Q.Arb (ADRIC / ADRIO)`; the acquisition date is
|
||||||
|
recorded in §4 and is not published. `check:claims` `q-arb-as-a-stage`
|
||||||
|
enforces the stage words and a date near the designation, on `dist/`.
|
||||||
|
⚠️ It cannot catch a stage expressed **without naming the designation** —
|
||||||
|
the struck h1 *"Available now, and open about the stage"* matches nothing —
|
||||||
|
and that gap belongs to the cutover claims pass, not to this checklist
|
||||||
|
- [ ] **C.Med-Arb appears nowhere** — decided by §4's struck *C.Med-Arb as a
|
||||||
|
long-term designation goal* row, which carries the reasoning and the
|
||||||
|
deliberate deviation from the strategy brief. `check:claims`
|
||||||
|
`c-med-arb-struck` sweeps `dist/`
|
||||||
|
- [ ] **A class statement about what Ontario law does or does not gate behind an
|
||||||
|
arbitral designation** — decided by §4 Forbidden's *"Anyone may be appointed
|
||||||
|
an arbitrator in Ontario" / "nothing in law gates the role"* row, which
|
||||||
|
carries the committed source and the reason the scoped replacement is
|
||||||
|
**attributed to Pouya rather than stamped**. It bars the claim in **both**
|
||||||
|
directions; this repository does not conclude a proposition of law.
|
||||||
|
`check:claims` `struck-universal-q39` sweeps `dist/`. *(Added 2026-08-31,
|
||||||
|
when Pouya ruled the §4 row into existence: this line could not exist before
|
||||||
|
it, because an item here cites a row rather than restating a bar.)*
|
||||||
|
- [ ] **A firm, a team, or offices that do not exist** — §4 Forbidden rows the
|
||||||
|
specific false artefacts (*"Since 2009"*, *"sixteen years"*, the London
|
||||||
|
and New York offices, the company number, and the fictitious founder);
|
||||||
|
the general rule is **D16** — the boutique is never named, and the
|
||||||
|
publishable form is *Director of Firm Operations at a Toronto litigation
|
||||||
|
and ADR boutique*. `check:claims` `fabricated-founder` sweeps `dist/`
|
||||||
- [ ] Contact page states that an inquiry creates no retainer and no
|
- [ ] Contact page states that an inquiry creates no retainer and no
|
||||||
mediator–party relationship
|
mediator–party relationship
|
||||||
- [ ] Any comparative claim is factual and verifiable
|
- [ ] Any comparative claim is factual and verifiable — **Q41(b), closed
|
||||||
|
2026-08-27**, which struck a comparative claim about other neutrals from
|
||||||
|
this file's own positioning statement. Pouya: *"comparative claims must be
|
||||||
|
factual and verifiable… assert his capability, not the field's
|
||||||
|
incapability."*
|
||||||
|
- [ ] Abbreviations expanded on §11 Glossary's authority only — and expansions
|
||||||
|
for the five ADR designations checked against
|
||||||
|
`docs/reference/adrio-designations.md`, not from memory. "Chartered
|
||||||
|
**Med**-Arbitrator", never "Mediator-Arbitrator"
|
||||||
|
|||||||
+160
-26
@@ -1,7 +1,14 @@
|
|||||||
# 04 — Discoverability
|
# 04 — Discoverability
|
||||||
|
|
||||||
The problem this project exists to fix. `AGENTS.md` §2 has the measurements: a
|
The problem this project exists to fix. `AGENTS.md` §2 has the measurements.
|
||||||
server-side fetch of the live site returns three words.
|
|
||||||
|
> **Those measurements are under review — `AGENTS.md` Q34.** They were taken on
|
||||||
|
> 2026-08-25; a re-fetch on 2026-08-26 returned a bundler harness whose real
|
||||||
|
> `<head>` sits JSON-escaped inside a `<script>` and whose application lives in
|
||||||
|
> nine UUID-named files that were not fetched. Some of §2 reproduced exactly
|
||||||
|
> (the 2.2 MB single file, the placeholder `<title>`); some could not be
|
||||||
|
> reproduced from the served HTML at all. **Cite §2, and cite Q34 with it. Do
|
||||||
|
> not put any of these figures in public copy until Q34 closes.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,13 +17,13 @@ server-side fetch of the live site returns three words.
|
|||||||
| | Now `[verified 2026-08-25]` | Target |
|
| | Now `[verified 2026-08-25]` | Target |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
|
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
|
||||||
| Indexable pages | 1 | 19 + articles |
|
| Indexable pages | 1 | 17 + articles (19 fixed URLs, less the two `/legal/*` pages, which are `noindex` and excluded from the sitemap) |
|
||||||
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
|
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
|
||||||
| Meta description | none | Unique per page |
|
| Meta description | none | Unique per page |
|
||||||
| `<meta viewport>` | **absent** | Present |
|
| `<meta viewport>` | **absent** | Present |
|
||||||
| Canonical URL | none | Every page |
|
| Canonical URL | none | Every page |
|
||||||
| OG / Twitter tags | none | Every page |
|
| OG / Twitter tags | none | Every page |
|
||||||
| Structured data | none | Person, LegalService, Article, FAQ, Breadcrumb |
|
| Structured data | none | Person, ProfessionalService, Article, FAQ, Breadcrumb |
|
||||||
| `robots.txt` | 403 | Served |
|
| `robots.txt` | 403 | Served |
|
||||||
| Sitemap | none | Generated at build |
|
| Sitemap | none | Generated at build |
|
||||||
| Favicon | none | Full set |
|
| Favicon | none | Full set |
|
||||||
@@ -41,8 +48,21 @@ D1.
|
|||||||
Every page passes through one `SEO` component. A page without it is not finished.
|
Every page passes through one `SEO` component. A page without it is not finished.
|
||||||
|
|
||||||
```
|
```
|
||||||
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
|
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi".
|
||||||
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
|
`/` composes its own from the constants — `SITE.name` +
|
||||||
|
`SITE.tagline` — rather than a literal, so the masthead and
|
||||||
|
the title cannot drift. This spec carried the literal with an
|
||||||
|
ampersand after the composition shipped with interpuncts;
|
||||||
|
cite the constants, do not restate them (AGENTS.md §7 rule).
|
||||||
|
ARTICLES ARE THE EXCEPTION: no " · Pouya Lajevardi" suffix.
|
||||||
|
The suffix is 18 chars, so a headline that already reads
|
||||||
|
50–60 renders at 68–78 — over this ceiling. Measured against
|
||||||
|
the five launch headlines in 03-content-spec.md, the suffix
|
||||||
|
rule fails 5 of 5; without it, 4 of 5 pass. An article's
|
||||||
|
headline IS its <title>; `seoTitle` in the frontmatter
|
||||||
|
overrides it when a headline that reads well is out of range.
|
||||||
|
src/content.config.ts enforces this and names the offending
|
||||||
|
string and its length in the build error.
|
||||||
description 140–160 chars, unique, written for a human, not stuffed
|
description 140–160 chars, unique, written for a human, not stuffed
|
||||||
canonical absolute, https, trailing slash
|
canonical absolute, https, trailing slash
|
||||||
og:title/description/image/url/type/site_name/locale (en_CA)
|
og:title/description/image/url/type/site_name/locale (en_CA)
|
||||||
@@ -50,9 +70,56 @@ twitter:card summary_large_image
|
|||||||
robots index,follow — except /legal/* which is noindex,follow
|
robots index,follow — except /legal/* which is noindex,follow
|
||||||
```
|
```
|
||||||
|
|
||||||
**OG images:** 1200 × 630. Generate at build with `satori` or `astro-og-canvas`
|
**OG images:** 1200 × 630. Never a screenshot.
|
||||||
using the site's own type and palette. One template: display headline on cream,
|
|
||||||
infinity mark, designation line. Never a screenshot.
|
**RULED 2026-08-27 (`AGENTS.md` Q40, R15) — TWO kinds of card, not one, and the
|
||||||
|
generator is deferred to build step 7.** This spec said "one template" for all
|
||||||
|
nineteen pages. Pouya split it:
|
||||||
|
|
||||||
|
> "A portrait is the **right** OG image for `/` and `/about/` — a face is the
|
||||||
|
> strongest social preview for a personal brand. It is the **wrong** one for
|
||||||
|
> nineteen pages, where a typed card carrying the page title would do the work.
|
||||||
|
>
|
||||||
|
> But do not build the generator now and do not leave 'portrait everywhere' as
|
||||||
|
> an untracked interim. **Ship it at step 7 alongside Insights, which needs
|
||||||
|
> per-article cards anyway — one build, one dependency, one review.**"
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
| Pages | Card |
|
||||||
|
|---|---|
|
||||||
|
| `/` 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 |
|
||||||
|
|
||||||
|
✅ **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
|
## Structured data
|
||||||
|
|
||||||
@@ -60,28 +127,55 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
|
|||||||
|
|
||||||
| Type | Where | Notes |
|
| Type | Where | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn — **Q12**), `image`, `worksFor` |
|
| `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` |
|
||||||
| `LegalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real |
|
| `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` | Each practice page | `serviceType`, `provider` → Person, `areaServed` |
|
| `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` |
|
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
|
||||||
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
|
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
|
||||||
| `FAQPage` | `/for-parties/`, `/med-arb/` | Only where the visible page genuinely is Q&A. Never fabricate questions to farm a rich result |
|
| `FAQPage` | `/for-parties/`, `/med-arb/` | Only where the visible page genuinely is Q&A. Never fabricate questions to farm a rich result |
|
||||||
|
|
||||||
**`hasCredential` must reflect reality.** Q.Med is held. Q.Arb is not. Marking an
|
**`hasCredential` must reflect reality.** ⚠️ **Q.Med AND Q.Arb are both held as
|
||||||
unheld credential as held in structured data is a misrepresentation that happens
|
of 2026-08-29** (`AGENTS.md` §4) and the field carries both — it was Q.Med-only
|
||||||
to be machine-readable.
|
while Q.Arb was a commenced pathway. The rule is unchanged and cuts both ways:
|
||||||
|
marking an unheld credential as held is a misrepresentation that happens to be
|
||||||
|
machine-readable, and **omitting a held one understates the record in a field
|
||||||
|
whose whole meaning is "holds"**. `personNode` maps `CREDENTIALS.designations`
|
||||||
|
rather than indexing it, so a designation added to that constant reaches the
|
||||||
|
graph automatically. **That links the CONSTANT to the graph, not §4 to the
|
||||||
|
graph** — a designation added to §4 and not to `src/data/site.ts` still drifts
|
||||||
|
silently, and no mechanism catches it. `src/data/schema.ts` states the condition
|
||||||
|
in full; this sentence overstated it for one pass.
|
||||||
|
|
||||||
## Crawlability
|
## Crawlability
|
||||||
|
|
||||||
**`public/robots.txt`:**
|
**`public/robots.txt` is the artefact — read it, do not read a copy of it
|
||||||
|
here.** This spec used to reproduce the file inline and the reproduction had
|
||||||
|
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
|
||||||
|
§7 rule exists to stop.
|
||||||
|
|
||||||
```
|
**It disallows exactly one path, and everything about that exception is in the
|
||||||
User-agent: *
|
file.** ⚠️ **THIS READ "It disallows nothing, and that is deliberate" UNTIL
|
||||||
Allow: /
|
2026-09-04.** `Disallow: /pouya-lajevardi-bio.pdf` was added that day as the
|
||||||
Disallow: /legal/
|
stand-in for `X-Robots-Tag: noindex` on `*.pdf`, which needs a CloudFront
|
||||||
|
response-headers policy the distribution's pricing plan forbids (`AGENTS.md` §7).
|
||||||
|
|
||||||
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
⚠️ **IT IS A SUBSTITUTE, NOT AN EQUIVALENT, AND THE RULE BELOW IS WHY.** It stops
|
||||||
```
|
the PDF being *fetched* — so its contents are never indexed and the
|
||||||
|
duplicate-of-`/bio/` problem is solved — but it does **not** de-index the URL,
|
||||||
|
and the PDF is linked from `/bio/` and `/about/`, so a bare listing remains
|
||||||
|
possible. That residual is accepted deliberately. **The rule below is unchanged
|
||||||
|
and this is its exception, not its repeal.**
|
||||||
|
|
||||||
|
The general rule: this spec previously
|
||||||
|
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
|
||||||
|
cancel each other: a crawler forbidden to *fetch* a URL never reads the
|
||||||
|
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
|
||||||
|
footer of every page, so they are discovered regardless — and the likely result
|
||||||
|
of the pair was Google listing the bare URLs as "no information available", the
|
||||||
|
opposite of the intent, with the directive that would have suppressed them
|
||||||
|
sitting unread behind the wall. **`noindex` is what de-indexes; `Disallow` is
|
||||||
|
what prevents fetching.** Use the one that matches the problem, and never both
|
||||||
|
on the same path.
|
||||||
|
|
||||||
Do not block AI crawlers. Being read by an assistant that a general counsel is
|
Do not block AI crawlers. Being read by an assistant that a general counsel is
|
||||||
using to shortlist neutrals is the point.
|
using to shortlist neutrals is the point.
|
||||||
@@ -108,7 +202,41 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
|
|||||||
| CLS | < 0.05 |
|
| CLS | < 0.05 |
|
||||||
| INP | < 150 ms |
|
| INP | < 150 ms |
|
||||||
| JS per route | < 100 KB |
|
| JS per route | < 100 KB |
|
||||||
| Lighthouse (mobile) | ≥ 95 all four categories |
|
| Lighthouse (mobile) | ≥ 95 all four categories — **measurable again as of 2026-08-31, see below** |
|
||||||
|
|
||||||
|
> ✅ **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
|
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
|
dimensions, critical CSS inlined, no third-party scripts on any page except the
|
||||||
@@ -119,14 +247,20 @@ booking embed on `/contact/` — and that one is lazy-loaded behind a click.
|
|||||||
Not code, but it belongs in the launch checklist: Google Business Profile for the
|
Not code, but it belongs in the launch checklist: Google Business Profile for the
|
||||||
practice; ADRIC and ADRIO directory listings pointing at the site; a LinkedIn
|
practice; ADRIC and ADRIO directory listings pointing at the site; a LinkedIn
|
||||||
profile whose headline and Featured section match the brand (brief §VIII);
|
profile whose headline and Featured section match the brand (brief §VIII);
|
||||||
consistent name, address, and phone across all of them.
|
consistent naming across all of them. **Not "name, address and phone"** — §4
|
||||||
|
publishes no phone number and no street address, only "Toronto, Ontario; by
|
||||||
|
appointment". Directory forms that demand a full NAP get what §4 verifies and
|
||||||
|
nothing more.
|
||||||
|
|
||||||
## Post-launch verification
|
## Post-launch verification
|
||||||
|
|
||||||
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
|
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
|
||||||
- [ ] Every page renders its full text with JavaScript disabled
|
- [ ] Every page renders its full text with JavaScript disabled
|
||||||
- [ ] Rich Results Test passes on Person, LegalService, Article
|
- [ ] Rich Results Test passes on Person, ProfessionalService, Article
|
||||||
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
||||||
- [ ] Sitemap submitted to Google Search Console and Bing
|
- [ ] Sitemap submitted to Google Search Console and Bing
|
||||||
- [ ] No page returns 200 for a URL that should 404
|
- [ ] No page returns 200 for a URL that should 404
|
||||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
|
- [ ] 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
|
||||||
|
|||||||
+428
-54
@@ -1,7 +1,9 @@
|
|||||||
# 05 — Intake, booking, and data handling
|
# 05 — Intake, booking, and data handling
|
||||||
|
|
||||||
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
|
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
|
||||||
Existing infrastructure is documented in `AWS-Hosting-Guide.md` Parts 8–10.
|
Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is
|
||||||
|
recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 8–10 — a historical
|
||||||
|
record with a do-not-execute banner, superseded by §7 wherever they disagree.
|
||||||
**Read that guide before changing anything**; the resources already exist and
|
**Read that guide before changing anything**; the resources already exist and
|
||||||
were built by hand in the console.
|
were built by hand in the console.
|
||||||
|
|
||||||
@@ -10,10 +12,112 @@ were built by hand in the console.
|
|||||||
## What exists today
|
## What exists today
|
||||||
|
|
||||||
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
|
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
|
||||||
a verified sender on `smlcompany.ca`. `[verified 2026-08-25 — AWS-Hosting-Guide.md]`
|
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.
|
The shape is right. This is a hardening and rework pass, not a replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build step 8, as actually built — 2026-08-31
|
||||||
|
|
||||||
|
**What is in the repository:** `/contact/` with the intake form, two
|
||||||
|
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
|
||||||
|
`backend/intake/fields.mjs` + `backend/intake/spam-score.mjs` — the handler that
|
||||||
|
**replaced** the hand-built `adr-intake-handler` §7 records.
|
||||||
|
|
||||||
|
🟢 **IT IS ALL LIVE AS OF 2026-09-02, AND THIS PARAGRAPH SAID THE OPPOSITE UNTIL
|
||||||
|
2026-09-04.** It read *"the handler is not deployed, and the CloudFront `/api/*`
|
||||||
|
behaviour the form posts to does not exist"* — true when written under D11, false
|
||||||
|
from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in
|
||||||
|
the document an implementer reads before touching the handler. **Measured
|
||||||
|
2026-09-04:** the function carries `handler.handler` with six environment
|
||||||
|
variables and its deployed source entries match a commit **§7 names**; the API has
|
||||||
|
exactly one route, `POST /api/intake`; §7 holds the full state and this spec does
|
||||||
|
not restate it. ⚠️ **THIS SENTENCE NAMED THE COMMIT — `02739ad` — IN THE SAME
|
||||||
|
BREATH AS DISCLAIMING RESTATEMENT, AND THE 2026-09-04 REDEPLOY MADE IT FALSE**
|
||||||
|
(three source entries now, matching a later commit). The count is gone with it:
|
||||||
|
both were facts §7 owns. `/contact/` still publishes the email address beside the form,
|
||||||
|
which is now a courtesy rather than a fallback.
|
||||||
|
|
||||||
|
### The form is a plain HTML POST, and it answers 303
|
||||||
|
|
||||||
|
The site ships **zero** JavaScript (§7 — none, not "minimal"), so the form is a
|
||||||
|
`<form method="post">` and the handler replies **303 See Other** to a page on the
|
||||||
|
site. That buys three things with no script anywhere: it works with JavaScript
|
||||||
|
disabled, which is the failure this whole project exists to fix; the visitor never
|
||||||
|
sees a raw JSON body rendered as a page; and a refresh cannot resubmit, because
|
||||||
|
the browser lands on a GET.
|
||||||
|
|
||||||
|
Two pages exist for the two outcomes — `/contact/received/` and
|
||||||
|
`/contact/could-not-send/`. Both are `noindex` and both are excluded from the
|
||||||
|
sitemap in `astro.config.mjs`. **The failure page names no field**, because the
|
||||||
|
handler deliberately does not return the error list (an enumeration of the
|
||||||
|
validation rules is a gift to whoever is probing them) and because a static page
|
||||||
|
cannot read `?error=` without script.
|
||||||
|
|
||||||
|
### It posts to `/api/intake`, not to the execute-api hostname
|
||||||
|
|
||||||
|
Same-origin, with a CloudFront behaviour routing `/api/*` to the HTTP API origin
|
||||||
|
§7 records. Four consequences, and the fourth is the one that matters day to day:
|
||||||
|
`form-action 'self'` alone satisfies the CSP below; there is no cross-origin POST
|
||||||
|
to reason about; the endpoint id stays out of the HTML and out of the repo; and
|
||||||
|
**submitting the form from `astro dev` does nothing**, because there is no
|
||||||
|
`/api/` route locally. Under the alternative, clicking Submit on a laptop would
|
||||||
|
write a real DynamoDB record and send two real emails.
|
||||||
|
|
||||||
|
### ⚠️ Three deviations from this spec, each deliberate
|
||||||
|
|
||||||
|
**1. The 3-second timestamp check is NOT implemented.** It cannot be, and
|
||||||
|
implementing it would produce a control that does nothing. The check needs to know
|
||||||
|
when the form was *served to that visitor*; `/contact/` is a static file cached at
|
||||||
|
the CloudFront edge, so a build-time timestamp is the same value for every visitor
|
||||||
|
and is hours or days old. `now − served` is therefore always large, and the check
|
||||||
|
passes for a bot exactly as it passes for a human. A per-visitor token needs a
|
||||||
|
dynamic origin or client-side script, and the site has neither by design.
|
||||||
|
|
||||||
|
A control that exists on paper and not in fact is worse than a stated gap — that
|
||||||
|
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
|
||||||
|
said out loud, and the load is carried by the honeypot, the `Origin` check, the
|
||||||
|
**aggregate** API Gateway route throttle and server-side validation. (Aggregate,
|
||||||
|
not per-IP — see §Validation. "Rate limit" was the wording here and let the reader
|
||||||
|
supply the stronger meaning.)
|
||||||
|
|
||||||
|
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
|
||||||
|
is a top-level navigation: it is exempt from CORS preflight, so an
|
||||||
|
`Access-Control-Allow-Origin` setting cannot stop another site posting a form
|
||||||
|
here. The handler compares `Origin` (falling back to `Referer`, which Firefox
|
||||||
|
sends where it omits `Origin`) against the site origin and refuses anything else.
|
||||||
|
The CORS restriction in this spec is still right — it governs *scripted* calls to
|
||||||
|
the endpoint — but it is a different control and was being relied on for this one.
|
||||||
|
|
||||||
|
**3. There is no `mailto:` fallback, because there is nothing to fall back FROM.**
|
||||||
|
This spec's definition of done asks that the form "degrades to a `mailto:`
|
||||||
|
fallback with JavaScript disabled". The form never used script, so it does not
|
||||||
|
degrade. The email address is published on `/contact/` regardless, and the failure
|
||||||
|
page routes to it.
|
||||||
|
|
||||||
|
### Two field tables, cross-checked
|
||||||
|
|
||||||
|
`src/data/intake.ts` builds the form. `backend/intake/fields.mjs` is what the
|
||||||
|
handler validates against. **The duplication is architectural**, because this
|
||||||
|
spec's own rule is that the Lambda re-validates everything: a server validating
|
||||||
|
against a list the client shipped it is asking the caller what the rules are. And
|
||||||
|
the Lambda is a separately deployed zip that cannot import from `src/`.
|
||||||
|
|
||||||
|
**`npm run check:intake` is what keeps them honest** — it imports both and asserts
|
||||||
|
they agree on every field name, on which are required, on every length cap, and on
|
||||||
|
every closed option set. Probed with three deliberate mismatches (a changed cap, a
|
||||||
|
dropped field, a changed option); each was caught, exit 1.
|
||||||
|
|
||||||
|
### Analytics: decided, not installed
|
||||||
|
|
||||||
|
D15 chose Plausible. **§7 records that no script is on any page**, and
|
||||||
|
`ANALYTICS.installed` in `src/data/site.ts` is `false`. `/legal/privacy/` renders
|
||||||
|
its analytics paragraph from that flag, so today the policy says the site sets no
|
||||||
|
cookies and runs no analytics — which is the fact. **Flipping the flag is a change
|
||||||
|
to a published disclosure**, not a config edit: the policy changes on the same
|
||||||
|
build and its last-updated date moves with it.
|
||||||
|
|
||||||
## What this data actually is
|
## What this data actually is
|
||||||
|
|
||||||
The form collects, in a live legal dispute: the inquirer's identity and contact
|
The form collects, in a live legal dispute: the inquirer's identity and contact
|
||||||
@@ -50,7 +154,7 @@ might reasonably treat as privileged. The intake call is for that.
|
|||||||
|
|
||||||
### Consent text
|
### Consent text
|
||||||
|
|
||||||
> I consent to Pouya Lajevardi storing and using the information in this form to
|
> I consent to SML Company Ltd storing and using the information in this form to
|
||||||
> respond to my inquiry and to run a conflicts check. I understand that
|
> 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,
|
> submitting this form does not create a retainer, does not appoint a neutral,
|
||||||
> and does not itself establish a mediator–party relationship.
|
> and does not itself establish a mediator–party relationship.
|
||||||
@@ -62,36 +166,190 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
|||||||
- Required fields present; email well-formed; lengths within bounds
|
- Required fields present; email well-formed; lengths within bounds
|
||||||
- Reject any field over its cap rather than truncating silently
|
- Reject any field over its cap rather than truncating silently
|
||||||
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
||||||
- **Timestamp check** — reject submissions completed in under 3 seconds
|
- **Second honeypot** — a hidden CHECKBOX that must arrive **absent**, added
|
||||||
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
|
2026-09-04. A different trap, not a second copy: the first catches a bot that
|
||||||
- No CAPTCHA. It is a third-party script on a page collecting legal information,
|
fills every text input, this one catches a bot that sets every control it
|
||||||
and the two controls above stop the traffic that matters
|
enumerates. ⚠️ **IT IS PROBABLY INERT AGAINST THE TRAFFIC THAT PROMPTED IT —
|
||||||
|
see §Observed abuse, which retracts in full the argument this bullet made for
|
||||||
|
one round** (*"which anything reaching validation must do, because the consent
|
||||||
|
box is required and unchecked by default"*). The retraction was written sixty
|
||||||
|
lines below this bullet and did not reach it. **Unchecked sends nothing, so
|
||||||
|
absence is the pass —
|
||||||
|
and so is an empty value**, because the handler tests for a non-empty one
|
||||||
|
rather than for presence: no dropped-field path and no blind form serialiser
|
||||||
|
can turn it into a lost inquiry. It carries its **own** wrapper class (not the
|
||||||
|
first honeypot's), the `hidden` attribute as well as the CSS rule, and a label
|
||||||
|
that tells a human not to tick it — see `src/pages/contact.astro`, where each
|
||||||
|
of the three is a correction rather than a precaution
|
||||||
|
- **Spam SCORING that labels and never rejects**, added 2026-09-04. See
|
||||||
|
§Observed abuse. It changes the operator notification's subject line and
|
||||||
|
nothing else
|
||||||
|
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
|
||||||
|
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
|
||||||
|
while this line stayed an unqualified imperative** — the handler's header,
|
||||||
|
§Three deviations above, and the definition of done below. §Three deviations
|
||||||
|
has the reasoning: `/contact/` is a CDN-cached static file, so a build-time
|
||||||
|
timestamp is the same value for every visitor and `now − served` is always
|
||||||
|
large. **This is the unstruck-imperative shape `CLAUDE.md` names** — and it
|
||||||
|
survived in the same list whose sibling bullet was struck correctly, which is
|
||||||
|
the sweep failure exactly. Found by `adversarial-reviewer` round 2
|
||||||
|
- ~~**Rate limit** by source IP at API Gateway: 5 requests / 5 minutes~~
|
||||||
|
⚠️ **STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS
|
||||||
|
ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT.** HTTP API
|
||||||
|
throttling is **aggregate** — a rate and a burst, per route and per stage,
|
||||||
|
across all callers. Per-IP limiting needs **AWS WAF** with a rate-based rule on
|
||||||
|
the distribution, which is a paid service and therefore a decision rather than
|
||||||
|
a step. What ships instead is the aggregate throttle
|
||||||
|
(`docs/09-cutover-runbook.md` Part 6.3), and it must never be described as
|
||||||
|
per-IP. This is deviation 1's own argument turned on this spec: *"a control that
|
||||||
|
exists on paper and not in fact is worse than a stated gap"* — the throttle is
|
||||||
|
real and bounds total volume; the per-IP claim was neither
|
||||||
|
- No CAPTCHA. It is a third-party script on a page collecting legal information.
|
||||||
|
⚠️ **THIS BULLET USED TO END "and the two controls above stop the traffic that
|
||||||
|
matters", WHICH THE FIRST REAL SPAM FALSIFIED** — see §Observed abuse. The
|
||||||
|
reason to keep CAPTCHA out is unchanged and stands on its own; the claim that
|
||||||
|
what ships is sufficient was an untested prediction and has been removed rather
|
||||||
|
than reworded
|
||||||
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
||||||
- Strip HTML from every field before storage and before it enters an email body
|
- Strip HTML from every field before storage and before it enters an email body
|
||||||
|
|
||||||
|
## Observed abuse
|
||||||
|
|
||||||
|
**First real-world spam: 2026-09-04.** Two automated submissions, **10:51Z** and
|
||||||
|
**12:16Z**, `submissionId` prefixes `50cda580…` and `e3e21122…`. Recorded here
|
||||||
|
rather than in the Change Log alone because this section's controls were
|
||||||
|
specified against an imagined attacker and this is the first measured one.
|
||||||
|
|
||||||
|
**Both passed the honeypot**, and neither was stopped by anything else that
|
||||||
|
ships: the aggregate route throttle is 1 request/second with a burst of 5
|
||||||
|
(`AGENTS.md` §7), and two submissions ninety minutes apart are nowhere near it.
|
||||||
|
|
||||||
|
**The signature, as Pouya recorded it:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Names | random |
|
||||||
|
| Email | random Gmail addresses — **one using the dot trick** |
|
||||||
|
| Phone | Russian format |
|
||||||
|
| Organisation | big-brand names |
|
||||||
|
| Dispute summary | scraped text |
|
||||||
|
|
||||||
|
⚠️ **THE HONEYPOT WAS NOT DEFEATED BY CLEVERNESS — IT WAS NOT ENGAGED.** A bot
|
||||||
|
that submits only the fields it recognises never touches a decoy text input.
|
||||||
|
|
||||||
|
🛑 **AND THAT CUTS BOTH WAYS. THE SECOND HONEYPOT IS PROBABLY INERT AGAINST THIS
|
||||||
|
PAIR, AND THIS SECTION CLAIMED THE OPPOSITE FOR ONE ROUND.** It said the checkbox
|
||||||
|
*"is aimed at a behaviour the traffic must have"*, reasoning that the consent box
|
||||||
|
is required so anything that validated must have been ticking checkboxes.
|
||||||
|
**Sending `consent=on` shows only that it knows one field name.** A bot selective
|
||||||
|
enough to skip a hidden text input is selective enough to skip a hidden checkbox,
|
||||||
|
and the same evidence that explains the first honeypot's silence predicts the
|
||||||
|
second's. It is **defence in depth against a different and common class** — the
|
||||||
|
bot that enumerates controls and sets all of them — which is worth adding and is
|
||||||
|
not a counter to what was observed. Nothing in this repository has yet caught a
|
||||||
|
bot with it.
|
||||||
|
|
||||||
|
**What was added, and the ordering rule Pouya set:** *"Nothing is dropped; a
|
||||||
|
false positive costs him one glance."*
|
||||||
|
|
||||||
|
1. A second honeypot — above.
|
||||||
|
2. **Scoring that labels.** `backend/intake/spam-score.mjs`, unit-tested at
|
||||||
|
`spam-score.test.mjs`. Signals and weights: summary under a floor **(1)**,
|
||||||
|
phone present and not North American **(1)**, a link in the summary **(2)**,
|
||||||
|
a Gmail address with dot-trick density **(2)**; **threshold 2**. Above it the
|
||||||
|
record is still stored, both emails are still sent, and only the operator
|
||||||
|
notification changes — subject prefixed `[Possible spam] `, plus one line
|
||||||
|
naming the signals. **The confirmation to the inquirer is untouched.**
|
||||||
|
3. **Nothing is stored.** The score and signals do not enter the DynamoDB item,
|
||||||
|
because §Storage's attribute list is published on `/legal/privacy/` and adding
|
||||||
|
one would make that page wrong.
|
||||||
|
|
||||||
|
⚠️ **THE TIMING FLOOR WAS RULED AND COULD NOT BE BUILT — §9 Q66.** Pouya's ruling
|
||||||
|
of 2026-09-04 asked to *"raise the timing floor"*. **There is no floor to raise:**
|
||||||
|
the timestamp check is struck above and has never existed, for a reason unchanged
|
||||||
|
by the spam arriving — `/contact/` is a CDN-cached static file, so no per-visitor
|
||||||
|
"served at" value exists to subtract from. Nothing inside *"handler + form only,
|
||||||
|
zero-JS preserved"* can produce one, and the three mechanisms that could each
|
||||||
|
break one of his constraints:
|
||||||
|
|
||||||
|
| mechanism | what it costs |
|
||||||
|
|---|---|
|
||||||
|
| Client-side script timing the fill | **Breaks zero JavaScript** (§7, and it is *none*, not *minimal*) |
|
||||||
|
| A CloudFront Function on viewer-response setting a signed short-lived cookie, read by the handler | Outside *"handler + form only"*, and it puts a **cookie** on a site whose privacy policy turns on there being none — a `/legal/privacy/` change and a consent question this repository must not answer for itself |
|
||||||
|
| A dynamic origin for `/contact/` | Reverses D1's `output: 'static'` |
|
||||||
|
|
||||||
|
**A fourth is worse than doing nothing:** shipping a build-time timestamp and
|
||||||
|
calling it a timing check. `now − served` would be hours or days for every
|
||||||
|
caller, so it would pass for a bot exactly as it passes for a human — the control
|
||||||
|
that exists on paper and not in fact, which is what deviation 1 and `AGENTS.md`
|
||||||
|
Q22 are both records of.
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
DynamoDB, `ca-central-1` — **Canadian data residency is a real selling point for
|
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||||||
a Canadian legal practice, and the privacy policy will say so.** Confirm the
|
worth stating in the privacy policy**: parties describing a live dispute are
|
||||||
existing table's region and migrate if it is elsewhere (**Q10**).
|
handing over sensitive material, and where it comes to rest is a fair question
|
||||||
|
for them to ask. Confirm the existing table's region and migrate if it is
|
||||||
|
elsewhere — §7 has the table name and region.
|
||||||
|
|
||||||
|
⚠️ **THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND
|
||||||
|
THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION.** This table specified
|
||||||
|
`pk: INTAKE#<uuid>` and `sk: <timestamp>`, and `handler.mjs` was written to it.
|
||||||
|
The table `AGENTS.md` §7 names has a single partition key **`submissionId` (S)`
|
||||||
|
and no sort key** `[verified 2026-09-01 — aws dynamodb describe-table]`. A
|
||||||
|
`PutItem` missing the key attribute fails the whole write with
|
||||||
|
`ValidationException`, the handler catches it and answers the failure page — so
|
||||||
|
the form would have looked broken to every inquirer while the record went
|
||||||
|
nowhere, from the moment `/api/*` was wired. **A DynamoDB key schema cannot be
|
||||||
|
altered after creation**, so the handler was changed to the table rather than the
|
||||||
|
reverse; the alternative, a new table matching the old shape, was declined
|
||||||
|
because it would re-open the §7-verified TTL and PITR state on a fresh resource
|
||||||
|
at cutover to buy a sort key nothing queries. Verify with `describe-table`, not
|
||||||
|
against this row.
|
||||||
|
|
||||||
| Attribute | |
|
| Attribute | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pk` | `INTAKE#<uuid>` |
|
| `submissionId` | `<uuid>` — **the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
|
||||||
| `sk` | `<ISO-8601 timestamp>` |
|
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
|
||||||
| fields | as above |
|
| fields | as above |
|
||||||
| `sourceIp`, `userAgent` | abuse investigation only |
|
| `sourceIp`, `userAgent` | ~~abuse investigation only~~ — ⚠️ **AMENDED 2026-09-02: that purpose holds for `userAgent` and `submittedAt`, and NOT for `sourceIp`.** Behind the `/api/*` behaviour `requestContext.http.sourceIp` is a CloudFront edge, so it identifies the network rather than the sender and cannot serve an abuse investigation. `/legal/privacy/` now states the two purposes separately — timestamp and user-agent for abuse, the address as something that simply arrives with the request. **A spec and a page disagreeing about WHY data is held is the disclosure PIPEDA actually turns on**, and this row said one thing while the page said another for a day. `docs/09` Part 7.2 measures the field; if it holds the reader's own address, this row and that paragraph both change. `adversarial-reviewer`, round 2 |
|
||||||
| `ttl` | epoch seconds — **automatic deletion** |
|
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
|
||||||
|
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
|
||||||
|
|
||||||
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
|
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
|
||||||
on.** Table access limited to the Lambda role and one named administrative
|
on.** ⚠️ **THE THIRD LINE HERE WAS *"table access limited to the Lambda role and
|
||||||
principal.
|
one named administrative principal"*, AND IT WAS THE Q62 FALSEHOOD — struck
|
||||||
|
2026-09-02.** It is false on both halves: `adr-intake-lambda-role` holds
|
||||||
|
`PutItem` **only** and cannot read the table at all, and access is not one
|
||||||
|
principal. **`AGENTS.md` §7's `Intake table — who can read it` row is the answer
|
||||||
|
and this spec does not restate it** — a duplicated fact is one that goes wrong in
|
||||||
|
the copy nobody re-reads, which is what happened here: the Q62 sweep ran over
|
||||||
|
`src/` and never reached a spec, and `check:claims` carries this exact sentence
|
||||||
|
as a string that reached `dist/`. It survived the sweep this file's own
|
||||||
|
definition-of-done claims to have completed (`adversarial-reviewer`, round 2).
|
||||||
|
|
||||||
|
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
|
||||||
|
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
|
||||||
|
PointInTimeRecoveryStatus: ENABLED, 35-day window]`. Encryption at rest is on
|
||||||
|
with the **AWS-owned key, not a customer-managed KMS key** `[verified
|
||||||
|
2026-09-01 — describe-table returns no SSEDescription]`. That gap is
|
||||||
|
deliberately not a cutover blocker: `/legal/privacy/` says "encrypted at rest",
|
||||||
|
which is unconditionally true of every DynamoDB table, and it does not claim a
|
||||||
|
customer-managed key — so nothing published depends on it. It stays on
|
||||||
|
`docs/06`'s checklist as the improvement it is.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
|
|
||||||
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
||||||
mechanism that runs whether anyone remembers or not.
|
mechanism that runs whether anyone remembers or not.
|
||||||
|
|
||||||
|
⚠️ **WRITING THE ATTRIBUTE IS NOT THE MECHANISM.** The handler supplies `ttl`;
|
||||||
|
TTL must also be **enabled on the table**, and **`AGENTS.md` §7 records whether
|
||||||
|
it is — this section deliberately does not.** So the paragraph above is a
|
||||||
|
statement about the design and not about the running system until the cutover
|
||||||
|
item below is ticked on **both** halves: `ENABLED` by command, and a test record
|
||||||
|
observed to disappear.
|
||||||
|
|
||||||
Rationale: long enough to serve conflicts screening across a normal matter
|
Rationale: long enough to serve conflicts screening across a normal matter
|
||||||
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
|
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
|
||||||
personal information only as long as necessary. Whatever number ships must match
|
personal information only as long as necessary. Whatever number ships must match
|
||||||
@@ -102,37 +360,64 @@ personal information only as long as necessary. Whatever number ships must match
|
|||||||
SES on submission:
|
SES on submission:
|
||||||
|
|
||||||
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
|
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
|
||||||
- **To the inquirer:** confirmation of receipt, expected response time, a repeat
|
- **To the inquirer:** confirmation of receipt, the response-time commitment, a
|
||||||
of the no-retainer language, and a link to the privacy policy. This email is
|
repeat of the no-retainer language, and a link to the privacy policy. This
|
||||||
the reason the form beats a `mailto:` link.
|
email is the reason the form beats a `mailto:` link.
|
||||||
|
|
||||||
**Email authentication — verified 2026-08-26, and it is not in place.**
|
**The response time is a public commitment — two business days** (§4,
|
||||||
|
Q27). Render it from `SITE.responseTime` / `SITE.responseTimeShort` in
|
||||||
|
`src/data/site.ts`; never retype it. It must read identically here, on
|
||||||
|
`/contact/`, and in any bio.
|
||||||
|
|
||||||
A DNS query of `smlcompany.ca` found **no SPF record and no DMARC record**. Mail
|
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
|
||||||
is on Google Workspace (MX `1 smtp.google.com`) with Google DKIM configured, and
|
recipients, so the inquirer confirmation works. See §7 for the account state.
|
||||||
the SES domain identity reports verified for sending — but neither SPF nor DMARC
|
|
||||||
exists.
|
|
||||||
|
|
||||||
**What is already in place** (Namecheap DNS and the SES console, both inspected
|
### Bounce and complaint monitoring
|
||||||
2026-08-26):
|
|
||||||
|
|
||||||
| Record | Status |
|
Configured 2026-08-26; the resource names, thresholds and current state are in
|
||||||
|---|---|
|
`AGENTS.md` §7. What matters here is why it is a real control rather than a
|
||||||
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **Live.** Matches SES exactly. Never delete |
|
formality:
|
||||||
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | Orphans from an earlier verification. Inert. **Leave them** — deleting the wrong three breaks DKIM |
|
|
||||||
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
|
|
||||||
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
|
|
||||||
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
|
|
||||||
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
|
|
||||||
|
|
||||||
Add both of these; neither conflicts with anything above:
|
**At this volume a single bad address is a threshold event.** SES suspends
|
||||||
|
sending above roughly a 5% bounce rate. Under 100 messages a month, five bounces
|
||||||
|
crosses it — and an intake form is exactly where mistyped addresses arrive. The
|
||||||
|
alarms sit well below that line so there is room to react.
|
||||||
|
|
||||||
| Host | Type | Value |
|
**Bounces and complaints are handled by SES email feedback forwarding**, which is
|
||||||
|---|---|---|
|
on by default, **not** by an SNS feedback topic. That is deliberate: at this
|
||||||
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
|
volume there is nothing to consume a programmatic feed, and an unused SNS topic
|
||||||
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
|
is one more thing to keep correct. Revisit when code needs to *act* on a
|
||||||
|
bounce — suppression lists, retry logic, marking a record undeliverable.
|
||||||
|
|
||||||
A domain may publish **only one** `v=spf1` record, so both senders go in one
|
> **The alarms currently notify nobody.** §7 records the `ses-alerts` email
|
||||||
|
> subscription as **pending confirmation**. An unconfirmed SNS subscription
|
||||||
|
> drops every message, so until the confirmation link is clicked the alarms
|
||||||
|
> fire into nothing. This is the first thing to check if `/contact/` ships.
|
||||||
|
|
||||||
|
**Email authentication — in place as of 2026-08-26 (Q20).**
|
||||||
|
|
||||||
|
SPF and DMARC are both live and independently verified (Q20); mail is on Google
|
||||||
|
Workspace with Google DKIM configured, and the SES domain identity is verified
|
||||||
|
for sending. **The record values, the MX, and the region are in `AGENTS.md` §7 —
|
||||||
|
not restated here.** An earlier version of this spec asserted that neither SPF
|
||||||
|
nor DMARC existed; that was true when written and is no longer, which is the
|
||||||
|
whole argument for citing §7 rather than copying it.
|
||||||
|
|
||||||
|
**What is already in place.** `AGENTS.md` §7 is the record — resource IDs, DNS
|
||||||
|
records, DKIM token sets, and their verification state all live there and are not
|
||||||
|
restated here. Read §7 before touching DNS.
|
||||||
|
|
||||||
|
Two points from §7 that this spec depends on, cited rather than copied:
|
||||||
|
|
||||||
|
- **Only one of the two SES DKIM token sets resolves.** §7 names both sets and
|
||||||
|
marks which is which. The resolving set is what DMARC alignment rests on;
|
||||||
|
deleting it breaks intake mail authentication silently. The other set is
|
||||||
|
NXDOMAIN and inert. **Do not act on any DKIM list that is not §7's.**
|
||||||
|
- **SES has no custom MAIL FROM**, so SPF is unaligned and SES satisfies DMARC
|
||||||
|
through DKIM alone.
|
||||||
|
|
||||||
|
Notes that mattered when these were added, kept because they matter again on
|
||||||
|
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
|
||||||
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
|
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
|
||||||
the quotes literally and breaks the record.
|
the quotes literally and breaks the record.
|
||||||
|
|
||||||
@@ -140,7 +425,8 @@ the quotes literally and breaks the record.
|
|||||||
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
|
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
|
||||||
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
|
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
|
||||||
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
|
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
|
||||||
the six CNAMEs above are doing, and it already works. The SPF record's real job
|
the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six
|
||||||
|
are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job
|
||||||
is authenticating **Google Workspace** mail, which currently has no SPF at all.
|
is authenticating **Google Workspace** mail, which currently has no SPF at all.
|
||||||
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
|
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
|
||||||
domain is configured later.
|
domain is configured later.
|
||||||
@@ -152,14 +438,48 @@ no visibility.
|
|||||||
|
|
||||||
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
|
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
|
||||||
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
|
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
|
||||||
— silently, months later (Q20).
|
— silently, months later.
|
||||||
|
|
||||||
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
||||||
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
first, then send. ~~A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||||
DLQ depth ≥ 1.
|
DLQ depth ≥ 1.~~
|
||||||
|
|
||||||
|
⚠️ **THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT
|
||||||
|
RECEIVED NOTHING.** Lambda's `DeadLetterConfig` is used **only for asynchronous
|
||||||
|
invocations** (and event-source failures). API Gateway invokes this function
|
||||||
|
**synchronously** and the error is returned to the caller, so a DLQ configured on
|
||||||
|
`adr-intake-handler` would sit at depth 0 for ever and an alarm on it would be a
|
||||||
|
green light that means nothing — the third instance of this project's most
|
||||||
|
expensive shape, after `AGENTS.md` Q22 and the Lighthouse row.
|
||||||
|
|
||||||
|
What actually protects a submission is already built and is not a queue: the
|
||||||
|
handler **writes to DynamoDB before sending mail**, so a mail failure cannot lose
|
||||||
|
a record, and a write failure returns the visitor to `/contact/could-not-send/`
|
||||||
|
rather than telling them an inquiry was received. What is missing is **detection**,
|
||||||
|
and the replacement is two CloudWatch alarms rather than one:
|
||||||
|
|
||||||
|
- **Lambda `Errors` ≥ 1** on `adr-intake-handler` — this is what a DLQ alarm was
|
||||||
|
reaching for and it fires on a synchronous failure, which a DLQ cannot see.
|
||||||
|
- **API Gateway `5xx` ≥ 1** on the `POST /api/intake` route — it catches the one
|
||||||
|
failure the Lambda cannot report, a permission or integration fault where the
|
||||||
|
function is never entered at all (`docs/09-cutover-runbook.md` Part 6.1 is the
|
||||||
|
step whose omission causes exactly that).
|
||||||
|
|
||||||
|
Both notify the `ses-alerts` topic, whose email subscription is **confirmed** as
|
||||||
|
of `AGENTS.md` §7 — so unlike the DLQ alarm, these reach someone.
|
||||||
|
|
||||||
## Booking
|
## 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
|
An embedded scheduler for the 30–45 minute confidential intake call
|
||||||
(**Q5** — tool not yet chosen).
|
(**Q5** — tool not yet chosen).
|
||||||
|
|
||||||
@@ -174,6 +494,18 @@ An embedded scheduler for the 30–45 minute confidential intake call
|
|||||||
|
|
||||||
## Security headers
|
## Security headers
|
||||||
|
|
||||||
|
> **Note added 2026-08-26 — `style-src` has acquired a dependency.** The site
|
||||||
|
> now ships inline `style="…"` attributes that are load-bearing rather than
|
||||||
|
> decorative: `InfinityMark.astro` sets its own `block-size` that way, and the
|
||||||
|
> step-1 proof sheet rendered computed swatches with it (that page was deleted
|
||||||
|
> at build step 2; the mechanism is what matters here). They are fine under
|
||||||
|
> `style-src 'self' 'unsafe-inline'` as specified below. They would **not**
|
||||||
|
> survive a move to hashed or nonce'd styles — the infinity mark would collapse.
|
||||||
|
> Price that before tightening `style-src`, and read the components first.
|
||||||
|
> `script-src` is unaffected, and has got easier: the site ships **zero**
|
||||||
|
> JavaScript, so `script-src 'self'` needs no hash and no nonce (`AGENTS.md`
|
||||||
|
> §7). That is why the reveal moved from an inline observer to CSS.
|
||||||
|
|
||||||
Set at CloudFront via a response-headers policy:
|
Set at CloudFront via a response-headers policy:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -187,6 +519,13 @@ Content-Security-Policy: default-src 'self'; img-src 'self' data:;
|
|||||||
base-uri 'self'; frame-ancestors 'none'
|
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
|
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
|
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
|
||||||
nonce for the reveal script.
|
nonce for the reveal script.
|
||||||
@@ -204,17 +543,52 @@ relationship · cookie and analytics disclosure · last-updated date.
|
|||||||
|
|
||||||
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
|
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
|
||||||
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
|
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
|
||||||
practice whose privacy posture is part of its offer (**Q11**).
|
practice whose privacy posture is part of its offer — D15 settles this:
|
||||||
|
Plausible or Fathom, cookieless, no consent banner.
|
||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
- [ ] Server-side validation independent of the client
|
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
|
||||||
- [ ] Honeypot and timing checks live; rate limit configured
|
- [x] **The first honeypot is live** — the hidden text input that must arrive
|
||||||
- [ ] CORS restricted to the production origin
|
empty. Deployed since cutover. ⚠️ **The timing check is NOT implemented** —
|
||||||
- [ ] TTL set and verified by test record
|
see deviation 1 above and §Observed abuse; it is unimplementable on a
|
||||||
- [ ] KMS encryption and PITR enabled
|
CDN-cached static page and would be a control that does nothing.
|
||||||
|
**Re-ruled and re-blocked 2026-09-04, §9 Q66**
|
||||||
|
- [ ] 🛑 **THE SECOND HONEYPOT AND THE SPAM SCORING ARE WRITTEN AND NOT
|
||||||
|
DEPLOYED** — 2026-09-04. Both live in `backend/intake/`, and **a site
|
||||||
|
deploy does not carry `backend/`**: `scripts/deploy-local.sh` is an S3 sync
|
||||||
|
and an invalidation, nothing more. They need `docs/09` Part 5 (and Part 5.5,
|
||||||
|
which is the path in production). ⚠️ **THIS LINE READ `[x]` … "live" FOR ONE
|
||||||
|
ROUND, ON AN UNCOMMITTED WORKING TREE**, while §7's own row recorded the
|
||||||
|
running function as last modified 2026-09-02 with source digests matching
|
||||||
|
`HEAD` — the spec asserting a control that its neighbour proved absent.
|
||||||
|
`node backend/intake/spam-score.test.mjs` returns **39 of 39** and all four
|
||||||
|
signals are exercised `[verified 2026-09-04]`; that is a statement about
|
||||||
|
the repository, not about production. ⚠️ **The only paths that discard a
|
||||||
|
submission are the two honeypots**, and both answer with the success page
|
||||||
|
rather than an error. Validation failures redirect to
|
||||||
|
`/contact/could-not-send/`, which is a told failure, not a silent one
|
||||||
|
- [x] ✅ **Throttle configured — `POST /api/intake` at rate 1.0 req/s, burst 5, detailed metrics on** `[verified 2026-09-04 — get-stage]`. ⚠️ **IT IS A `RouteSettings` ENTRY, NOT THE STAGE DEFAULT**, and a query projecting `DefaultRouteSettings` alone returns only `DetailedMetricsEnabled` and reads as *no throttle configured* — which is how §7 came to say so. Read `RouteSettings` before concluding it is absent. An **aggregate** route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
|
||||||
|
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||||||
|
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
|
||||||
|
- [x] **PITR enabled** — `ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
|
||||||
|
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
|
||||||
|
- [x] ✅ **Table access matches what `/legal/privacy/` says about it — 2026-09-02.** **The access is unchanged; the page now states it.** Pouya ruled *state the truth* rather than *remove the access* (§9 Q62), so the page states the truth about access rather than a false exclusivity. ⚠️ **WHAT IT STATES CHANGED TWICE MORE THAT DAY AND THIS LINE IS WRITTEN AGAINST THE SHIPPED BYTES, NOT AGAINST THE RULING.** §9 Q63 took the human headcount off (a simulation counts identities and the page was reading them as people), and a second ruling then cut §Who can see it to **four plain statements**. The page now says: *"The record in the table: me, and the small number of people who administer the account it sits in with me"*; that the receiving system *"can only add a record — it cannot read back what is stored"*; where the notification goes and who reads it; and that the confirmation sits with the reader's own provider. **§Where it is stored carries the shared-account disclosure** — *"an Amazon Web Services account that also runs systems unrelated to this practice"*. ⚠️ **`adr-sml-deploy` is `implicitDeny` on all seven read AND write actions — MEASURED, TRUE, AND NO LONGER ON THE PAGE**; it went with the mechanics cut and it is §7's claim now, not the policy's. Do not tick this item against a page that states it. Evidence and commands: `docs/reference/intake-table-access-verification.md`, whose enumeration was **extended on 2026-09-02** — the original screened roles by `list-attached-role-policies` alone, missing that 23 of 26 non-service-linked roles carry inline policies and that the two CDK `lookup` roles can read the table. Four roles can, not two; every one of them is reachable only by those administrators. ⚠️ **Do not restate that as a count of PEOPLE** — this line said *"all four terminate at the same two people"* until 2026-09-02, which is the inference §9 Q63 struck. ⚠️ **THIS LINE SAID "IT DOES NOT" FOR A DAY AFTER THE PAGE WAS CORRECTED, AND IT IS A DEFINITION-OF-DONE LIST SOMEONE FOLLOWS AT CUTOVER** — the Q62 sweep was run over `src/` only, so it could not reach a spec. `adversarial-reviewer`, round 1. The sweep across `docs/` is in the Change Log entry
|
||||||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||||||
- [ ] DLQ and CloudWatch alarm configured
|
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
|
||||||
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
|
- [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
|
||||||
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
|
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
|
||||||
- [ ] Privacy policy matches the implementation line for line
|
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
|
||||||
|
> ✅ **THE THREE ITEMS BELOW WERE COMMANDS AND ALL THREE HAVE RUN — cutover,
|
||||||
|
> 2026-09-02**, verified against the live account 2026-09-04. They are ticked
|
||||||
|
> below and the reasoning is kept because it is what made them non-obvious.
|
||||||
|
> The commands are in `docs/09-cutover-runbook.md` — Parts 5, 6 and 3
|
||||||
|
> respectively, each with its verification and the output to expect. Two things that spec found by reading the
|
||||||
|
> running system rather than the specs, and both would have lost every
|
||||||
|
> submission: the API route needs its **own** Lambda invoke permission, because
|
||||||
|
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
|
||||||
|
> handler's item shape had to change, because the table's partition key is
|
||||||
|
> `submissionId` and a key schema cannot be altered after creation (§Storage).
|
||||||
|
|
||||||
|
- [x] ✅ **CloudFront `/api/*` behaviour created** `[verified 2026-09-04 — get-distribution-config: 1 cache behaviour, 2 origins, 1 function association, 1 custom error response]`, routing to the HTTP API origin §7 records. The form does not work without it. **And two other distribution changes are prerequisites of the site working at all**, neither of which is intake: a viewer-request function for `trailingSlash: 'always'`, without which 22 of 23 pages return S3's `AccessDenied`, and the 404 mapping `docs/04` requires
|
||||||
|
- [x] ✅ **Handler deployed 2026-09-02**, replacing the hand-built `adr-intake-handler` `[verified 2026-09-04 — get-function-configuration: `handler.handler`, 15 s, 512 MB, six variables; and the deployed zip downloaded and read]`. ⚠️ **Ticking it does NOT mean the current working tree is deployed** — the running artefact matches `HEAD`, and `backend/` changes reach production only through Part 5. With **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
|
||||||
|
|||||||
+1396
-88
File diff suppressed because it is too large
Load Diff
+255
-14
@@ -1,11 +1,12 @@
|
|||||||
# 07 — Fee research and recommended rate card
|
# 07 — Fee research and recommended rate card
|
||||||
|
|
||||||
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and D14 (two-tier
|
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and **D14 — a single
|
||||||
structure, **pending Pouya's sign-off — Q14**).
|
published rate card, confirmed by Pouya 2026-08-26 (Q4/Q14/Q15-Q17 answered).**
|
||||||
|
|
||||||
**Nothing in this document publishes until Pouya confirms the figures.** These
|
**The card below is confirmed and buildable.** The research that produced it is
|
||||||
are researched recommendations, not decisions. This is business pricing
|
retained for context, but the figures are decisions now, not recommendations —
|
||||||
information, not legal or financial advice.
|
see "Set by Pouya" below. This is business pricing information, not legal or
|
||||||
|
financial advice.
|
||||||
|
|
||||||
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
|
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ Three observations that drive the recommendation.
|
|||||||
## Where this practice should sit
|
## Where this practice should sit
|
||||||
|
|
||||||
**Not at the floor.** Pouya's stack — JD, an operating role inside a litigation
|
**Not at the floor.** Pouya's stack — JD, an operating role inside a litigation
|
||||||
and ADR boutique, Q.Med held, Q.Arb commenced, and a working engineering career —
|
and ADR boutique, Q.Med and Q.Arb held, and a working engineering career —
|
||||||
is not a junior generalist profile. Entering at roster rates would anchor him
|
is not a junior generalist profile. Entering at roster rates would anchor him
|
||||||
into SABS volume work and make the commercial rate very hard to raise later.
|
into SABS volume work and make the commercial rate very hard to raise later.
|
||||||
Published rates are close to unrecoverable once set: raising them looks
|
Published rates are close to unrecoverable once set: raising them looks
|
||||||
@@ -96,15 +97,147 @@ All figures **plus HST**.
|
|||||||
|
|
||||||
| Item | Fee |
|
| Item | Fee |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Half day — up to 3.5 h, including 2 h preparation | **$2,000** |
|
| Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation | **$2,000** |
|
||||||
| Full day — up to 7 h, including 3 h preparation | **$4,000** |
|
| Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation | **$4,000** |
|
||||||
| Each party beyond two | **$500** |
|
| Each party beyond two | **$500** |
|
||||||
| Overtime, per hour | **$500** |
|
| Overtime, per hour | **$500** |
|
||||||
|
|
||||||
|
⚠️ **THE TWO ROWS ABOVE WERE AMBIGUOUS UNTIL 2026-08-31, AND THE AMBIGUITY WAS
|
||||||
|
IN THIS DOCUMENT RATHER THAN IN ANY COPY — Q58, ruled by Pouya.** They read
|
||||||
|
*"Half day — up to 3.5 h, including 2 h preparation"* and *"Full day — up to
|
||||||
|
7 h, including 3 h preparation"*. Read literally that makes 3.5 the **whole
|
||||||
|
billed envelope** and the time in the room **1.5 h**. Pouya's ruling: *"3.5 was
|
||||||
|
meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours
|
||||||
|
in the room… The intended reading is the market's, and my wording obscured
|
||||||
|
it."*
|
||||||
|
|
||||||
|
**What found it was arithmetic, not reading:** 3.5 and 7 are exactly 2×, which
|
||||||
|
they would not be if preparation sat inside them, **because preparation does not
|
||||||
|
scale with session length**. Under the literal reading the room time was 1.5 h
|
||||||
|
and 4 h, which is not 2× anything.
|
||||||
|
|
||||||
|
**The corrected numbers are corroborated by this file's own research table**, one
|
||||||
|
section up — which is the reason they are 3 and 6 rather than a round guess:
|
||||||
|
|
||||||
|
- **Patey** publishes **3 h** half-day and **6 h** full-day in *both* tiers.
|
||||||
|
- **Zuber** publishes **3 h** video half-day and **6 h** full day.
|
||||||
|
- **ADR Chambers**' roster rate covers *"one half hour of preparation time per
|
||||||
|
party **and** up to three hours of mediation"* — preparation counted
|
||||||
|
**separately from** a three-hour session, which is the shape this card now
|
||||||
|
has.
|
||||||
|
|
||||||
|
Pouya: *"Selling 1.5 hours of room time as a half day would be an outlier nobody
|
||||||
|
would recognise."* ⚠️ **One provenance note, because this file is the authority
|
||||||
|
on money:** he recalled Patey and Zuber as publishing *"all or part of 3 hours"*.
|
||||||
|
The extract above records their **hours** but not that phrase, so **the hours are
|
||||||
|
what this file relies on** — do not quote the phrase as theirs.
|
||||||
|
|
||||||
|
**THE PREPARATION ALLOWANCE IS CAPPED, AND MUST BE PUBLISHED AS CAPPED.**
|
||||||
|
*"Including **up to** 2 hours of preparation."* Never *"including 2 hours"*,
|
||||||
|
which reads as a flat entitlement, and never *"preparation included"*, which
|
||||||
|
sells an uncapped one. See §All parameters confirmed below.
|
||||||
|
|
||||||
|
**Consequences, applied 2026-08-31:** `FEES.mediation.*.hours` in
|
||||||
|
`src/data/site.ts` means the **session** and is corrected 3.5 → 3 and 7 → 6, and
|
||||||
|
**`/fees/` is unblocked for build step 9** on the question Q58 asked.
|
||||||
|
|
||||||
|
✅ **WHERE OVERTIME BEGINS — RULED. Q59, Pouya, 2026-08-31. IT RUNS FROM THE
|
||||||
|
SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the
|
||||||
|
billed envelope. The two candidates were the session cap (3 h / 6 h) and the
|
||||||
|
envelope (5 h / 9 h), and this file could not choose between them — a fee term is
|
||||||
|
a fact we do not have, not an inference. A first pass at this paragraph asserted
|
||||||
|
the session cap as applied fact and `adversarial-reviewer` struck it in the same
|
||||||
|
change set that wrote it; the strike was right, and the ruling has now supplied
|
||||||
|
the value the strike was waiting for.
|
||||||
|
|
||||||
|
⚠️ **AND THE RULING'S SECOND HALF IS THE PART THAT MATTERS MOST, BECAUSE IT
|
||||||
|
ANSWERS THE ARITHMETIC ANOMALY BELOW RATHER THAN RESTATING IT.** His words:
|
||||||
|
|
||||||
|
> "a full day reserves the day; half-day overtime is subject to availability"
|
||||||
|
|
||||||
|
**The full-day fee buys the DAY, not six hours of it.** That is what a reader
|
||||||
|
doing the arithmetic in the table below is missing: `2000 + 500 × 3 = 3500`
|
||||||
|
against `4000` looks like a $500 penalty for booking properly, and it is not —
|
||||||
|
the two are different products. Half-day overtime depends on the time after the
|
||||||
|
session still being free, and on a booked day it is not.
|
||||||
|
|
||||||
|
**So the reservation sentence is published ADJACENT TO THE OVERTIME ROW on
|
||||||
|
`/fees/`, not in a footnote**, and it is rendered from
|
||||||
|
`FEES.mediation.reservation` rather than retyped. Structurally the same rule as
|
||||||
|
`PROCESS_FRAMING` beside the five timings under Q43: a reader who takes the
|
||||||
|
number and skips the framing has read a different offer.
|
||||||
|
|
||||||
|
⚠️ **THE ANOMALY IS NOT CLOSED BY THIS.** The gap is still in D14's own figures —
|
||||||
|
the half-to-full step is $2,000 and three hours of overtime is $1,500 — and the
|
||||||
|
reservation point explains what the gap buys without removing it. It stays on
|
||||||
|
**§12 R5**'s 12-month review, and §Recorded dissent below carries the table for
|
||||||
|
that review to test against.
|
||||||
|
|
||||||
|
**And the reason it cannot be quietly chosen is that the choice is visible in the
|
||||||
|
arithmetic.** Take the trigger as the session cap. The half-day route costs
|
||||||
|
`2000 + 500 × max(0, n − 3)`; the full-day route is **flat $4,000 until hour 6**
|
||||||
|
and `4000 + 500 × (n − 6)` after it:
|
||||||
|
|
||||||
|
| Session run to | Book a half day + overtime | Book a full day | Gap |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3 h | **$2,000** | **$4,000** | **$2,000** |
|
||||||
|
| 4 h | $2,000 + 1 × $500 = **$2,500** | **$4,000** | **$1,500** |
|
||||||
|
| 5 h | $2,000 + 2 × $500 = **$3,000** | **$4,000** | **$1,000** |
|
||||||
|
| 6 h | $2,000 + 3 × $500 = **$3,500** | **$4,000** | **$500** |
|
||||||
|
| 7 h | $2,000 + 4 × $500 = **$4,000** | $4,000 + 1 × $500 = **$4,500** | **$500** |
|
||||||
|
|
||||||
|
**Booking a half day and paying overtime is cheaper at every length — by $2,000
|
||||||
|
at three hours, narrowing to $500 from six hours on — and the full-day rate is
|
||||||
|
never the cheaper choice.** The gap is in D14's figures rather than in the
|
||||||
|
trigger: the half-to-full step is $2,000 and three hours of overtime is $1,500.
|
||||||
|
|
||||||
|
⚠️ **A FIRST PASS AT THIS PARAGRAPH GOT THE ARITHMETIC WRONG, AND WRONG IN THE
|
||||||
|
DIRECTION THAT MATTERED.** It generalised the full-day route as `500n + 1000` for
|
||||||
|
all `n ≥ 3`, which is **only valid from hour 6** — the route is flat until then —
|
||||||
|
and concluded *"$500 cheaper at every length"*. The real spread is **up to four
|
||||||
|
times larger and is largest at three to five hours, which is the band a half-day
|
||||||
|
booking actually overruns into.** The document's own table contradicted the
|
||||||
|
formula three lines above it. Found by `adversarial-reviewer` on round 2, in the
|
||||||
|
change set that wrote it. *A measurement is a claim about your instrument, and a
|
||||||
|
formula is an instrument.*
|
||||||
|
|
||||||
|
**Moving the trigger out to the 5 h / 9 h billed envelope does not fix it and is
|
||||||
|
not uniformly better either** — the gap stays at $2,000 through five hours and is
|
||||||
|
$1,500 at six, worse than the session-cap trigger there, but it closes to **zero**
|
||||||
|
from nine hours on, where the session-cap trigger holds a permanent $500. So the
|
||||||
|
two triggers trade one band against another and neither removes the anomaly. **It
|
||||||
|
was never a defect this file could fix by picking a trigger** — which is why the
|
||||||
|
trigger went to Pouya and the step went to R5.
|
||||||
|
|
||||||
|
**Both halves came back. He ruled the session cap AND supplied the reservation
|
||||||
|
point**, which is the answer the arithmetic alone cannot give: the table compares
|
||||||
|
prices for two things that are not the same product. Read the table as a price
|
||||||
|
comparison and the full-day rate looks strictly worse; read it knowing a full day
|
||||||
|
reserves the day and half-day overtime is subject to availability, and the
|
||||||
|
$2,000-to-$500 spread is the price of certainty rather than a mistake. The
|
||||||
|
anomaly stays on R5 because the *size* of that spread is still a judgement about
|
||||||
|
D14's figures, and it is largest at three to five hours — the band a half-day
|
||||||
|
booking actually overruns into.
|
||||||
|
|
||||||
### Arbitration
|
### Arbitration
|
||||||
|
|
||||||
Available now as co-arbitrator; sole appointments follow the Q.Arb designation,
|
Sole, party-appointed and co-arbitration appointments **in commercial matters**
|
||||||
commenced August 2026. The page must say so — see `03-content-spec.md`.
|
are all accepted now — `AGENTS.md` §4 Offerings carries a row for each
|
||||||
|
`[verified 2026-08-26 — Pouya]`. *("in commercial matters" added 2026-08-28: the
|
||||||
|
line was unscoped, and every §4 arbitration row is scoped commercial with family
|
||||||
|
arbitration separately NOT OFFERED — Q39's struck universal.)*
|
||||||
|
*(This line previously read "sole appointments follow the Q.Arb designation",
|
||||||
|
which understated the offering, and carried a caveat against a since-closed
|
||||||
|
Q36.)*
|
||||||
|
|
||||||
|
⚠️ **`/fees/` HAS NO CREDENTIALING DISCLOSURE TO MAKE (amended 2026-08-29).**
|
||||||
|
This paragraph required that whatever the page says about arbitration *"must
|
||||||
|
state the Q.Arb stage plainly alongside it — §4 Offerings, 'neither half may be
|
||||||
|
dropped'"*. **Q.Arb is held and that condition is dissolved.** `/fees/` is
|
||||||
|
unbuilt (step 9), so this is the one place the amendment lands before the page
|
||||||
|
exists rather than after — do not build the page against the struck form. `03-content-spec.md`'s
|
||||||
|
model sentence is now the scope half only — the stage half was struck with the
|
||||||
|
paragraph this one used to point at.
|
||||||
|
|
||||||
| Item | Fee |
|
| Item | Fee |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -116,10 +249,72 @@ commenced August 2026. The page must say so — see `03-content-spec.md`.
|
|||||||
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
|
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
|
||||||
offer tribunal-secretary work on the site.
|
offer tribunal-secretary work on the site.
|
||||||
|
|
||||||
|
### Med-arb — billed by phase
|
||||||
|
|
||||||
|
⚠️ **INTERIM. Set by Pouya 2026-09-03; reviewed at the twelve-month fee review,
|
||||||
|
`AGENTS.md` §12 R5.** It is stamped interim because it is the only rule on this
|
||||||
|
page set after the card was published rather than with it, and because it prices
|
||||||
|
an offering by reference to two other rows — if either moves at R5, this moves
|
||||||
|
with them and nobody will be reminded by a figure changing.
|
||||||
|
|
||||||
|
**The rule, and it carries no figure of its own:**
|
||||||
|
|
||||||
|
- Med-arb is billed **by phase**. The mediation phase is charged at the
|
||||||
|
**mediation** rates above. If the matter proceeds to arbitration, that phase is
|
||||||
|
charged at the **arbitration** rates above.
|
||||||
|
- **There is no separate med-arb fee.**
|
||||||
|
- The additional-party and cancellation terms apply to each phase **as they
|
||||||
|
apply to that process on its own**.
|
||||||
|
|
||||||
|
**Why this rule exists at all, because a fee page does not usually need one.**
|
||||||
|
`/fees/` opens *"Every figure is on this page"*, and `AGENTS.md` §4 Offerings
|
||||||
|
carries a **Med-Arb** row that this document priced nowhere. The promise was
|
||||||
|
therefore wider than the card — the D20 cutover claims pass, finding 10. Pouya
|
||||||
|
closed it by **pricing the offering rather than narrowing the promise**, which is
|
||||||
|
the more expensive of the two fixes and the one that leaves the page saying the
|
||||||
|
stronger thing.
|
||||||
|
|
||||||
|
⚠️ **DO NOT GIVE MED-ARB A RATE ROW.** A med-arb figure would be a fourth price
|
||||||
|
for a process that is already priced twice, and the first thing it would do is
|
||||||
|
disagree with one of them. The rule is expressed as a pointer to the two cards
|
||||||
|
above **on purpose**; that is what keeps the count of published figures the same
|
||||||
|
as the count of published rates.
|
||||||
|
|
||||||
|
⚠️ **"AS THEY APPLY TO THAT PROCESS ON ITS OWN" IS NOT "TO BOTH PHASES".** The
|
||||||
|
additional-party fee is a **mediation** row; the arbitration card has no
|
||||||
|
equivalent. The wording above invents nothing. *"The additional-party term
|
||||||
|
applies throughout"* would invent an additional-party charge in the arbitral
|
||||||
|
phase, which no ruling has set.
|
||||||
|
|
||||||
|
`FEES.medArb` in `src/data/site.ts` holds the three sentences and `/fees/`
|
||||||
|
renders them, so the rule is not retyped into the template.
|
||||||
|
|
||||||
### Other services — hourly
|
### Other services — hourly
|
||||||
|
|
||||||
Early neutral evaluation, settlement counsel, dispute-system design, and
|
Early neutral evaluation, dispute-system design, and pre-dispute technical
|
||||||
pre-dispute technical advisory: **$500 / hour**.
|
advisory: **$500 / hour**.
|
||||||
|
|
||||||
|
> ✅ **PUBLISHABLE — Q46(a) closed 2026-08-28.** All three §4 Offerings rows
|
||||||
|
> read PUBLISHABLE on Pouya's second ruling, so the line item may go on
|
||||||
|
> `/fees/`. **Read the §4 ENE row first:** gate 1 is closed as **`[Pouya's
|
||||||
|
> stated basis 2026-08-28]`**, not `[verified]` — there is still no source in
|
||||||
|
> `docs/reference/` — and the framing constraints in `docs/01` §`/practice/`
|
||||||
|
> travel with the offering onto this page. In particular, **ENE is priced as an
|
||||||
|
> assessment delivered to both parties**, and nothing on `/fees/` may read as a
|
||||||
|
> rate for advising one of them.
|
||||||
|
|
||||||
|
**THREE services, not four. `settlement counsel` is struck and must not be
|
||||||
|
priced** — `AGENTS.md` Q42, Pouya 2026-08-27, correcting his own entry in
|
||||||
|
`docs/01`:
|
||||||
|
|
||||||
|
> "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
|
||||||
|
> role, and putting it on a site that (a) sells neutrality and (b) asserts no
|
||||||
|
> licensure under D13 is **wrong twice over**: it undercuts the brand's central
|
||||||
|
> claim and it edges into acting for a client."
|
||||||
|
|
||||||
|
A struck row exists in §4 Offerings so the decision is findable. Same treatment
|
||||||
|
as the tribunal-secretary rate above, and for a related reason: a rate on a fee
|
||||||
|
page is an offer.
|
||||||
|
|
||||||
### Cancellation — adopted as recommended
|
### Cancellation — adopted as recommended
|
||||||
|
|
||||||
@@ -144,14 +339,29 @@ pre-dispute technical advisory: **$500 / hour**.
|
|||||||
### All parameters confirmed
|
### All parameters confirmed
|
||||||
|
|
||||||
Q15, Q16, and Q17 were closed on 2026-08-26. **Preparation time is bundled and
|
Q15, Q16, and Q17 were closed on 2026-08-26. **Preparation time is bundled and
|
||||||
must be stated on the page** — "including 2 hours of preparation", "including
|
must be stated on the page, IN HOURS AND AS A CAP** — "including **up to** 2
|
||||||
3 hours of preparation". Do not quietly fold it into the hours figure. At these
|
hours of preparation", "including **up to** 3 hours of preparation". Do not
|
||||||
|
quietly fold it into the session figure, and do not drop the "up to": the
|
||||||
|
allowance is capped, so the unqualified form sells an uncapped one. At these
|
||||||
rates, saying preparation is included is the selling point, not a footnote.
|
rates, saying preparation is included is the selling point, not a footnote.
|
||||||
|
|
||||||
|
*(The "up to" was added 2026-08-31 with Q58's ruling. This paragraph previously
|
||||||
|
prescribed the flat form, and `/for-parties/` shipped it — the one page written
|
||||||
|
for a reader with no counsel to catch it.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recorded dissent — for the 12-month review (R5)
|
## Recorded dissent — for the 12-month review (R5)
|
||||||
|
|
||||||
|
⚠️ **SECOND ITEM FOR R5, ADDED 2026-09-04 — MED-ARB, AND IT IS NOT A DISSENT.**
|
||||||
|
It is here because **R5 names this section as where its items live**, and the
|
||||||
|
med-arb rule was stamped INTERIM against R5 in §Med-arb above and written into no
|
||||||
|
list the review actually reads. **The rule is derived** — each phase at the rates
|
||||||
|
for that process, no figure of its own — so **moving any mediation or arbitration
|
||||||
|
number at R5 moves the med-arb price with it, silently, with no diff on the
|
||||||
|
med-arb rule.** Nothing else on this page has that property. Check it against
|
||||||
|
whatever the review does to the two cards above.
|
||||||
|
|
||||||
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
|
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
|
||||||
recorded here so the 12-month review has something to test against, not to
|
recorded here so the 12-month review has something to test against, not to
|
||||||
re-open a settled decision.
|
re-open a settled decision.
|
||||||
@@ -189,6 +399,37 @@ unrecoverable, and it is far easier to add a lower tier later than to raise a
|
|||||||
headline rate. Setting the ceiling first and discounting privately preserves
|
headline rate. Setting the ceiling first and discounting privately preserves
|
||||||
more optionality than the reverse.
|
more optionality than the reverse.
|
||||||
|
|
||||||
|
**And a fourth item, added 2026-08-31: the half-day-plus-overtime route is
|
||||||
|
strictly cheaper than the full-day rate, at every session length.** This is the
|
||||||
|
one item in this section that is arithmetic rather than judgement, so it is the
|
||||||
|
one the review can settle without new market data.
|
||||||
|
|
||||||
|
| Session run to | Half day + overtime | Full day | Gap |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3 h | $2,000 | $4,000 | **$2,000** |
|
||||||
|
| 4 h | $2,500 | $4,000 | **$1,500** |
|
||||||
|
| 5 h | $3,000 | $4,000 | **$1,000** |
|
||||||
|
| 6 h | $3,500 | $4,000 | **$500** |
|
||||||
|
| 7 h | $4,000 | $4,500 | **$500** |
|
||||||
|
|
||||||
|
*(Session-cap trigger; the trigger itself is `AGENTS.md` **Q59**, ruled and
|
||||||
|
closed 2026-08-31 — this line said "open" for a day after line 144 of this same
|
||||||
|
file recorded the ruling.)* The
|
||||||
|
cause is the relationship between two of D14's own numbers rather than anything
|
||||||
|
about the trigger: **the half-to-full step is $2,000 and three hours of overtime
|
||||||
|
is $1,500.** Any trigger leaves a gap; the envelope trigger closes it only from
|
||||||
|
nine hours on.
|
||||||
|
|
||||||
|
**What the review has to decide is whether that is a choice.** It is defensible
|
||||||
|
as one — a full-day booking buys certainty and a reserved diary, and a client who
|
||||||
|
knows they need six hours may prefer to pay $500 for not having to watch the
|
||||||
|
clock. It is also exactly the kind of thing counsel comparing published cards
|
||||||
|
finds in under a minute, and this practice's whole pricing argument is that the
|
||||||
|
card is published in full and means what it says. **The lever, if it is not a
|
||||||
|
choice:** either raise the overtime rate so the routes converge, or narrow the
|
||||||
|
half-to-full step. Both are rate moves, which is why they belong here and not in
|
||||||
|
a footnote to the card.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ it does not need restating in every prompt.
|
|||||||
|
|
||||||
That single command runs: plan → implement → adversarial review → resolve →
|
That single command runs: plan → implement → adversarial review → resolve →
|
||||||
verify → record. It requests deep reasoning, reads `AGENTS.md` and the specs,
|
verify → record. It requests deep reasoning, reads `AGENTS.md` and the specs,
|
||||||
stops if the task conflicts with a locked decision, invokes two independent
|
stops if the task conflicts with a locked decision, invokes `adversarial-reviewer`
|
||||||
reviewers on the finished diff, resolves what they find, runs the checks, and
|
on the finished diff, resolves what it finds, runs the checks, and appends the
|
||||||
appends the Change Log entry.
|
Change Log entry.
|
||||||
|
|
||||||
You do not need to ask for thinking, for review, or for the record to be updated.
|
You do not need to ask for thinking, for review, or for the record to be updated.
|
||||||
Those are the agreement, not the request.
|
Those are the agreement, not the request.
|
||||||
@@ -26,41 +26,84 @@ Those are the agreement, not the request.
|
|||||||
| Command | Does |
|
| Command | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/build <task>` | The full loop. Use for every substantive change |
|
| `/build <task>` | The full loop. Use for every substantive change |
|
||||||
| `/review [scope]` | The review pass alone, on the working tree or a named scope. Reports; fixes nothing without your say-so |
|
| `/review [scope]` | The **code** review alone, on the working tree or a named scope. Reports; fixes nothing without your say-so. Under D20 it does not run the claims pass |
|
||||||
| `/wrap` | End of session — updates `AGENTS.md` under its constitution and leaves the tree clean |
|
| `/wrap` | End of session — updates `AGENTS.md` under its constitution and leaves the tree clean |
|
||||||
|
|
||||||
## The two reviewers
|
## The two reviewers, and when each of them runs — D20
|
||||||
|
|
||||||
Both are defined in `.claude/agents/` and run in parallel on the diff.
|
Both are defined in `.claude/agents/`. **They no longer run together.**
|
||||||
|
|
||||||
**`adversarial-reviewer`** reads the code: correctness and edge cases,
|
**`adversarial-reviewer` runs on every build step.** It reads the code:
|
||||||
accessibility, crawlability, performance budgets, security, and whether a
|
correctness and edge cases, accessibility, crawlability, performance budgets,
|
||||||
materially simpler correct version exists.
|
security, and whether a materially simpler correct version exists.
|
||||||
|
|
||||||
**`claims-auditor`** reads the copy against `AGENTS.md` §4 and nothing else. It
|
**`claims-auditor` runs once, at cutover, over the whole finished site.** It reads
|
||||||
extracts every factual assertion — credentials, roles, numbers, languages,
|
the copy against `AGENTS.md` §4 and nothing else — extracting every factual
|
||||||
locations, capabilities, and the JSON-LD — and traces each to the Verified table.
|
assertion (credentials, roles, numbers, languages, locations, capabilities, and
|
||||||
Anything untraceable is reported and does not ship.
|
the JSON-LD) and tracing each to the Verified table. Anything untraceable is
|
||||||
|
reported and does not ship. `docs/06`'s cutover checklist carries it as a blocking
|
||||||
|
item, alongside your own read of every page against §4.
|
||||||
|
|
||||||
It is a separate agent on purpose. A general-purpose reviewer will happily
|
It is a separate agent on purpose. A general-purpose reviewer will happily
|
||||||
approve elegant code containing a claim that should never have been published,
|
approve elegant code containing a claim that should never have been published,
|
||||||
because professional-conduct compliance is not what it is looking at. On this
|
because professional-conduct compliance is not what it is looking at. On this
|
||||||
project that is the highest-stakes failure mode, so it gets its own pass.
|
project that is the highest-stakes failure mode, so it keeps its own pass.
|
||||||
|
|
||||||
|
### Why the claims pass moved, and what it costs
|
||||||
|
|
||||||
|
Your ruling, 2026-08-30. `AGENTS.md` D20 carries it in full; the short form:
|
||||||
|
|
||||||
|
- **Nothing has shipped.** Every claims finding to date has been about a page no
|
||||||
|
visitor can reach. The risk is deferred to cutover whether or not the audit is.
|
||||||
|
- **One pass over twenty finished pages catches more than nine over drafts**,
|
||||||
|
because it reads the site as a visitor does. The `/med-arb/` ADRIC gloss is the
|
||||||
|
proof: no individual claim was false, the defect was **adjacency**, and
|
||||||
|
adjacency does not exist until the pages sit next to each other.
|
||||||
|
- **The code reviewer stays per step because what it catches compounds.** An
|
||||||
|
accessibility or crawlability defect propagates into the next page built on it.
|
||||||
|
A claims defect does not compound; it sits there until someone reads it.
|
||||||
|
|
||||||
|
**What it costs, stated rather than glossed:** a claims defect can now live in an
|
||||||
|
unpublished draft for weeks. Two things carry that risk in the meantime, and
|
||||||
|
neither is a substitute for the cutover pass — **`npm run check:claims`**, which
|
||||||
|
is unchanged and runs on every build and both deploy paths, and **you reading the
|
||||||
|
copy as it is built.**
|
||||||
|
|
||||||
|
## Verifying the agents are loaded
|
||||||
|
|
||||||
|
**`.claude/agents/` is the correct location.** To confirm the agents are live,
|
||||||
|
invoke one directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
Use the claims-auditor agent to audit README.md against AGENTS.md §4.
|
||||||
|
```
|
||||||
|
|
||||||
|
A verdict table back means both are wired. "No such agent" means the frontmatter
|
||||||
|
needs looking at.
|
||||||
|
|
||||||
|
**Agent definitions load at session start.** An edit to `.claude/agents/*.md`
|
||||||
|
does not reach a running session — the version in force is the one that was on
|
||||||
|
disk when the session began. So after changing a brief, restart before relying on
|
||||||
|
it, and **say in the report which version actually ran.** This is not theoretical:
|
||||||
|
the gloss lens was added to `claims-auditor` on 2026-08-30 and the agent
|
||||||
|
reconstructed it from the Change Log rather than having it in its brief, because
|
||||||
|
the edit and the run were in the same session.
|
||||||
|
|
||||||
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
|
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
|
||||||
wrong. That is the intended trade: explaining why a finding is mistaken costs
|
wrong. That is the intended trade: explaining why a finding is mistaken costs
|
||||||
minutes, and a missed defect on a licensed professional's public marketing page
|
minutes, and a missed defect on this project's public marketing pages
|
||||||
costs a great deal more.
|
costs a great deal more. **This weighs heavier on the cutover pass, not lighter**
|
||||||
|
— there is nothing behind it, so D19's two-round cap does not apply there.
|
||||||
|
|
||||||
## The rule that makes it work
|
## The rule that makes it work
|
||||||
|
|
||||||
**The reviewers are given the diff and the specs — never the implementer's
|
**A reviewer is given the diff and the specs — never the implementer's
|
||||||
explanation of why the work is correct.**
|
explanation of why the work is correct.**
|
||||||
|
|
||||||
A rationale anchors the reviewer. Told why something is right, a reviewer looks
|
A rationale anchors the reviewer. Told why something is right, a reviewer looks
|
||||||
for confirmation and finds it; given only the artefact, it forms an independent
|
for confirmation and finds it; given only the artefact, it forms an independent
|
||||||
view. That independence is the entire mechanism. Every other detail of this
|
view. That independence is the entire mechanism. Every other detail of this
|
||||||
protocol is adjustable. This one is not.
|
protocol is adjustable — D20 just adjusted one of them. This one is not.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,3 +168,7 @@ see that judgement was exercised rather than the finding missed.
|
|||||||
**A check reported as passing that was not run.** "Should pass" is not a result.
|
**A check reported as passing that was not run.** "Should pass" is not a result.
|
||||||
If a build, a Lighthouse run, or a JavaScript-disabled render was not actually
|
If a build, a Lighthouse run, or a JavaScript-disabled render was not actually
|
||||||
executed, it must say so.
|
executed, it must say so.
|
||||||
|
|
||||||
|
**A report that says "reviewed" without naming which agent ran.** Under D20 a
|
||||||
|
build step gets `adversarial-reviewer` and not the claims pass; "reviewed" reads
|
||||||
|
as both. The report must name the one that ran.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
|||||||
|
# Hosting `adr.smlcompany.ca` on AWS — A Step-by-Step Guide
|
||||||
|
|
||||||
|
> ---
|
||||||
|
> ## REFERENCE ONLY — DO NOT EXECUTE
|
||||||
|
>
|
||||||
|
> **This is a historical record of how the existing AWS infrastructure was
|
||||||
|
> built. It is not a procedure to follow.** The live deployment procedure is
|
||||||
|
> [`docs/06-deployment.md`](../06-deployment.md); the authoritative inventory of
|
||||||
|
> what actually exists is `AGENTS.md` §7.
|
||||||
|
>
|
||||||
|
> Following this document would, among other things: create an IAM user with
|
||||||
|
> `AdministratorAccess` in account `327082975128` — which `AGENTS.md` §10 rates
|
||||||
|
> **High** blast-radius; rebuild the site through the standalone-HTML pipeline
|
||||||
|
> that D1 and D3 replace; and wire SES to addresses this project does not use.
|
||||||
|
>
|
||||||
|
> **Known contradictions with Current Truth**, all of which §7 and the specs win:
|
||||||
|
>
|
||||||
|
> | This guide says | Current Truth |
|
||||||
|
> |---|---|
|
||||||
|
> | Intake mail to `adr@` / `intake@smlcompany.ca` | **`info@smlcompany.ca`** — §4, D18 |
|
||||||
|
> | Lambda runtime Node.js 20.x | **`nodejs24.x`** — §7 |
|
||||||
|
> | "the SES sandbox is perfectly fine and free" | Sandbox is a **confirmed blocker**, Q19 |
|
||||||
|
> | `rebuild-standalone.py` / `sections.jsx` / a ~2.2 MB self-contained `index.html` | Astro static build — D1 |
|
||||||
|
> | SES policy with `"Resource": "*"` | Scope it; see §10 on this account |
|
||||||
|
> | "You (a lawyer, not a sysadmin)" — the original audience line, **corrected in place** | §4 records licence status as **NOT ESTABLISHED**; the word is barred outright |
|
||||||
|
> | The consent line "does not create a lawyer-client relationship" | Superseded by `NO_RETAINER_NOTICE` in `src/data/site.ts`, written to avoid exactly that phrasing |
|
||||||
|
>
|
||||||
|
> Retained because it is the only record of how the bucket, distribution,
|
||||||
|
> certificate, DNS, Lambda, DynamoDB table, and SES identities came to exist.
|
||||||
|
> Read it for that. Do not run it.
|
||||||
|
> ---
|
||||||
|
|
||||||
|
**Audience:** the site owner — comfortable clicking around, new to AWS.
|
||||||
|
**Goal:** Get the revamped site live at `https://adr.smlcompany.ca` with a working intake form whose submissions are stored in a database **and** emailed to `adr@smlcompany.ca`.
|
||||||
|
|
||||||
|
**Architecture you're building:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────┐
|
||||||
|
Browser ─────► │ CloudFront (CDN) │ ◄── ACM (free TLS cert)
|
||||||
|
adr.smlcompany.ca │ HTTPS + cache │
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ S3 bucket (origin) │ ← your standalone HTML + /assets
|
||||||
|
│ adr-smlcompany-site │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
Form submit ─► API Gateway ─► Lambda ─┬─► DynamoDB (permanent record)
|
||||||
|
└─► SES (emails adr@smlcompany.ca)
|
||||||
|
|
||||||
|
DNS stays at Namecheap (you add a CNAME for `adr` + cert/DKIM validation records)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total time:** about 2–3 hours the first time, in chunks. Most steps take a minute or two of clicking but DNS propagation and CloudFront deploys mean there's some waiting.
|
||||||
|
|
||||||
|
**Total monthly cost at low traffic:** under $1 USD. S3, CloudFront, Lambda, DynamoDB, and SES will all stay in or near their free tiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ A note about DNS choice
|
||||||
|
|
||||||
|
You've chosen to keep DNS at Namecheap rather than move to Route 53. That's perfectly fine and is actually cheaper (no $0.50/month hosted zone) and lower-risk (your existing MX records and email keep working untouched). The trade-offs:
|
||||||
|
|
||||||
|
- **CNAMEs can't sit at the apex.** Your apex `smlcompany.ca` will not be servable on CloudFront from Namecheap DNS — only subdomains like `adr.smlcompany.ca`. This is a DNS standard, not a Namecheap limitation. Since you're using a subdomain, you're fine. If you ever want the apex on CloudFront, you'd either move DNS to Route 53 (alias records can be at apex) or use Namecheap's "URL Redirect Record" feature to redirect the apex to the subdomain.
|
||||||
|
- **You'll add records by hand.** Each time AWS asks you to publish a DNS record (for cert validation, for SES DKIM, etc.), you'll copy/paste it into Namecheap → Advanced DNS yourself, instead of AWS writing it for you.
|
||||||
|
- **No automatic DNS updates.** Not really a downside at this scale — just something to know.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 0 — Prerequisites (15 min)
|
||||||
|
|
||||||
|
You said you already have an AWS account. Quick hardening pass:
|
||||||
|
|
||||||
|
### 0.1 Sign in as a non-root IAM user
|
||||||
|
- AWS strongly recommends you don't use the root account day-to-day. If you've been using root: in the console, open **IAM → Users → Create user**. Name it `pouya-admin`. Attach the AWS-managed policy `AdministratorAccess`. Enable **console access** with a custom password.
|
||||||
|
- Sign out and sign back in as `pouya-admin` going forward. Reserve the root login for billing changes only.
|
||||||
|
|
||||||
|
### 0.2 Turn on MFA for the root account
|
||||||
|
- IAM → Security credentials (under your root user) → **Assign MFA device** → use Authy / Google Authenticator / 1Password.
|
||||||
|
|
||||||
|
### 0.3 Set a billing alarm
|
||||||
|
- Console → **Billing and Cost Management → Budgets → Create budget**.
|
||||||
|
- Template: **Monthly cost budget**, $20 USD, notify at 80% and 100% to `pouya@meshkinilaw.ca`.
|
||||||
|
- This catches misconfiguration before it gets expensive.
|
||||||
|
|
||||||
|
### 0.4 Set your region
|
||||||
|
- Top-right of the AWS console: switch the region selector to **Canada (Central) — ca-central-1**.
|
||||||
|
- Everything in this guide is in `ca-central-1` **except** ACM (which for CloudFront *must* live in `us-east-1` — explained in Part 3) and CloudFront itself (which is global).
|
||||||
|
|
||||||
|
### 0.5 (Optional but useful) Install the AWS CLI
|
||||||
|
- macOS: `brew install awscli` then `aws configure` and paste an access key generated from IAM → your user → Security credentials.
|
||||||
|
- You don't strictly need it — every step below has a console path — but a few things (S3 sync, CloudFront invalidations) are much faster from the terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 1 — Put the website in an S3 bucket (15 min)
|
||||||
|
|
||||||
|
S3 is just object storage. We'll create one bucket, drop your standalone HTML and the `assets/` folder in it, and leave it private — CloudFront will be the only thing allowed to read from it.
|
||||||
|
|
||||||
|
### 1.1 Create the bucket
|
||||||
|
- Console → **S3 → Create bucket**.
|
||||||
|
- **Bucket name:** `adr-smlcompany-site` (must be globally unique across all of AWS — if it's taken, add a suffix like `-2026`).
|
||||||
|
- **Region:** Canada (Central) ca-central-1.
|
||||||
|
- **Block all public access:** leave the box **checked** (yes, fully blocked — CloudFront will use an Origin Access Control to read from it).
|
||||||
|
- **Bucket versioning:** Enable. This gives you a free undo if you ever overwrite the site with a broken version.
|
||||||
|
- Leave everything else default. **Create bucket**.
|
||||||
|
|
||||||
|
### 1.2 Upload your files
|
||||||
|
Your export contains a few HTML files. The one you want to serve is `SML ADR Site (Standalone).html` — that's the ~2.2 MB self-contained build with everything inlined.
|
||||||
|
|
||||||
|
- Open the bucket → **Upload**.
|
||||||
|
- **Add files** → select `SML ADR Site (Standalone).html`.
|
||||||
|
- **IMPORTANT:** before uploading, rename it locally to `index.html` (CloudFront's default root object). Or upload as-is and use the S3 console to rename it after upload (Actions → Rename).
|
||||||
|
- Also upload your `assets/` folder using **Add folder** so that `assets/sml-logo-full.png` and `assets/sml-logo-mark.png` end up at `s3://adr-smlcompany-site/assets/...`.
|
||||||
|
|
||||||
|
After upload, your bucket should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html
|
||||||
|
assets/
|
||||||
|
sml-logo-full.png
|
||||||
|
sml-logo-mark.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Set cache-control on the HTML (recommended)
|
||||||
|
Because we'll deploy by overwriting `index.html` later, you want browsers/CDN to re-check it often.
|
||||||
|
|
||||||
|
- Click `index.html` → **Properties → Edit metadata**.
|
||||||
|
- Add metadata: **System defined → Cache-Control → `public, max-age=300, must-revalidate`** (5 minutes).
|
||||||
|
- For the images in `assets/`, leave defaults (they can cache for much longer; CloudFront will use defaults).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 2 — Put CloudFront in front of S3 (20 min including wait)
|
||||||
|
|
||||||
|
CloudFront is AWS's CDN. It gives you HTTPS, global edge caching, and lets you put a real domain in front of an otherwise-private S3 bucket.
|
||||||
|
|
||||||
|
### 2.1 Create the distribution
|
||||||
|
- Console → **CloudFront → Create distribution**.
|
||||||
|
- **Origin domain:** click the dropdown and pick your bucket — `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`. The console will offer a "Use website endpoint" suggestion — **ignore that**, leave the REST endpoint selected.
|
||||||
|
- **Origin access:** select **Origin access control settings (recommended)**.
|
||||||
|
- Click **Create new OAC**. Name: `adr-smlcompany-oac`. Signing behavior: **Sign requests**. Origin type: **S3**. Create.
|
||||||
|
- You'll see a yellow banner saying *"You must update the S3 bucket policy."* Note this — we'll do it in a moment.
|
||||||
|
- **Viewer protocol policy:** **Redirect HTTP to HTTPS**.
|
||||||
|
- **Allowed HTTP methods:** GET, HEAD (default).
|
||||||
|
- **Cache policy:** **CachingOptimized** (managed).
|
||||||
|
- **Origin request policy:** leave blank.
|
||||||
|
- **Response headers policy:** **SecurityHeadersPolicy** (managed) — adds HSTS, X-Frame-Options, etc.
|
||||||
|
- **Compress objects automatically:** Yes.
|
||||||
|
- **Price class:** **Use only North America and Europe** (cheaper; your clients aren't in Tokyo).
|
||||||
|
- **Web Application Firewall (WAF):** **Do not enable** for now. (Could add later if needed; ~$5/mo.)
|
||||||
|
- **Alternate domain names (CNAMEs):** leave blank for now — we'll add `adr.smlcompany.ca` in Part 6, after the cert exists.
|
||||||
|
- **Custom SSL certificate:** leave **Default CloudFront Certificate** for now.
|
||||||
|
- **Default root object:** `index.html`.
|
||||||
|
- **Standard logging:** Off (can enable later).
|
||||||
|
- **Create distribution**.
|
||||||
|
|
||||||
|
### 2.2 Update the S3 bucket policy
|
||||||
|
After creating the distribution, you'll see a banner *"Copy policy"* with a JSON snippet — that snippet allows your specific CloudFront distribution to read from S3.
|
||||||
|
|
||||||
|
- Click **Copy policy**.
|
||||||
|
- Open the S3 bucket → **Permissions → Bucket policy → Edit** → paste → **Save changes**.
|
||||||
|
|
||||||
|
The policy looks roughly like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2008-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "AllowCloudFrontServicePrincipal",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "Service": "cloudfront.amazonaws.com" },
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::adr-smlcompany-site/*",
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT-ID>:distribution/<DIST-ID>" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Wait for "Deployed"
|
||||||
|
- CloudFront → your distribution → wait until **Last modified** shows a timestamp and the status reads **Deployed** (5–15 min the first time).
|
||||||
|
- Then visit the **Distribution domain name** shown at the top — something like `d123abc4xyz.cloudfront.net`. Your site should load over HTTPS.
|
||||||
|
- If you see XML access-denied: the bucket policy isn't saved yet, or `index.html` isn't named exactly that.
|
||||||
|
|
||||||
|
✅ **Checkpoint:** site loads on the `*.cloudfront.net` URL. We'll attach your real domain in Part 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 3 — Get a free SSL certificate (10 min, validation later)
|
||||||
|
|
||||||
|
CloudFront requires its TLS certificate to live in **us-east-1**, regardless of where the rest of your stack lives. This trips up everyone the first time.
|
||||||
|
|
||||||
|
### 3.1 Request the cert
|
||||||
|
- Top-right region selector → switch to **US East (N. Virginia) — us-east-1**. (You'll switch back to ca-central-1 after this part.)
|
||||||
|
- Console → **Certificate Manager → Request certificate → Request a public certificate**.
|
||||||
|
- **Domain names:**
|
||||||
|
- `adr.smlcompany.ca`
|
||||||
|
- (Optional, recommended) Add a second name: `*.smlcompany.ca`. A wildcard means you'll be able to use the same cert for `www.smlcompany.ca`, `mail.smlcompany.ca`, etc. without re-requesting.
|
||||||
|
- **Validation method:** **DNS validation** (the recommended option — uses a CNAME record).
|
||||||
|
- **Key algorithm:** RSA 2048.
|
||||||
|
- **Request**.
|
||||||
|
|
||||||
|
You'll land on the cert page in **Pending validation** state. ACM will show you one or two CNAME records of the form `_abc123.adr.smlcompany.ca` → `_xyz789.acm-validations.aws.`. Leave this tab open — you'll publish these in Namecheap in Part 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 4 — Open Namecheap's DNS panel (2 min)
|
||||||
|
|
||||||
|
We're not migrating DNS, but we will be coming back to this panel four times across the rest of the guide (ACM cert validation, the CloudFront CNAME, three SES DKIM records). So get familiar with where it is now.
|
||||||
|
|
||||||
|
### 4.1 Locate Advanced DNS
|
||||||
|
- Log in to Namecheap → **Domain List**.
|
||||||
|
- Find `smlcompany.ca` → click **Manage** on its row.
|
||||||
|
- Click the **Advanced DNS** tab. This is where you'll add every record below. (Do **not** touch the **Domain** tab's Nameservers section — leave it set to *Namecheap BasicDNS*.)
|
||||||
|
|
||||||
|
### 4.2 Make a "before" screenshot (1 min)
|
||||||
|
Take a screenshot of the current Host Records table. You won't need to touch any of the existing rows — they're handling your email and anything else you have set up. The screenshot is just an undo reference in case you ever paste over the wrong row.
|
||||||
|
|
||||||
|
### 4.3 How to add a record in Namecheap (reference for later steps)
|
||||||
|
Namecheap's row-add UX:
|
||||||
|
- Scroll to the **Host Records** section → click **ADD NEW RECORD**.
|
||||||
|
- Pick a **Type** from the dropdown (A, AAAA, CNAME, TXT, MX, etc.).
|
||||||
|
- **Host:** the subdomain part only. So for `adr.smlcompany.ca` the Host is `adr`. For the apex itself, use `@`. For something like `_abc123.adr.smlcompany.ca`, use `_abc123.adr`.
|
||||||
|
- **Value:** what AWS gives you. **Important:** Namecheap will sometimes append a trailing dot to CNAME values when it shows them back — that's normal. When *entering* a CNAME, you can include or omit the trailing dot; both work.
|
||||||
|
- **TTL:** Automatic (~30 min) is fine. For records you'll change often (testing), pick a low TTL like 5 min.
|
||||||
|
- Click the green checkmark on the right to save the row.
|
||||||
|
|
||||||
|
That's it — you'll do this five-ish times over Parts 5, 7, and 9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 5 — Validate the ACM cert via Namecheap (10 min including wait)
|
||||||
|
|
||||||
|
Back to the cert you requested in Part 3.
|
||||||
|
|
||||||
|
- Region selector → **us-east-1**.
|
||||||
|
- ACM → your pending cert → click into it.
|
||||||
|
- You'll see one (or two, if you added the wildcard) **CNAME validation records** of the form:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: _abc1234567890.adr.smlcompany.ca.
|
||||||
|
Value: _xyz9876543210.acm-validations.aws.
|
||||||
|
```
|
||||||
|
|
||||||
|
ACM gives you a **Copy** button next to each — handy.
|
||||||
|
|
||||||
|
- Switch tab to Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** the bit *before* `.smlcompany.ca` in the Name field. For example, if ACM shows `_abc1234567890.adr.smlcompany.ca.`, the Host you enter in Namecheap is `_abc1234567890.adr`. (Drop the trailing `.smlcompany.ca` — Namecheap appends it automatically.)
|
||||||
|
- **Target:** the Value from ACM, e.g. `_xyz9876543210.acm-validations.aws.` (trailing dot is fine).
|
||||||
|
- **TTL:** Automatic.
|
||||||
|
- Click the green checkmark.
|
||||||
|
|
||||||
|
- If you added the wildcard `*.smlcompany.ca` in Part 3, you'll see a second validation row in ACM — add a second CNAME the same way. (Often ACM gives the same Name/Value for the apex and wildcard, in which case you only need one CNAME.)
|
||||||
|
|
||||||
|
- Back in ACM, refresh the cert page after 2–10 min. Status flips from **Pending validation** to **Issued**. If it's still pending after 15 minutes, you've almost certainly got a Host typo — re-check that what's in Namecheap matches what ACM shows, character for character.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 6 — Attach the cert + domain to CloudFront (10 min including wait)
|
||||||
|
|
||||||
|
- Region selector → **us-east-1** (CloudFront is global but lives under us-east-1 in the console nav).
|
||||||
|
- CloudFront → your distribution → **General → Settings → Edit**.
|
||||||
|
- **Alternate domain name (CNAME):** add `adr.smlcompany.ca`. (Add `www.adr.smlcompany.ca` too if you want both — otherwise leave as just the one.)
|
||||||
|
- **Custom SSL certificate:** dropdown → select the cert you just issued.
|
||||||
|
- **Security policy:** TLSv1.2_2021.
|
||||||
|
- **Save changes**.
|
||||||
|
- Wait ~5–10 min for **Deployed** status again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 7 — Point DNS at CloudFront via Namecheap (3 min)
|
||||||
|
|
||||||
|
- Grab your CloudFront distribution domain from the CloudFront console (top of the distribution page) — it looks like `d123abc4xyz.cloudfront.net`.
|
||||||
|
- Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** `adr`
|
||||||
|
- **Target:** your CloudFront domain, e.g. `d123abc4xyz.cloudfront.net.` (trailing dot optional)
|
||||||
|
- **TTL:** 5 min (for the initial setup — you can raise it to Automatic once everything's stable)
|
||||||
|
- Save with the green checkmark.
|
||||||
|
|
||||||
|
> **About IPv6:** a CNAME delegates resolution to the target's records, and CloudFront serves both A (IPv4) and AAAA (IPv6) records. So a single CNAME automatically covers both — you don't need a separate AAAA record like you would with a Route 53 alias.
|
||||||
|
|
||||||
|
> **About the apex:** Namecheap DNS can't put a CNAME at `@` (the apex `smlcompany.ca`). That's a hard DNS-standards limit, not Namecheap's fault. Since you're using `adr.smlcompany.ca`, this doesn't affect you. If you also wanted `smlcompany.ca` (without the `adr.`) to land on the site, the easiest route is Namecheap's **URL Redirect Record** type: Host `@`, Target `https://adr.smlcompany.ca`, Type `Unmasked (301)`.
|
||||||
|
|
||||||
|
Within a couple of minutes, `https://adr.smlcompany.ca` should serve your site.
|
||||||
|
|
||||||
|
✅ **Checkpoint:** open `https://adr.smlcompany.ca` in an incognito window. You should see the revamped site, with a green padlock, no warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 8 — Backend: DynamoDB table + Lambda + API Gateway (45 min)
|
||||||
|
|
||||||
|
Now the intake form. The flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser POST → API Gateway (HTTPS) → Lambda function → DynamoDB.put_item()
|
||||||
|
→ SES.send_email() to adr@smlcompany.ca
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.1 Create the DynamoDB table
|
||||||
|
- Region → **ca-central-1**.
|
||||||
|
- Console → **DynamoDB → Tables → Create table**.
|
||||||
|
- **Table name:** `adr-intake-submissions`.
|
||||||
|
- **Partition key:** `submissionId` (String).
|
||||||
|
- **Sort key:** leave blank.
|
||||||
|
- **Settings:** **Default settings** — this gives you on-demand capacity (you pay per request, ~$0 at your volume) and encryption at rest by default.
|
||||||
|
- **Create**.
|
||||||
|
- After it's `Active`: click the table → **Backups → Point-in-time recovery → Edit → Turn on**. Costs cents/month and lets you restore to any second in the last 35 days.
|
||||||
|
|
||||||
|
### 8.2 Create the Lambda execution role (IAM)
|
||||||
|
- IAM → **Roles → Create role**.
|
||||||
|
- Trusted entity: **AWS service** → use case **Lambda**.
|
||||||
|
- Permissions: attach these AWS managed policies for now:
|
||||||
|
- `AWSLambdaBasicExecutionRole` (lets it write CloudWatch logs)
|
||||||
|
- **Role name:** `adr-intake-lambda-role`. Create.
|
||||||
|
- After creation: open the role → **Add permissions → Create inline policy** → JSON tab → paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["dynamodb:PutItem"],
|
||||||
|
"Resource": "arn:aws:dynamodb:ca-central-1:*:table/adr-intake-submissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Name it `adr-intake-lambda-inline`. Save.
|
||||||
|
|
||||||
|
### 8.3 Create the Lambda function
|
||||||
|
- Console → **Lambda → Create function**.
|
||||||
|
- **Author from scratch.**
|
||||||
|
- **Function name:** `adr-intake-handler`.
|
||||||
|
- **Runtime:** Node.js 20.x.
|
||||||
|
- **Architecture:** arm64 (cheaper).
|
||||||
|
- **Execution role:** *Use an existing role* → `adr-intake-lambda-role`.
|
||||||
|
- **Create function.**
|
||||||
|
|
||||||
|
In the Code tab, replace the contents of `index.mjs` with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
|
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
|
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ca-central-1" }));
|
||||||
|
const ses = new SESv2Client({ region: "ca-central-1" });
|
||||||
|
|
||||||
|
const TABLE = "adr-intake-submissions";
|
||||||
|
const FROM_ADDR = "adr@smlcompany.ca"; // must be SES-verified (Part 9)
|
||||||
|
const NOTIFY_ADDR = "adr@smlcompany.ca"; // must be SES-verified while SES is in sandbox
|
||||||
|
const ALLOWED_ORIGIN = "https://adr.smlcompany.ca";
|
||||||
|
|
||||||
|
const CORS = {
|
||||||
|
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
|
||||||
|
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export const handler = async (event) => {
|
||||||
|
// Preflight
|
||||||
|
if (event.requestContext?.http?.method === "OPTIONS") {
|
||||||
|
return { statusCode: 204, headers: CORS };
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(event.body || "{}");
|
||||||
|
} catch {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_json" }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract + light validation — adjust to taste.
|
||||||
|
const name = (body.name || "").toString().trim().slice(0, 200);
|
||||||
|
const org = (body.org || "").toString().trim().slice(0, 200);
|
||||||
|
const email = (body.email || "").toString().trim().slice(0, 200);
|
||||||
|
const phone = (body.phone || "").toString().trim().slice(0, 50);
|
||||||
|
const matter = (body.matter || "").toString().trim().slice(0, 100);
|
||||||
|
const message = (body.message || "").toString().trim().slice(0, 5000);
|
||||||
|
const honeypot = (body.website || "").toString(); // bot trap; see Part 10
|
||||||
|
|
||||||
|
if (honeypot) {
|
||||||
|
// Silently accept and drop — looks successful to bots.
|
||||||
|
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
|
||||||
|
}
|
||||||
|
if (!name || !email || !message) {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "missing_fields" }) };
|
||||||
|
}
|
||||||
|
if (!EMAIL_RE.test(email)) {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_email" }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = randomUUID();
|
||||||
|
const submittedAt = new Date().toISOString();
|
||||||
|
const sourceIp = event.requestContext?.http?.sourceIp || "unknown";
|
||||||
|
const userAgent = event.headers?.["user-agent"] || "unknown";
|
||||||
|
|
||||||
|
// 1) Store in DynamoDB
|
||||||
|
await ddb.send(new PutCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Item: { submissionId, submittedAt, name, org, email, phone, matter, message, sourceIp, userAgent },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2) Email adr@smlcompany.ca
|
||||||
|
const text =
|
||||||
|
`New intake form submission
|
||||||
|
|
||||||
|
Name: ${name}
|
||||||
|
Org: ${org || "(not provided)"}
|
||||||
|
Email: ${email}
|
||||||
|
Phone: ${phone || "(not provided)"}
|
||||||
|
Service: ${matter || "(not provided)"}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
${message}
|
||||||
|
|
||||||
|
—
|
||||||
|
Submission ID: ${submissionId}
|
||||||
|
Submitted: ${submittedAt}
|
||||||
|
IP: ${sourceIp}
|
||||||
|
|
||||||
|
Reply directly to this email — it will route to the submitter.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await ses.send(new SendEmailCommand({
|
||||||
|
FromEmailAddress: FROM_ADDR,
|
||||||
|
Destination: { ToAddresses: [NOTIFY_ADDR] },
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
Subject: { Data: `New intake: ${name}${org ? " — " + org : ""}`, Charset: "UTF-8" },
|
||||||
|
Body: { Text: { Data: text, Charset: "UTF-8" } },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ReplyToAddresses: [email], // hitting Reply in your inbox goes straight to the submitter
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true, submissionId }) };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** This version uses `adr@smlcompany.ca` as both From and To (per the simpler Option B in Part 9.3). The "Reply-To" header is set to the submitter's email, so when you hit *Reply* in your mail client, the response goes to them — not to yourself.
|
||||||
|
|
||||||
|
- Click **Deploy**.
|
||||||
|
- Set the runtime timeout to 10 seconds: **Configuration → General configuration → Edit → Timeout: 10 sec → Save**.
|
||||||
|
|
||||||
|
> **If you see a "module not found" error** on first invocation (rare but possible — AWS sometimes drops packages from the included SDK between runtime versions), you'll need to deploy your code as a zip with `node_modules`. Locally: `npm init -y && npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sesv2`, put your `index.mjs` next to `node_modules/`, then `zip -r function.zip index.mjs node_modules package*.json` and upload via Lambda → Code → Upload from → .zip file. The three SDK packages are normally pre-installed in the Node.js 20.x runtime, so you should be able to skip this step.
|
||||||
|
|
||||||
|
### 8.4 Create the HTTP API in API Gateway
|
||||||
|
- Console → **API Gateway → Create API → HTTP API → Build**.
|
||||||
|
- **Integrations:** click *Add integration* → Lambda → region ca-central-1 → function `adr-intake-handler`.
|
||||||
|
- **API name:** `adr-intake-api`.
|
||||||
|
- **Configure routes:**
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/submissions`
|
||||||
|
- Integration target: `adr-intake-handler`
|
||||||
|
- **Configure stages:** leave default (`$default`, auto-deploy enabled).
|
||||||
|
- **Create**.
|
||||||
|
|
||||||
|
After it's created:
|
||||||
|
- Open the API → **CORS** → **Configure**:
|
||||||
|
- Access-Control-Allow-Origin: `https://adr.smlcompany.ca`
|
||||||
|
- Access-Control-Allow-Methods: `POST`
|
||||||
|
- Access-Control-Allow-Headers: `content-type`
|
||||||
|
- Save.
|
||||||
|
- Note the **Invoke URL** at the top — looks like `https://abc123.execute-api.ca-central-1.amazonaws.com`. Your endpoint is `<invoke-url>/submissions`.
|
||||||
|
|
||||||
|
### 8.5 Quick smoke-test (without the front-end)
|
||||||
|
From your terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"Test User","org":"Test","email":"test@example.com","phone":"+1-416-555-0100","matter":"Mediation","message":"This is a test."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response: `{"ok":true,"submissionId":"..."}`.
|
||||||
|
|
||||||
|
At this point the DynamoDB write should succeed, but **SES will fail** until Part 9. Check **CloudWatch → Log groups → /aws/lambda/adr-intake-handler** — you'll see the error there. That's fine; Part 9 fixes it.
|
||||||
|
|
||||||
|
To see the row landed in the DB: DynamoDB → Tables → `adr-intake-submissions` → **Explore table items**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 9 — Verify your sender domain in SES (15 min)
|
||||||
|
|
||||||
|
By default SES is in **sandbox**: it can only send *from* verified identities *to* verified identities. For a low-volume contact-form notifier sending only to yourself, the sandbox is perfectly fine and free.
|
||||||
|
|
||||||
|
### 9.1 Verify the domain `smlcompany.ca`
|
||||||
|
- Region → **ca-central-1**.
|
||||||
|
- Console → **Amazon SES → Configuration → Identities → Create identity**.
|
||||||
|
- Identity type: **Domain**.
|
||||||
|
- Domain: `smlcompany.ca`.
|
||||||
|
- **Use a custom MAIL FROM domain:** skip (optional).
|
||||||
|
- **DKIM:** **Easy DKIM**, RSA 2048-bit. Leave **Publish DNS records to Route 53** unchecked (you don't have a Route 53 hosted zone).
|
||||||
|
- Create.
|
||||||
|
|
||||||
|
You'll land on the identity page with three CNAME records that SES wants published. They look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: abc1234567890._domainkey.smlcompany.ca
|
||||||
|
Value: abc1234567890.dkim.amazonses.com
|
||||||
|
|
||||||
|
Name: def0987654321._domainkey.smlcompany.ca
|
||||||
|
Value: def0987654321.dkim.amazonses.com
|
||||||
|
|
||||||
|
Name: ghi5555555555._domainkey.smlcompany.ca
|
||||||
|
Value: ghi5555555555.dkim.amazonses.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Add each in Namecheap → Advanced DNS → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** the part before `.smlcompany.ca` — e.g. `abc1234567890._domainkey`
|
||||||
|
- **Target:** the SES value, e.g. `abc1234567890.dkim.amazonses.com`
|
||||||
|
- **TTL:** Automatic
|
||||||
|
- Save with the green checkmark. Repeat for the other two.
|
||||||
|
|
||||||
|
⚠️ If you currently have **any other DKIM CNAMEs** for `smlcompany.ca` from your existing email provider (e.g. Google Workspace's `google._domainkey`), **leave them alone**. SES's DKIM uses different selector names, so it won't collide. Multiple DKIM keys on the same domain is normal and supported.
|
||||||
|
|
||||||
|
After 5–15 minutes, refresh the SES identity page. The DKIM status flips to **Successful** and the overall identity status flips to **Verified**. If it's still pending after 30 minutes, check the Host fields in Namecheap for typos.
|
||||||
|
|
||||||
|
> **Bonus — SPF alignment for SES.** Your existing `v=spf1 ...` TXT record at the apex tells the world which servers may send mail "as" smlcompany.ca. If you want SES-sent mail to pass SPF too (improves deliverability of intake notifications), add `include:amazonses.com` to the existing SPF record. Edit it in Namecheap so it becomes e.g.: `v=spf1 include:_spf.google.com include:amazonses.com ~all`. Don't create a *second* SPF TXT record — only one is allowed per domain.
|
||||||
|
|
||||||
|
### 9.2 Verify the recipient
|
||||||
|
While SES is in sandbox, the *To:* address also has to be verified.
|
||||||
|
|
||||||
|
- SES → Identities → **Create identity** → Email address → `adr@smlcompany.ca` → Create.
|
||||||
|
- AWS sends a verification email to that address. Click the link. Status → **Verified**.
|
||||||
|
|
||||||
|
### 9.3 Verify the From address
|
||||||
|
The Lambda above uses `intake@smlcompany.ca` as the From. Verify it too:
|
||||||
|
- SES → Identities → **Create identity** → Email address → `intake@smlcompany.ca` → Create.
|
||||||
|
- Either have your email provider deliver mail at that alias to your real inbox, *or* just use `adr@smlcompany.ca` as the From in the Lambda code and skip this step.
|
||||||
|
|
||||||
|
### 9.4 Retest
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"Test 2","org":"Test","email":"test@example.com","matter":"Mediation","message":"Now with email."}'
|
||||||
|
```
|
||||||
|
You should get the success response AND see an email arrive at `adr@smlcompany.ca` within a few seconds.
|
||||||
|
|
||||||
|
### 9.5 (Optional, later) Request production access
|
||||||
|
If you ever want the form to **send a confirmation email back to the submitter**, you'll need to exit sandbox. SES Console → top right → **Request production access**. AWS asks a few questions about how you'll use it; approval is usually under 24 hr for legitimate business use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 10 — Wire the front-end form to your API (20 min)
|
||||||
|
|
||||||
|
### How the site is bundled
|
||||||
|
|
||||||
|
Your site export uses a custom format from Anthropic's Artifacts bundler:
|
||||||
|
|
||||||
|
- **`SML ADR Site (Standalone).html`** — a single 2.2 MB file containing the rendered HTML *plus* all JSX, JavaScript, fonts, and the logo PNG bundled together as gzipped+base64 entries inside a `<script type="__bundler/manifest">` JSON blob. This is the file you uploaded to S3 as `index.html`.
|
||||||
|
- **`components-standalone/*.jsx`** — the loose JSX source files (sections, hero, nav, etc.). The standalone HTML was originally built *from* these but isn't automatically rebuilt when you edit them.
|
||||||
|
|
||||||
|
So if you change a JSX file, you have two options:
|
||||||
|
|
||||||
|
| Approach | What you upload to S3 | Pros | Cons |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Stay with the bundled HTML** *(recommended)* | One file (`index.html`) | Same as before. Fast page load. CloudFront caches it well. | Need to "rebundle" after JSX edits. |
|
||||||
|
| **Switch to loose files** | `(standalone-src).html` + the whole `components-standalone/` folder + `tweaks-panel.jsx` + `assets/` | No rebuild step — just upload changed JSX. | Extra ~500ms first page load while Babel compiles JSX in the browser. Many small files. |
|
||||||
|
|
||||||
|
This guide assumes you stay with the bundled HTML, because that's the architecture you started with.
|
||||||
|
|
||||||
|
### The form code is already updated
|
||||||
|
|
||||||
|
The `components-standalone/sections.jsx` file has been edited. The new Contact component:
|
||||||
|
|
||||||
|
- Adds two new required-flag-aware fields: **Email** (required, `type="email"`) and **Phone** (optional, `type="tel"`).
|
||||||
|
- Wires `onSubmit` to a real `fetch()` POST against your API Gateway endpoint.
|
||||||
|
- Adds `submitting` and `error` state so the button shows "Sending…" while in flight and a clear maroon-bordered error message on failure.
|
||||||
|
- Adds a hidden **honeypot** field (`website`) to silently drop bot submissions.
|
||||||
|
- Adds a small-print **consent line** under the submit button. **Superseded — do not use this wording:** the live text is `NO_RETAINER_NOTICE` in `src/data/site.ts`, which deliberately avoids the phrase below. Historical text: *"Submitting this form does not create a lawyer-client relationship. By submitting, you consent to storage of this information by SML Company in Canada for the purpose of responding to your inquiry."*
|
||||||
|
- Extends the shared `Field` component to accept `type` and `required` props, rendering a gold asterisk next to required-field labels.
|
||||||
|
|
||||||
|
The API endpoint is hard-coded at the top of the Contact section:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const INTAKE_API_URL = 'https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com/submissions';
|
||||||
|
```
|
||||||
|
|
||||||
|
If your API Gateway URL ever changes, update that one constant and rebuild (next step).
|
||||||
|
|
||||||
|
### Rebuilding the standalone HTML (`rebuild-standalone.py`)
|
||||||
|
|
||||||
|
A small Python script sits alongside the JSX in the project folder. It reads the original `SML ADR Site (Standalone).html`, swaps in the current contents of `components-standalone/sections.jsx`, re-compresses, and writes out a fresh `index.html` that's ready to upload to S3.
|
||||||
|
|
||||||
|
From a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web"
|
||||||
|
python3 rebuild-standalone.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see output like:
|
||||||
|
|
||||||
|
```
|
||||||
|
patched sections.jsx -> 8830e633-... (42,792 bytes → 10,513 gz → 14,020 b64)
|
||||||
|
Wrote .../Pouya Personal Branding Web/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
That `index.html` is the file you upload to S3.
|
||||||
|
|
||||||
|
The script is intentionally limited to `sections.jsx` (where the form lives). If you ever want to edit the hero, nav, or any other component, open `rebuild-standalone.py` and uncomment the relevant line in the `JSX_FILES` mapping after discovering each component's UUID (the script docstring explains how).
|
||||||
|
|
||||||
|
> **For the first run we already did this for you** — a fresh `index.html` containing the email/phone form is sitting in the project folder right now, ready to upload.
|
||||||
|
|
||||||
|
### Deploying the change
|
||||||
|
|
||||||
|
1. Upload the new `index.html` to your S3 bucket `adr-smlcompany-site`, **replacing** the existing `index.html`. S3 versioning (enabled in Part 1.1) keeps the old version recoverable if anything goes wrong.
|
||||||
|
|
||||||
|
Console path: S3 → `adr-smlcompany-site` → **Upload** → drag `index.html` from the project folder → **Cache-Control:** `public, max-age=300, must-revalidate` → **Upload**.
|
||||||
|
|
||||||
|
Or from the terminal:
|
||||||
|
```bash
|
||||||
|
aws s3 cp \
|
||||||
|
"/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web/index.html" \
|
||||||
|
s3://adr-smlcompany-site/index.html \
|
||||||
|
--cache-control 'public, max-age=300, must-revalidate'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Invalidate CloudFront** so users see the new version immediately rather than waiting for the 5-minute cache to expire.
|
||||||
|
|
||||||
|
Console: CloudFront → your distribution → **Invalidations → Create invalidation** → object path: `/index.html` (and `/` for safety) → **Create**. Costs $0.005 per path (first 1,000 paths/month are free).
|
||||||
|
|
||||||
|
Or from the terminal:
|
||||||
|
```bash
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id <YOUR-DIST-ID> \
|
||||||
|
--paths '/' '/index.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Hard-refresh `https://adr.smlcompany.ca` in an incognito window. Confirm the form now shows the Email and Phone fields and the consent line under the submit button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 11 — End-to-end smoke test (10 min)
|
||||||
|
|
||||||
|
In an incognito window:
|
||||||
|
|
||||||
|
1. Open `https://adr.smlcompany.ca`. Confirm green padlock, all sections render, logos load.
|
||||||
|
2. Open the browser devtools → Network tab. Submit the intake form with realistic values.
|
||||||
|
3. Confirm the network call to your API Gateway returns 200.
|
||||||
|
4. Within 30 seconds, check `adr@smlcompany.ca` — you should have a "New intake: …" email.
|
||||||
|
5. Open the DynamoDB table → **Explore table items** → you should see your test row.
|
||||||
|
6. (Optional) Try submitting from `curl` with the honeypot field set — `{"website":"http://spam"}`. You should get a 200 but **no email and no DB row** (silent drop).
|
||||||
|
|
||||||
|
✅ If all five pass, you're live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 12 — Day-2 operations
|
||||||
|
|
||||||
|
### How to update the site
|
||||||
|
1. Re-export the standalone HTML.
|
||||||
|
2. Upload to S3 as `index.html` (overwrites; old version preserved by versioning).
|
||||||
|
3. CloudFront invalidate `/index.html` (and `/` for safety).
|
||||||
|
|
||||||
|
Doable in 2 minutes via the CLI:
|
||||||
|
```bash
|
||||||
|
aws s3 cp index.html s3://adr-smlcompany-site/index.html \
|
||||||
|
--cache-control 'public, max-age=300, must-revalidate'
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id <DIST-ID> --paths '/' '/index.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- **CloudWatch alarm — Lambda errors:** CloudWatch → Alarms → Create alarm → Metric: Lambda → ByFunctionName → `adr-intake-handler` → Errors → Statistic Sum, period 5 min, threshold `>= 1`. Notify via an SNS topic that emails you. Alerts you within minutes if the form starts failing.
|
||||||
|
- **CloudFront 5xx error rate alarm:** same pattern, threshold `> 1%`.
|
||||||
|
- Set both with low thresholds — your traffic is low enough that any sustained error matters.
|
||||||
|
|
||||||
|
### Backups & retention
|
||||||
|
- DynamoDB PITR (Part 8.1) gives you 35-day rollback.
|
||||||
|
- S3 versioning (Part 1.1) gives you forever-rollback on the site files.
|
||||||
|
- Consider a quarterly export of the DynamoDB table to S3 if you want a clean audit trail.
|
||||||
|
|
||||||
|
### Privacy / PIPEDA hygiene (legal-services context)
|
||||||
|
- Everything lives in `ca-central-1`. CloudFront caches *static* HTML at edge locations globally, but your form *submissions* never touch CloudFront — they go directly to API Gateway in ca-central-1.
|
||||||
|
- Consider adding a one-line consent notice under the form: *"By submitting this form, you consent to its storage by SML Company in Canada for the purpose of responding to your inquiry. We do not share this information with third parties."*
|
||||||
|
- DynamoDB rows include the submitter's IP and user-agent for abuse defense. If you'd rather not store those, remove `sourceIp` and `userAgent` from the `PutCommand` Item.
|
||||||
|
|
||||||
|
### Cost expectations (USD, monthly)
|
||||||
|
| Service | Expected | Notes |
|
||||||
|
|-----------------|---------------|-----------------------------------------|
|
||||||
|
| Namecheap DNS | $0 | Included with your domain registration. |
|
||||||
|
| S3 | <$0.05 | 3 MB of files + a few requests. |
|
||||||
|
| CloudFront | $0.10–1 | Free tier covers first 1 TB out/month. |
|
||||||
|
| ACM cert | $0 | Free. |
|
||||||
|
| API Gateway | <$0.05 | $1 per million requests. |
|
||||||
|
| Lambda | $0 | Free tier covers 1M requests/mo. |
|
||||||
|
| DynamoDB | $0 | On-demand, low volume. |
|
||||||
|
| SES | $0 | First 62k emails/mo from Lambda free. |
|
||||||
|
| **Total** | **under $1** | |
|
||||||
|
|
||||||
|
### What to do if something breaks
|
||||||
|
- **Site won't load:** check CloudFront *Status = Deployed*, and that the Namecheap CNAME for `adr` points to the CloudFront domain (paste the value from `dig adr.smlcompany.ca CNAME` or `nslookup adr.smlcompany.ca` to verify it actually resolves to a `.cloudfront.net` host).
|
||||||
|
- **403 from CloudFront:** the S3 bucket policy isn't right — re-copy from CloudFront's "Origins → Edit" page.
|
||||||
|
- **TLS error:** ACM cert is in us-east-1, not ca-central-1; or the *Alternate domain name* on the CloudFront distribution doesn't match exactly.
|
||||||
|
- **Form returns 500:** open CloudWatch logs for the Lambda — almost always an unverified SES identity or a permissions gap on the role.
|
||||||
|
- **Email not arriving:** SES is still in sandbox AND the destination isn't verified, OR the From address isn't verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — File / resource manifest
|
||||||
|
|
||||||
|
When you're done, here's what you should be able to point at in your AWS console:
|
||||||
|
|
||||||
|
| Resource | Name / ID |
|
||||||
|
|-----------------------|----------------------------------------------------------------------|
|
||||||
|
| S3 bucket | `adr-smlcompany-site` (ca-central-1) |
|
||||||
|
| CloudFront dist | `E…` (CNAME: adr.smlcompany.ca) |
|
||||||
|
| ACM certificate | for `adr.smlcompany.ca` (us-east-1) |
|
||||||
|
| DNS provider | Namecheap (Advanced DNS for smlcompany.ca) |
|
||||||
|
| DynamoDB table | `adr-intake-submissions` (ca-central-1) |
|
||||||
|
| Lambda function | `adr-intake-handler` (ca-central-1) |
|
||||||
|
| IAM role | `adr-intake-lambda-role` |
|
||||||
|
| API Gateway | `adr-intake-api` (HTTP API, ca-central-1) |
|
||||||
|
| SES verified ids | domain `smlcompany.ca`, email `adr@smlcompany.ca`, `intake@…` |
|
||||||
|
|
||||||
|
## Appendix B — Future enhancements (when you want them)
|
||||||
|
|
||||||
|
- **Admin dashboard for submissions:** build a tiny password-protected page that calls a second Lambda (`GET /submissions`) to list the DynamoDB table. Or just use the DynamoDB console for now — it's perfectly serviceable for low volume.
|
||||||
|
- **Confirmation email to submitter:** request SES production access (Part 9.5), then add a second `SendEmailCommand` call in the Lambda thanking them and setting expectations.
|
||||||
|
- **Calendar booking:** integrate Calendly or Cal.com link inside the "Thank you" view.
|
||||||
|
- **File uploads on intake** (e.g., a PDF of the dispute summary): add S3 presigned-URL generation in the Lambda, let the front-end upload directly to a private bucket. Keep file size limits sane.
|
||||||
|
- **Move DNS to Route 53 later:** if you ever want apex (`smlcompany.ca`) on CloudFront, or want AWS to manage records for you automatically, the migration is straightforward — inventory Namecheap records, recreate them in a new Route 53 hosted zone, switch nameservers at Namecheap. Doable in ~30 min once you have a downtime window for any DNS-sensitive integrations.
|
||||||
|
- **Bilingual (EN/FA) routing:** add a `lang` query param or subpath, serve from the same S3 bucket via CloudFront behaviors.
|
||||||
|
- **Search / analytics over submissions:** stream DynamoDB updates to a small OpenSearch index, or just export weekly to a private S3 bucket and query with Athena.
|
||||||
|
- **WAF in front of CloudFront:** if you ever see scraper/bot traffic, add AWS WAF with the AWS-managed core rule set (~$5/mo + per-request).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of guide. If you hit a wall on any specific step, come back here and tell me which Part and what the screen says — most issues are 1-line fixes.*
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
# The exact published names of ADRIC, ADRIO and ADR Chambers rule sets, designations and codes
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.** Fetched from the primary sources listed below and
|
||||||
|
extracted with quotations pasted verbatim. This file is the artefact; the pages
|
||||||
|
cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a regulation and a tribunal page all move.** Every consolidation
|
||||||
|
> date and effective date below is as at the retrieval date and nothing more.
|
||||||
|
> Re-check before cutover, and before any republish that turns on one of them.
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||||
|
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||||
|
> are still the original retrieval and were not re-fetched** — what was
|
||||||
|
> re-checked is whether the *facts* they support have moved.
|
||||||
|
>
|
||||||
|
> - **(g) ADRIC's Code of Ethics, quoted verbatim on `/process/` — NOT
|
||||||
|
> RE-RETRIEVED.** Held unchanged on a cadence judgement `[assumed
|
||||||
|
> 2026-09-01 — Pouya]`. It is the slowest-moving of the seven; the live link
|
||||||
|
> on `/process/` is what makes a stale quotation visible to a reader.
|
||||||
|
|
||||||
|
**Topic as researched:** The Canadian ADR institutions this practice names, and the exact form of their names — ADRIC / ADRIO rule sets, designations, codes; ADR Chambers; early neutral evaluation and dispute-system design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| institution | Rules & Codes - ADR Institute of Canada | <https://adric.ca/rules-codes/> |
|
||||||
|
| institution | National Mediation Rules - ADR Institute of Canada | <https://adric.ca/rules-codes/national-mediation-rules/> |
|
||||||
|
| institution | ADR Institute of Canada, Inc. — National Mediation Rules (PDF linked from the National Mediation Rules page) | <https://adric.ca/pdf/ADRMEDIATIONRULES2014.pdf> |
|
||||||
|
| institution | ADRIC Arbitration Rules - ADR Institute of Canada | <https://adric.ca/rules-codes/arbrules/> |
|
||||||
|
| institution | ADRIC ARBITRATION RULES — Effective 01 March 2025 (PDF, 42 pp.) | <https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf> |
|
||||||
|
| institution | ADRIC ARBITRATOR APPOINTMENT PROTOCOL — Effective 01 March 2025 (PDF) | <https://adric.ca/rules/ADRIC-Arbitration-Protocol-2025.pdf> |
|
||||||
|
| institution | ADRIC Med-Arb Rules - ADR Institute of Canada | <https://adric.ca/rules-codes/adric-med-arb-rules/> |
|
||||||
|
| institution | ADRIC Med-Arb Rules (PDF, 8.5 x 11 format) | <https://adric.ca/wp-content/uploads/2023/12/ADRIC_Med_Arb_Rules_2020_8_5-X-11-p.-1.pdf> |
|
||||||
|
| institution | Code of Conduct - ADR Institute of Canada | <https://adric.ca/rules-codes/code-of-conduct/> |
|
||||||
|
| institution | Code of Ethics - ADR Institute of Canada | <https://adric.ca/rules-codes/code-of-ethics/> |
|
||||||
|
| institution | Ethics & Professional Practice - ADR Institute of Canada | <https://adric.ca/ethics-professional-practice/> |
|
||||||
|
| institution | Professional Designations - ADR Institute of Canada | <https://adric.ca/professional-designations/> |
|
||||||
|
| institution | Chartered Med-Arb - ADR Institute of Canada | <https://adric.ca/professional-designations/chartered-med-arb/> |
|
||||||
|
| institution | Arbitrator Designations - ADR Institute of Canada | <https://adric.ca/professional-designations/arbitrator-designations/> |
|
||||||
|
| institution | Mediator Designations - ADR Institute of Canada | <https://adric.ca/professional-designations/mediator-designations/> |
|
||||||
|
| institution | Services - ADR Institute of Canada | <https://adric.ca/services/> |
|
||||||
|
| institution | Custom ADR Systems and Roster Development - ADR Institute of Canada | <https://adric.ca/services/custom-adr-systems-rosters/> |
|
||||||
|
| institution | ADRIC Professional Practice Manuals - ADR Institute of Canada | <https://adric.ca/adric-professional-practice-manuals/> |
|
||||||
|
| institution | ADRIC Sponsored Professional Liability Insurance Program brochure (PDF, PDF creation date 2016) | <https://adric.ca/wp-content/uploads/2015/05/ADR-Brochure-EN-final.pdf> |
|
||||||
|
| institution | Professional Designations – The ADR Institute of Ontario | <https://adr-ontario.ca/professional-designations/> |
|
||||||
|
| institution | Chartered Med-Arbitrator (C.Med-Arb) – The ADR Institute of Ontario | <https://adr-ontario.ca/chartered-med-arbitrator-c-med-arb/> |
|
||||||
|
| institution | Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb) – The ADR Institute of Ontario | <https://adr-ontario.ca/qualified-mediator-q-med-qualified-arbitrator-q-arb/> |
|
||||||
|
| institution | Rules & Codes – The ADR Institute of Ontario | <https://adr-ontario.ca/rules-codes/> |
|
||||||
|
| institution | Code of Ethics – The ADR Institute of Ontario | <https://adr-ontario.ca/code-of-ethics/> |
|
||||||
|
| proponent | About ADR Chambers — Trusted ADR Experts in Canada | <https://adrchambers.com/about-adr-chambers/> |
|
||||||
|
| proponent | Arbitration rules - ADR Chambers | <https://adrchambers.com/arbitration/rules/> |
|
||||||
|
| proponent | ADR Chambers Arbitration Rules — current version PDF (Revised February 10, 2026) | <https://adrchambers.com/wp-content/uploads/2026/02/Revised-ADRC-Arbitration-Rules-Feb-10-2026.pdf> |
|
||||||
|
| proponent | Mediation Rules - ADR Chambers | <https://adrchambers.com/mediation/rules/> |
|
||||||
|
| proponent | ADR Chambers Mediation Rules (PDF) | <https://adrchambers.com/wp-content/uploads/2017/11/Mediation-Rules.pdf> |
|
||||||
|
| proponent | ADR Chambers Expedited Arbitration Rules and Efficiency | <https://adrchambers.com/expedited-arbitration/rules/> |
|
||||||
|
| proponent | ADR Chambers Expedited Arbitration Rules (PDF, revised April 2026) | <https://adrchambers.com/wp-content/uploads/2023/04/ADRC-Expedited-Arbitration-Rules-Revised-April-2026.pdf> |
|
||||||
|
| proponent | Mediation Model Clauses - ADR Chambers | <https://adrchambers.com/mediation-model-clause/> |
|
||||||
|
| proponent | ADR Chambers Neutral Evaluation – Expert Case Assessment | <https://adrchambers.com/neutral-evaluation/> |
|
||||||
|
| proponent | ADR Systems Design - ADR Chambers | <https://adrchambers.com/adr-systems-design/> |
|
||||||
|
| proponent | ADR Chambers International – International Arbitration and Mediation | <https://adrchambersinternational.com/> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### Rules & Codes - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Rules & Codes - ADR Institute of Canada
|
||||||
|
|
||||||
|
> ADRIC By-laws
|
||||||
|
> Federation MoU
|
||||||
|
> ADRIC Arbitration Rules
|
||||||
|
> National Mediation Rules
|
||||||
|
> ADRIC Med-Arb Rules
|
||||||
|
> Ethics & Professional Practice
|
||||||
|
> Code of Ethics
|
||||||
|
> Code of Conduct
|
||||||
|
> Conflict of Interest
|
||||||
|
> Complaints & Discipline Policy
|
||||||
|
> Privacy Policy
|
||||||
|
> Online Dispute Resolution (ODR) Vision
|
||||||
|
|
||||||
|
> ADRIC By-Laws
|
||||||
|
> Learn More
|
||||||
|
> Federation MoU
|
||||||
|
> Learn More
|
||||||
|
> ADRIC Arbitration Rules
|
||||||
|
> Learn More
|
||||||
|
> ADRIC Mediation Rules
|
||||||
|
> Learn More
|
||||||
|
> ADRIC Med-Arb Rules
|
||||||
|
> Learn More
|
||||||
|
|
||||||
|
> <h2 style="color: #FFFFFF;text-align: center;font-family:Montserrat;font-weight:400;font-style:normal" class="vc_custom_heading">ADRIC Mediation Rules</h2>
|
||||||
|
|
||||||
|
> Chartered Med-Arbitrator
|
||||||
|
|
||||||
|
> ADR Institute of Canada, Inc. 705-130 Albert Street, Ottawa, ON K1P5G4 1-877-475-4353 info@adric.ca
|
||||||
|
|
||||||
|
### National Mediation Rules - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/national-mediation-rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>National Mediation Rules - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> <h1><strong>The ADRIC National Mediation Rules</strong></h1>
|
||||||
|
|
||||||
|
> The National Mediation Rules provide rules for initiating mediations, including the appointment of a mediator should the parties be unable to come to an agreement.
|
||||||
|
|
||||||
|
> The National Mediation Rules document contains the following:
|
||||||
|
> Mediation Rules including Code of Conduct
|
||||||
|
> Standard Form Agreement to Mediate (Schedule B)
|
||||||
|
> Administration fees payable to the ADR Institute of Canada- see Schedule A of the Rules
|
||||||
|
|
||||||
|
> All disputes arising out of or in connection with this agreement, or in respect of any legal relationship associated with or derived from this agreement, shall be mediated pursuant to the National Mediation Rules of the ADR Institute of Canada, Inc.
|
||||||
|
|
||||||
|
> Important Update on the ADR Institute of Canada (ADRIC) National Mediation Rules: As of 2025, the ADRIC Mediation Committee is currently reviewing the Mediation Rules to ensure they remain current, practical, and aligned with best practices in the field. In the meantime, the existing rules remain in effect and should continue to be used until any updates are formally adopted.
|
||||||
|
|
||||||
|
> Commercial contracts drafted by law firms of all sizes across Canada commonly contain a clause indicating that any dispute that arises with respect to the contract will be administered by ADR Canada, or one of its affiliates, pursuant to the National Mediation Rules or the ADRIC Arbitration Rules of the ADR Institute of Canada.
|
||||||
|
|
||||||
|
### ADR Institute of Canada, Inc. — National Mediation Rules (PDF linked from the National Mediation Rules page)
|
||||||
|
|
||||||
|
<https://adric.ca/pdf/ADRMEDIATIONRULES2014.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADR INSTITUTE OF CANADA, INC.
|
||||||
|
> NATIONAL MEDIATION RULES
|
||||||
|
|
||||||
|
> ADR INSTITUTE OF CANADA, INC.
|
||||||
|
> CODE OF CONDUCT FOR MEDIATORS
|
||||||
|
|
||||||
|
> ADR Institute of Canada, Inc. © As amended August 3, 2012
|
||||||
|
|
||||||
|
> (g) "Rules" means the National Mediation Rules of the Institute.
|
||||||
|
|
||||||
|
> Parties who agree to mediate under the National Mediation Rules may use the following clause in their agreement:
|
||||||
|
|
||||||
|
### ADRIC Arbitration Rules - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/arbrules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>ADRIC Arbitration Rules - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> <h1><strong>The ADRIC Arbitration Rules</strong></h1>
|
||||||
|
|
||||||
|
> ADRIC has adopted new Arbitration Rules and a new Arbitrator Appointment Protocol, effective March 1, 2025. This modernization effort aims to support both domestic and international arbitration with up-to-date procedures and streamlined institutional support. The ADRIC Arbitration Rules have been the leading choice for Canadian businesses since 2002.
|
||||||
|
|
||||||
|
> ADRIC Arbitration Rules – Effective 2025
|
||||||
|
> ADRIC Arbitrator Appointment Protocol
|
||||||
|
|
||||||
|
> All disputes arising out of or in connection with this agreement, or in relation to any legal relationship associated with or derived from this agreement, will be resolved by final and binding arbitration under the Arbitration Rules of the ADR Institute of Canada, Inc. [or the Simplified Arbitration Rules of the ADR Institute of Canada, Inc.] The Seat of Arbitration will be [specify]. The language of the arbitration will be [specify].
|
||||||
|
|
||||||
|
> — Comment by William G. Horton, C.Arb, co-chair and discussion leader of the ADRIC Modernization Committee that drafted the new Arbitration Rules.
|
||||||
|
|
||||||
|
### ADRIC ARBITRATION RULES — Effective 01 March 2025 (PDF, 42 pp.)
|
||||||
|
|
||||||
|
<https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADRIC
|
||||||
|
> ARBITRATION RULES
|
||||||
|
> Effective 01 March 2025
|
||||||
|
|
||||||
|
> ADR INSTITUTE OF CANADA, INC.
|
||||||
|
>
|
||||||
|
> ARBITRATION RULES
|
||||||
|
|
||||||
|
> IV. CURRENT VERSION OF THE RULES AND UPDATES
|
||||||
|
> Go to https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf for the most current version of the Rules.
|
||||||
|
|
||||||
|
> 6.2 EXPEDITED/SIMPLIFIED ARBITRATION PROCEDURE ............................................................................... 19
|
||||||
|
|
||||||
|
> 6.2.1 If the parties agree in writing or the Tribunal orders, the arbitration will follow the Expedited/Simplified
|
||||||
|
|
||||||
|
### ADRIC ARBITRATOR APPOINTMENT PROTOCOL — Effective 01 March 2025 (PDF)
|
||||||
|
|
||||||
|
<https://adric.ca/rules/ADRIC-Arbitration-Protocol-2025.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADRIC
|
||||||
|
> ARBITRATOR APPOINTMENT PROTOCOL
|
||||||
|
>
|
||||||
|
> Effective 01 March 2025
|
||||||
|
> Revised: 2026-01-30
|
||||||
|
|
||||||
|
> ADR Institute of Canada (ADRIC)
|
||||||
|
>
|
||||||
|
> ADRIC Arbitrator Appointment Protocol
|
||||||
|
|
||||||
|
> ADRIC makes arbitrator appointments ("Direct Appointments") and generates lists of candidates for appointment by the parties ("Candidate Lists") in accordance with the ADRIC Arbitration Rules (the "Rules")
|
||||||
|
|
||||||
|
> At least three members must have an ADRIC Chartered Arbitrator designation.
|
||||||
|
|
||||||
|
### ADRIC Med-Arb Rules - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/adric-med-arb-rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>ADRIC Med-Arb Rules - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> <h1><strong>The ADRIC Med-Arb Rules</strong></h1>
|
||||||
|
|
||||||
|
> The Rules are designed to work in tandem with ADRIC's existing Mediation Rules and Arbitration Rules, integrating seamlessly.
|
||||||
|
|
||||||
|
> Download the ADRIC Med-Arb Rules:
|
||||||
|
> Booklet format
|
||||||
|
> 8.5 x 11 format
|
||||||
|
|
||||||
|
> Designation : We have also developed criteria for a specialized designation: the Chartered Med-Arb
|
||||||
|
|
||||||
|
> Course : We have worked with course designers to develop the Med-Arb Foundational Course.
|
||||||
|
|
||||||
|
> All disputes arising out of or in connection with this agreement, or in respect of any legal relationship associated with or derived from this agreement, will be finally resolved by Med-Arb under the Med-Arb Rules of the ADR Institute of Canada, Inc. The Seat of Arbitration under the ADRIC Arbitration Rules will be [specify]. The language of the Med-Arb will be [specify].
|
||||||
|
|
||||||
|
### ADRIC Med-Arb Rules (PDF, 8.5 x 11 format)
|
||||||
|
|
||||||
|
<https://adric.ca/wp-content/uploads/2023/12/ADRIC_Med_Arb_Rules_2020_8_5-X-11-p.-1.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADRIC Med-Arb Rules
|
||||||
|
|
||||||
|
> MED-ARB
|
||||||
|
> RULES
|
||||||
|
|
||||||
|
> ADRIC Med-Arb Rules
|
||||||
|
> Published by the ADR Institute of Canada
|
||||||
|
|
||||||
|
> Version 1 - 1 June 2020
|
||||||
|
> You are welcome to use and modify these Rules.
|
||||||
|
> We request that ADRIC be acknowledged.
|
||||||
|
|
||||||
|
> THE ADRIC MED-ARB RULES
|
||||||
|
> ADRIC is pleased to have the ADRIC Med-Arb Rules take their place alongside ADRIC's other flagship ADR rules.
|
||||||
|
|
||||||
|
> ADRIC's Rules are state of the art; Canada's first and foremost for the Canadian jurisdiction. The ADRIC Arbitration Rules (which include expedited arbitration), Mediation Rules and Med-Arb Rules: designed to integrate seamlessly.
|
||||||
|
|
||||||
|
> ADRIC also provides dispute resolution services such as ADR systems design, roster development, education, training and certification, and issues publications for practitioners, parties and counsel.
|
||||||
|
|
||||||
|
### Code of Conduct - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/code-of-conduct/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Code of Conduct - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> ADRIC members are held accountable to practice standards that include a Code of Conduct for Mediators and a National Code of Ethics. In effect, ADRIC provides an infrastructure that allows ADR practitioners to be self-regulating professionals.
|
||||||
|
|
||||||
|
> To view the Code of Conduct for Mediators in booklet form, please click here .
|
||||||
|
|
||||||
|
> CODE OF CONDUCT FOR MEDIATORS
|
||||||
|
|
||||||
|
> This Code of Conduct for Mediators (the "Code") applies in its entirety to every Mediator who is a member of the ADR Institute of Canada, Inc. (the "Institute") or any of its Regional Affiliates, or who accepts from the Institute an appointment as Mediator.
|
||||||
|
|
||||||
|
> 12.1 Nothing in the Code replaces or supersedes any other ethical standard or code that may govern the Mediator.
|
||||||
|
|
||||||
|
### Code of Ethics - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/code-of-ethics/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Code of Ethics - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> This code is applicable to all members of the ADR Institute of Canada.
|
||||||
|
|
||||||
|
> A member shall uphold and abide by the Code of Ethics, the Code of Conduct for Mediators, the regulations, and other professional requirements adopted by the ADR Institute of Canada.
|
||||||
|
|
||||||
|
> A Member shall uphold the integrity and fairness of the arbitration and mediation processes.
|
||||||
|
|
||||||
|
> A Member shall be faithful to the relationship of trust and confidentiality inherent in the office of arbitrator or mediator.
|
||||||
|
|
||||||
|
### Ethics & Professional Practice - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/ethics-professional-practice/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Code of Ethics
|
||||||
|
> Learn More
|
||||||
|
> Code of Conduct
|
||||||
|
> Learn More
|
||||||
|
> Conflict of Interest
|
||||||
|
> Learn More
|
||||||
|
> Complaints & Discipline Policy
|
||||||
|
> Learn More
|
||||||
|
|
||||||
|
> ADR Institute of Canada members are held accountable to practice standards that include a Mediator's Code of Conduct and a National Code of Ethics.
|
||||||
|
|
||||||
|
> Complaints about members who breach the standards set out in these documents can be brought to the attention of the Board of Directors of the affiliate or national for investigation and potential discipline as per the procedure set out in Regulations issued under the By-laws of the ADR Institute of Canada, or by disciplinary processes set by the affiliate.
|
||||||
|
|
||||||
|
### Professional Designations - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/professional-designations/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Entry-level <em>Qualified</em> Designations</strong></p>
|
||||||
|
> <ul>
|
||||||
|
> <li><a href="https://adric.ca/professional-designations/arbitrator-designations/">Qualified Arbitrator – Q.Arb</a></li>
|
||||||
|
> <li><a href="https://adric.ca/professional-designations/mediator-designations/">Qualified Mediator – Q.Med</a></li>
|
||||||
|
> </ul>
|
||||||
|
> <p><strong>Senior-level <em>Chartered</em> Designations</strong></p>
|
||||||
|
> <ul>
|
||||||
|
> <li><a href="https://adric.ca/professional-designations/arbitrator-designations/">Chartered Arbitrator – C.Arb</a></li>
|
||||||
|
> <li><a href="https://adric.ca/professional-designations/mediator-designations/">Chartered Mediator – C.Med</a></li>
|
||||||
|
> <li><a href="https://adric.ca/professional-designations/chartered-med-arb/">Chartered Med-Arbitrator – C.Med-Arb</a></li>
|
||||||
|
> </ul>
|
||||||
|
|
||||||
|
> Mediators , Arbitrators and Med-Arbitrators
|
||||||
|
|
||||||
|
> Entry-level Qualified Designations
|
||||||
|
> Qualified Arbitrator – Q.Arb
|
||||||
|
> Qualified Mediator – Q.Med
|
||||||
|
> Senior-level Chartered Designations
|
||||||
|
> Chartered Arbitrator – C.Arb
|
||||||
|
> Chartered Mediator – C.Med
|
||||||
|
> Chartered Med-Arbitrator – C.Med-Arb
|
||||||
|
|
||||||
|
> Specialized Designations:
|
||||||
|
> Family Practice Designations (available in some regions – check with your affiliate)
|
||||||
|
> Qualified Arbitrator (Family) – Q.Arb(Fam) – coming soon
|
||||||
|
> Qualified Mediator (Family) – Q.Med(Fam)
|
||||||
|
> Chartered Arbitrator – C.Arb (Family) – C.Arb(Fam) – coming soon
|
||||||
|
> Chartered Mediator – C.Med (Family) – C.Med(Fam) – coming soon
|
||||||
|
> Construction Adjudication Designations
|
||||||
|
> Qualified Construction Adjudicator – Q.Adj (Const)
|
||||||
|
|
||||||
|
> Generalist Designations
|
||||||
|
> C.Arb or C.Med
|
||||||
|
> $ 280.00
|
||||||
|
> C.Arb and C.Med
|
||||||
|
> $ 420.00
|
||||||
|
> C.Med-Arb
|
||||||
|
> $ 420.00
|
||||||
|
> Q.Arb, Q.Med or Q.Adj (Const)
|
||||||
|
> $ 165.00
|
||||||
|
|
||||||
|
### Chartered Med-Arb - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/professional-designations/chartered-med-arb/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Chartered Med-Arb - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> <h3><strong>ADRIC Chartered Med-Arb Designation (C.Med-Arb)<br />
|
||||||
|
> </strong></h3>
|
||||||
|
|
||||||
|
> <a href="https://adric.ca/professional-designations/chartered-med-arb/">Chartered Med-Arbitrator</a>
|
||||||
|
|
||||||
|
> Chartered Med-Arb Criteria
|
||||||
|
> Chartered Med-Arb application form
|
||||||
|
|
||||||
|
> The ADRIC Med-Arb designation is unique in the ADR world and provides clearly defined criteria for those practitioners who wish to obtain it.
|
||||||
|
|
||||||
|
### Arbitrator Designations - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/professional-designations/arbitrator-designations/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Arbitrator Designations - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> Qualified Arbitrator (Q.Arb)
|
||||||
|
> The Qualified Arbitrator (Q.Arb) designation is an entry-level designation for arbitrators while they continue to learn and practice for the Chartered Arbitration designation. It recognizes member applicants who have completed an ADRIC-Accredited 40 hour arbitration training that includes a written exam.
|
||||||
|
|
||||||
|
> Chartered Arbitrator (C.Arb)
|
||||||
|
> Assessment Criteria
|
||||||
|
> The Chartered Arbitrator (C.Arb) designation recognizes competence at a high level.
|
||||||
|
|
||||||
|
> The C.Arb or Chartered Arbitrator designation is Canada's only official senior designation for experienced, practicing Arbitrators.
|
||||||
|
|
||||||
|
> ADR Institute of British Columbia (ADRBC)
|
||||||
|
> ADR Institute of Alberta (ADRIA)
|
||||||
|
> ADR Institute of Saskatchewan Inc. (ADRISK)
|
||||||
|
> ADR Institute of Manitoba (ADRIM)
|
||||||
|
> ADR Institute of Ontario, Inc. (ADRIO)
|
||||||
|
> L'Institut de médiation et d'arbitrage du Québec (IMAQ)
|
||||||
|
> ADR Atlantic Institute (ADRAI)
|
||||||
|
|
||||||
|
### Mediator Designations - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/professional-designations/mediator-designations/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Mediator Designations - ADR Institute of Canada</title>
|
||||||
|
|
||||||
|
> <h1><strong>ADRIC’s National Designations for Mediators</strong></h1>
|
||||||
|
|
||||||
|
> Qualified Mediator (Q.Med)
|
||||||
|
> The Qualified Mediator designation is an entry-level step for mediators while they continue to learn and practice for the Chartered Mediator designation. It is available to members who have completed at least 80 hours of mediation and related dispute resolution training and have some practice experience.
|
||||||
|
|
||||||
|
> Chartered Mediator (C.Med)
|
||||||
|
> Assessment Criteria
|
||||||
|
> T he Chartered Mediator (C.Med) designation recognizes competence at a high level .
|
||||||
|
|
||||||
|
> The C.Med or Chartered Mediator designation is Canada's most senior designation for mediators with a general or special practice.
|
||||||
|
|
||||||
|
### Services - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/services/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADRIC's Rules are state of the art; Canada's first and foremost for the Canadian jurisdiction. The ADRIC Arbitration Rules , Mediation Rules and the Med-Arb Rules are designed to integrate seamlessly.
|
||||||
|
|
||||||
|
> ADRIC also provides dispute resolution services such as ADR systems design, roster development, training and certification , and issues publications for practitioners, parties and counsel.
|
||||||
|
|
||||||
|
> Construction Adjudication
|
||||||
|
> Learn More
|
||||||
|
> CTA Arbitrator Roster
|
||||||
|
> Learn more
|
||||||
|
> Arbitrator Appointments
|
||||||
|
> Learn More
|
||||||
|
> Mediator Appointments
|
||||||
|
> Learn More
|
||||||
|
> ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
> Learn more
|
||||||
|
> Custom ADR Systems and Roster Development
|
||||||
|
> Learn More
|
||||||
|
> In-House Designations
|
||||||
|
> Learn More
|
||||||
|
|
||||||
|
### Custom ADR Systems and Roster Development - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/services/custom-adr-systems-rosters/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Custom ADR Systems and Roster Development
|
||||||
|
|
||||||
|
> ADRIC can assist organizations of all kinds to develop and administer ADR programs designed to resolve disputes between various parties.
|
||||||
|
|
||||||
|
> Option 1: Custom ADR Systems and Rosters Development
|
||||||
|
> ADRIC assists organizations in developing and administering practical and efficient dispute resolution programs.
|
||||||
|
|
||||||
|
> Option 2: Roster Selection Services
|
||||||
|
|
||||||
|
> Option 3: Roster Management Services
|
||||||
|
|
||||||
|
### ADRIC Professional Practice Manuals - ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/adric-professional-practice-manuals/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The ADRIC Arbitration Practice Handbook
|
||||||
|
|
||||||
|
> The ADRIC Commercial Mediation Practice Handbook
|
||||||
|
|
||||||
|
> The Mediation Handbook is designed to function as a guide for professionals working in the field of commercial mediation.
|
||||||
|
|
||||||
|
### ADRIC Sponsored Professional Liability Insurance Program brochure (PDF, PDF creation date 2016)
|
||||||
|
|
||||||
|
<https://adric.ca/wp-content/uploads/2015/05/ADR-Brochure-EN-final.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> •• Early Neutral Evaluation
|
||||||
|
|
||||||
|
> •• ADR Systems Design / Implementation and/or
|
||||||
|
> Management
|
||||||
|
|
||||||
|
> •• Pre-ADR Processes (assisting the parties to
|
||||||
|
> select an appropriate ADR process)
|
||||||
|
|
||||||
|
> THE INFORMATION PROVIDED ABOVE IS ONLY A GENERAL OUTLINE OF COVERAGES AVAILABLE. FOR EXACT TERMS, DEFINITIONS,
|
||||||
|
> LIMITATIONS, AND EXCLUSIONS, PLEASE SPEAK WITH YOUR LICENSED MARSH CANADA BROKER, OR REFER TO THE POLICY WORDING.
|
||||||
|
|
||||||
|
### Professional Designations – The ADR Institute of Ontario
|
||||||
|
|
||||||
|
<https://adr-ontario.ca/professional-designations/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Professional Designations – The ADR Institute of Ontario</title>
|
||||||
|
|
||||||
|
> Designations
|
||||||
|
> The Path to your Professional ADR designation
|
||||||
|
> ADRIC Accredited Courses
|
||||||
|
> Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
> Chartered Mediator (C.Med)
|
||||||
|
> Chartered Arbitrator (C.Arb)
|
||||||
|
> Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
> Insurance
|
||||||
|
> FAQ
|
||||||
|
|
||||||
|
> Professional Designations
|
||||||
|
> To read more about the path to designations, click here .
|
||||||
|
|
||||||
|
### Chartered Med-Arbitrator (C.Med-Arb) – The ADR Institute of Ontario
|
||||||
|
|
||||||
|
<https://adr-ontario.ca/chartered-med-arbitrator-c-med-arb/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Chartered Med-Arbitrator (C.Med-Arb) – The ADR Institute of Ontario</title>
|
||||||
|
|
||||||
|
> <h1 class="page-title"><span>Chartered Med-Arbitrator (C.Med-Arb)</span></h1>
|
||||||
|
|
||||||
|
> Med-Arb is a distinct, innovative standalone process that is not as well known or understood by consumers of ADR services compared to mediation and arbitration.
|
||||||
|
|
||||||
|
> To read more about the C.Med-Arb designation, including how to apply, click here .
|
||||||
|
|
||||||
|
### Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb) – The ADR Institute of Ontario
|
||||||
|
|
||||||
|
<https://adr-ontario.ca/qualified-mediator-q-med-qualified-arbitrator-q-arb/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <h1 class="page-title"><span>Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)</span></h1>
|
||||||
|
|
||||||
|
> Qualified Mediator (Q.Med) / Qualified Arbitrator (Q.Arb)
|
||||||
|
> These designations are to recognize practitioners who have completed sufficient mediation / arbitration and related dispute resolution training to be qualified to practice. They are an intermediate step for practitioners working to receive their Chartered designation.
|
||||||
|
|
||||||
|
> *Q.Med criteria vary across affiliates. For the criteria specific to Ontario, be sure to read the checklist on the application form.
|
||||||
|
|
||||||
|
### Rules & Codes – The ADR Institute of Ontario
|
||||||
|
|
||||||
|
<https://adr-ontario.ca/rules-codes/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Rules & Codes – The ADR Institute of Ontario</title>
|
||||||
|
|
||||||
|
> Rules & Codes
|
||||||
|
> Code of Ethics
|
||||||
|
> View Code of Ethics.
|
||||||
|
> Code of Conduct
|
||||||
|
> View Code of Conduct.
|
||||||
|
> Complaints Policy
|
||||||
|
> ...
|
||||||
|
> ADRIC Arbitration Rules
|
||||||
|
> View ADRIC Arbitration Rules.
|
||||||
|
> National Mediation Rules
|
||||||
|
> View Selection Protocols.
|
||||||
|
> ADRIO's Statement Re: Zero Tolerance for Unacceptable Behaviour
|
||||||
|
> ...
|
||||||
|
> ADRIO Policy Statement on Conduct & Communication
|
||||||
|
|
||||||
|
> Code of Ethics</h2>
|
||||||
|
> <p class="wp-block-paragraph"><a href="https://adr-ontario.ca/code-of-ethics/">View Code of Ethics.</a></p>
|
||||||
|
|
||||||
|
> <h2 ...>Code of Conduct</h2>
|
||||||
|
> <p class="wp-block-paragraph"><a href="https://adric.ca/rules-codes/code-of-conduct/">View Code of Conduct.</a></p>
|
||||||
|
|
||||||
|
> <h2 ...>ADRIC Arbitration Rules</h2>
|
||||||
|
> <p class="wp-block-paragraph"><a href="https://adric.ca/rules-codes/arbrules/">View ADRIC Arbitration Rules.</a></p>
|
||||||
|
|
||||||
|
> <h2 ...>National Mediation Rules</h2>
|
||||||
|
> <p class="wp-block-paragraph"><a href="https://adric.ca/rules-codes/national-mediation-rules/" data-type="URL" target="_blank" rel="noreferrer noopener">View Selection Protocols.</a></p>
|
||||||
|
|
||||||
|
### Code of Ethics – The ADR Institute of Ontario
|
||||||
|
|
||||||
|
<https://adr-ontario.ca/code-of-ethics/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Code of Ethics – The ADR Institute of Ontario</title>
|
||||||
|
|
||||||
|
> <h1 class="page-title"><span>Code of Ethics</span></h1>
|
||||||
|
|
||||||
|
> Code of Ethics
|
||||||
|
> The Code is applicable to all members of the Institute.
|
||||||
|
|
||||||
|
> A Member shall uphold and abide by the Rules of Conduct, regulations and other professional requirements adopted by the Institute.
|
||||||
|
|
||||||
|
> A Member shall uphold the integrity and fairness of the arbitration and mediation processes.
|
||||||
|
|
||||||
|
### About ADR Chambers — Trusted ADR Experts in Canada
|
||||||
|
|
||||||
|
<https://adrchambers.com/about-adr-chambers/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <h1>About ADR Chambers — Trusted ADR Experts in Canada</h1>
|
||||||
|
|
||||||
|
> For more than 30 years, ADR Chambers, the renowned arbitration mediation firm, has provided conflict resolution services across Canada and internationally.
|
||||||
|
|
||||||
|
> ADR Chambers' dispute resolution services include mediation , arbitration , ombuds services , workplace investigations , neutral evaluation , med/arb , fairness monitoring , workplace restoration , private appeals and independent assessment of workplace accommodation requests .
|
||||||
|
|
||||||
|
> The world's largest dispute resolution service provider *
|
||||||
|
|
||||||
|
> * Over the past 15 years, ADR Chambers has administered over 55,000 mediations and arbitrations through their reputable arbitration mediation firm.
|
||||||
|
|
||||||
|
### Arbitration rules - ADR Chambers
|
||||||
|
|
||||||
|
<https://adrchambers.com/arbitration/rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Arbitration rules - ADR Chambers</title>
|
||||||
|
|
||||||
|
> <h1>Arbitration Rules</h1>
|
||||||
|
|
||||||
|
> Download current Arbitration Rules (PDF).
|
||||||
|
> Download previous version of Rules : June 3, 2025 – February 9, 2026
|
||||||
|
> Download previous version of Rules : June 1, 2024 to June 2, 2025
|
||||||
|
> Download previous version of Rules : September 10, 2013 to May 31, 2024
|
||||||
|
|
||||||
|
> "ADR Chambers" means ADR Chambers Inc.
|
||||||
|
|
||||||
|
> "Rules" means the ADR Chambers Arbitration Rules in force at the time of the commencement of the arbitration.
|
||||||
|
|
||||||
|
> "Med-Arb" means a process by which the Parties agree to first submit their dispute to mediation and, in the event that the dispute does not fully settle at mediation, to an arbitration in accordance with these Rules, where the arbitrator will be the same individual as the mediator.
|
||||||
|
|
||||||
|
> 2.6 These Rules may be amended by ADR Chambers in its sole discretion. Amendments become effective when they are posted to the ADR Chambers website.
|
||||||
|
|
||||||
|
### ADR Chambers Arbitration Rules — current version PDF (Revised February 10, 2026)
|
||||||
|
|
||||||
|
<https://adrchambers.com/wp-content/uploads/2026/02/Revised-ADRC-Arbitration-Rules-Feb-10-2026.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> 14. "Rules" means the ADR Chambers Arbitration Rules in force at the time of the
|
||||||
|
|
||||||
|
> These Rules are Effective February 10, 2026 and are Subject to Change Without Notice.
|
||||||
|
|
||||||
|
> In accordance with Rule 10.1 of the ADR Chambers Arbitration Rules, a preliminary
|
||||||
|
|
||||||
|
> 12. Mediation
|
||||||
|
> 13. Med/Arb
|
||||||
|
|
||||||
|
### Mediation Rules - ADR Chambers
|
||||||
|
|
||||||
|
<https://adrchambers.com/mediation/rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>Mediation Rules - ADR Chambers</title>
|
||||||
|
|
||||||
|
> <h1>Mediation Rules</h1>
|
||||||
|
|
||||||
|
> Download Mediation Rules (PDF).
|
||||||
|
|
||||||
|
> 1.1 These Rules, and all amendments to them, shall be deemed to have been made a part of any agreement which provides for mediation with ADR Chambers.
|
||||||
|
|
||||||
|
> Appendix A: Sample Agreement to Mediate
|
||||||
|
|
||||||
|
### ADR Chambers Mediation Rules (PDF)
|
||||||
|
|
||||||
|
<https://adrchambers.com/wp-content/uploads/2017/11/Mediation-Rules.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADR Chambers Mediation Rules
|
||||||
|
|
||||||
|
> 1. Agreement of Parties
|
||||||
|
> 1.1 These Rules, and all amendments to them, shall be deemed to have been made a part
|
||||||
|
> of any agreement which provides for mediation with ADR Chambers.
|
||||||
|
|
||||||
|
### ADR Chambers Expedited Arbitration Rules and Efficiency
|
||||||
|
|
||||||
|
<https://adrchambers.com/expedited-arbitration/rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>ADR Chambers Expedited Arbitration Rules and Efficiency</title>
|
||||||
|
|
||||||
|
> <h1>Expedited Arbitration Rules</h1>
|
||||||
|
|
||||||
|
> Download Expedited Arbitration Rules (PDF).
|
||||||
|
> Download previous version of Rules : March 29, 2017 to April 17, 2026
|
||||||
|
|
||||||
|
> These Rules will apply whenever the parties agree in writing to have their dispute decided "under the Expedited Arbitration Rules of ADR Chambers" or words to that effect.
|
||||||
|
|
||||||
|
### ADR Chambers Expedited Arbitration Rules (PDF, revised April 2026)
|
||||||
|
|
||||||
|
<https://adrchambers.com/wp-content/uploads/2023/04/ADRC-Expedited-Arbitration-Rules-Revised-April-2026.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADR Chambers Expedited Arbitration Rules
|
||||||
|
|
||||||
|
> b) These Rules will apply whenever the parties agree in writing to have their dispute decided "under the
|
||||||
|
> Expedited Arbitration Rules of ADR Chambers" or words to that effect.
|
||||||
|
|
||||||
|
### Mediation Model Clauses - ADR Chambers
|
||||||
|
|
||||||
|
<https://adrchambers.com/mediation-model-clause/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <h1>Mediation Model Clauses</h1>
|
||||||
|
|
||||||
|
> the dispute will be resolved by arbitration at ADR Chambers pursuant to the general ADR Chambers Rules for Arbitration.
|
||||||
|
|
||||||
|
> shall be referred to and finally resolved by arbitration at ADR Chambers under the ADR Chambers Arbitration Rules.
|
||||||
|
|
||||||
|
> the Parties agree to resolve the dispute by arbitration at ADR Chambers using the ADR Chambers Expedited Arbitration Rules.
|
||||||
|
|
||||||
|
### ADR Chambers Neutral Evaluation – Expert Case Assessment
|
||||||
|
|
||||||
|
<https://adrchambers.com/neutral-evaluation/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADR Chambers Neutral Evaluation – Expert Case Assessment
|
||||||
|
|
||||||
|
> Neutral Evaluation Overview
|
||||||
|
> Neutral Evaluation enables parties to hear an expert's assessment of a likely trial outcome. Neutral Evaluation may involve briefs, oral presentations, and sometimes witnesses. The process can be tailored to the dispute.
|
||||||
|
|
||||||
|
> Neutral Evaluation Rules (PDF)
|
||||||
|
> Neutral Evaluation Sample Agreement (PDF)
|
||||||
|
|
||||||
|
> Neutral Evaluation fees range from $250 to $750 plus HST per hour, depending on the experience of the neutral.
|
||||||
|
|
||||||
|
### ADR Systems Design - ADR Chambers
|
||||||
|
|
||||||
|
<https://adrchambers.com/adr-systems-design/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADR Systems Design
|
||||||
|
|
||||||
|
> ADR Chambers offers dispute resolution systems design consultation services. Consultants help organizations that have problems managing disputes, whether because of their frequency, size, complexity, timing, or number. For more information, please contact ADR Chambers.
|
||||||
|
|
||||||
|
> What is Alternative Dispute Resolution System Design?
|
||||||
|
|
||||||
|
> ADR system design helps to match the appropriate dispute resolution process with the type of dispute and the culture of an organization.
|
||||||
|
|
||||||
|
### ADR Chambers International – International Arbitration and Mediation
|
||||||
|
|
||||||
|
<https://adrchambersinternational.com/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> <title>ADR Chambers International – International Arbitration and Mediation</title>
|
||||||
|
|
||||||
|
> ADR Chambers International ("ADRCI") is the leading Canadian organization that specializes in International Arbitration and Mediation. Through the use of the UNCITRAL Arbitration Rules as supplemented by its own state of the art rules, ADRCI provides practitioners and their clients uniformity and credibility in the field of international arbitration and mediation.
|
||||||
|
|
||||||
|
> Arbitration
|
||||||
|
> Arbitration Rules
|
||||||
|
> Roster of Arbitrators
|
||||||
|
> Mediation
|
||||||
|
> Mediation Rules
|
||||||
|
> Roster of Mediators
|
||||||
|
> Model Clause
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
Each item names the source it rests on. An item here that no quotation above
|
||||||
|
supports is a defect in this file, not a fact.
|
||||||
|
|
||||||
|
- ADRIC's corporate name, as it appears in its own site footer and in its rule documents, is "ADR Institute of Canada, Inc." — the comma and the "Inc." are part of the name.
|
||||||
|
*Source:* <https://adric.ca/rules-codes/>
|
||||||
|
- ADRIC's arbitration rules are titled "ADRIC Arbitration Rules". The web page's <h1> reads "The ADRIC Arbitration Rules" and the browser title is "ADRIC Arbitration Rules - ADR Institute of Canada".
|
||||||
|
*Source:* <https://adric.ca/rules-codes/arbrules/>
|
||||||
|
- The current arbitration rules edition is effective 1 March 2025. The page says "ADRIC has adopted new Arbitration Rules and a new Arbitrator Appointment Protocol, effective March 1, 2025" and labels the download "ADRIC Arbitration Rules – Effective 2025".
|
||||||
|
*Source:* <https://adric.ca/rules-codes/arbrules/>
|
||||||
|
- The PDF cover of the current edition reads "ADRIC / ARBITRATION RULES / Effective 01 March 2025" — ADRIC writes the date day-first on the artefact itself, and "March 1, 2025" in web prose. Its internal title page reads "ADR INSTITUTE OF CANADA, INC. / ARBITRATION RULES".
|
||||||
|
*Source:* <https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf>
|
||||||
|
- ADRIC publishes a companion document titled "ADRIC Arbitrator Appointment Protocol", also effective 01 March 2025, whose PDF cover carries "Revised: 2026-01-30".
|
||||||
|
*Source:* <https://adric.ca/rules/ADRIC-Arbitration-Protocol-2025.pdf>
|
||||||
|
- ADRIC's mediation rules are titled "National Mediation Rules". The page <h1> is "The ADRIC National Mediation Rules", the browser title is "National Mediation Rules - ADR Institute of Canada", and the model clause reads "pursuant to the National Mediation Rules of the ADR Institute of Canada, Inc."
|
||||||
|
*Source:* <https://adric.ca/rules-codes/national-mediation-rules/>
|
||||||
|
- ADRIC is internally inconsistent about the mediation rules' name: the Rules & Codes index nav item reads "National Mediation Rules" while the index card on the same page reads "ADRIC Mediation Rules". Both strings appear in the same fetched document.
|
||||||
|
*Source:* <https://adric.ca/rules-codes/>
|
||||||
|
- The mediation rules PDF that ADRIC currently links carries no 2025-era effective date; its internal headings read "ADR INSTITUTE OF CANADA, INC. / NATIONAL MEDIATION RULES" and every page footer reads "ADR Institute of Canada, Inc. © As amended August 3, 2012".
|
||||||
|
*Source:* <https://adric.ca/pdf/ADRMEDIATIONRULES2014.pdf>
|
||||||
|
- As of the page state on 2026-08-29 the mediation rules are under review but unchanged: "the ADRIC Mediation Committee is currently reviewing the Mediation Rules ... In the meantime, the existing rules remain in effect and should continue to be used until any updates are formally adopted."
|
||||||
|
*Source:* <https://adric.ca/rules-codes/national-mediation-rules/>
|
||||||
|
- ADRIC's med-arb rules are titled "ADRIC Med-Arb Rules" — hyphenated "Med-Arb", not "Med/Arb" and not "Mediation-Arbitration". The page <h1> is "The ADRIC Med-Arb Rules".
|
||||||
|
*Source:* <https://adric.ca/rules-codes/adric-med-arb-rules/>
|
||||||
|
- The ADRIC Med-Arb Rules PDF is stamped "Version 1 - 1 June 2020" and its running head and title block read "ADRIC Med-Arb Rules" / "MED-ARB RULES" / "THE ADRIC MED-ARB RULES".
|
||||||
|
*Source:* <https://adric.ca/wp-content/uploads/2023/12/ADRIC_Med_Arb_Rules_2020_8_5-X-11-p.-1.pdf>
|
||||||
|
- The chartered med-arb designation is written by ADRIC as "Chartered Med-Arbitrator", abbreviated "C.Med-Arb". The Professional Designations page lists verbatim: "Chartered Med-Arbitrator – C.Med-Arb" (raw HTML: "Chartered Med-Arbitrator – C.Med-Arb"), and the site-wide nav item linking to that page reads "Chartered Med-Arbitrator".
|
||||||
|
*Source:* <https://adric.ca/professional-designations/>
|
||||||
|
- The string "Chartered Mediator-Arbitrator" does not appear anywhere on the ADRIC or ADRIO pages fetched. A grep for "Mediator-Arbitrator" across all 20+ extracted ADRIC/ADRIO page texts returned no matches; "Med-Arbitrator" matched on every page (nav) plus the designation list.
|
||||||
|
*Source:* <https://adric.ca/professional-designations/>
|
||||||
|
- ADRIC uses a second, shorter form for the same designation on the designation's own page: the heading is "ADRIC Chartered Med-Arb Designation (C.Med-Arb)", the browser title is "Chartered Med-Arb - ADR Institute of Canada", and the linked criteria are "Chartered Med-Arb Criteria".
|
||||||
|
*Source:* <https://adric.ca/professional-designations/chartered-med-arb/>
|
||||||
|
- The ADRIC Med-Arb Rules page uses a third form again: "we have also developed criteria for a specialized designation: the Chartered Med-Arb".
|
||||||
|
*Source:* <https://adric.ca/rules-codes/adric-med-arb-rules/>
|
||||||
|
- ADRIC's four generalist designations expand as: Qualified Arbitrator (Q.Arb), Qualified Mediator (Q.Med), Chartered Arbitrator (C.Arb), Chartered Mediator (C.Med). ADRIC groups the first two as "Entry-level Qualified Designations" and the latter two as "Senior-level Chartered Designations".
|
||||||
|
*Source:* <https://adric.ca/professional-designations/>
|
||||||
|
- ADRIC describes C.Arb as "Canada's only official senior designation for experienced, practicing Arbitrators" and Q.Arb as "an entry-level designation for arbitrators".
|
||||||
|
*Source:* <https://adric.ca/professional-designations/arbitrator-designations/>
|
||||||
|
- ADRIC describes C.Med as "Canada's most senior designation for mediators with a general or special practice" and Q.Med as "an entry-level step for mediators". The mediator designations page <h1> is "ADRIC's National Designations for Mediators".
|
||||||
|
*Source:* <https://adric.ca/professional-designations/mediator-designations/>
|
||||||
|
- ADRIC designations are conferred nationally but administered regionally: "You must be a 'Full' Member via one of ADRIC's Regional Affiliates before applying for an ADRIC designation."
|
||||||
|
*Source:* <https://adric.ca/professional-designations/>
|
||||||
|
- ADRIC names its seven regional affiliates verbatim as: ADR Institute of British Columbia (ADRBC), ADR Institute of Alberta (ADRIA), ADR Institute of Saskatchewan Inc. (ADRISK), ADR Institute of Manitoba (ADRIM), ADR Institute of Ontario, Inc. (ADRIO), L'Institut de médiation et d'arbitrage du Québec (IMAQ), ADR Atlantic Institute (ADRAI).
|
||||||
|
*Source:* <https://adric.ca/professional-designations/arbitrator-designations/>
|
||||||
|
- ADRIC publishes exactly two conduct/ethics instruments for practitioners, and their exact titles are "Code of Ethics" and "Code of Conduct for Mediators": "ADRIC members are held accountable to practice standards that include a Code of Conduct for Mediators and a National Code of Ethics."
|
||||||
|
*Source:* <https://adric.ca/rules-codes/code-of-conduct/>
|
||||||
|
- The Code of Ethics applies to all ADRIC members and covers both processes: "This code is applicable to all members of the ADR Institute of Canada" and "A Member shall uphold the integrity and fairness of the arbitration and mediation processes."
|
||||||
|
*Source:* <https://adric.ca/rules-codes/code-of-ethics/>
|
||||||
|
- ADRIC itself calls the same two documents by varying names: the Code of Ethics page says "the Code of Ethics, the Code of Conduct for Mediators"; the Code of Conduct page says "a Code of Conduct for Mediators and a National Code of Ethics"; the Ethics & Professional Practice page says "a Mediator's Code of Conduct and a National Code of Ethics".
|
||||||
|
*Source:* <https://adric.ca/ethics-professional-practice/>
|
||||||
|
- The Code of Conduct for Mediators is also bound into the National Mediation Rules booklet: "The National Mediation Rules document contains the following: Mediation Rules including Code of Conduct".
|
||||||
|
*Source:* <https://adric.ca/rules-codes/national-mediation-rules/>
|
||||||
|
- ADRIC's Ethics & Professional Practice section also names two further instruments: "Conflict of Interest" and "Complaints & Discipline Policy".
|
||||||
|
*Source:* <https://adric.ca/ethics-professional-practice/>
|
||||||
|
- ADRIO's own name, as it writes it, is "The ADR Institute of Ontario" in page titles and "ADR Institute of Ontario, Inc. (ADRIO)" in ADRIC's affiliate list.
|
||||||
|
*Source:* <https://adr-ontario.ca/professional-designations/>
|
||||||
|
- ADRIO writes the designation as "Chartered Med-Arbitrator (C.Med-Arb)" — that is the page <h1>, the browser title, and the nav item.
|
||||||
|
*Source:* <https://adr-ontario.ca/chartered-med-arbitrator-c-med-arb/>
|
||||||
|
- ADRIO's designation nav lists, verbatim: "Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb) / Chartered Mediator (C.Med) / Chartered Arbitrator (C.Arb) / Chartered Med-Arbitrator (C.Med-Arb)".
|
||||||
|
*Source:* <https://adr-ontario.ca/professional-designations/>
|
||||||
|
- ADRIO publishes its own "Code of Ethics" as a page on its own site, applicable "to all members of the Institute", but points to ADRIC for the Code of Conduct — its "Code of Conduct" entry links to https://adric.ca/rules-codes/code-of-conduct/.
|
||||||
|
*Source:* <https://adr-ontario.ca/rules-codes/>
|
||||||
|
- ADRIO does not publish its own mediation or arbitration rules: its Rules & Codes page lists "ADRIC Arbitration Rules" and "National Mediation Rules", both linking out to adric.ca.
|
||||||
|
*Source:* <https://adr-ontario.ca/rules-codes/>
|
||||||
|
- ADRIO also publishes two policy documents of its own, titled "ADRIO's Statement Re: Zero Tolerance for Unacceptable Behaviour" and "ADRIO Policy Statement on Conduct & Communication", plus a "Complaints Policy".
|
||||||
|
*Source:* <https://adr-ontario.ca/rules-codes/>
|
||||||
|
- ADR Chambers is a private dispute-resolution firm, not a professional body. Its own about page: "For more than 30 years, ADR Chambers, the renowned arbitration mediation firm, has provided conflict resolution services across Canada and internationally." Its rules define "'ADR Chambers' means ADR Chambers Inc."
|
||||||
|
*Source:* <https://adrchambers.com/about-adr-chambers/>
|
||||||
|
- ADR Chambers does publish its own rule sets. The exact titles are "ADR Chambers Arbitration Rules", "ADR Chambers Mediation Rules" and "ADR Chambers Expedited Arbitration Rules".
|
||||||
|
*Source:* <https://adrchambers.com/wp-content/uploads/2017/11/Mediation-Rules.pdf>
|
||||||
|
- The current ADR Chambers Arbitration Rules are stamped "These Rules are Effective February 10, 2026 and are Subject to Change Without Notice." The website also links three superseded versions (June 3 2025 – February 9 2026; June 1 2024 to June 2 2025; September 10 2013 to May 31 2024).
|
||||||
|
*Source:* <https://adrchambers.com/wp-content/uploads/2026/02/Revised-ADRC-Arbitration-Rules-Feb-10-2026.pdf>
|
||||||
|
- The ADR Chambers Arbitration Rules are self-amending without notice: "These Rules may be amended by ADR Chambers in its sole discretion. Amendments become effective when they are posted to the ADR Chambers website." Any citation to a specific edition should therefore carry a retrieval date.
|
||||||
|
*Source:* <https://adrchambers.com/arbitration/rules/>
|
||||||
|
- ADR Chambers is itself inconsistent about the arbitration rules' name: on a single model-clauses page it writes both "the general ADR Chambers Rules for Arbitration" and "the ADR Chambers Arbitration Rules". The rules document's own definition uses "ADR Chambers Arbitration Rules".
|
||||||
|
*Source:* <https://adrchambers.com/mediation-model-clause/>
|
||||||
|
- The ADR Chambers Arbitration Rules contain a med-arb regime internally, spelled "Med-Arb" in the definitions and "Med/Arb" in the table of contents heading 13; the definition reads "a process by which the Parties agree to first submit their dispute to mediation and, in the event that the dispute does not fully settle at mediation, to an arbitration in accordance with these Rules, where the arbitrator will be the same individual as the mediator."
|
||||||
|
*Source:* <https://adrchambers.com/arbitration/rules/>
|
||||||
|
- ADR Chambers offers a distinct "Neutral Evaluation" service — its own name for it does not include the word "Early" — and publishes "Neutral Evaluation Rules (PDF)" and a "Neutral Evaluation Sample Agreement (PDF)".
|
||||||
|
*Source:* <https://adrchambers.com/neutral-evaluation/>
|
||||||
|
- ADR Chambers offers dispute-system design under the name "ADR Systems Design": "ADR Chambers offers dispute resolution systems design consultation services."
|
||||||
|
*Source:* <https://adrchambers.com/adr-systems-design/>
|
||||||
|
- ADR Chambers International ("ADRCI") is a separate, differently-named entity with its own site and its own arbitration and mediation rules, built on the UNCITRAL Arbitration Rules. It must not be conflated with ADR Chambers Inc.
|
||||||
|
*Source:* <https://adrchambersinternational.com/>
|
||||||
|
- ADRIC does name dispute-system design as a service it provides: "ADRIC also provides dispute resolution services such as ADR systems design, roster development, training and certification , and issues publications for practitioners, parties and counsel."
|
||||||
|
*Source:* <https://adric.ca/services/>
|
||||||
|
- ADRIC's dispute-system-design service has a formal page title: "Custom ADR Systems and Roster Development" (the site nav renders it "Custom ADR Systems and Rosters Development"), covering systems development, roster selection and roster management.
|
||||||
|
*Source:* <https://adric.ca/services/custom-adr-systems-rosters/>
|
||||||
|
- ADRIC publishes two practitioner manuals, titled "The ADRIC Arbitration Practice Handbook" and "The ADRIC Commercial Mediation Practice Handbook".
|
||||||
|
*Source:* <https://adric.ca/adric-professional-practice-manuals/>
|
||||||
|
- The only ADRIC-hosted document found mentioning early neutral evaluation is its sponsored professional-liability insurance brochure, which lists "Early Neutral Evaluation" and "ADR Systems Design / Implementation and/or Management" among insurable ADR activities. This is a schedule of coverage, not a rule set, standard or designation, and the brochure's own PDF creation date is 2016.
|
||||||
|
*Source:* <https://adric.ca/wp-content/uploads/2015/05/ADR-Brochure-EN-final.pdf>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.** It is the half that keeps a page
|
||||||
|
honest, and on this project it is the half that has twice been skipped.
|
||||||
|
|
||||||
|
- **Does ADRIC publish anything about EARLY NEUTRAL EVALUATION as a recognised neutral service — rules, standards, a designation, or a definitional page?**
|
||||||
|
- *Searched:* WebSearch for 'adric.ca "early neutral evaluation" ADR Institute of Canada' (which surfaced https://adric.ca/ufaqs/what-is-early-neutral-evaluation/); direct curl of that URL; direct curl of https://adric.ca/ufaqs/; ADRIC's own site search at https://adric.ca/?s=early+neutral+evaluation; a grep for 'neutral evaluation' across the extracted text of all 20+ ADRIC and ADRIO pages fetched; a grep for 'neutral evaluation' through the full text of the 42-page ADRIC Arbitration Rules 2025 PDF.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — and the search-result page is dead. https://adric.ca/ufaqs/what-is-early-neutral-evaluation/ returns HTTP 404 and https://adric.ca/ufaqs/ returns HTTP 404 (both verified by curl reading the status code, stderr not suppressed). ADRIC's own site search returns only loosely-matching journal articles, no ENE page. 'Neutral evaluation' appears nowhere in ADRIC's rules, codes, designations or services pages. The ONLY ADRIC-hosted artefact naming it is the insurance brochure listed in sources — a schedule of insurable activities, roughly a decade old. Do NOT write that ADRIC recognises, defines, or publishes on early neutral evaluation. ADR Chambers, by contrast, does offer it (as 'Neutral Evaluation', without 'Early') and does publish Neutral Evaluation Rules.
|
||||||
|
- **Is there a separately published rule set titled 'Simplified Arbitration Rules of the ADR Institute of Canada, Inc.'? ADRIC's own model arbitration clause offers it as an alternative.**
|
||||||
|
- *Searched:* The model clause text on https://adric.ca/rules-codes/arbrules/ and on page 1 of the 2025 Rules PDF; a case-insensitive grep for 'simplified' and 'expedited' through the full 42-page 2025 Rules PDF; a grep of the arbrules page HTML for any href containing 'simplified' (exit status 1 — no match); ADRIC site search at https://adric.ca/?s=Simplified+Arbitration+Rules; speculative curl of https://adric.ca/rules/ADRIC-Simplified-Arbitration-Rules-2025.pdf and https://adric.ca/rules-codes/simplified-arbitration-rules/.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — both speculative URLs returned HTTP 404, the arbrules page links no such document, and the site search surfaced only articles. What actually exists is Rule 6.2 INSIDE the ADRIC Arbitration Rules, headed 'EXPEDITED/SIMPLIFIED ARBITRATION PROCEDURE', plus 'Appendix R2' and a 'SAMPLE EXPEDITED/SIMPLIFIED PROCEDURE'. Treat 'Simplified Arbitration Rules' as a phrase in ADRIC's model clause, not as the title of a document you can cite or link. If copy needs to mention it, say it is a procedure within the ADRIC Arbitration Rules.
|
||||||
|
- **What is the current effective date or edition of the ADRIC National Mediation Rules?**
|
||||||
|
- *Searched:* https://adric.ca/rules-codes/national-mediation-rules/ page text and HTML (no effective-date string present); every PDF link on that page; the linked rules PDF at https://adric.ca/pdf/ADRMEDIATIONRULES2014.pdf — pdfinfo metadata and pdftotext of pages 1–6 and 20–22.
|
||||||
|
- *Outcome:* NOT ESTABLISHED as a current-edition date. The page publishes no effective date. The PDF's own footers read 'As amended August 3, 2012'; its filename says 2014; its PDF metadata Title is 'ADR MEDIATION RULES 2011 Cover.p65' with a CreationDate of 24 Feb 2014. Three different years attach to one artefact. The page states the rules are under review by the ADRIC Mediation Committee as of 2025 and that the existing rules remain in effect. Recommendation: name the rules, do not date them.
|
||||||
|
- **Does ADRIC publish a code of conduct for ARBITRATORS, parallel to its Code of Conduct for Mediators?**
|
||||||
|
- *Searched:* https://adric.ca/rules-codes/ (full Rules & Codes index, nav and cards), https://adric.ca/ethics-professional-practice/, https://adric.ca/rules-codes/code-of-conduct/ (full text) and https://adric.ca/rules-codes/code-of-ethics/ (full text); the table of contents of the National Mediation Rules booklet PDF.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — no arbitrator-specific code of conduct was found. ADRIC's own framing is consistently two documents: 'a Code of Conduct for Mediators and a National Code of Ethics'. The Code of Ethics is the instrument that covers arbitrators ('the integrity and fairness of the arbitration and mediation processes'; 'the office of arbitrator or mediator'). Do not write that ADRIC has a code of conduct for arbitrators. The 2025 Arbitration Rules do carry their own conflicts-disclosure standards and a 'Standard statement of arbitrator independence and impartiality', which is a different thing from a code of conduct.
|
||||||
|
- **Which single string is 'correct' for the chartered med-arb designation, given ADRIC uses more than one?**
|
||||||
|
- *Searched:* All ADRIC designation pages plus the Med-Arb Rules page and the site-wide nav; all five ADRIO designation pages; raw-HTML inspection of the designation list markup to confirm the dash character.
|
||||||
|
- *Outcome:* PARTIALLY ESTABLISHED, with a caution. 'Chartered Mediator-Arbitrator' is WRONG and appears nowhere — that much is settled. But ADRIC uses TWO forms of the correct name: 'Chartered Med-Arbitrator' (designation list, site nav) and 'Chartered Med-Arb' (that designation page's own heading, its criteria, and the Med-Arb Rules page). ADRIO uses 'Chartered Med-Arbitrator (C.Med-Arb)' only. Recommendation for copy: 'Chartered Med-Arbitrator (C.Med-Arb)' — the form both institutions share and the only one ADRIO uses. The separator in ADRIC's list is an EN DASH (raw HTML '–'), not a hyphen; the abbreviation 'C.Med-Arb' takes a hyphen.
|
||||||
|
- **The cover/front-matter title of the ADRIC Code of Conduct for Mediators as a standalone PDF.**
|
||||||
|
- *Searched:* curl of https://adric.ca/wp-content/uploads/2016/04/Code-of-Conduct-for-Mediators.pdf (HTTP 200, 779,235 bytes, 3 pages); pdftotext with -layout over pages 1-2 and over the whole file.
|
||||||
|
- *Outcome:* NOT ESTABLISHED from that PDF — pdftotext returned zero characters at exit 0, i.e. the file is a scanned image with no text layer. The title is nevertheless established from two other fetched artefacts: the HTML page renders the heading 'CODE OF CONDUCT FOR MEDIATORS' and the opening sentence 'This Code of Conduct for Mediators (the "Code")...', and the National Mediation Rules booklet's contents page carries 'ADR INSTITUTE OF CANADA, INC. / CODE OF CONDUCT FOR MEDIATORS'.
|
||||||
|
- **Whether ADR Chambers publishes rules for its med-arb service, under a distinct title.**
|
||||||
|
- *Searched:* curl of https://adrchambers.com/med-arb/ (HTTP 404); the ADR Chambers Arbitration Rules page and PDF; the model-clauses page; the sidebar link inventory on the mediation, arbitration and expedited-arbitration pages.
|
||||||
|
- *Outcome:* NOT ESTABLISHED as a separate document. Med-arb at ADR Chambers is governed INSIDE the ADR Chambers Arbitration Rules (Rule 13, 'Med/Arb', with 'Med-Arb', 'Med-Arb Agreement' and 'Notice of Request for Med-Arb' all defined in Rule 1.1), and the firm links a 'Notice of Request for Med-Arb' form. There is no standalone 'ADR Chambers Med-Arb Rules'. Note the firm's service menu spells it 'Med/Arb' while the rules text spells it 'Med-Arb'.
|
||||||
|
- **A WebFetch-based read of adric.ca, per the instructed method.**
|
||||||
|
- *Searched:* WebFetch of https://adric.ca/rules-codes/.
|
||||||
|
- *Outcome:* BLOCKED — WebFetch returned HTTP 403 Forbidden with no body (adric.ca appears to filter by user agent). All ADRIC content in this report was therefore fetched with curl -sSL through Bash, reading the HTTP status code and curl exit status on every request, with stderr NOT suppressed. adr-ontario.ca and adrchambers.com fetched cleanly the same way. HTML was converted to text with the repository's committed extractor at /Users/pouya/Dev/Websites/adr-sml/docs/reference/adrio-extract/extract.mjs, and PDFs with /opt/homebrew/bin/pdftotext -layout.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `WebSearch: ADR Institute of Canada arbitration rules mediation rules official titles adric.ca`
|
||||||
|
- `WebSearch: ADR Institute of Ontario ADRIO designations Q.Med C.Med chartered mediator official site`
|
||||||
|
- `WebSearch: ADR Chambers arbitration rules mediation rules adrchambers.com`
|
||||||
|
- `WebSearch: adric.ca "early neutral evaluation" ADR Institute of Canada`
|
||||||
|
- `WebFetch https://adric.ca/rules-codes/ — HTTP 403 Forbidden, no body (blocked; all ADRIC reads redone via curl)`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/national-mediation-rules/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/arbrules/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/adric-med-arb-rules/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/code-of-ethics/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/code-of-conduct/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/ethics-professional-practice/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/professional-designations/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/professional-designations/arbitrator-designations/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/professional-designations/mediator-designations/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/professional-designations/chartered-med-arb/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/services/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/services/custom-adr-systems-rosters/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/adric-professional-practice-manuals/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/about/about-us/ — 200`
|
||||||
|
- `curl -sSL https://adric.ca/ufaqs/what-is-early-neutral-evaluation/ — 404`
|
||||||
|
- `curl -sSL https://adric.ca/ufaqs/ — 404`
|
||||||
|
- `curl -sSL https://adric.ca/rules/ADRIC-Simplified-Arbitration-Rules-2025.pdf — 404`
|
||||||
|
- `curl -sSL https://adric.ca/rules-codes/simplified-arbitration-rules/ — 404`
|
||||||
|
- `ADRIC site search: https://adric.ca/?s=early+neutral+evaluation — 200, no ENE page in results`
|
||||||
|
- `ADRIC site search: https://adric.ca/?s=Simplified+Arbitration+Rules — 200, no such document in results`
|
||||||
|
- `curl + pdftotext -layout: https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf (200, 42 pp.)`
|
||||||
|
- `curl + pdftotext -layout: https://adric.ca/rules/ADRIC-Arbitration-Protocol-2025.pdf (200)`
|
||||||
|
- `curl + pdftotext -layout: https://adric.ca/pdf/ADRMEDIATIONRULES2014.pdf (200, 22 pp.)`
|
||||||
|
- `curl + pdftotext -layout: https://adric.ca/wp-content/uploads/2023/12/ADRIC_Med_Arb_Rules_2020_8_5-X-11-p.-1.pdf (200)`
|
||||||
|
- `curl + pdftotext: https://adric.ca/wp-content/uploads/2016/04/Code-of-Conduct-for-Mediators.pdf (200, 3 pp., NO text layer — scanned image, zero characters extracted at exit 0)`
|
||||||
|
- `curl + pdftotext -layout: https://adric.ca/wp-content/uploads/2015/05/ADR-Brochure-EN-final.pdf (200)`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/professional-designations/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/qualified-mediator-q-med-qualified-arbitrator-q-arb/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/chartered-mediator-c-med/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/chartered-arbitrator-c-arb/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/chartered-med-arbitrator-c-med-arb/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/rules-codes/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/code-of-ethics/ — 200`
|
||||||
|
- `curl -sSL https://adr-ontario.ca/what-is-adr/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/about-us/ — 404 (correct path is /about-adr-chambers/)`
|
||||||
|
- `curl -sSL https://adrchambers.com/about-adr-chambers/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/arbitration/rules/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/mediation/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/mediation/rules/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/mediation-model-clause/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/model-adr-clauses/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/expedited-arbitration/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/expedited-arbitration/rules/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/med-arb/ — 404`
|
||||||
|
- `curl -sSL https://adrchambers.com/neutral-evaluation/ — 200`
|
||||||
|
- `curl -sSL https://adrchambers.com/adr-systems-design/ — 200`
|
||||||
|
- `curl -sSL https://adrchambersinternational.com/ — 200`
|
||||||
|
- `curl + pdftotext: ADR Chambers arbitration, mediation and expedited-arbitration rule PDFs (all 200)`
|
||||||
|
- `grep -rniE 'neutral evaluation' across all extracted page texts — matched only ADR Chambers pages and the ADRIC search-results page title`
|
||||||
|
- `grep -rniE 'systems? design|dispute system' across all extracted page texts — matched ADR Chambers pages and adric.ca/services/`
|
||||||
|
- `grep -n 'Med-Arbitrator' across all extracted page texts — matched ADRIC nav on every page plus the designation list; grep -c 'Mediator-Arbitrator' — zero matches on every file`
|
||||||
|
- `perl -0777 raw-HTML context reads of the ADRIC designation list and nav, to confirm the en dash and exact strings before quoting`
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# The extracted text behind `../adric-rules.md`
|
||||||
|
|
||||||
|
**Why this directory exists, and why it matters more here than for ADRIO.**
|
||||||
|
Every term count in `../adric-rules.md` — including Finding 1, which corrected a
|
||||||
|
rule-set name `docs/01` had directed onto a public page — was run against these
|
||||||
|
extracts, not against the raw HTML. R14: a count taken from an artefact nobody
|
||||||
|
can reach is unverifiable by construction.
|
||||||
|
|
||||||
|
**And unlike the ADRIO fetch, the HTML digests here do not reproduce.** The
|
||||||
|
served pages change on every request. `../adric-rules.md` §Provenance records
|
||||||
|
the two measured digests and the four causes read out of the diff. **The
|
||||||
|
extracts below are byte-stable across those re-fetches** — verified, not
|
||||||
|
assumed: a second fetch of the mediation-rules page produced different HTML and
|
||||||
|
an identical `.txt`.
|
||||||
|
|
||||||
|
So for these pages, **the `.txt` files are the artefact and the digests are only
|
||||||
|
a record of what was fetched.**
|
||||||
|
|
||||||
|
## Reproducing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sSL -o <slug>.html "https://adric.ca/rules-codes/<path>/"
|
||||||
|
node ../adrio-extract/extract.mjs <slug>.html > <slug>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The four URLs and their slugs are in the provenance table of
|
||||||
|
`../adric-rules.md`.
|
||||||
|
|
||||||
|
**`extract.mjs` is not duplicated here.** It is the same unmodified script
|
||||||
|
committed at `../adrio-extract/extract.mjs`, and a second copy is a second thing
|
||||||
|
to keep true — `CLAUDE.md`'s single-source rule applied to a tool rather than to
|
||||||
|
a fact. If you change it, both reference documents' counts have to be re-run.
|
||||||
|
|
||||||
|
## The one command that reproduces Finding 1
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for f in *.txt; do
|
||||||
|
printf '%-40s %s\n' "$f" "$(grep -o -F 'Model Mediation Rules' "$f" | wc -l)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `0` on all four. `grep -o -F 'National Mediation Rules'` returns 10 on
|
||||||
|
`rules-codes__national-mediation-rules.txt`.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
Rules & Codes - ADR Institute of Canada
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
ADR Connect - Directory of Professionals Affiliate Member Portals
|
||||||
|
ADRIC Member Portal
|
||||||
|
Language:
|
||||||
|
en
|
||||||
|
fr
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
ADRIC By-Laws
|
||||||
|
Learn More
|
||||||
|
Federation MoU
|
||||||
|
Learn More
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
Learn More
|
||||||
|
ADRIC Mediation Rules
|
||||||
|
Learn More
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Learn More
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Learn More
|
||||||
|
Privacy Policy
|
||||||
|
Learn More
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Learn More
|
||||||
|
Corporate Members
|
||||||
|
News
|
||||||
|
Abusive Arbitration Clauses in Contracts of Adhesion: The Québec Court of Appeal Draws the Line
|
||||||
|
The Role of Law in Mediation: Between Authority and Autonomy
|
||||||
|
Preserving Mediation’s Distinct Role in Civil Justice Reform: The Promise and Peril in Ontario’s Civil Rules Review
|
||||||
|
Contact
|
||||||
|
ADR Institute of Canada, Inc. 705-130 Albert Street, Ottawa, ON K1P5G4 1-877-475-4353 info@adric.ca
|
||||||
|
© 2026 ADR Institute of Canada, Inc. | Privacy Policy | Subscribe
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
ADRIC Med-Arb Rules - ADR Institute of Canada
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
ADR Connect - Directory of Professionals Affiliate Member Portals
|
||||||
|
ADRIC Member Portal
|
||||||
|
Language:
|
||||||
|
en
|
||||||
|
fr
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
The ADRIC Med-Arb Rules
|
||||||
|
As part of ADRIC’s role in protecting the public and promoting best practices in ADR, the need became apparent for specialized rules of procedure for med-arb, so a Task Force was formed with a dedicated working group of med-arb professionals. The Task Force completed an initial draft of the Rules, which were then referred to the Rules Committee for its consideration.
|
||||||
|
The Committee followed past practice by consulting with the membership at large as the Rules were being reviewed, and a discussion draft of the ADRIC Med-Arb Rules was presented to the membership at ADRIC’s Annual Conference in November 2019. The Committee received extensive and very helpful feedback. The Committee also followed past practice by sending the draft ADRIC Med-Arb Rules for “wordsmithing” by a plain language specialist and many of the recommended plain language revisions were adopted.
|
||||||
|
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, which requires a high level of practitioner competence to do successfully.
|
||||||
|
The Rules are designed to work in tandem with ADRIC’s existing Mediation Rules and Arbitration Rules, integrating seamlessly.
|
||||||
|
Download the ADRIC Med-Arb Rules:
|
||||||
|
Booklet format
|
||||||
|
8.5 x 11 format
|
||||||
|
Designation : We have also developed criteria for a specialized designation: the Chartered Med-Arb
|
||||||
|
Course : We have worked with course designers to develop the Med-Arb Foundational Course. See the training schedule .
|
||||||
|
Note: This course is presented in English.
|
||||||
|
ADRIC is grateful for the exceptional work of the following volunteers who gave so generously of their time and expertise to develop the Rules and Designation Criteria:
|
||||||
|
Sara Ahlstrom, C.Med
|
||||||
|
Anik Béland
|
||||||
|
Glen Bell, C.Arb
|
||||||
|
Colm Brannigan, FCIArb, C.Arb, C.Med
|
||||||
|
Genevieve Chornenki, C.Arb, C.Med
|
||||||
|
Olivier Després, C.Med, C.Arb
|
||||||
|
Barry Effler
|
||||||
|
Martina Faith
|
||||||
|
Angus Gunn, Q.C.
|
||||||
|
Arlene Henry, Q.C., C.Med
|
||||||
|
Jim McCartney, C.Arb, C.Med
|
||||||
|
David McCutcheon, C.Arb
|
||||||
|
Gerry Nera, C.Med, Q.Arb
|
||||||
|
Louise Novinger Grant, Q.C.
|
||||||
|
Elton Simoes, C.Med, Q.Arb
|
||||||
|
Marjorie Lee Thompson
|
||||||
|
Rick Weiler
|
||||||
|
Michael Welsh, Q.C., FCIArb, C.Med, Q.Arb
|
||||||
|
How can I comment to enhance the next revision?
|
||||||
|
Send email or correspondence to the Executive Director: [email protected]
|
||||||
|
MODEL DISPUTE RESOLUTION CLAUSE
|
||||||
|
Parties who agree to submit disputes under the Med-Arb Rules may use this clause in their agreement:
|
||||||
|
All disputes arising out of or in connection with this agreement, or in respect of any legal relationship associated with or derived from this agreement, will be finally resolved by Med-Arb under the Med-Arb Rules of the ADR Institute of Canada, Inc. The Seat of Arbitration under the ADRIC Arbitration Rules will be [specify]. The language of the Med-Arb will be [specify].
|
||||||
|
TYPES OF DISPUTES TO WHICH THE MED-ARB RULES APPLY
|
||||||
|
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. [1]
|
||||||
|
Parties should examine the Med-Arb Rules to ensure that their provisions are appropriate and conform with applicable legislation.
|
||||||
|
[1] In Québec, Article 2639 of the Civil Code of Québec, CQLR, c. CCQ-1991, provides that disputes over the status and capacity of persons, family matters, or other matters of public order may not be submitted to arbitration.
|
||||||
|
ADRIC ADMINISTRATION SERVICES
|
||||||
|
To commence a med-arb, send the Notice of Request to med-arbitrate to each Respondent under the Agreement and to ADRIC at [email protected] , and courier the Commencement Fee as set out in Schedule A of the Rules plus HST to ADRIC. Cheques are to be made payable to ADR Institute of Canada.
|
||||||
|
The Case Service Fee is to be remitted by the Respondent(s) with their Statement of Defence and/or Counterclaim (see Schedule A).
|
||||||
|
Templates : We regret we are not able to provide templates as the content of any such agreements would need to be the subject of legal advice.
|
||||||
|
Corporate Members
|
||||||
|
News
|
||||||
|
Abusive Arbitration Clauses in Contracts of Adhesion: The Québec Court of Appeal Draws the Line
|
||||||
|
The Role of Law in Mediation: Between Authority and Autonomy
|
||||||
|
Preserving Mediation’s Distinct Role in Civil Justice Reform: The Promise and Peril in Ontario’s Civil Rules Review
|
||||||
|
Contact
|
||||||
|
ADR Institute of Canada, Inc. 705-130 Albert Street, Ottawa, ON K1P5G4 1-877-475-4353 info@adric.ca
|
||||||
|
© 2026 ADR Institute of Canada, Inc. | Privacy Policy | Subscribe
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
ADRIC Arbitration Rules - ADR Institute of Canada
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
ADR Connect - Directory of Professionals Affiliate Member Portals
|
||||||
|
ADRIC Member Portal
|
||||||
|
Language:
|
||||||
|
en
|
||||||
|
fr
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
The ADRIC Arbitration Rules
|
||||||
|
Important Update on the ADR Institute of Canada (ADRIC) Arbitration Rules:
|
||||||
|
ADRIC has adopted new Arbitration Rules and a new Arbitrator Appointment Protocol, effective March 1, 2025. This modernization effort aims to support both domestic and international arbitration with up-to-date procedures and streamlined institutional support. The ADRIC Arbitration Rules have been the leading choice for Canadian businesses since 2002.
|
||||||
|
ADRIC Arbitration Rules – Effective 2025
|
||||||
|
ADRIC Arbitrator Appointment Protocol
|
||||||
|
CLICK FOR ARBITRATION FORMS
|
||||||
|
The ADR Institute of Canada (ADRIC) has developed forms to aid parties pursuing arbitration under the ADRIC Arbitration Rules. To file your case with ADRIC, you will need to complete the appropriate form below.
|
||||||
|
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
|
||||||
|
Notice of Appeal
|
||||||
|
“The new Arbitration Rules reflect two years of thoughtful discussion and analysis by a committed group of arbitrators and arbitration counsel representing all regions in Canada. Their focus has been on providing a framework for arbitration as a means of resolving disputes in a full, fair and final manner using up-to-date arbitration procedures and streamlined institutional support when needed.”
|
||||||
|
— Comment by William G. Horton, C.Arb, co-chair and discussion leader of the ADRIC Modernization Committee that drafted the new Arbitration Rules.
|
||||||
|
Highlights of the New Rules:
|
||||||
|
Customized ADRIC services based on a flexible “à la carte” menu tailored to meet your specific needs
|
||||||
|
Elimination of distinction between international and non-international disputes
|
||||||
|
Enhanced arbitrator appointment process
|
||||||
|
Expedited and integrated challenge process
|
||||||
|
Conflicts disclosure processes and standards
|
||||||
|
Practical precedents that can be customized
|
||||||
|
Checklist for first procedural meeting
|
||||||
|
Draft first procedural order
|
||||||
|
Standard terms of appointment of an arbitrator
|
||||||
|
Standard statement of arbitrator independence and impartiality
|
||||||
|
Important Notice : Our team is here to provide clarity and support, ensuring you have the information you need. Questions should be directed to ADRIC Case Services at [email protected] . However, please note that the ultimate decision-maker in any arbitration process is the Tribunal. For personalized legal advice, we strongly recommend consulting with your own lawyer.
|
||||||
|
MODEL DISPUTE RESOLUTION CLAUSE
|
||||||
|
Parties who agree to arbitrate under the Rules may use the following clause in their agreement:
|
||||||
|
“All disputes arising out of or in connection with this agreement, or in relation to any legal relationship associated with or derived from this agreement, will be resolved by final and binding arbitration under the Arbitration Rules of the ADR Institute of Canada, Inc. [or the Simplified Arbitration Rules of the ADR Institute of Canada, Inc.] The Seat of Arbitration will be [specify]. The language of the arbitration will be [specify].”
|
||||||
|
ADRIC ARBITRATION INFORMATION TO ARBITRATORS
|
||||||
|
Do you wish to be considered for an arbitrator appointment? The arbitrator appointment criteria are listed in the arbitrator appointment protocol above.
|
||||||
|
A candidate who wishes to be considered for an arbitrator appointment must:
|
||||||
|
Be a member in good standing of ADRIC and a regional affiliate;
|
||||||
|
Confirm that they carry at least $1 million in arbitrator errors and omissions insurance;
|
||||||
|
Commit to responding promptly to inquiries from ADRIC concerning their willingness to accept an appointment, failing which they may not be considered for appointments;
|
||||||
|
Advise whether there is any equity, diversity, and inclusion information they would like to be considered by the sub-committee in making Direct Appointments or generating Candidate Lists; and
|
||||||
|
Submit an ADR Connect profile that contains the following information:
|
||||||
|
i. their area(s) of expertise;
|
||||||
|
ii. their professional arbitration designations, if any;
|
||||||
|
iii. whether they wish to be considered for Interim Arbitrator or Challenge Adjudicator appointments;
|
||||||
|
iv. their experience as arbitrator and/or arbitration counsel;
|
||||||
|
v. their standard rate(s);
|
||||||
|
vi. any other information they deem relevant; and
|
||||||
|
vii. at the candidate’s option, any equity, diversity, and inclusion information they would like to be considered.
|
||||||
|
Any member who wishes to be considered for appointment as a Challenge Adjudicator or Interim Arbitrator must confirm that they are willing to accept an appointment on the basis of Schedules B and C of the Rules.
|
||||||
|
All requests for Direct Appointments or to generate Candidate Lists must be sent by email to ADRIC Case Services at [email protected] .
|
||||||
|
All requests by one or more parties must include the following information:
|
||||||
|
Party Disclosure required under the Rules;
|
||||||
|
full names of all parties, their addresses, and other contact information, if known;
|
||||||
|
names of all legal counsel or party representatives, their addresses, and other contact information, if known;
|
||||||
|
brief description of the dispute, without argument;
|
||||||
|
copy of the arbitration agreement, if any;
|
||||||
|
amount(s) in dispute, if any;
|
||||||
|
any qualifications the parties request or require of the arbitrator, including any subject area expertise;
|
||||||
|
time constraints, if any, imposed by the parties, the arbitration agreement, or the nature of the dispute;
|
||||||
|
whether appointment of an Arbitrator, Interim Arbitrator or Challenge Adjudicator is requested and, if so, all information required under the Rules; and
|
||||||
|
any other information the party making the request considers necessary.
|
||||||
|
All party requests will also be copied to those identified in paragraphs 7b) and c) above, unless the request is for appointment of an ex parte Interim Arbitrator as permitted pursuant to the Rules.
|
||||||
|
We are grateful to the following individuals:
|
||||||
|
Arbitrator Appointment Committee:
|
||||||
|
Megan Keenberg, Joan Cotie, Olivier Després, David Eaton, Jim McLandress, Lisa Munro, and Rachel Howie.
|
||||||
|
Arbitration Modernization Project (AMP):
|
||||||
|
William G. Horton, Jim McCartney, Jim Musgrave, Glen Bell, Mary Comeau, Stephen Drymer, Bryan Duguid, Angus Gunn, Joshua Karton, Lisa Munro, David McCutcheon, Emily McMurtry, and Lauren Tomasich .
|
||||||
|
Advisory Committee to the AMP:
|
||||||
|
Brian Casey, Megan Keenberg, Jack Marshall, Eric Morgan, Murray Smith, Doug Stollery, and Hon. Neil Wittmann.
|
||||||
|
Arbitrator Appointment Protocol Sub-Committee:
|
||||||
|
Lisa Munro, Amy Crosbie, Stephen Drymer, Douglas Harrison, Matti Lemmens, Jim McCartney, Sabri Shawa, and Junior Sirivar.
|
||||||
|
Any comments or suggestions concerning the New Arbitration Rules may be emailed to the ADRIC Executive Director at [email protected] .
|
||||||
|
Click here for previous versions of the Rules.
|
||||||
|
Corporate Members
|
||||||
|
News
|
||||||
|
Abusive Arbitration Clauses in Contracts of Adhesion: The Québec Court of Appeal Draws the Line
|
||||||
|
The Role of Law in Mediation: Between Authority and Autonomy
|
||||||
|
Preserving Mediation’s Distinct Role in Civil Justice Reform: The Promise and Peril in Ontario’s Civil Rules Review
|
||||||
|
Contact
|
||||||
|
ADR Institute of Canada, Inc. 705-130 Albert Street, Ottawa, ON K1P5G4 1-877-475-4353 info@adric.ca
|
||||||
|
© 2026 ADR Institute of Canada, Inc. | Privacy Policy | Subscribe
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
National Mediation Rules - ADR Institute of Canada
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
ADR Connect - Directory of Professionals Affiliate Member Portals
|
||||||
|
ADRIC Member Portal
|
||||||
|
Language:
|
||||||
|
en
|
||||||
|
fr
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
Board of Directors
|
||||||
|
Staff & Contact Us
|
||||||
|
Operational Committees
|
||||||
|
Diversity, Equity, Inclusion (DEI)
|
||||||
|
ADRIC Regional Affiliates
|
||||||
|
McGowan Awards
|
||||||
|
Refund & Cancellation Policy
|
||||||
|
Partners
|
||||||
|
Job Opportunities
|
||||||
|
Rules & Codes
|
||||||
|
ADRIC By-laws
|
||||||
|
Federation MoU
|
||||||
|
ADRIC Arbitration Rules
|
||||||
|
National Mediation Rules
|
||||||
|
ADRIC Med-Arb Rules
|
||||||
|
Ethics & Professional Practice
|
||||||
|
Code of Ethics
|
||||||
|
Code of Conduct
|
||||||
|
Conflict of Interest
|
||||||
|
Complaints & Discipline Policy
|
||||||
|
Privacy Policy
|
||||||
|
Online Dispute Resolution (ODR) Vision
|
||||||
|
Membership
|
||||||
|
Become a Member
|
||||||
|
Member Benefits
|
||||||
|
Membership Renewals
|
||||||
|
Events
|
||||||
|
ADRIC 2026 National Conference
|
||||||
|
Webinars
|
||||||
|
ADRIC Past Conferences
|
||||||
|
ADRIC 2025: Annual National Conference
|
||||||
|
ADRIC 2024: Annual National Conference
|
||||||
|
ADRIC 2023: Annual National Conference
|
||||||
|
ADRIC 2022: Annual National Conference
|
||||||
|
ADRIC 2021 Conference Webinar Series
|
||||||
|
Home
|
||||||
|
Program
|
||||||
|
Sponsor
|
||||||
|
Publications & News
|
||||||
|
ADRIC News – Monthly Newsletter
|
||||||
|
ADR Perspectives
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Canadian Arbitration and Mediation Journal
|
||||||
|
Editorial Board
|
||||||
|
Book Reviews
|
||||||
|
Media Centre
|
||||||
|
Marketing Opportunities
|
||||||
|
ADRIC Annual Report
|
||||||
|
Announcements
|
||||||
|
ADR Training
|
||||||
|
Construction Adjudication Training Program
|
||||||
|
Correspondence Course in Arbitration
|
||||||
|
Med-Arb Workshop
|
||||||
|
National Introductory Arbitration Course
|
||||||
|
National Introductory Mediation Course
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
ADRIC Professional Practice Manuals
|
||||||
|
Disability Accessibility Guidebook
|
||||||
|
Designations & CEE
|
||||||
|
Continuing Education & Engagement
|
||||||
|
Professional Designations
|
||||||
|
Arbitrator Designations
|
||||||
|
Mediator Designations
|
||||||
|
Chartered Med-Arbitrator
|
||||||
|
Construction Adjudicator
|
||||||
|
Family Practice Mediation
|
||||||
|
IMI Recognition
|
||||||
|
Indigenous Practitioner Inclusion Initiative
|
||||||
|
Services
|
||||||
|
Construction Adjudication
|
||||||
|
CTA-OTC Arbitrator Roster
|
||||||
|
Arbitrator Appointments
|
||||||
|
Mediator Appointments
|
||||||
|
ADRIC Med-Arb Rules Case Administration Services
|
||||||
|
Custom ADR Systems and Rosters Development
|
||||||
|
In-House Designations
|
||||||
|
The ADRIC National Mediation Rules
|
||||||
|
The National Mediation Rules provide rules for initiating mediations, including the appointment of a mediator should the parties be unable to come to an agreement.
|
||||||
|
The National Mediation Rules document contains the following:
|
||||||
|
Mediation Rules including Code of Conduct
|
||||||
|
Standard Form Agreement to Mediate (Schedule B)
|
||||||
|
Administration fees payable to the ADR Institute of Canada- see Schedule A of the Rules
|
||||||
|
Model Dispute Resolution Clause
|
||||||
|
The Model Dispute Resolution Clause set out below is of particular importance to those drafting or entering into contracts. Commercial contracts drafted by law firms of all sizes across Canada commonly contain a clause indicating that any dispute that arises with respect to the contract will be administered by ADR Canada, or one of its affiliates, pursuant to the National Mediation Rules or the ADRIC Arbitration Rules of the ADR Institute of Canada.
|
||||||
|
The National Mediation Rules provide a Model Dispute Resolution Clause for Mediation and /or Arbitration:
|
||||||
|
All disputes arising out of or in connection with this agreement, or in respect of any legal relationship associated with or derived from this agreement, shall be mediated pursuant to the National Mediation Rules of the ADR Institute of Canada, Inc. The place of mediation shall be [specify City and Province of Canada]. The language of the mediation shall be English or French [specify language].
|
||||||
|
Important Update on the ADR Institute of Canada (ADRIC) National Mediation Rules: As of 2025, the ADRIC Mediation Committee is currently reviewing the Mediation Rules to ensure they remain current, practical, and aligned with best practices in the field. In the meantime, the existing rules remain in effect and should continue to be used until any updates are formally adopted.
|
||||||
|
Corporate Members
|
||||||
|
News
|
||||||
|
Abusive Arbitration Clauses in Contracts of Adhesion: The Québec Court of Appeal Draws the Line
|
||||||
|
The Role of Law in Mediation: Between Authority and Autonomy
|
||||||
|
Preserving Mediation’s Distinct Role in Civil Justice Reform: The Promise and Peril in Ontario’s Civil Rules Review
|
||||||
|
Contact
|
||||||
|
ADR Institute of Canada, Inc. 705-130 Albert Street, Ottawa, ON K1P5G4 1-877-475-4353 info@adric.ca
|
||||||
|
© 2026 ADR Institute of Canada, Inc. | Privacy Policy | Subscribe
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# ADRIC rules and codes — the published rule sets, in ADRIC's own words
|
||||||
|
|
||||||
|
**Why this file exists.** `docs/01-architecture.md` directs `/mediation/` to
|
||||||
|
name *"ADRIC Model Mediation Rules"* and `/arbitration/` to name *"ADRIC, ADR
|
||||||
|
Chambers, ad hoc"*. A rule set's **name** is a fact about a published document,
|
||||||
|
so under Q46(b)'s standard it may be published — but only from a source, not
|
||||||
|
from recall. R14: a claim whose artefact is unreachable is unverifiable by
|
||||||
|
construction. This is the same fetch-before-writing that caught
|
||||||
|
`Chartered Mediator-Arbitrator` (`docs/reference/adrio-designations.md`).
|
||||||
|
|
||||||
|
**It caught one immediately. See Finding 1.**
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||||
|
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||||
|
> are still the original retrieval and were not re-fetched** — what was
|
||||||
|
> re-checked is whether the *facts* they support have moved.
|
||||||
|
>
|
||||||
|
> - **(f) the ADRIC National Mediation Rules, under review by ADRIC's own
|
||||||
|
> committee — NOT RE-RETRIEVED.** Held unchanged on a cadence judgement
|
||||||
|
> rather than a fresh fetch `[assumed 2026-09-01 — Pouya]`. The digests in
|
||||||
|
> the Provenance table below are the 2026-08-28 bytes and were not
|
||||||
|
> recomputed, so this stamp says nothing about whether the page changed.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Retrieved | **2026-08-28** |
|
||||||
|
| Method | `curl -sSL` — redirects followed, exit status read, stderr not suppressed |
|
||||||
|
| Text extracts | `docs/reference/adric-extract/`, produced by the committed `adrio-extract/extract.mjs` (same script, unmodified) |
|
||||||
|
|
||||||
|
| URL | HTTP | bytes | text | sha256 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `https://adric.ca/rules-codes/` | 200 | 120908 | 4579 | `e7281b28e3d739f8d91985d5094fbdff7dea24e3a360d00ec32e387e32e9a1c9` |
|
||||||
|
| `https://adric.ca/rules-codes/national-mediation-rules/` | 200 | 110894 | 6130 | `c55fe482da8aa7a69377703b9192bda0d926f012e45a90ca5ce5d27a7b0344ac` |
|
||||||
|
| `https://adric.ca/rules-codes/arbrules/` | 200 | 122211 | 11004 | `07b9850f41bbf40f6ce3a3778876853091ac25a6bb255ef0c4b2827af69a937e` |
|
||||||
|
| `https://adric.ca/rules-codes/adric-med-arb-rules/` | 200 | 114700 | 8445 | `153479af8235022170b31c9673262f4e10a51e9f99fcffcb3e0c7dd23d037393` |
|
||||||
|
|
||||||
|
⚠️ **THE HTML DIGESTS DRIFT ON EVERY REQUEST. THE TEXT EXTRACTS DO NOT.**
|
||||||
|
Unlike the ADRIO fetch, where the digests were stable and are therefore stamped
|
||||||
|
as the artefact, these pages change per response. **Measured, not assumed** — the
|
||||||
|
mediation-rules page was fetched twice, three minutes apart:
|
||||||
|
|
||||||
|
```
|
||||||
|
first : c55fe482da8aa7a69377703b9192bda0d926f012e45a90ca5ce5d27a7b0344ac
|
||||||
|
second: e9f74014ec04e8fcab0f1cbbd75ef4cb104c8d94992b93c832d41c8259ce78b2
|
||||||
|
text identical? YES
|
||||||
|
```
|
||||||
|
|
||||||
|
Four causes, read out of the diff rather than guessed at:
|
||||||
|
|
||||||
|
1. a per-render page-builder class suffix (`highend_6a91df2f53035` →
|
||||||
|
`highend_6a91dfca9a8be`);
|
||||||
|
2. a **rotating corporate-member logo carousel** in the footer — nine sponsor
|
||||||
|
logos, reordered per request;
|
||||||
|
3. **Cloudflare email obfuscation**, which re-keys the `email-protection#…`
|
||||||
|
hash per response;
|
||||||
|
4. a **Cloudflare challenge-platform token** (`__CF$cv$params` `r` and `t`).
|
||||||
|
|
||||||
|
*(This paragraph first asserted "a WordPress nonce and rotating asset query
|
||||||
|
strings" — a guess, written before the second fetch. None of the four causes is
|
||||||
|
that. Corrected before commit, and recorded because a guessed cause for an
|
||||||
|
observed difference is exactly what `CLAUDE.md` bars.)*
|
||||||
|
|
||||||
|
**So the stable artefact is `docs/reference/adric-extract/`**, and the term
|
||||||
|
counts below were run against those extracts. A digest mismatch on re-fetch is
|
||||||
|
**not** evidence the content changed — re-extract and compare the text.
|
||||||
|
|
||||||
|
Also on the record: `https://adric.ca/rules/` returns **403**, and
|
||||||
|
`/mediation-rules/` and `/arbitration-rules/` return **404**. The working paths
|
||||||
|
are all under `/rules-codes/`. Recorded because a 403 body is 52 bytes and reads
|
||||||
|
like an empty page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finding 1 — "ADRIC Model Mediation Rules" is NOT the name of anything
|
||||||
|
|
||||||
|
`docs/01-architecture.md` §`/mediation/` item 3 directed the page to name the
|
||||||
|
**"ADRIC Model Mediation Rules"**. The string does not exist in ADRIC's
|
||||||
|
materials.
|
||||||
|
|
||||||
|
| Term | rules-codes | national-mediation-rules | arbrules | adric-med-arb-rules |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Model Mediation Rules` | 0 | 0 | 0 | 0 |
|
||||||
|
| `National Mediation Rules` | 2 | 10 | 2 | 2 |
|
||||||
|
| `ADRIC Mediation Rules` | 1 | 0 | 0 | 0 |
|
||||||
|
| `ADRIC Arbitration Rules` | 3 | 3 | 7 | 3 |
|
||||||
|
| `ADRIC Med-Arb Rules` | 5 | 4 | 4 | 9 |
|
||||||
|
|
||||||
|
**Where "Model" actually belongs.** ADRIC publishes a **Model Dispute Resolution
|
||||||
|
Clause** — a contract clause, inside the rules document. The spec's phrase
|
||||||
|
conflates the clause's name with the rules' name.
|
||||||
|
|
||||||
|
**The canonical title, from the page's own heading:** *"The ADRIC National
|
||||||
|
Mediation Rules"*. The Model Clause it publishes uses the same form —
|
||||||
|
*"shall be mediated pursuant to the **National Mediation Rules** of the ADR
|
||||||
|
Institute of Canada, Inc."*
|
||||||
|
|
||||||
|
**One inconsistency in ADRIC's own materials, recorded so it is not read later
|
||||||
|
as our error.** The `/rules-codes/` index has a card labelled *"ADRIC Mediation
|
||||||
|
Rules"* (1 occurrence, card link text) while its nav and the document itself say
|
||||||
|
*"National Mediation Rules"*. **Use `ADRIC National Mediation Rules`** — the
|
||||||
|
document's own title, and the form inside the model clause.
|
||||||
|
|
||||||
|
## Finding 2 — the three rule sets, with the facts a page may state
|
||||||
|
|
||||||
|
**ADRIC National Mediation Rules.** *"The National Mediation Rules provide rules
|
||||||
|
for initiating mediations, including the appointment of a mediator should the
|
||||||
|
parties be unable to come to an agreement."* The document contains: Mediation
|
||||||
|
Rules including a Code of Conduct; a Standard Form Agreement to Mediate
|
||||||
|
(Schedule B); ADRIC administration fees (Schedule A); and the Model Dispute
|
||||||
|
Resolution Clause.
|
||||||
|
|
||||||
|
⚠️ **Currency caveat, verbatim:** *"As of 2025, the ADRIC Mediation Committee is
|
||||||
|
currently reviewing the Mediation Rules… In the meantime, the existing rules
|
||||||
|
remain in effect and should continue to be used until any updates are formally
|
||||||
|
adopted."* So do not date them, and do not describe them as recently revised.
|
||||||
|
|
||||||
|
**ADRIC Arbitration Rules.** *"ADRIC has adopted new Arbitration Rules and a new
|
||||||
|
Arbitrator Appointment Protocol, effective March 1, 2025."* Published as
|
||||||
|
**"ADRIC Arbitration Rules – Effective 2025"**, alongside an **ADRIC Arbitrator
|
||||||
|
Appointment Protocol** and named forms: 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,
|
||||||
|
Notice of Appeal.
|
||||||
|
|
||||||
|
**ADRIC Med-Arb Rules.** Developed by a **Task Force** — *"a Task Force was
|
||||||
|
formed with a dedicated working group of med-arb professionals. The Task Force
|
||||||
|
completed an initial draft of the Rules, which were then referred to the Rules
|
||||||
|
Committee"* — and a discussion draft was presented to the membership at
|
||||||
|
ADRIC's Annual Conference in **November 2019**. Two sentences are directly
|
||||||
|
useful to `/med-arb/`, both verbatim:
|
||||||
|
|
||||||
|
> "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, **which
|
||||||
|
> requires a high level of practitioner competence to do successfully**."
|
||||||
|
|
||||||
|
> "The Rules are designed to work in tandem with ADRIC's existing Mediation
|
||||||
|
> Rules and Arbitration Rules, integrating seamlessly."
|
||||||
|
|
||||||
|
**Scope, verbatim:** *"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."* That matches §4's commercial scoping
|
||||||
|
without being cited for it — it is ADRIC's statement about its own rules, not
|
||||||
|
authority for what this practice offers.
|
||||||
|
|
||||||
|
ADRIC also publishes a **Med-Arb Foundational Course** (in English) and, per this
|
||||||
|
page, *"criteria for a specialized designation: the Chartered Med-Arb"*.
|
||||||
|
|
||||||
|
## Finding 3 — "Chartered Med-Arbitrator" is corroborated by a second body
|
||||||
|
|
||||||
|
`Chartered Med-Arbitrator` appears **2 times on every one of the four pages** —
|
||||||
|
it is ADRIC's own navigation label, under *Designations & CEE → Professional
|
||||||
|
Designations*. So the long form §11 now carries, sourced from **ADRIO**, is
|
||||||
|
independently the form **ADRIC** uses in its nav. The body prose on the Med-Arb
|
||||||
|
Rules page uses the short *"Chartered Med-Arb"*
|
||||||
|
**once** — *"criteria for a specialized designation: the Chartered Med-Arb"*.
|
||||||
|
|
||||||
|
⚠️ *This read "3 occurrences" for one pass. `grep -o -F 'Chartered Med-Arb'`
|
||||||
|
does return 3, but **two of the three are substrings of the nav label
|
||||||
|
"Chartered Med-Arbitrator"** counted separately one sentence above. `grep -o
|
||||||
|
'Chartered Med-Arb(?!itrator)'` returns 1. That is `CLAUDE.md`'s "a grep that
|
||||||
|
matches is not a finding until you read what it matched", committed inside the
|
||||||
|
document whose whole purpose is sourced counts. Found by
|
||||||
|
`adversarial-reviewer`.* **Neither body writes "Mediator-Arbitrator" anywhere in this
|
||||||
|
fetch.**
|
||||||
|
|
||||||
|
## Finding 4 — what is NOT in this fetch, and must not be inferred from it
|
||||||
|
|
||||||
|
- **The rules' actual provisions.** Every page above is a *landing page*; the
|
||||||
|
rules themselves are PDFs behind download links and were **not** fetched. So
|
||||||
|
this file supports naming a rule set and describing what a document contains
|
||||||
|
at the level its own landing page describes it. It supports **no statement of
|
||||||
|
what any rule requires, permits or provides** — including anything about
|
||||||
|
consent mechanics, caucus information, or the switch from mediation to
|
||||||
|
arbitration in med-arb. Write those as this practice's own protocol, or not at
|
||||||
|
all.
|
||||||
|
- **Anything about ADR Chambers.** It is not sourced here, and as of 2026-08-30
|
||||||
|
it is named on no page: Pouya struck it from `/arbitration/` and from
|
||||||
|
`docs/01` item 3. `docs/07-fees.md` cites it only for *published fee ranges*,
|
||||||
|
which is a different claim and is internal.
|
||||||
|
- **Legal effect.** Nothing here establishes the enforceability of an award, in
|
||||||
|
Ontario or anywhere. §4 bars this repository from concluding a proposition of
|
||||||
|
law.
|
||||||
|
|
||||||
|
## Finding 5 — ADRIC superlatives deliberately NOT lifted
|
||||||
|
|
||||||
|
Same treatment as `adrio-designations.md` Finding 5. These are ADRIC's marketing
|
||||||
|
claims about itself and **must not travel onto this site**, where §4 Forbidden
|
||||||
|
bars superlatives:
|
||||||
|
|
||||||
|
- *"The ADRIC Arbitration Rules have been **the leading choice** for Canadian
|
||||||
|
businesses since 2002."*
|
||||||
|
- *"integrating **seamlessly**"* — quoted above inside a quotation, and it stays
|
||||||
|
inside one.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Reference — ADRIO's own expansions of the five ADR designations
|
||||||
|
|
||||||
|
**Why this file exists.** `AGENTS.md` Q46(b) asked whether §11 Glossary may be
|
||||||
|
published as the source for **definitional expansions** — what `Q.Med`, `Q.Arb`
|
||||||
|
and `C.Med-Arb` stand for. Pouya ratified the standard on 2026-08-28 and
|
||||||
|
attached a condition: *"Fetch and commit
|
||||||
|
https://adr-ontario.ca/members-practitioners/professional-designations to
|
||||||
|
docs/reference/ per R14."* This is that fetch.
|
||||||
|
|
||||||
|
It also settles a **correction Pouya raised in the same ruling**, and the
|
||||||
|
correction was his own: §11 had said *"Chartered Mediator-Arbitrator"* since the
|
||||||
|
file was created. ADRIO's term is **"Chartered Med-Arbitrator"**. See Finding 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance — read this before quoting anything below
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| URL as given | `https://adr-ontario.ca/members-practitioners/professional-designations` |
|
||||||
|
| **Redirects to** | `https://adr-ontario.ca/professional-designations/` — HTTP **301**, one hop |
|
||||||
|
| Retrieved | **2026-08-28** |
|
||||||
|
| Method | `curl -sSL -o <file> '<url>'` — HTTP **200** on all five after the redirect |
|
||||||
|
|
||||||
|
**The redirect is recorded because the first fetch did not follow it.** `curl -sS`
|
||||||
|
without `-L` returned HTTP 301 and wrote a **0-byte file**, which a reader of
|
||||||
|
the output could easily have taken for "the page is empty". It is not; it is
|
||||||
|
one hop away. `CLAUDE.md`: *a command that did not run is not evidence of
|
||||||
|
absence* — and a command that ran and returned nothing useful looks the same.
|
||||||
|
|
||||||
|
### The five files, with digests
|
||||||
|
|
||||||
|
| Page | Bytes | Text | `sha256` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `professional-designations/` | 54,961 | 1,932 | `5eca45791b3629d16186dd2a10df43fdd5981cbe89a6ce23d8b43ec7c4c415a8` |
|
||||||
|
| `qualified-mediator-q-med-qualified-arbitrator-q-arb/` | 57,174 | 2,814 | `c8cb8ad556b7ffd193cb672f136d4c0b0bdffdcaa8ac20739f31c1ccef84301f` |
|
||||||
|
| `chartered-mediator-c-med/` | 58,929 | 4,151 | `47cb8edb20681865b55103e265c693405a8dfde369fd5f4eefd340fb58f0c056` |
|
||||||
|
| `chartered-arbitrator-c-arb/` | 58,013 | 3,524 | `44526cb40f3e99bec03e3b45b13d4dc4b6cb5fd4e13b388ed533f7baa6fb1199` |
|
||||||
|
| `chartered-med-arbitrator-c-med-arb/` | 55,966 | 2,504 | `4c3a3214383c80209884125703e89625e855b550f01ea8141df496603cdd69c7` |
|
||||||
|
|
||||||
|
*Text* = characters remaining after `<script>`/`<style>`/`<noscript>`/comments
|
||||||
|
and tags are stripped, entities unescaped, whitespace collapsed.
|
||||||
|
|
||||||
|
> 📁 **THE EXTRACTED TEXT AND THE SCRIPT THAT PRODUCED IT ARE COMMITTED** —
|
||||||
|
> `adrio-extract/`. Every term count in this file was run against those `.txt`
|
||||||
|
> files, and `extract.mjs` is the extraction. **The digests above cover the raw
|
||||||
|
> HTML; they said nothing about the extraction**, so the counts were unverifiable
|
||||||
|
> by construction (R14) even while the digests were not —
|
||||||
|
> `adversarial-reviewer`, 2026-08-28. `adrio-extract/README.md` carries the
|
||||||
|
> reproduction command and the caveat that a future digest mismatch is most
|
||||||
|
> likely a WordPress plugin bump rather than a changed designation name.
|
||||||
|
|
||||||
|
> ✅ **THESE DIGESTS ARE STAMPED, AND `lat-case-conference.md`'S ARE NOT — THE
|
||||||
|
> DIFFERENCE WAS CHECKED, NOT ASSUMED.** That file removed its hashes because the
|
||||||
|
> LAT pages could not reproduce one: `LAT-Rules.html` carries a per-request
|
||||||
|
> bot-detection nonce (`__uzdbm_1`, `__uzdbm_2`) and the AABS page carries
|
||||||
|
> rotating WordPress `?ver=` cache-busters.
|
||||||
|
>
|
||||||
|
> Both causes were looked for here and **both are absent**:
|
||||||
|
>
|
||||||
|
> - `__uzdbm` — **0 occurrences.**
|
||||||
|
> - `?ver=` — 17 occurrences, and they are **static plugin version strings**
|
||||||
|
> (`?ver=1.3.12`, `?ver=2.3.2`, `?ver=3.4.1`, `?ver=4.7.0`), not rotating
|
||||||
|
> values. Same string on every fetch.
|
||||||
|
>
|
||||||
|
> Then measured rather than reasoned about: **four independent fetches of each of
|
||||||
|
> the five URLs, and the file on disk re-hashed against a fresh fetch. All five
|
||||||
|
> MATCH.** The digests above are of the exact bytes the quotes below came from.
|
||||||
|
>
|
||||||
|
> **One instrument failure happened during that check and is recorded because of
|
||||||
|
> its shape.** The first comparison loop used `set -- $pair` under zsh, which
|
||||||
|
> does not word-split unquoted variables, so the loop body received one argument
|
||||||
|
> and every comparison silently failed — printing **`DIFFER` on all five rows**.
|
||||||
|
> That output reads exactly like *"the pages changed under me"*. It was the
|
||||||
|
> `timeout 60 ls "$DRIVE"` shape: the comparison had never run. Re-run with
|
||||||
|
> `while IFS=: read`, all five returned MATCH.
|
||||||
|
|
||||||
|
**Instrument note on JS-rendered shells.** The parent page extracts to **1,932
|
||||||
|
characters from 54,961 bytes — 3.5%**, which is the signature `adric.ca` produced
|
||||||
|
when it defeated an earlier attempt at this same sourcing (4,840 chars from
|
||||||
|
114,985). Here the low ratio is **not** a shell: the parent page really is nearly
|
||||||
|
empty — its body is a title and one sentence pointing elsewhere. The substance is
|
||||||
|
on the four child pages, which extract at 4.5–7.0% because ~1,800 characters of
|
||||||
|
the extraction on every page is the shared navigation. Read the ratio against the
|
||||||
|
page, not against a threshold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finding 1 — all five expansions, verbatim
|
||||||
|
|
||||||
|
Present **twice** on the parent page (desktop and mobile navigation), and again
|
||||||
|
as the `<h1>` of each child page. ADRIO's own words, entities unescaped:
|
||||||
|
|
||||||
|
| Abbrev. | ADRIO's expansion | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `Q.Med` | **Qualified Mediator** | nav + child `<h1>` |
|
||||||
|
| `Q.Arb` | **Qualified Arbitrator** | nav + child `<h1>` |
|
||||||
|
| `C.Med` | **Chartered Mediator** | nav + child `<h1>` |
|
||||||
|
| `C.Arb` | **Chartered Arbitrator** | nav + child `<h1>` |
|
||||||
|
| `C.Med-Arb` | **Chartered Med-Arbitrator** | nav + child `<h1>` |
|
||||||
|
|
||||||
|
The navigation markup, verbatim:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="https://adr-ontario.ca/qualified-mediator-q-med-qualified-arbitrator-q-arb/">Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)</a>
|
||||||
|
<a href="https://adr-ontario.ca/chartered-mediator-c-med/">Chartered Mediator (C.Med)</a>
|
||||||
|
<a href="https://adr-ontario.ca/chartered-arbitrator-c-arb/">Chartered Arbitrator (C.Arb)</a>
|
||||||
|
<a href="https://adr-ontario.ca/chartered-med-arbitrator-c-med-arb/">Chartered Med-Arbitrator (C.Med-Arb)</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
**The URL slugs corroborate independently of the link text** — `chartered-med-arbitrator-c-med-arb`, not `chartered-mediator-arbitrator`.
|
||||||
|
|
||||||
|
## Finding 2 — the expansion this repo had wrong
|
||||||
|
|
||||||
|
`AGENTS.md` §11 read **"Chartered Mediator-Arbitrator"** from the file's creation
|
||||||
|
until 2026-08-28. Pouya's ruling: *"§11 has said 'Chartered Mediator-Arbitrator'
|
||||||
|
throughout. ADRIO's own term is 'Chartered Med-Arbitrator'. I took the expansion
|
||||||
|
from the strategy brief and never sourced it."*
|
||||||
|
|
||||||
|
**It had reached a public page.** `src/pages/about.astro`'s credential arc
|
||||||
|
rendered it as body copy, and `grep -c 'Chartered Mediator-Arbitrator'
|
||||||
|
dist/about/index.html` returned **1** — so the built artefact carried it too. Four
|
||||||
|
instances in the repo, swept 2026-08-28.
|
||||||
|
|
||||||
|
The child page's own heading and first two sentences, verbatim:
|
||||||
|
|
||||||
|
> **Chartered Med-Arbitrator (C.Med-Arb)**
|
||||||
|
>
|
||||||
|
> Med-Arb is a distinct, innovative standalone process that is not as well known
|
||||||
|
> or understood by consumers of ADR services compared to mediation and
|
||||||
|
> arbitration.
|
||||||
|
>
|
||||||
|
> 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, which requires
|
||||||
|
> a high level of practitioner competence to do successfully.
|
||||||
|
|
||||||
|
## Finding 3 — how ADRIO characterises the Q-level designations
|
||||||
|
|
||||||
|
Verbatim, `qualified-mediator-q-med-qualified-arbitrator-q-arb/`:
|
||||||
|
|
||||||
|
> **Qualified Mediator (Q.Med) / Qualified Arbitrator (Q.Arb)**
|
||||||
|
>
|
||||||
|
> These designations are to recognize practitioners who have completed sufficient
|
||||||
|
> mediation / arbitration and related dispute resolution training to be qualified
|
||||||
|
> to practice. They are an intermediate step for practitioners working to receive
|
||||||
|
> their Chartered designation.
|
||||||
|
>
|
||||||
|
> *Q.Med criteria vary across affiliates. For the criteria specific to Ontario,
|
||||||
|
> be sure to read the checklist on the application form.*
|
||||||
|
|
||||||
|
⚠️ **Do not lift "qualified to practice" into copy, in any form.** It is ADRIO
|
||||||
|
describing its own designation, and on this site — where §4 records licence
|
||||||
|
status as `[unestablished]` and D13 bars any phrasing that *implies* entitlement
|
||||||
|
to practise law — the phrase would do work ADRIO never intended it to do. The
|
||||||
|
quote is here so a reviewer can see the full sentence, not so it can be reused.
|
||||||
|
|
||||||
|
The *"intermediate step … working to receive their Chartered designation"* half
|
||||||
|
is the sourced basis for describing the arc as an arc. It is a fact about the
|
||||||
|
designation structure, not about Pouya.
|
||||||
|
|
||||||
|
## Finding 4 — two of the four pages state a retention condition, and the split is by PAGE TYPE, not by designation level
|
||||||
|
|
||||||
|
> ⚠️ **THIS FINDING'S FIRST VERSION WAS FRAMED WRONG, AND THE PAGE THAT
|
||||||
|
> FALSIFIED IT WAS ONE THIS FILE HAD ALREADY FETCHED.** It was headed *"the two
|
||||||
|
> Chartered pages state a retention condition; the Q page does not"* and argued a
|
||||||
|
> **Qualified-vs-Chartered** asymmetry. There are **three** Chartered pages. The
|
||||||
|
> count was never run on `chartered-med-arbitrator-c-med-arb/` — at the time,
|
||||||
|
> the designation §4 recorded as the practice's stated goal, and therefore the
|
||||||
|
> one most load-bearing for Q48. Found by `claims-auditor`, 2026-08-28.
|
||||||
|
> *(That §4 row was **struck 2026-08-29** — C.Med-Arb is off the site entirely.
|
||||||
|
> The counting defect this paragraph records is unaffected.)*
|
||||||
|
|
||||||
|
Counted on the extracted text of **all four** designation pages:
|
||||||
|
|
||||||
|
| Page | `good standing` | `annual` | `maintain` | `retain` | `member of the ADR Institute` |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `qualified-mediator-q-med-qualified-arbitrator-q-arb/` | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| `chartered-mediator-c-med/` | 1 | 1 | 1 | 1 | 1 |
|
||||||
|
| `chartered-arbitrator-c-arb/` | 1 | 1 | 1 | 1 | 1 |
|
||||||
|
| `chartered-med-arbitrator-c-med-arb/` | **0** | **0** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
Verbatim, `chartered-mediator-c-med/`:
|
||||||
|
|
||||||
|
> There is an annual fee to maintain your C.Med designation, payable to ADR
|
||||||
|
> Institute of Canada, Inc. The national organization bills you every year. You
|
||||||
|
> must also remain a member in good standing with the ADR Institute of Ontario™
|
||||||
|
> to retain this designation.
|
||||||
|
|
||||||
|
`chartered-arbitrator-c-arb/` says the same of C.Arb.
|
||||||
|
|
||||||
|
**The corrected reading.** C.Med-Arb scores zero on every term, exactly like the
|
||||||
|
Q-level page — so the line does **not** fall between Qualified and Chartered. It
|
||||||
|
falls between **page types**: `chartered-mediator-c-med/` and
|
||||||
|
`chartered-arbitrator-c-arb/` are long application FAQs with numbered sections
|
||||||
|
("Are there prerequisites?", "Is there an ongoing cost?"), while the Q page and
|
||||||
|
the C.Med-Arb page are short descriptions that link out. The C.Med-Arb page says
|
||||||
|
so in as many words: *"To read more about the C.Med-Arb designation, including
|
||||||
|
how to apply, click here."*
|
||||||
|
|
||||||
|
**And that cuts the opposite way from how the first version read it.** A stub
|
||||||
|
page's silence is evidence about the *page*, not about the designation — so
|
||||||
|
Q.Med's silence is most likely "this page does not cover fees or retention", not
|
||||||
|
"no such condition exists". ADRIC bills annually for the two designations that
|
||||||
|
say so, and ADRIO notes that *"Q.Med criteria vary across affiliates"*.
|
||||||
|
|
||||||
|
**Still: this source does not answer it, in either direction, and must not be
|
||||||
|
read as doing so.** Raised as **Q48** — if `Q.Med` retention is contingent on
|
||||||
|
ADRIO membership currency, then §4's Q.Med row, this site's central credential,
|
||||||
|
inherits the shelf life of the memberships Pouya has declined to track by date
|
||||||
|
(R10). The answer needs the Q.Med criteria document or ADRIC/ADRIO directly.
|
||||||
|
|
||||||
|
## Finding 5 — two superlatives, deliberately not lifted
|
||||||
|
|
||||||
|
The Chartered pages call C.Med *"Canada's most senior official designation for
|
||||||
|
practising mediators"* and C.Arb *"Canada's only official designation for
|
||||||
|
practising Arbitrators"*. Both are ADRIO's words about its own designations and
|
||||||
|
both are quotable in principle.
|
||||||
|
|
||||||
|
**Neither goes on the site.** §4 Forbidden bars superlatives outright
|
||||||
|
(*"best", "leading", "top-rated"*), and the reason given there is not only
|
||||||
|
verifiability — it is that *"they read as insecure to the audience this site is
|
||||||
|
for"*. A superlative does not stop being one because a third party said it first,
|
||||||
|
and the copy did not need it. *(This sentence justified the superlative's
|
||||||
|
removal by pointing at **"the designation this practice is built toward"** as
|
||||||
|
"Pouya's own stated goal" with "a §4 row". **That row was struck 2026-08-29** and
|
||||||
|
the phrase is off the site. The reason superlatives stay inside quotation marks
|
||||||
|
is unchanged and never depended on it.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes, and what it does not
|
||||||
|
|
||||||
|
**Establishes:**
|
||||||
|
|
||||||
|
1. All five expansions, in ADRIO's own words, from ADRIO's own site, with
|
||||||
|
reproducible digests. **R14 is satisfied for Q46(b)** — the standard no longer
|
||||||
|
rests on §11 asserting its own content.
|
||||||
|
2. `C.Med-Arb` = **Chartered Med-Arbitrator**. The form this repo published was
|
||||||
|
wrong.
|
||||||
|
3. That the Q-level designations are, in ADRIO's characterisation, an
|
||||||
|
intermediate step toward the Chartered designations.
|
||||||
|
|
||||||
|
**Does not establish:**
|
||||||
|
|
||||||
|
- **Anything about Pouya.** Not that he holds Q.Med, not that he holds Q.Arb,
|
||||||
|
not that he is a member of ADRIO. *(This read "not that he has **commenced**
|
||||||
|
Q.Arb" until 2026-08-30 — a struck stage form surviving in the file an
|
||||||
|
implementer opens to write designation copy.)* Those are §4 Verified rows and this
|
||||||
|
file is not a substitute for one. The standard Pouya ratified is exactly this
|
||||||
|
split: *"§11 Glossary is the source for DEFINITIONAL expansions; §4 remains
|
||||||
|
the only source for claims about Pouya."*
|
||||||
|
- **Anything about gating** — see `AGENTS.md` §4's offering test, question 1.
|
||||||
|
ADRIO describes its designations as recognising completed training; it says
|
||||||
|
nothing about what any activity requires by law, and it is not a source that
|
||||||
|
could. Q46(a) is not advanced by this file.
|
||||||
|
- **Anything about Q.Med retention.** Finding 4. That is Q48.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# The extracted text behind `../adrio-designations.md`
|
||||||
|
|
||||||
|
**Why this directory exists.** `adversarial-reviewer` (2026-08-28, C10): the
|
||||||
|
`sha256` digests in that file cover the **raw HTML**, but Finding 4's term counts
|
||||||
|
were taken from **extracted text produced by an uncommitted script** — so the
|
||||||
|
counts were unverifiable by construction (R14) even though the digests were not.
|
||||||
|
|
||||||
|
`extract.mjs` is that script. `*.txt` are its outputs, which are what every
|
||||||
|
count in `../adrio-designations.md` was run against.
|
||||||
|
|
||||||
|
## Reproducing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sSL -o <slug>.html "https://adr-ontario.ca/<slug>/"
|
||||||
|
node extract.mjs <slug>.html > <slug>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The five slugs are listed in the provenance table of `../adrio-designations.md`,
|
||||||
|
which also carries each file's raw-HTML byte count and `sha256`.
|
||||||
|
|
||||||
|
**Expect small differences in the character totals if you re-implement the
|
||||||
|
extraction.** Both review agents reproduced every digest, every byte count and
|
||||||
|
every term count exactly; one got 1924 / 2806 / 4121 / 3510 / 2496 characters
|
||||||
|
against this script's 1932 / 2814 / 4151 / 3524 / 2504, because tag-stripping
|
||||||
|
inserts whitespace differently. **The term counts are the finding; the character
|
||||||
|
totals are only a size indicator.**
|
||||||
|
|
||||||
|
⚠️ **The digests are valid until ADRIO's next WordPress plugin update.** The
|
||||||
|
served HTML carries 17 `?ver=` strings that are static plugin versions today
|
||||||
|
(`?ver=1.3.12`, `?ver=2.3.2`, …) — which is *why* these pages hash reproducibly
|
||||||
|
where the LAT pages could not. Those strings will move when ADRIO updates a
|
||||||
|
plugin. **A future digest mismatch is most likely a plugin bump, not tampering
|
||||||
|
and not a changed designation name** — re-run the term counts before concluding
|
||||||
|
anything from a mismatch.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
Chartered Arbitrator (C.Arb) – The ADR Institute of Ontario
|
||||||
|
Skip to content
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
What is ADR?
|
||||||
|
Resolve a Dispute
|
||||||
|
ADRIO Board of Directors and Staff
|
||||||
|
Rules & Codes
|
||||||
|
ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
Event Calendar
|
||||||
|
Webcasts – Recorded Events
|
||||||
|
Approved Courses
|
||||||
|
Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
Communities of Practice and Sections
|
||||||
|
Committees
|
||||||
|
Membership & Benefits
|
||||||
|
Member Portal
|
||||||
|
Benefits and Application Process
|
||||||
|
Practical Ethics for Working Mediators
|
||||||
|
Insurance
|
||||||
|
Rules & Codes
|
||||||
|
FAQ
|
||||||
|
Designations
|
||||||
|
The Path to your Professional ADR designation
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Insurance
|
||||||
|
FAQ
|
||||||
|
News & Blog
|
||||||
|
Blog
|
||||||
|
Updates
|
||||||
|
Newsletter
|
||||||
|
ADRIO E-Mailing List Signup
|
||||||
|
President’s Circle
|
||||||
|
ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
About
|
||||||
|
- About Us
|
||||||
|
- What is ADR?
|
||||||
|
- Resolve a Dispute
|
||||||
|
- ADRIO Board of Directors and Staff
|
||||||
|
- Rules & Codes
|
||||||
|
- ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
- Event Calendar
|
||||||
|
- Webcasts - Recorded Events
|
||||||
|
- Approved Courses
|
||||||
|
- Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
- Communities of Practice and Sections
|
||||||
|
- Committees
|
||||||
|
Membership & Benefits
|
||||||
|
- Member Portal
|
||||||
|
- Benefits and Application Process
|
||||||
|
- Practical Ethics for Working Mediators
|
||||||
|
- Insurance
|
||||||
|
- Rules & Codes
|
||||||
|
- FAQ
|
||||||
|
Designations
|
||||||
|
- The Path to your Professional ADR designation
|
||||||
|
- ADRIC Accredited Courses
|
||||||
|
- Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
- Chartered Mediator (C.Med)
|
||||||
|
- Chartered Arbitrator (C.Arb)
|
||||||
|
- Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
- Insurance
|
||||||
|
- FAQ
|
||||||
|
News & Blog
|
||||||
|
- Blog
|
||||||
|
- Updates
|
||||||
|
- Newsletter
|
||||||
|
- ADRIO E-Mailing List Signup
|
||||||
|
- President’s Circle
|
||||||
|
- ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Please do not mail your applications to our head office. Please email your application to membership@adr-ontario.ca
|
||||||
|
1. What is a Chartered Arbitrator (C.Arb) designation?
|
||||||
|
The C.Arb Or Chartered Arbitrator designation is Canada’s only official designation for practising Arbitrators. Please click here for a more complete description of the requirements can be found.
|
||||||
|
2. Are there prerequisites?
|
||||||
|
The applicant must be a member of the ADR Institute of Ontario™ (or any other regional association).
|
||||||
|
There are extensive training and experience requirements which you should review before applying.
|
||||||
|
3. Where do I get the C.Arb Application Form?
|
||||||
|
The C.Arb application form is linked below.
|
||||||
|
4. Is there a fee for the C.Arb application?
|
||||||
|
Yes, the Application Fee is $500.00 (plus HST). This covers the costs of administering the accreditation process. This is a one-time application fee.
|
||||||
|
5. Is there an ongoing cost?
|
||||||
|
There is an annual fee to maintain your C.Arb designation. The national organization bills you every year. You must also remain a member in good standing with the ADR Institute of Ontario™ to retain this designation.
|
||||||
|
6. How do I apply?
|
||||||
|
Forward your completed application form, and payment along with any attachments necessary to verify the courses you have taken and the work you have done (think of it as marketing yourself to the accreditation committee), to:
|
||||||
|
Please do not mail your applications to our head office. Please email your application to membership@adr-ontario.ca
|
||||||
|
(Looking for courses that have been pre-approved to qualify for designation applications? Click here .)
|
||||||
|
View C.Arb Application Form
|
||||||
|
View C.Arb Criteria
|
||||||
|
Theme by Think Up Themes Ltd . Powered by WordPress .
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
Chartered Med-Arbitrator (C.Med-Arb) – The ADR Institute of Ontario
|
||||||
|
Skip to content
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
What is ADR?
|
||||||
|
Resolve a Dispute
|
||||||
|
ADRIO Board of Directors and Staff
|
||||||
|
Rules & Codes
|
||||||
|
ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
Event Calendar
|
||||||
|
Webcasts – Recorded Events
|
||||||
|
Approved Courses
|
||||||
|
Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
Communities of Practice and Sections
|
||||||
|
Committees
|
||||||
|
Membership & Benefits
|
||||||
|
Member Portal
|
||||||
|
Benefits and Application Process
|
||||||
|
Practical Ethics for Working Mediators
|
||||||
|
Insurance
|
||||||
|
Rules & Codes
|
||||||
|
FAQ
|
||||||
|
Designations
|
||||||
|
The Path to your Professional ADR designation
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Insurance
|
||||||
|
FAQ
|
||||||
|
News & Blog
|
||||||
|
Blog
|
||||||
|
Updates
|
||||||
|
Newsletter
|
||||||
|
ADRIO E-Mailing List Signup
|
||||||
|
President’s Circle
|
||||||
|
ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
About
|
||||||
|
- About Us
|
||||||
|
- What is ADR?
|
||||||
|
- Resolve a Dispute
|
||||||
|
- ADRIO Board of Directors and Staff
|
||||||
|
- Rules & Codes
|
||||||
|
- ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
- Event Calendar
|
||||||
|
- Webcasts - Recorded Events
|
||||||
|
- Approved Courses
|
||||||
|
- Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
- Communities of Practice and Sections
|
||||||
|
- Committees
|
||||||
|
Membership & Benefits
|
||||||
|
- Member Portal
|
||||||
|
- Benefits and Application Process
|
||||||
|
- Practical Ethics for Working Mediators
|
||||||
|
- Insurance
|
||||||
|
- Rules & Codes
|
||||||
|
- FAQ
|
||||||
|
Designations
|
||||||
|
- The Path to your Professional ADR designation
|
||||||
|
- ADRIC Accredited Courses
|
||||||
|
- Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
- Chartered Mediator (C.Med)
|
||||||
|
- Chartered Arbitrator (C.Arb)
|
||||||
|
- Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
- Insurance
|
||||||
|
- FAQ
|
||||||
|
News & Blog
|
||||||
|
- Blog
|
||||||
|
- Updates
|
||||||
|
- Newsletter
|
||||||
|
- ADRIO E-Mailing List Signup
|
||||||
|
- President’s Circle
|
||||||
|
- ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Med-Arb is a distinct, innovative standalone process that is not as well known or understood by consumers of ADR services compared to mediation and arbitration.
|
||||||
|
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, which requires a high level of practitioner competence to do successfully.
|
||||||
|
To read more about the C.Med-Arb designation, including how to apply, click here .
|
||||||
|
Theme by Think Up Themes Ltd . Powered by WordPress .
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
Chartered Mediator (C.Med) – The ADR Institute of Ontario
|
||||||
|
Skip to content
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
What is ADR?
|
||||||
|
Resolve a Dispute
|
||||||
|
ADRIO Board of Directors and Staff
|
||||||
|
Rules & Codes
|
||||||
|
ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
Event Calendar
|
||||||
|
Webcasts – Recorded Events
|
||||||
|
Approved Courses
|
||||||
|
Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
Communities of Practice and Sections
|
||||||
|
Committees
|
||||||
|
Membership & Benefits
|
||||||
|
Member Portal
|
||||||
|
Benefits and Application Process
|
||||||
|
Practical Ethics for Working Mediators
|
||||||
|
Insurance
|
||||||
|
Rules & Codes
|
||||||
|
FAQ
|
||||||
|
Designations
|
||||||
|
The Path to your Professional ADR designation
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Insurance
|
||||||
|
FAQ
|
||||||
|
News & Blog
|
||||||
|
Blog
|
||||||
|
Updates
|
||||||
|
Newsletter
|
||||||
|
ADRIO E-Mailing List Signup
|
||||||
|
President’s Circle
|
||||||
|
ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
About
|
||||||
|
- About Us
|
||||||
|
- What is ADR?
|
||||||
|
- Resolve a Dispute
|
||||||
|
- ADRIO Board of Directors and Staff
|
||||||
|
- Rules & Codes
|
||||||
|
- ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
- Event Calendar
|
||||||
|
- Webcasts - Recorded Events
|
||||||
|
- Approved Courses
|
||||||
|
- Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
- Communities of Practice and Sections
|
||||||
|
- Committees
|
||||||
|
Membership & Benefits
|
||||||
|
- Member Portal
|
||||||
|
- Benefits and Application Process
|
||||||
|
- Practical Ethics for Working Mediators
|
||||||
|
- Insurance
|
||||||
|
- Rules & Codes
|
||||||
|
- FAQ
|
||||||
|
Designations
|
||||||
|
- The Path to your Professional ADR designation
|
||||||
|
- ADRIC Accredited Courses
|
||||||
|
- Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
- Chartered Mediator (C.Med)
|
||||||
|
- Chartered Arbitrator (C.Arb)
|
||||||
|
- Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
- Insurance
|
||||||
|
- FAQ
|
||||||
|
News & Blog
|
||||||
|
- Blog
|
||||||
|
- Updates
|
||||||
|
- Newsletter
|
||||||
|
- ADRIO E-Mailing List Signup
|
||||||
|
- President’s Circle
|
||||||
|
- ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Please do not mail your applications to our head office. Please email your application to membership@adr-ontario.ca
|
||||||
|
1. What is a Chartered Mediator (C.Med) designation?
|
||||||
|
The “C.Med” or “Chartered Mediator” designation is Canada’s most senior official designation for practising mediators.
|
||||||
|
Clarification re Footnote #4 in the C.Med Criteria “The intended interpretation of Footnote #4 relating to a “Paid Mediation” is as follows: The criteria in Footnote #4 is intended to be applied individually to each of the 15 mediations presented by the applicant, and specifically where a mediation is not fee-paid, the RCMAC shall look at whether the mediation is demonstrably complex and involved, and may at its discretion accept the mediation being considered toward the 15 total mediations. There is no specific minimum number of fee paid mediations of the 15 total mediations required, providing this criteria is applied to each unpaid mediation.”
|
||||||
|
Use the Application for Chartered Mediator designation linked below.
|
||||||
|
2. Are there prerequisites?
|
||||||
|
The applicant must be a member of the ADR Institute of Ontario™.
|
||||||
|
There are extensive training and experience requirements which you should review before you consider applying.
|
||||||
|
3. Is there a fee for the C.Med application?
|
||||||
|
Yes, the Application Fee is $500.00 (plus HST) made payable to the ADR Institute of Ontario, Inc. This covers the costs of administering the accreditation process. This is a one-time application fee.
|
||||||
|
4. Is there an ongoing cost?
|
||||||
|
Yes. There is an annual fee to maintain your C.Med designation, payable to ADR Institute of Canada, Inc. The national organization bills you every year. You must also remain a member in good standing with the ADR Institute of Ontario™ to retain this designation.
|
||||||
|
6. How do I apply?
|
||||||
|
Forward your completed application form along with any attachments necessary to verify the courses you have taken and the work you have done (think of it as marketing yourself to the accreditation committee), to:
|
||||||
|
Please do not mail your applications to our head office. Please email your application to membership@adr-ontario.ca
|
||||||
|
(Looking for courses that have been pre-approved to qualify for designation applications? Click here .)
|
||||||
|
View C.Med Application Form
|
||||||
|
View C.Med Criteria
|
||||||
|
Theme by Think Up Themes Ltd . Powered by WordPress .
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
const raw = readFileSync(process.argv[2], 'utf8');
|
||||||
|
let t = raw
|
||||||
|
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, ' ')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ');
|
||||||
|
const ents = { amp:'&', lt:'<', gt:'>', quot:'"', apos:"'", nbsp:' ', ndash:'–', mdash:'—', rsquo:'’', lsquo:'‘', ldquo:'“', rdquo:'”' };
|
||||||
|
t = t.replace(/&([a-zA-Z]+);/g, (m,n)=> n in ents ? ents[n] : m)
|
||||||
|
.replace(/&#(\d+);/g, (_m,d)=> String.fromCodePoint(+d))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_m,h)=> String.fromCodePoint(parseInt(h,16)));
|
||||||
|
t = t.replace(/[ \t ]+/g,' ').replace(/\s*\n\s*/g,'\n').replace(/\n{2,}/g,'\n').trim();
|
||||||
|
process.stdout.write(t);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
Professional Designations – The ADR Institute of Ontario
|
||||||
|
Skip to content
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
What is ADR?
|
||||||
|
Resolve a Dispute
|
||||||
|
ADRIO Board of Directors and Staff
|
||||||
|
Rules & Codes
|
||||||
|
ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
Event Calendar
|
||||||
|
Webcasts – Recorded Events
|
||||||
|
Approved Courses
|
||||||
|
Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
Communities of Practice and Sections
|
||||||
|
Committees
|
||||||
|
Membership & Benefits
|
||||||
|
Member Portal
|
||||||
|
Benefits and Application Process
|
||||||
|
Practical Ethics for Working Mediators
|
||||||
|
Insurance
|
||||||
|
Rules & Codes
|
||||||
|
FAQ
|
||||||
|
Designations
|
||||||
|
The Path to your Professional ADR designation
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Insurance
|
||||||
|
FAQ
|
||||||
|
News & Blog
|
||||||
|
Blog
|
||||||
|
Updates
|
||||||
|
Newsletter
|
||||||
|
ADRIO E-Mailing List Signup
|
||||||
|
President’s Circle
|
||||||
|
ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
About
|
||||||
|
- About Us
|
||||||
|
- What is ADR?
|
||||||
|
- Resolve a Dispute
|
||||||
|
- ADRIO Board of Directors and Staff
|
||||||
|
- Rules & Codes
|
||||||
|
- ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
- Event Calendar
|
||||||
|
- Webcasts - Recorded Events
|
||||||
|
- Approved Courses
|
||||||
|
- Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
- Communities of Practice and Sections
|
||||||
|
- Committees
|
||||||
|
Membership & Benefits
|
||||||
|
- Member Portal
|
||||||
|
- Benefits and Application Process
|
||||||
|
- Practical Ethics for Working Mediators
|
||||||
|
- Insurance
|
||||||
|
- Rules & Codes
|
||||||
|
- FAQ
|
||||||
|
Designations
|
||||||
|
- The Path to your Professional ADR designation
|
||||||
|
- ADRIC Accredited Courses
|
||||||
|
- Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
- Chartered Mediator (C.Med)
|
||||||
|
- Chartered Arbitrator (C.Arb)
|
||||||
|
- Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
- Insurance
|
||||||
|
- FAQ
|
||||||
|
News & Blog
|
||||||
|
- Blog
|
||||||
|
- Updates
|
||||||
|
- Newsletter
|
||||||
|
- ADRIO E-Mailing List Signup
|
||||||
|
- President’s Circle
|
||||||
|
- ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
Professional Designations
|
||||||
|
To read more about the path to designations, click here .
|
||||||
|
Theme by Think Up Themes Ltd . Powered by WordPress .
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb) – The ADR Institute of Ontario
|
||||||
|
Skip to content
|
||||||
|
About
|
||||||
|
About Us
|
||||||
|
What is ADR?
|
||||||
|
Resolve a Dispute
|
||||||
|
ADRIO Board of Directors and Staff
|
||||||
|
Rules & Codes
|
||||||
|
ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
Event Calendar
|
||||||
|
Webcasts – Recorded Events
|
||||||
|
Approved Courses
|
||||||
|
Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
Communities of Practice and Sections
|
||||||
|
Committees
|
||||||
|
Membership & Benefits
|
||||||
|
Member Portal
|
||||||
|
Benefits and Application Process
|
||||||
|
Practical Ethics for Working Mediators
|
||||||
|
Insurance
|
||||||
|
Rules & Codes
|
||||||
|
FAQ
|
||||||
|
Designations
|
||||||
|
The Path to your Professional ADR designation
|
||||||
|
ADRIC Accredited Courses
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Chartered Mediator (C.Med)
|
||||||
|
Chartered Arbitrator (C.Arb)
|
||||||
|
Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
Insurance
|
||||||
|
FAQ
|
||||||
|
News & Blog
|
||||||
|
Blog
|
||||||
|
Updates
|
||||||
|
Newsletter
|
||||||
|
ADRIO E-Mailing List Signup
|
||||||
|
President’s Circle
|
||||||
|
ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
About
|
||||||
|
- About Us
|
||||||
|
- What is ADR?
|
||||||
|
- Resolve a Dispute
|
||||||
|
- ADRIO Board of Directors and Staff
|
||||||
|
- Rules & Codes
|
||||||
|
- ADRIO DEI Policy Framework
|
||||||
|
Professional Development
|
||||||
|
- Event Calendar
|
||||||
|
- Webcasts - Recorded Events
|
||||||
|
- Approved Courses
|
||||||
|
- Lead a Professional Development Workshop with us!
|
||||||
|
Sections
|
||||||
|
- Communities of Practice and Sections
|
||||||
|
- Committees
|
||||||
|
Membership & Benefits
|
||||||
|
- Member Portal
|
||||||
|
- Benefits and Application Process
|
||||||
|
- Practical Ethics for Working Mediators
|
||||||
|
- Insurance
|
||||||
|
- Rules & Codes
|
||||||
|
- FAQ
|
||||||
|
Designations
|
||||||
|
- The Path to your Professional ADR designation
|
||||||
|
- ADRIC Accredited Courses
|
||||||
|
- Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
- Chartered Mediator (C.Med)
|
||||||
|
- Chartered Arbitrator (C.Arb)
|
||||||
|
- Chartered Med-Arbitrator (C.Med-Arb)
|
||||||
|
- Insurance
|
||||||
|
- FAQ
|
||||||
|
News & Blog
|
||||||
|
- Blog
|
||||||
|
- Updates
|
||||||
|
- Newsletter
|
||||||
|
- ADRIO E-Mailing List Signup
|
||||||
|
- President’s Circle
|
||||||
|
- ADRIO STAR Award Recipients
|
||||||
|
Contact & Staff
|
||||||
|
Qualified Mediator (Q.Med) & Qualified Arbitrator (Q.Arb)
|
||||||
|
Qualified Mediator (Q.Med) / Qualified Arbitrator (Q.Arb)
|
||||||
|
These designations are to recognize practitioners who have completed sufficient mediation / arbitration and related dispute resolution training to be qualified to practice. They are an intermediate step for practitioners working to receive their Chartered designation.
|
||||||
|
Please do not mail your applications to our head office. Please email your application to membership@adr-ontario.ca
|
||||||
|
(Looking for courses that have been pre-approved to qualify for designation applications? Click here .)
|
||||||
|
Read more by downloading the following resources:
|
||||||
|
Qualified Arbitrator (Q.Arb) Requirements
|
||||||
|
Qualified Arbitrator (Q.Arb) Application Form
|
||||||
|
Qualified Mediator (Q.Med) Criteria and Application Form*
|
||||||
|
*Q.Med criteria vary across affiliates. For the criteria specific to Ontario, be sure to read the checklist on the application form.
|
||||||
|
Theme by Think Up Themes Ltd . Powered by WordPress .
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Brand assets — provenance
|
||||||
|
|
||||||
|
The repository holds the artwork it makes claims about. That is not tidiness: it
|
||||||
|
is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the
|
||||||
|
infinity mark was reconstructed wrongly and **two adversarial review passes could
|
||||||
|
not catch it**, since the real artwork was not in the repo to compare against.
|
||||||
|
|
||||||
|
**The measurements here are computed with `sharp` against the files in this
|
||||||
|
repository**, and are re-derivable by anyone from the commands given.
|
||||||
|
|
||||||
|
**Unless a section says otherwise, every figure below is `[verified
|
||||||
|
2026-08-26]`. Where a section carries its own stamp, that stamp wins** — the
|
||||||
|
render ladders are `[measured 2026-08-27]` and §The icon set is `[verified
|
||||||
|
2026-09-02]`. ⚠️ *This line has been wrong in both directions in one week: first
|
||||||
|
as a blanket 2026-08-26 that was already stale for the ladders, then as
|
||||||
|
"each section carries its own stamp" when five sections carry none. It is a floor
|
||||||
|
plus overrides because that is the only shape that covers every section without
|
||||||
|
mis-dating one* (`adversarial-reviewer`, rounds 1 and 2).
|
||||||
|
|
||||||
|
⚠️ **Two things in §The icon set are NOT read off this repository and are
|
||||||
|
labelled where they appear:** the tab-strip colours used in the contrast figures,
|
||||||
|
and iOS's handling of a transparent touch icon. Neither is derivable from
|
||||||
|
anything committed, so neither is stamped as if it were.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/assets/brand/sml-infinity-mark-master.png` | The mark as supplied: **3000 × 3000, alpha**, mark inset within the canvas | **The master.** Committed so the crop below can be re-derived and checked in-repo, not taken on trust |
|
||||||
|
| `src/assets/brand/sml-infinity-mark.png` | The same mark tight-cropped to its ink bounding box: **2668 × 1704, alpha, 1.5657:1** | **The render source.** `InfinityMark.astro` feeds it to Astro's `<Picture>` |
|
||||||
|
| `src/assets/brand/sml-logo-full.png` | Mark **plus** the "SML Company" wordmark, 3000 × 3000, alpha; ink bbox 2414 × 1440 = 1.676:1 | Not currently rendered. Held for the OG-image template (`docs/04`) and print |
|
||||||
|
| `src/assets/brand/sml-logo-source.svg` | 1500 × 1500 viewBox, **257,278 bytes** | Reference. Faithful, but not what is served — see below |
|
||||||
|
|
||||||
|
## Reproducing the crop, in-repo
|
||||||
|
|
||||||
|
The crop is derived, so it is re-derivable — and now from a file that is here,
|
||||||
|
which is the whole point of R14. Scan the master for the first and last pixel
|
||||||
|
that is neither transparent (`alpha < 24`) nor near-white (`r,g,b > 243`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ink bbox of sml-infinity-mark-master.png -> 2668 x 1704 at (159, 646)
|
||||||
|
sharp(master).extract({ left: 159, top: 646, width: 2668, height: 1704 })
|
||||||
|
```
|
||||||
|
|
||||||
|
Crop to that box and the file's own aspect ratio becomes the mark's, so layout
|
||||||
|
can be tuned against the asset directly rather than against a number written
|
||||||
|
down beside it. `InfinityMark.astro` pins `aspect-ratio: 667 / 426`, which is
|
||||||
|
`2668 / 1704` reduced — **1.56573**.
|
||||||
|
|
||||||
|
## Why the SVG is held but not served
|
||||||
|
|
||||||
|
It renders **faithfully**. Rasterised at 8333 px it reproduces the master
|
||||||
|
exactly, at the same **1.566:1**. It is not bad artwork, and an earlier version
|
||||||
|
of this file implied it was; that was wrong.
|
||||||
|
|
||||||
|
What rules it out is weight and composition:
|
||||||
|
|
||||||
|
- **257,278 bytes**, against **3,063 bytes** for the AVIF a Retina device
|
||||||
|
actually takes in the header. **84×.** *(This line said "9,468 bytes... 27×"
|
||||||
|
until 2026-08-27. 9,468 was the DPR-1 figure — the number for the devices the
|
||||||
|
performance budget does **not** target. Quote the figure for the device the
|
||||||
|
budget is written for.)*
|
||||||
|
- **7 embedded base64 PNGs** (`<image>` elements), so it is a hybrid rather than
|
||||||
|
pure vector — inlining it would breach `CLAUDE.md`'s rule against
|
||||||
|
base64-inlining images, which is one of the specific faults of the build this
|
||||||
|
project replaces.
|
||||||
|
- 10 `<linearGradient>` carrying **1,225 `<stop>`** elements, 64 `<path>`,
|
||||||
|
59 `<clipPath>`, 23 `<mask>`, 1 `<filter>`. Expensive to rasterise.
|
||||||
|
|
||||||
|
What `AGENTS.md` Q38 asks for is a master that is faithful **and** light.
|
||||||
|
|
||||||
|
## What the browser actually downloads
|
||||||
|
|
||||||
|
`<Picture>` emits AVIF, WebP and a PNG fallback at `densities` 1×, 2× and 3× of
|
||||||
|
whatever intrinsic `width` the call site passes. **There are now TWO ladders,
|
||||||
|
because there are two sizes of call site** `[measured 2026-08-27 — every figure
|
||||||
|
below read from the file on disk]`.
|
||||||
|
|
||||||
|
**`width={64}` — the default. The header (50.1 px wide) and footer (56.4 px).**
|
||||||
|
|
||||||
|
| | 1× (64 px) | 2× (128 px) | 3× (192 px) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AVIF | **1,720 B** | **3,063 B** | **4,843 B** |
|
||||||
|
| WebP | 2,368 B | 5,368 B | 8,664 B |
|
||||||
|
| PNG (fallback) | 6,137 B | 16,996 B | 29,780 B |
|
||||||
|
|
||||||
|
**`width={232}` — the home page's approach section, which renders at 225.5 px.**
|
||||||
|
|
||||||
|
| | 1× (232 px) | 2× (464 px) | 3× (696 px) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AVIF | **6,017 B** | **14,555 B** | **22,639 B** |
|
||||||
|
|
||||||
|
That instance is `loading="lazy"`: it sits roughly a screen and a half down, so
|
||||||
|
it is not on the LCP path. The header and footer marks stay `eager`.
|
||||||
|
|
||||||
|
**THE LADDER HAS NOW BEEN WRONG IN BOTH DIRECTIONS, which is why `width` is a
|
||||||
|
prop rather than a constant.**
|
||||||
|
|
||||||
|
- *Too big, 2026-08-26.* Sized at 320 px, justified by a 4 rem sample on the
|
||||||
|
proof sheet (a page step 2 has since deleted), with `densities` stacked on
|
||||||
|
top — so the ladder double-counted its own headroom and every DPR-2 device
|
||||||
|
pulled a 640 px image into a 56 px slot: **20,629 B**, while the docs claimed
|
||||||
|
9 KB because that is what DPR 1 took.
|
||||||
|
- *Too small, 2026-08-27.* The home page added a 225.5 px call site and
|
||||||
|
inherited the 64 px ladder, whose largest file is 192 px: **3.52× upscale at
|
||||||
|
DPR 3.** Now 232/464/696, and 696 covers the 676 device px a DPR-3 screen
|
||||||
|
asks for. All three instances measure ≤1.0× upscale at DPR 1, 2 and 3.
|
||||||
|
|
||||||
|
**Do not measure this with `img.naturalWidth`.** For an image chosen from a
|
||||||
|
`srcset` with an `x` descriptor it is **density-corrected**: the 192 px file
|
||||||
|
selected at `3x` reports 64, so reading it at DPR 1, 2 and 3 returns 64 every
|
||||||
|
time — which looks exactly like the ladder not being generated at all. Read the
|
||||||
|
files on disk.
|
||||||
|
|
||||||
|
Passing an explicit `width` is load-bearing: without it Astro emits the
|
||||||
|
untouched 2668 px master as the `<img src>` fallback — **1,146,406 bytes** —
|
||||||
|
which any client without AVIF or WebP support would actually download.
|
||||||
|
|
||||||
|
## The icon set
|
||||||
|
|
||||||
|
`[verified 2026-09-02 — every figure below read off the icon files in this
|
||||||
|
repository, EXCEPT the two external constants flagged inline]`
|
||||||
|
|
||||||
|
| File | What it is | Ground |
|
||||||
|
|---|---|---|
|
||||||
|
| `public/favicon.ico` | 16, 32 and 48 px frames, each a PNG-encoded 32-bit RGBA image inside the ICO container | **Transparent** |
|
||||||
|
| `public/apple-touch-icon.png` | 180 × 180 | **Opaque cream `#faf7f2`, and that is deliberate — see below** |
|
||||||
|
|
||||||
|
**The two grounds differ on purpose, and this is the line that stops someone
|
||||||
|
"fixing" it.** ⚠️ *The reason is an external platform behaviour, not a repository
|
||||||
|
fact, and it is recorded here as what it is:* **iOS does not honour transparency
|
||||||
|
in a touch icon — it composites it onto black**, so a transparent touch icon
|
||||||
|
ships a black tile on the home screen. Stated by Pouya in his ruling of
|
||||||
|
**2026-09-02** and not independently re-tested here; **it has not been checked on
|
||||||
|
a handset in this repository, and the place that does check it is the unticked
|
||||||
|
`docs/06` item "Tested on iOS Safari…".** The tab favicon has the opposite
|
||||||
|
requirement: a tab strip is dark for many readers, and an opaque ground shows
|
||||||
|
there as a visible rectangle around the mark. So the favicon is transparent, the
|
||||||
|
touch icon is matted, and **neither should be changed to match the other.**
|
||||||
|
|
||||||
|
### Composition
|
||||||
|
|
||||||
|
The mark spans **7/8 of the canvas width**, centred on both axes — 14/16, 28/32,
|
||||||
|
42/48, and 158/180 on the touch icon. That was measured off the icons as they
|
||||||
|
already shipped, so a regeneration reproduces the composition rather than
|
||||||
|
restyling the mark.
|
||||||
|
|
||||||
|
### Regenerating
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run icons
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/icons.mjs` resizes `src/assets/brand/sml-infinity-mark.png` onto a
|
||||||
|
transparent square canvas and assembles the ICO container itself, because
|
||||||
|
`sharp` does not write `.ico`. **It writes the favicon only** and never touches
|
||||||
|
the touch icon. It refuses to run if the render source has no alpha channel, and
|
||||||
|
it asserts that the source is still byte-identical to the documented crop of
|
||||||
|
`sml-infinity-mark-master.png` — R14, so the icon stays traceable to committed
|
||||||
|
artwork. It verifies a **candidate** file and renames it into place only on
|
||||||
|
success, so a rejected build cannot replace a good favicon.
|
||||||
|
|
||||||
|
**It also carries the halo test**, because the check that matters was living
|
||||||
|
only in prose here while the generator could not run it
|
||||||
|
(`adversarial-reviewer`, round 2). For each frame it takes every painted pixel
|
||||||
|
touching a fully transparent one and measures how many sit within 20 of cream:
|
||||||
|
|
||||||
|
| | boundary px | near cream | share |
|
||||||
|
|---|---|---|---|
|
||||||
|
| correct, 16 / 32 / 48 px | 61 / 146 / 258 | 1 / 2 / 5 | **1.6 / 1.4 / 1.9 %** |
|
||||||
|
| haloed fixture, 16 px | 56 | 11 | **19.6 %** |
|
||||||
|
|
||||||
|
The gate is **10 %**, roughly 5× clear of both. ⚠️ **The first version of this
|
||||||
|
guard inspected only partial-alpha pixels and MISSED the fixture completely** —
|
||||||
|
a knockout sets alpha per pixel and leaves **no partial alpha at all**, so there
|
||||||
|
was nothing for it to look at. The fixture is built by matting the mark on cream
|
||||||
|
and then knocking the ground out by alpha, which is the defect exactly; it fires
|
||||||
|
at 19.6 % and leaves `public/favicon.ico` untouched. **A guard that cannot see
|
||||||
|
the defect it is named for is worse than none**, and this one passed the real
|
||||||
|
icon while blind, which is the shape that ends a check instead of starting one.
|
||||||
|
|
||||||
|
### Why the favicon was regenerated, 2026-09-02
|
||||||
|
|
||||||
|
It had **no transparency at all**: all three frames declared a 32-bit alpha
|
||||||
|
channel and then carried `alpha = 255` on every one of their 256 / 1,024 / 2,304
|
||||||
|
pixels, with the ground opaque cream `rgba(250,247,242,255)`. Found by Pouya on a
|
||||||
|
read-through, confirmed by parsing the container directly.
|
||||||
|
|
||||||
|
Three measurements stand behind the replacement.
|
||||||
|
|
||||||
|
**1. The composition did not change.** Composited back onto cream, the new icon
|
||||||
|
reproduces the old matted one to within **1 of 255 on every channel of every
|
||||||
|
pixel** at all three sizes (mean delta 0.01–0.02, 0 pixels over a delta of 8).
|
||||||
|
So the regeneration did not restyle, rescale or reposition the mark.
|
||||||
|
|
||||||
|
⚠️ **That is NOT a test for a halo, and an earlier draft of this file filed it as
|
||||||
|
one.** Composite-onto-cream returns ~0 whether the edge is correct *or* is a
|
||||||
|
cream-matted edge that has merely had its background knocked out — the second
|
||||||
|
case composites straight back to what it came from. Both branches pass, so the
|
||||||
|
test cannot discriminate. Found by `adversarial-reviewer`, 2026-09-02.
|
||||||
|
|
||||||
|
**2. No halo — the measurement that does discriminate.** Read the RGB the
|
||||||
|
partial-alpha pixels actually carry. A cream halo means that RGB is near cream; a
|
||||||
|
correct export means it is the ribbon's own colour. Of the partial-alpha pixels,
|
||||||
|
**0 of 94 / 300 / 603** are within 12 of `rgb(250,247,242)`; the nearest is 13
|
||||||
|
away, the mean distance is **174.0 / 178.3 / 177.0**, and the mean colour per
|
||||||
|
frame is about `rgb(118,86,74)` — maroon-brown, not cream and not black.
|
||||||
|
|
||||||
|
**3. Legible on dark and on light, and NOT at the same cost.** ⚠️ *The ground
|
||||||
|
colours below are external constants, not repository facts: `#202124` is
|
||||||
|
Chrome's dark tab strip and `#f1f3f4` its light one, both `[observed
|
||||||
|
2026-09-02]`.* All ratios are computed on **composited** pixels, and the ink
|
||||||
|
colours are **fully opaque** pixels only — a ratio taken from a raw channel value
|
||||||
|
is a claim about a colour that is never painted, which is how an earlier draft
|
||||||
|
came to quote `14.02:1` for a pixel whose alpha is 251.
|
||||||
|
|
||||||
|
| | darkest opaque ink | lightest opaque ink | vs `#202124` | vs `#f1f3f4` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 16 px | `rgb(70,33,33)` | `rgb(216,188,143)` | champagne **8.82:1** | maroon **12.58:1** |
|
||||||
|
| 32 px | `rgb(60,29,29)` | `rgb(234,214,172)` | champagne **11.28:1** | maroon **13.62:1** |
|
||||||
|
| 48 px | `rgb(61,26,26)` | `rgb(238,219,175)` | champagne **11.80:1** | maroon **13.86:1** |
|
||||||
|
|
||||||
|
Whichever ground it sits on, one end of the ribbon carries the silhouette. **But
|
||||||
|
the other end does not merely dim — on dark it goes.** The maroon lobe
|
||||||
|
composites to **1.04–1.15:1**, and counting pixels that reach 3:1 against each
|
||||||
|
ground gives **23 / 87 / 189** on dark against **40 / 168 / 378** on light, at
|
||||||
|
16 / 32 / 48 px. **So about half as many pixels reach 3:1 on dark as on light.**
|
||||||
|
⚠️ *Those pixels are not absent — they are painted and fall below 3:1, and the
|
||||||
|
mark's full outline is still there, dim. An earlier draft said "roughly half the
|
||||||
|
mark's visible pixels are absent", which is neither what was counted (pixels at
|
||||||
|
`alpha > 0` are 109 / 388 / 805) nor what a render shows* — `adversarial-reviewer`,
|
||||||
|
round 2. The previous opaque-cream icon was still more legible on dark.
|
||||||
|
|
||||||
|
**That is the price of the change and it was accepted, not overlooked.** What was
|
||||||
|
bought is the removal of a cream rectangle from every dark tab strip and from
|
||||||
|
white. Raised by `adversarial-reviewer`, 2026-09-02, against a draft that
|
||||||
|
recorded the change as costless.
|
||||||
|
|
||||||
|
**The obvious alternative is an icon pair keyed on `prefers-color-scheme`, and it
|
||||||
|
was NOT ruled out on measurement.** ⚠️ *An earlier draft dismissed it on a
|
||||||
|
mechanism that is wrong: it said the query keys off the page's colour scheme. It
|
||||||
|
does not — it reports the user's system or browser preference, and this site
|
||||||
|
declares `color-scheme` nowhere (`git grep -n color-scheme -- src`, exit 1), so
|
||||||
|
it would resolve to the same preference that makes the tab strip dark.* The real
|
||||||
|
objections are **untested here** and are recorded as such: `media` on
|
||||||
|
`<link rel="icon">` is unevenly supported for raster icons, a browser theme can
|
||||||
|
be set independently of the system preference, and it doubles the artefact
|
||||||
|
`npm run icons` has to keep in sync. **If this cost is ever revisited, that is
|
||||||
|
the option to test** — do not re-dismiss it on the reason struck above.
|
||||||
|
|
||||||
|
## The colours are the artwork's, not the palette's
|
||||||
|
|
||||||
|
`tokens.css` is not involved. The ribbon carries its own gradient and it is
|
||||||
|
close to but not identical with `--maroon` `#5a1a1c` and `--gold` `#c9a876`.
|
||||||
|
Do not "correct" the artwork toward the tokens, and do not derive tokens from
|
||||||
|
the artwork — D7 keeps the palette *and* the mark, as they are.
|
||||||
|
|
||||||
|
## Where these came from
|
||||||
|
|
||||||
|
Supplied by Pouya. `sml-logo-source.svg` was added by him directly to the repo;
|
||||||
|
the PNGs were taken from the `smlcompany.ca` Google Drive, under `SML/Designs/`
|
||||||
|
and `Research/Law/ADR Personal Branding Project/`. **The Drive copies are not the
|
||||||
|
record — these files are.** That is the point of R14: an artefact that lives only
|
||||||
|
in Drive cannot be compared against a claim by any reviewer, which is exactly how
|
||||||
|
the traced mark survived two review passes.
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
# Canadian privacy and AI legislation status, and technology-dispute context
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.**
|
||||||
|
|
||||||
|
> ⚠️ **THIS FILE EXISTS BECAUSE A SPEC NAMED A STATUTE THAT DOES NOT EXIST.**
|
||||||
|
> `docs/03-content-spec.md` §Practice areas listed *"the 2026 privacy statute"*
|
||||||
|
> among the market context for `/practice/technology/`. **There is no 2026
|
||||||
|
> Canadian privacy statute, federal or Ontario.** Bill C-27 — which would have
|
||||||
|
> enacted the Consumer Privacy Protection Act and the Artificial Intelligence and
|
||||||
|
> Data Act — died without royal assent when the 44th Parliament's first session
|
||||||
|
> ended, and was never reinstated. **PIPEDA remains the operative federal
|
||||||
|
> private-sector statute.** Caught before a word of it reached a page, and only
|
||||||
|
> because the check was run rather than the phrase trusted.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a bill and a regulation all move.** Bill C-36 in particular was
|
||||||
|
> at second reading on the retrieval date and could be law, or dead, by the time
|
||||||
|
> anyone reads this. **Re-check before cutover.**
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||||
|
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||||
|
> are still the original retrieval and were not re-fetched** — what was
|
||||||
|
> re-checked is whether the *facts* they support have moved.
|
||||||
|
>
|
||||||
|
> - **(a) Bill C-36 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 — Pouya,
|
||||||
|
> <https://www.parl.ca/legisinfo/en/bill/45-1/c-36>]`. Still at second
|
||||||
|
> reading in the House of Commons; latest completed stage is first reading,
|
||||||
|
> 2026-06-15; no advance since. `/practice/technology/`'s sentence stands as
|
||||||
|
> written.
|
||||||
|
|
||||||
|
**Topic as researched:** Canadian technology / data / AI dispute context — privacy legislation status as at 2026-08-29, Ontario public-sector and health privacy statutes, data residency law, and Canadian arbitral-institution rules for technology/AI disputes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| statute | LEGISinfo — Bill C-27 (44-1), Digital Charter Implementation Act, 2022 — Parliament of Canada | <https://www.parl.ca/legisinfo/en/bill/44-1/c-27> |
|
||||||
|
| statute | LEGISinfo bills data (JSON), 44th Parliament 1st Session — Parliament of Canada | <https://www.parl.ca/legisinfo/en/bills/json?parlsession=44-1> |
|
||||||
|
| institution | House of Commons Procedure and Practice, Fourth Edition (2025), Ch. 8 — Prorogation and Dissolution | <https://www.ourcommons.ca/procedure/procedure-and-practice-4/ch08-7-e.html> |
|
||||||
|
| statute | Personal Information Protection and Electronic Documents Act (S.C. 2000, c. 5) — Justice Laws Website | <https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html> |
|
||||||
|
| statute | LEGISinfo bills data (JSON), 45th Parliament 1st Session — Parliament of Canada | <https://www.parl.ca/legisinfo/en/bills/json?parlsession=45-1> |
|
||||||
|
| statute | LEGISinfo — Bill C-36 (45-1), An Act to enact the Protecting Privacy and Consumer Data Act — Parliament of Canada | <https://www.parl.ca/legisinfo/en/bill/45-1/c-36> |
|
||||||
|
| statute | Bill C-36 (45-1), first reading text — Parliament of Canada | <https://www.parl.ca/DocumentViewer/en/45-1/bill/C-36/first-reading> |
|
||||||
|
| statute | Statutes of Canada 2026, c. 9 — An Act respecting cyber security … — Justice Laws Website | <https://laws-lois.justice.gc.ca/eng/AnnualStatutes/2026_9/> |
|
||||||
|
| statute | Bill C-8 (45-1), royal assent text — Parliament of Canada | <https://www.parl.ca/DocumentViewer/en/45-1/bill/C-8/royal-assent> |
|
||||||
|
| regulator | Summary of privacy laws in Canada — Office of the Privacy Commissioner of Canada | <https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/02_05_d_15/> |
|
||||||
|
| regulator | Guidelines for processing personal data across borders — Office of the Privacy Commissioner of Canada | <https://www.priv.gc.ca/en/privacy-topics/airports-and-borders/gl_dab_090127/> |
|
||||||
|
| regulator | Announcement: Commissioner concludes consultation on transfers for processing — Office of the Privacy Commissioner of Canada | <https://www.priv.gc.ca/en/opc-news/news-and-announcements/2019/an_190923/> |
|
||||||
|
| statute | Personal Health Information Protection Act, 2004 — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/04p03) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03> |
|
||||||
|
| regulation | O. Reg. 329/04 (GENERAL) under PHIPA — Ontario e-Laws consolidated text (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/040329> |
|
||||||
|
| statute | Freedom of Information and Protection of Privacy Act — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/90f31) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90f31> |
|
||||||
|
| statute | Municipal Freedom of Information and Protection of Privacy Act — Ontario e-Laws (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90m56> |
|
||||||
|
| statute | Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/24e24) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/24e24> |
|
||||||
|
| regulation | Ontario e-Laws — regulations made under the Enhancing Digital Security and Trust Act, 2024 (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-reg/regulation?title=enhancing%20digital%20security%20and%20trust%20act%2C%202024&sort=citation> |
|
||||||
|
| regulation | O. Reg. 51/26 (CYBER SECURITY) under the Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260051> |
|
||||||
|
| regulation | O. Reg. 52/26 (DIGITAL TECHNOLOGY AFFECTING INDIVIDUALS UNDER AGE 18) under the Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260052> |
|
||||||
|
| institution | Bill 194, Strengthening Cyber Security and Building Trust in the Public Sector Act, 2024 — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-194> |
|
||||||
|
| institution | Bills — 44th Parliament, 1st Session — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1> |
|
||||||
|
| institution | Bill 61, Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025 — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-61> |
|
||||||
|
| statute | Kids' Online Safety and Privacy Month Act, 2025 — Ontario e-Laws consolidated text (JSON endpoint) | <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/25k25> |
|
||||||
|
| regulator | Directive on Service and Digital — Treasury Board of Canada Secretariat | <https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=32601> |
|
||||||
|
| regulator | Direction on the Secure Use of Commercial Cloud Services: Security Policy Implementation Notice (SPIN 2017-01) — Government of Canada | <https://www.canada.ca/en/government/system/digital-government/digital-government-innovations/cloud-services/direction-secure-use-commercial-cloud-services-spin.html> |
|
||||||
|
| institution | ADRIC Arbitration Rules, effective 01 March 2025 — ADR Institute of Canada, Inc. (PDF) | <https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf> |
|
||||||
|
| institution | Rules & Codes — ADR Institute of Canada | <https://adric.ca/rules-codes/> |
|
||||||
|
| institution | Artificial Intelligence and Arbitration: A Perfect Fit? — ADR Institute of Canada | <https://adric.ca/artificial-intelligence-and-arbitration-a-perfect-fit/> |
|
||||||
|
| institution | Who We Are — Canadian International Internet Dispute Resolution Centre (CIIDRC) | <https://ciidrc.org/about-ciidrc/> |
|
||||||
|
| institution | CIIDRC Supplemental Rules — Canadian International Internet Dispute Resolution Centre | <https://ciidrc.org/domain-name-disputes/ciidrc-supplemental-rules/> |
|
||||||
|
| institution | CIRA Domain Name Dispute Resolution Policy (reproduced by CIIDRC, a CIRA-approved provider) | <https://ciidrc.org/domain-name-disputes/cdrp-policy/> |
|
||||||
|
| institution | Rules of Procedure — VanIAC (Vancouver International Arbitration Centre) | <https://vaniac.org/arbitration/rules-of-procedure/> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### LEGISinfo — Bill C-27 (44-1), Digital Charter Implementation Act, 2022 — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/legisinfo/en/bill/44-1/c-27> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> An Act to enact the Consumer Privacy Protection Act, the Personal Information and Data Protection Tribunal Act and the Artificial Intelligence and Data Act and to make consequential and related amendments to other Acts
|
||||||
|
|
||||||
|
> Digital Charter Implementation Act, 2022
|
||||||
|
|
||||||
|
> At consideration in committee in the House of Commons
|
||||||
|
|
||||||
|
> Second reading and referral to committee on Monday, April 24, 2023
|
||||||
|
|
||||||
|
> 44th Parliament, 1st session (November 22, 2021 to January 6, 2025)
|
||||||
|
|
||||||
|
### LEGISinfo bills data (JSON), 44th Parliament 1st Session — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/legisinfo/en/bills/json?parlsession=44-1> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "NumberCode":"C-27" ... "StatusNameEn":"At consideration in committee in the House of Commons"
|
||||||
|
|
||||||
|
> "LatestCompletedMajorStageNameEn":"Second reading"
|
||||||
|
|
||||||
|
> "ReceivedRoyalAssent":false
|
||||||
|
|
||||||
|
> "ReceivedRoyalAssentDateTime":null
|
||||||
|
|
||||||
|
> "DidReinstateInNextSession":false
|
||||||
|
|
||||||
|
> "IsSessionOngoing":false
|
||||||
|
|
||||||
|
> "ParliamentNumber":44, "SessionNumber":1
|
||||||
|
|
||||||
|
### House of Commons Procedure and Practice, Fourth Edition (2025), Ch. 8 — Prorogation and Dissolution
|
||||||
|
|
||||||
|
<https://www.ourcommons.ca/procedure/procedure-and-practice-4/ch08-7-e.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Government bills which have not received royal assent before prorogation die and, in order to be proceeded with in the new session, must be reintroduced as if they had never existed.
|
||||||
|
|
||||||
|
> All items on the Order Paper including government and private members' bills die.
|
||||||
|
|
||||||
|
### Personal Information Protection and Electronic Documents Act (S.C. 2000, c. 5) — Justice Laws Website
|
||||||
|
|
||||||
|
<https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Personal Information Protection and Electronic Documents Act
|
||||||
|
|
||||||
|
> Act current to 2026-06-21 and last amended on 2025-03-04.
|
||||||
|
|
||||||
|
> 4 (1) This Part applies to every organization in respect of personal information that (a) the organization collects, uses or discloses in the course of commercial activities; or (b) is about an employee of, or an applicant for employment with, the organization and that the organization collects, uses or discloses in connection with the operation of a federal work, undertaking or business.
|
||||||
|
|
||||||
|
> 4.1.3 An organization is responsible for personal information in its possession or custody, including information that has been transferred to a third party for processing. The organization shall use contractual or other means to provide a comparable level of protection while the information is being processed by a third party.
|
||||||
|
|
||||||
|
> (grep over the full text for the phrases "outside Canada", "stored in Canada", "within Canada" and "localiz" returned exit status 1 and zero lines; instrument check on the same file returned 113 occurrences of "personal information")
|
||||||
|
|
||||||
|
### LEGISinfo bills data (JSON), 45th Parliament 1st Session — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/legisinfo/en/bills/json?parlsession=45-1> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> (185 bills in the session; a regex scan of every LongTitleEn and ShortTitleEn for /privacy|personal information|artificial intelligence|consumer privacy|data protection|cyber|digital charter/i returned exactly two: C-8 and C-36)
|
||||||
|
|
||||||
|
> "NumberCode":"C-8" ... "An Act respecting cyber security, amending the Telecommunications Act and making consequential amendments to other Acts" ... "StatusNameEn":"Royal assent received" ... "ReceivedRoyalAssentDateTime":"2026-06-15T06:15:00-04:00"
|
||||||
|
|
||||||
|
> "NumberCode":"C-36" ... "An Act to enact the Protecting Privacy and Consumer Data Act, to amend the Personal Information Protection and Electronic Documents Act and to make amendments to other Acts" ... "StatusNameEn":"At second reading in the House of Commons" ... "ReceivedRoyalAssent":false
|
||||||
|
|
||||||
|
> "PassedHouseFirstReadingDateTime":"2026-06-15T11:18:34.507-04:00"
|
||||||
|
|
||||||
|
> (a regex scan of every 45-1 bill title for /intellig/i returned 0 matches)
|
||||||
|
|
||||||
|
### LEGISinfo — Bill C-36 (45-1), An Act to enact the Protecting Privacy and Consumer Data Act — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/legisinfo/en/bill/45-1/c-36> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> An Act to enact the Protecting Privacy and Consumer Data Act, to amend the Personal Information Protection and Electronic Documents Act and to make amendments to other Acts
|
||||||
|
|
||||||
|
> Sponsor: Minister of Artificial Intelligence and Digital Innovation
|
||||||
|
|
||||||
|
> At second reading in the House of Commons
|
||||||
|
|
||||||
|
> First reading: Completed Monday, June 15, 2026
|
||||||
|
|
||||||
|
> Royal Assent: Not received
|
||||||
|
|
||||||
|
### Bill C-36 (45-1), first reading text — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/DocumentViewer/en/45-1/bill/C-36/first-reading> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> This enactment enacts the Protecting Privacy and Consumer Data Act to govern the protection of personal information of individuals while taking into account the need of organizations to collect, use or disclose personal information in the course of commercial activities.
|
||||||
|
|
||||||
|
> This Act may be cited as the Protecting Privacy and Consumer Data Act.
|
||||||
|
|
||||||
|
### Statutes of Canada 2026, c. 9 — An Act respecting cyber security … — Justice Laws Website
|
||||||
|
|
||||||
|
<https://laws-lois.justice.gc.ca/eng/AnnualStatutes/2026_9/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> An Act respecting cyber security, amending the Telecommunications Act and making consequential amendments to other Acts (S.C. 2026, c. 9)
|
||||||
|
|
||||||
|
> Assented to June 15, 2026
|
||||||
|
|
||||||
|
### Bill C-8 (45-1), royal assent text — Parliament of Canada
|
||||||
|
|
||||||
|
<https://www.parl.ca/DocumentViewer/en/45-1/bill/C-8/royal-assent> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> STATUTES OF CANADA 2026 CHAPTER 9
|
||||||
|
|
||||||
|
> ASSENTED TO June 15, 2026
|
||||||
|
|
||||||
|
> Part 2 enacts the Critical Cyber Systems Protection Act
|
||||||
|
|
||||||
|
### Summary of privacy laws in Canada — Office of the Privacy Commissioner of Canada
|
||||||
|
|
||||||
|
<https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/02_05_d_15/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Canada has two federal privacy laws that are enforced by the Office of the Privacy Commissioner of Canada: the Privacy Act, which covers how the federal government handles personal information; the Personal Information Protection and Electronic Documents Act (PIPEDA), which covers how businesses handle personal information.
|
||||||
|
|
||||||
|
> PIPEDA sets the ground rules for how private-sector organizations collect, use, and disclose personal information in the course of for-profit, commercial activities across Canada.
|
||||||
|
|
||||||
|
> PIPEDA generally applies to personal information held by private sector organizations that are not federally-regulated, and conduct business in: Manitoba New Brunswick Newfoundland and Labrador Northwest Territories Nova Scotia Nunavut Ontario Prince Edward Island Saskatchewan Yukon.
|
||||||
|
|
||||||
|
> Date modified: 2018-01-31
|
||||||
|
|
||||||
|
### Guidelines for processing personal data across borders — Office of the Privacy Commissioner of Canada
|
||||||
|
|
||||||
|
<https://www.priv.gc.ca/en/privacy-topics/airports-and-borders/gl_dab_090127/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> PIPEDA does not prohibit organizations in Canada from transferring personal information to an organization in another jurisdiction for processing. However, under PIPEDA, organizations are held accountable for the protection of personal information transfers under each individual outsourcing arrangement.
|
||||||
|
|
||||||
|
> Principle 4.1.3 of Schedule 1 of PIPEDA specifically recognizes that personal information may be transferred to third parties for processing. It also requires organizations to use contractual or other means to "provide a comparable level of protection while the information is being processed by the third party."
|
||||||
|
|
||||||
|
> In contrast to this state-to-state approach, Canada has, through PIPEDA, chosen an organization-to-organization approach that is not based on the concept of adequacy.
|
||||||
|
|
||||||
|
> Date modified: 2009-01-27
|
||||||
|
|
||||||
|
### Announcement: Commissioner concludes consultation on transfers for processing — Office of the Privacy Commissioner of Canada
|
||||||
|
|
||||||
|
<https://www.priv.gc.ca/en/opc-news/news-and-announcements/2019/an_190923/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Commissioner concludes consultation on transfers for processing (September 23, 2019)
|
||||||
|
|
||||||
|
> guidelines for processing personal data across borders
|
||||||
|
|
||||||
|
> remain unchanged under the current law
|
||||||
|
|
||||||
|
### Personal Health Information Protection Act, 2004 — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/04p03)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "Personal Health Information Protection Act, 2004, S.O. 2004, c. 3, Sched. A"
|
||||||
|
|
||||||
|
> "description": "Consolidation Period: From January 1, 2026 to the e-Laws currency date." / "comment": "Last amendment: 2025, c. 7, Sched. 6, s. 1-13"
|
||||||
|
|
||||||
|
> 12 (1) A health information custodian shall take steps that are reasonable in the circumstances to ensure that personal health information in the custodian's custody or control is protected against theft, loss and unauthorized use or disclosure and to ensure that the records containing the information are protected against unauthorized copying, modification or disposal.
|
||||||
|
|
||||||
|
> Place where records kept 14 (1) A health information custodian may keep a record of personal health information about an individual in the individual's home in any reasonable manner to which the individual consents, subject to any restrictions set out in a regulation, by-law or published guideline under the Regulated Health Professions Act, 1991 …
|
||||||
|
|
||||||
|
> Records kept in other places (2) A health care practitioner may keep a record of personal health information about an individual in a place other than the individual's home and other than a place in the control of the practitioner if, (a) the record is kept in a reasonable manner; (b) the individual consents; …
|
||||||
|
|
||||||
|
> Disclosure outside Ontario 50 (1) A health information custodian may disclose personal health information about an individual collected in Ontario to a person outside Ontario only if, (a) the individual consents to the disclosure; (b) this Act permits the disclosure; …
|
||||||
|
|
||||||
|
> (grep over the extracted plain text for "outside Canada" returned 0 matches; the only "outside Ontario" provisions are s. 44 research approval, and s. 50 disclosure)
|
||||||
|
|
||||||
|
### O. Reg. 329/04 (GENERAL) under PHIPA — Ontario e-Laws consolidated text (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/040329> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "actTitle": "Personal Health Information Protection Act, 2004, S.O. 2004, c. 3, Sched. A"
|
||||||
|
|
||||||
|
> "consolidationPeriod": "January 1, 2026"
|
||||||
|
|
||||||
|
> (7) Despite subsection 45 (6) of the Act, the Canadian Institute for Health Information may disclose personal health information about an individual to a person outside Ontario where,
|
||||||
|
|
||||||
|
> (10) Despite subsection 45 (6) of the Act, Ontario Health may disclose personal health information about an individual to a person outside Ontario where,
|
||||||
|
|
||||||
|
> (a grep over the extracted text for "outside canada", "in canada", "outside ontario" and "stored" returned 3 lines, all of them disclosure-permission or health-number provisions; none imposes a storage-location requirement)
|
||||||
|
|
||||||
|
### Freedom of Information and Protection of Privacy Act — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/90f31)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90f31> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "Freedom of Information and Protection of Privacy Act, R.S.O. 1990, c. F.31"
|
||||||
|
|
||||||
|
> "description": "Consolidation Period: From July 1, 2026 to the e-Laws currency date." / "comment": "Last amendment: 2026, c. 2, Sched. 7"
|
||||||
|
|
||||||
|
> Privacy safeguards (5) The head of an institution shall take steps that are reasonable in the circumstances to ensure that personal information in the custody or under the control of the institution is protected against theft, loss and unauthorized use or disclosure and to ensure that the records containing the personal information are protected against unauthorized copying, modification or disposal. 2024, c. 24, Sched. 2, s. 5. / Section Amendments with date in force (d/m/y) 2024, c. 24, Sched. 2, s. 5 - 01/07/2025
|
||||||
|
|
||||||
|
> Breach of privacy safeguards 40.1 (1) The head of an institution shall report to the Commissioner any theft, loss or unauthorized use or disclosure of personal information in the custody or under the control of the institution if it is reasonable in the circumstances to believe that there is real risk that a significant harm to an individual would result or if any other prescribed circumstances exist. 2024, c. 24, Sched. 2, s. 6.
|
||||||
|
|
||||||
|
> (grep for "outside Canada" returned exit status 1 and 0 lines; instrument check on the same file returned 248 occurrences of "personal information". "in Canada" appears only at 3 law-enforcement disclosure clauses)
|
||||||
|
|
||||||
|
### Municipal Freedom of Information and Protection of Privacy Act — Ontario e-Laws (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90m56> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "Municipal Freedom of Information and Protection of Privacy Act, R.S.O. 1990, c. M.56"
|
||||||
|
|
||||||
|
> "description": "Consolidation Period: From July 1, 2026 to the e-Laws currency date."
|
||||||
|
|
||||||
|
### Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws consolidated text (JSON endpoint behind https://www.ontario.ca/laws/statute/24e24)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/24e24> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "Enhancing Digital Security and Trust Act, 2024, S.O. 2024, c. 24, Sched. 1"
|
||||||
|
|
||||||
|
> "description": "Consolidation Period: From January 29, 2025 to the e-Laws currency date." / "comment": "No amendments."
|
||||||
|
|
||||||
|
> "artificial intelligence system" means, (a) a machine-based system that, for explicit or implicit objectives, infers from the input it receives in order to generate outputs such as predictions, content, recommendations or decisions that can influence physical or virtual environments, and (b) such other systems as may be prescribed;
|
||||||
|
|
||||||
|
> 5 (1) This section applies to such public sector entities as may be prescribed for the purposes of this section if they use or intend to use an artificial intelligence system in prescribed circumstances.
|
||||||
|
|
||||||
|
> No establishment of private law duty of care 12 Nothing in the Strengthening Cyber Security and Building Trust in the Public Sector Act, 2024 , this Act or any regulation made or directive issued under this Act establishes a private law duty of care owing to any person.
|
||||||
|
|
||||||
|
> Effect of failure to comply 13 Failure to comply with this Act or any regulation made or directive issued under this Act does not affect the validity of any policy, Act, regulation, directive, instrument or decision.
|
||||||
|
|
||||||
|
### Ontario e-Laws — regulations made under the Enhancing Digital Security and Trust Act, 2024 (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-reg/regulation?title=enhancing%20digital%20security%20and%20trust%20act%2C%202024&sort=citation> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> current: 2 results — "regulation/260052" DIGITAL TECHNOLOGY AFFECTING INDIVIDUALS UNDER AGE 18; "regulation/260051" CYBER SECURITY
|
||||||
|
|
||||||
|
> revoked: 0 results
|
||||||
|
|
||||||
|
### O. Reg. 51/26 (CYBER SECURITY) under the Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260051> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "CYBER SECURITY" / "actTitle": "Enhancing Digital Security and Trust Act, 2024, S.O. 2024, c. 24, Sched. 1"
|
||||||
|
|
||||||
|
> "consolidationPeriod": "July 1, 2026" / "comment": "No amendments."
|
||||||
|
|
||||||
|
> CONTENTS 1. Interpretation 2. Prescribed public sector entities 3. Program 4. Primary point of contact and alternate 5. Cyber security maturity assessment 6. Cyber security maturity assessment summary 7. Critical cyber security incident, report
|
||||||
|
|
||||||
|
> (a case-insensitive count of "artificial intelligence" in the extracted text returned 0)
|
||||||
|
|
||||||
|
### O. Reg. 52/26 (DIGITAL TECHNOLOGY AFFECTING INDIVIDUALS UNDER AGE 18) under the Enhancing Digital Security and Trust Act, 2024 — Ontario e-Laws (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260052> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "DIGITAL TECHNOLOGY AFFECTING INDIVIDUALS UNDER AGE 18" / "actTitle": "Enhancing Digital Security and Trust Act, 2024, S.O. 2024, c. 24, Sched. 1"
|
||||||
|
|
||||||
|
> "consolidationPeriod": "July 1, 2026" / "comment": "No amendments."
|
||||||
|
|
||||||
|
> (a case-insensitive count of "artificial intelligence" in the extracted text returned 0)
|
||||||
|
|
||||||
|
### Bill 194, Strengthening Cyber Security and Building Trust in the Public Sector Act, 2024 — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-194> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Bill 194, Strengthening Cyber Security and Building Trust in the Public Sector Act, 2024
|
||||||
|
|
||||||
|
> Royal Assent received. Statutes of Ontario 2024, chapter 24
|
||||||
|
|
||||||
|
> cyber security and artificial intelligence systems at public sector entities
|
||||||
|
|
||||||
|
> public sector entities may be required to comply with requirements respecting the use of artificial intelligence, including requirements to provide information, to develop and implement accountability frameworks and to take steps respecting risk management
|
||||||
|
|
||||||
|
### Bills — 44th Parliament, 1st Session — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-44/session-1> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> (139 numbered bills, 1 through 139, listed on a single unpaginated page; a keyword scan of all titles for /privacy|personal information|freedom of information|health information|artificial intelligence|data|digital|cyber|technolog|online/i returned exactly four)
|
||||||
|
|
||||||
|
> Bill 15: Kids' Online Safety and Privacy Month Act, 2025
|
||||||
|
|
||||||
|
> Bill 61: Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025
|
||||||
|
|
||||||
|
> Bill 66: Kids' Online Safety and Privacy Month Act, 2025
|
||||||
|
|
||||||
|
> Bill 137: Keeping Our Kids Safe Online Act, 2026
|
||||||
|
|
||||||
|
### Bill 61, Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025 — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-61> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Bill 61, Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025
|
||||||
|
|
||||||
|
> Private member's bill
|
||||||
|
|
||||||
|
> November 24, 2025 — Second Reading — Lost on division
|
||||||
|
|
||||||
|
### Kids' Online Safety and Privacy Month Act, 2025 — Ontario e-Laws consolidated text (JSON endpoint)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/25k25> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": "Kids' Online Safety and Privacy Month Act, 2025, S.O. 2025, c. 25"
|
||||||
|
|
||||||
|
> "description": "Consolidation Period: From December 11, 2025 to the e-Laws currency date."
|
||||||
|
|
||||||
|
> Kids' Online Safety and Privacy Month 1 The month of October in each year is proclaimed as Kids' Online Safety and Privacy Month.
|
||||||
|
|
||||||
|
> 2 Omitted ( provides for coming into force of provisions of this Act ). 3 Omitted (enacts short title of this Act).
|
||||||
|
|
||||||
|
### Directive on Service and Digital — Treasury Board of Canada Secretariat
|
||||||
|
|
||||||
|
<https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=32601> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Information and data residency
|
||||||
|
|
||||||
|
> 4.3.24 Ensuring that computing facilities located within the geographic boundaries of Canada or within the premises of a GC department located abroad, such as a diplomatic or consular mission, be identified and evaluated as a principal delivery option for all sensitive electronic information and data under government control that has been categorized as Protected B or Protected C or is classified;
|
||||||
|
|
||||||
|
> Date modified: 2025-08-29
|
||||||
|
|
||||||
|
### Direction on the Secure Use of Commercial Cloud Services: Security Policy Implementation Notice (SPIN 2017-01) — Government of Canada
|
||||||
|
|
||||||
|
<https://www.canada.ca/en/government/system/digital-government/digital-government-innovations/cloud-services/direction-secure-use-commercial-cloud-services-spin.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> SPIN No.: 2017-01 Date: November 1, 2017 Date modified: June 23, 2022
|
||||||
|
|
||||||
|
> 6.2.2 Data residency — Departments are expected to apply the Directive on Service and Digital when implementing safeguards for GC electronic data residency.
|
||||||
|
|
||||||
|
### ADRIC Arbitration Rules, effective 01 March 2025 — ADR Institute of Canada, Inc. (PDF)
|
||||||
|
|
||||||
|
<https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADRIC ARBITRATION RULES Effective 01 March 2025
|
||||||
|
|
||||||
|
> ADRIC intends these Rules for Canadian commercial disputes; however, parties can apply them to international or non-commercial disputes.
|
||||||
|
|
||||||
|
> Privacy and Security of Evidence 31. Each party and its counsel are responsible for ensuring that all relevant privacy and data security requirements prescribed by law or contract in relation to evidence put forward by that party are complied with, and that the Tribunal is made aware of any steps that the Tribunal needs to take in that regard.
|
||||||
|
|
||||||
|
> (a case-insensitive grep of the extracted 103,811-character text for "artificial intelligence", "machine learning" and the standalone token "AI" returned no matches; the only hits for "technolog|cyber|data|electronic" were three lines about electronic data as evidence, electronic delivery, and the clause quoted above)
|
||||||
|
|
||||||
|
### Rules & Codes — ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/rules-codes/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Rules & Codes — ADR Institute of Canada
|
||||||
|
|
||||||
|
> ADRIC By-laws / Federation MoU / ADRIC Arbitration Rules / National Mediation Rules / ADRIC Med-Arb Rules
|
||||||
|
|
||||||
|
> Ethics & Professional Practice — Code of Ethics / Code of Conduct / Conflict of Interest / Complaints & Discipline Policy / Privacy Policy / Online Dispute Resolution (ODR) Vision
|
||||||
|
|
||||||
|
### Artificial Intelligence and Arbitration: A Perfect Fit? — ADR Institute of Canada
|
||||||
|
|
||||||
|
<https://adric.ca/artificial-intelligence-and-arbitration-a-perfect-fit/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Artificial Intelligence and Arbitration: A Perfect Fit?
|
||||||
|
|
||||||
|
> March 2, 2023
|
||||||
|
|
||||||
|
> By Robin Dodokin, Sarah McEachern, Les Honywill
|
||||||
|
|
||||||
|
> Machine learning and AI have progressed so far that their integration into the arbitral process seems inevitable, with the only question being a matter of time and degree.
|
||||||
|
|
||||||
|
### Who We Are — Canadian International Internet Dispute Resolution Centre (CIIDRC)
|
||||||
|
|
||||||
|
<https://ciidrc.org/about-ciidrc/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The Canadian International Internet Dispute Resolution Centre ("CIIDRC", "the Centre") serves global Internet users by providing trusted and efficient resolution of domain name disputes under the Uniform Domain Name Dispute Resolution Policy (the UDRP) and the CIRA Domain Name Dispute Resolution Policy (the CDRP).
|
||||||
|
|
||||||
|
> CIIDRC is a division of the Vancouver International Arbitration Centre, formerly known as the British Columbia International Commercial Arbitration Centre ("the Centre").
|
||||||
|
|
||||||
|
> CIIDRC's parent organization, VanIAC (formerly BCICAC), has been a service provider for the Canadian Internet Registration Authority (CIRA) since 2002, successfully managing .ca (dot ca) domain name disputes.
|
||||||
|
|
||||||
|
### CIIDRC Supplemental Rules — Canadian International Internet Dispute Resolution Centre
|
||||||
|
|
||||||
|
<https://ciidrc.org/domain-name-disputes/ciidrc-supplemental-rules/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> CIIDRC Supplemental Rules OF THE CANADIAN INTERNATIONAL INTERNET DISPUTE RESOLUTION CENTRE (the "Centre" or the "CIIDRC" or the "Provider") FOR THE UNIFORM DOMAIN NAME DISPUTE RESOLUTION POLICY (the "Policy") AND THE RULES FOR THE UNIFORM DOMAIN NAME DISPUTE RESOLUTION POLICY (the "UDRP Rules")
|
||||||
|
|
||||||
|
> The Supplemental Rules (In effect as of May 9, 2018)
|
||||||
|
|
||||||
|
### CIRA Domain Name Dispute Resolution Policy (reproduced by CIIDRC, a CIRA-approved provider)
|
||||||
|
|
||||||
|
<https://ciidrc.org/domain-name-disputes/cdrp-policy/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> CIRA Domain Name Dispute Resolution Policy — Version 1.3 (August 22, 2011)
|
||||||
|
|
||||||
|
> 1.1 Purpose. The purpose of this CIRA Domain Name Dispute Resolution Policy (the "Policy") is to provide a forum in which cases of bad faith registration of domain names registered in the dot-ca country code top level domain name registry operated by CIRA (the "Registry") can be dealt with relatively inexpensively and quickly.
|
||||||
|
|
||||||
|
> 1.2 Scope. The Policy sets forth the terms and conditions for resolution by arbitration of a dispute between a person (the "Registrant") who has obtained the registration of a domain name in the Registry (the "Registration") and any other person …
|
||||||
|
|
||||||
|
> 1.5 Dispute Resolution Service Provider. All Proceedings will be administered by a dispute resolution service provider approved by CIRA (the "Provider").
|
||||||
|
|
||||||
|
### Rules of Procedure — VanIAC (Vancouver International Arbitration Centre)
|
||||||
|
|
||||||
|
<https://vaniac.org/arbitration/rules-of-procedure/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Rules of Procedure — Domestic Arbitration Rules (as amended Sept. 1, 2020)
|
||||||
|
|
||||||
|
> International Commercial Arbitration Rules of Procedure (as amended July 1, 2022)
|
||||||
|
|
||||||
|
> International Commercial Arbitration Rules of Procedure (as amended Jan. 1, 2000)
|
||||||
|
|
||||||
|
> (the page's full navigation lists arbitration, mediation and motor-vehicle rules, forms, fee schedules and an Arbitrator Code of Conduct; no rule set, guideline or note on artificial intelligence or technology disputes appears)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
- PIPEDA — the Personal Information Protection and Electronic Documents Act, S.C. 2000, c. 5 — is the federal private-sector privacy statute in force. The Justice Laws consolidation states "Act current to 2026-06-21 and last amended on 2025-03-04."
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html>
|
||||||
|
- PIPEDA Part 1 applies to every organization in respect of personal information it "collects, uses or discloses in the course of commercial activities" (s. 4(1)(a)), and to employee information in connection with a federal work, undertaking or business (s. 4(1)(b)).
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html>
|
||||||
|
- The Office of the Privacy Commissioner of Canada states that Canada has two federal privacy laws it enforces — the Privacy Act (federal government) and PIPEDA, which "sets the ground rules for how private-sector organizations collect, use, and disclose personal information in the course of for-profit, commercial activities across Canada." PIPEDA generally applies to non-federally-regulated private-sector organizations doing business in Ontario (among other provinces and territories).
|
||||||
|
*Source:* <https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/02_05_d_15/>
|
||||||
|
- Bill C-27 (44th Parliament, 1st Session) was the bill that would have enacted the Consumer Privacy Protection Act, the Personal Information and Data Protection Tribunal Act and the Artificial Intelligence and Data Act. Its short title was the Digital Charter Implementation Act, 2022.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bill/44-1/c-27>
|
||||||
|
- Bill C-27 never received royal assent. LEGISinfo records its last completed major stage as "Second reading", its status as "At consideration in committee in the House of Commons", ReceivedRoyalAssent = false, ReceivedRoyalAssentDateTime = null, IsSessionOngoing = false, and DidReinstateInNextSession = false.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bills/json?parlsession=44-1>
|
||||||
|
- The 44th Parliament's 1st session ran to January 6, 2025, and Bill C-27 had not advanced past committee when it ended.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bill/44-1/c-27>
|
||||||
|
- Under House of Commons Procedure and Practice (4th ed., 2025), "Government bills which have not received royal assent before prorogation die and, in order to be proceeded with in the new session, must be reintroduced as if they had never existed," and on dissolution "All items on the Order Paper including government and private members' bills die." Combined with the LEGISinfo record, this means the Consumer Privacy Protection Act and the Artificial Intelligence and Data Act were never enacted and do not exist as Canadian law.
|
||||||
|
*Source:* <https://www.ourcommons.ca/procedure/procedure-and-practice-4/ch08-7-e.html>
|
||||||
|
- In the 45th Parliament, 1st Session, a scan of all 185 bills found only two whose titles touch privacy, AI, cyber or data protection: C-8 and C-36. No bill in the session has "intellig" (i.e. "intelligence") anywhere in its title — there is no successor AI bill to AIDA before Parliament.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bills/json?parlsession=45-1>
|
||||||
|
- Bill C-36 (45-1), "An Act to enact the Protecting Privacy and Consumer Data Act, to amend the Personal Information Protection and Electronic Documents Act and to make amendments to other Acts", received first reading on June 15, 2026, is sponsored by the Minister of Artificial Intelligence and Digital Innovation, and its status is "At second reading in the House of Commons". Royal assent has NOT been received.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bill/45-1/c-36>
|
||||||
|
- Bill C-36 would enact the "Protecting Privacy and Consumer Data Act" to govern protection of personal information collected, used or disclosed in the course of commercial activities. It is a bill, not a statute — nothing in it is in force.
|
||||||
|
*Source:* <https://www.parl.ca/DocumentViewer/en/45-1/bill/C-36/first-reading>
|
||||||
|
- There is no "2026 privacy statute" in Canadian federal law. The only 2026 federal privacy instrument is Bill C-36, introduced 15 June 2026 and still at second reading with no royal assent, so PIPEDA remains the operative federal private-sector privacy statute as at 2026-08-29.
|
||||||
|
*Source:* <https://www.parl.ca/legisinfo/en/bills/json?parlsession=45-1>
|
||||||
|
- The one cyber/data-adjacent federal statute enacted in 2025–2026 is Bill C-8, "An Act respecting cyber security, amending the Telecommunications Act and making consequential amendments to other Acts", which received royal assent on June 15, 2026 and is S.C. 2026, c. 9. It enacts the Critical Cyber Systems Protection Act. It is a critical-infrastructure cyber security statute, not a privacy or AI statute.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/AnnualStatutes/2026_9/>
|
||||||
|
- Ontario public-sector access/privacy statute: Freedom of Information and Protection of Privacy Act, R.S.O. 1990, c. F.31 (e-Laws consolidation period from July 1, 2026; last amendment 2026, c. 2, Sched. 7).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90f31>
|
||||||
|
- Ontario municipal-sector equivalent: Municipal Freedom of Information and Protection of Privacy Act, R.S.O. 1990, c. M.56 (e-Laws consolidation period from July 1, 2026).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90m56>
|
||||||
|
- Ontario health privacy statute: Personal Health Information Protection Act, 2004, S.O. 2004, c. 3, Sched. A (e-Laws consolidation period from January 1, 2026; last amendment 2025, c. 7, Sched. 6, ss. 1–13).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03>
|
||||||
|
- FIPPA's privacy-safeguard duty (s. 40(5)) and mandatory breach reporting to the Commissioner and notification to affected individuals (s. 40.1) were enacted by S.O. 2024, c. 24, Sched. 2, ss. 5–6, and the e-Laws in-force note records both as in force 01/07/2025.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90f31>
|
||||||
|
- Ontario's AI-relevant statute is the Enhancing Digital Security and Trust Act, 2024, S.O. 2024, c. 24, Sched. 1 (enacted by Bill 194, royal assent giving Statutes of Ontario 2024, chapter 24), consolidated from January 29, 2025 with no amendments. It defines "artificial intelligence system" as "a machine-based system that, for explicit or implicit objectives, infers from the input it receives in order to generate outputs such as predictions, content, recommendations or decisions that can influence physical or virtual environments".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/24e24>
|
||||||
|
- Every EDSTA AI obligation is conditional on regulations: s. 5(1) applies only "to such public sector entities as may be prescribed … if they use or intend to use an artificial intelligence system in prescribed circumstances." The Act also states at s. 12 that nothing in it "establishes a private law duty of care owing to any person", and at s. 13 that failure to comply "does not affect the validity of any policy, Act, regulation, directive, instrument or decision."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/24e24>
|
||||||
|
- Only two regulations have been made under EDSTA — O. Reg. 51/26 (Cyber Security) and O. Reg. 52/26 (Digital Technology Affecting Individuals Under Age 18); the e-Laws listing shows 2 current and 0 revoked. No AI regulation has been made, so EDSTA's artificial-intelligence sections have no prescribed entities or circumstances and impose no operative obligation as at 2026-08-29.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-reg/regulation?title=enhancing%20digital%20security%20and%20trust%20act%2C%202024&sort=citation>
|
||||||
|
- O. Reg. 51/26 (Cyber Security) under EDSTA has a consolidation period from July 1, 2026 and covers prescribed public sector entities, cyber security programs, a primary point of contact, cyber security maturity assessments and critical incident reporting. The phrase "artificial intelligence" does not appear in it.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260051>
|
||||||
|
- O. Reg. 52/26 (Digital Technology Affecting Individuals Under Age 18) under EDSTA has a consolidation period from July 1, 2026 and deals with prescribed school boards and notice of disclosure of students' personal digital information. The phrase "artificial intelligence" does not appear in it.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/260052>
|
||||||
|
- No Ontario privacy or AI regulatory statute was enacted in 2025 or 2026. Of the 139 bills in the Ontario 44th Parliament 1st Session, only four have privacy/AI/online titles: Bill 61, the Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025 (a private member's bill) was lost on division at second reading on November 24, 2025; Bill 137 is still at first reading; and Bills 15/66 are commemorative-month bills.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1>
|
||||||
|
- Bill 61, the Ontario Artificial Intelligence, Talent and Innovation Strategy Act, 2025, was a private member's bill and was lost on division at second reading on November 24, 2025 — Ontario has no AI strategy statute.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-61>
|
||||||
|
- The only Ontario statute with "Privacy" in its title enacted in this period is the Kids' Online Safety and Privacy Month Act, 2025, S.O. 2025, c. 25 (in force December 11, 2025). Its entire operative content is s. 1: "The month of October in each year is proclaimed as Kids' Online Safety and Privacy Month." It creates no privacy obligations.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/25k25>
|
||||||
|
- DATA RESIDENCY — PIPEDA contains no data-localization requirement. A grep of the full Justice Laws consolidation for "outside Canada", "stored in Canada", "within Canada" and "localiz" returned zero matches (grep exit status 1), against 113 occurrences of "personal information" in the same file as an instrument check.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html>
|
||||||
|
- The OPC states directly: "PIPEDA does not prohibit organizations in Canada from transferring personal information to an organization in another jurisdiction for processing. However, under PIPEDA, organizations are held accountable for the protection of personal information transfers under each individual outsourcing arrangement." Canada's approach is organization-to-organization accountability, not EU-style adequacy.
|
||||||
|
*Source:* <https://www.priv.gc.ca/en/privacy-topics/airports-and-borders/gl_dab_090127/>
|
||||||
|
- What PIPEDA requires instead of residency is accountability: Schedule 1, clause 4.1.3 — "An organization is responsible for personal information in its possession or custody, including information that has been transferred to a third party for processing. The organization shall use contractual or other means to provide a comparable level of protection while the information is being processed by a third party."
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html>
|
||||||
|
- The OPC reopened and then closed this question: on September 23, 2019 the Commissioner concluded the consultation on transfers for processing, confirming that the guidelines for processing personal data across borders "remain unchanged under the current law."
|
||||||
|
*Source:* <https://www.priv.gc.ca/en/opc-news/news-and-announcements/2019/an_190923/>
|
||||||
|
- DATA RESIDENCY — Ontario PHIPA imposes no requirement that personal health information be stored in Ontario or in Canada. The section headed "Place where records kept" (s. 14) is about keeping records in the individual's home or a place other than the practitioner's control, not about jurisdiction. The phrase "outside Canada" does not appear anywhere in the Act.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03>
|
||||||
|
- PHIPA s. 50 ("Disclosure outside Ontario") is a disclosure-permission rule, not a storage rule: it permits a custodian to disclose personal health information collected in Ontario to a person outside Ontario where, among other gateways, the individual consents, the Act permits the disclosure, or the disclosure is reasonably necessary for the provision of health care to the individual.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03>
|
||||||
|
- PHIPA's security duty (s. 12(1)) is a reasonableness standard — "steps that are reasonable in the circumstances" to protect against theft, loss and unauthorized use or disclosure — with no location component.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/04p03>
|
||||||
|
- O. Reg. 329/04 (General) under PHIPA likewise imposes no storage-location requirement. Its only "outside Ontario" provisions permit the Canadian Institute for Health Information and Ontario Health to disclose to persons outside Ontario in defined circumstances.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/regulation/040329>
|
||||||
|
- DATA RESIDENCY — Ontario FIPPA contains no data-localization requirement either. A grep of the full consolidated text for "outside Canada" returned zero matches (grep exit status 1) against 248 occurrences of "personal information" as an instrument check; the only "in Canada" occurrences are law-enforcement disclosure clauses.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-content/statute/90f31>
|
||||||
|
- The closest thing to a Canadian residency rule is a federal internal-administration policy, not a law of general application, and it is not absolute. Treasury Board's Directive on Service and Digital, s. 4.3.24, requires only that Canadian computing facilities "be identified and evaluated as a principal delivery option" for Government of Canada data categorized Protected B, Protected C or classified. It binds federal departments, not private organizations.
|
||||||
|
*Source:* <https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=32601>
|
||||||
|
- The cloud direction commonly cited for "data must stay in Canada" (SPIN 2017-01) does not itself set a residency rule: its s. 6.2.2 says only that "Departments are expected to apply the Directive on Service and Digital when implementing safeguards for GC electronic data residency."
|
||||||
|
*Source:* <https://www.canada.ca/en/government/system/digital-government/digital-government-innovations/cloud-services/direction-secure-use-commercial-cloud-services-spin.html>
|
||||||
|
- ARBITRAL INSTITUTIONS — The ADR Institute of Canada's current ADRIC Arbitration Rules (effective 01 March 2025) contain no provision on artificial intelligence, machine learning, or technology disputes. A case-insensitive grep of the full 103,811-character extracted text for "artificial intelligence", "machine learning" and the token "AI" returned no matches. The only data-related clause is a party-responsibility rule for privacy and data security of evidence.
|
||||||
|
*Source:* <https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf>
|
||||||
|
- ADRIC's published Rules & Codes are: ADRIC By-laws, Federation MoU, ADRIC Arbitration Rules, National Mediation Rules, ADRIC Med-Arb Rules, Code of Ethics, Code of Conduct, Conflict of Interest, Complaints & Discipline Policy, Privacy Policy, and an Online Dispute Resolution (ODR) Vision. None is specific to technology, data or AI disputes.
|
||||||
|
*Source:* <https://adric.ca/rules-codes/>
|
||||||
|
- ADRIC's only AI-related publication located is an article, not a rule or guideline: "Artificial Intelligence and Arbitration: A Perfect Fit?", dated March 2, 2023, by Robin Dodokin, Sarah McEachern and Les Honywill. It is commentary about AI's likely role in arbitration, not institutional guidance to arbitrators or parties.
|
||||||
|
*Source:* <https://adric.ca/artificial-intelligence-and-arbitration-a-perfect-fit/>
|
||||||
|
- There IS a Canadian arbitral institution with rules specific to one class of technology dispute: the Canadian International Internet Dispute Resolution Centre (CIIDRC), a division of the Vancouver International Arbitration Centre (VanIAC, formerly BCICAC), which resolves domain-name disputes under the UDRP and CIRA's CDRP and has been a CIRA service provider since 2002.
|
||||||
|
*Source:* <https://ciidrc.org/about-ciidrc/>
|
||||||
|
- CIIDRC publishes its own Supplemental Rules for the UDRP, in effect as of May 9, 2018, which govern communications, complaints and annexes, panelist appointment, fees, word limits and file format for domain-name proceedings.
|
||||||
|
*Source:* <https://ciidrc.org/domain-name-disputes/ciidrc-supplemental-rules/>
|
||||||
|
- The CIRA Domain Name Dispute Resolution Policy, Version 1.3 (August 22, 2011), provides for "resolution by arbitration" of disputes over bad-faith registration of .ca domain names, administered by a dispute resolution service provider approved by CIRA.
|
||||||
|
*Source:* <https://ciidrc.org/domain-name-disputes/cdrp-policy/>
|
||||||
|
- VanIAC's own Rules of Procedure page lists only its Domestic Arbitration Rules (as amended Sept. 1, 2020) and International Commercial Arbitration Rules of Procedure (as amended July 1, 2022 and Jan. 1, 2000), plus mediation and motor-vehicle rules. No AI or technology-dispute rule set or guidance note appears.
|
||||||
|
*Source:* <https://vaniac.org/arbitration/rules-of-procedure/>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.**
|
||||||
|
|
||||||
|
- **Does "the 2026 privacy statute" referred to in docs/03-content-spec.md line 299 exist?**
|
||||||
|
- *Searched:* LEGISinfo bill records for the 44th Parliament 1st Session (all 412 bills) and 45th Parliament 1st Session (all 185 bills), fetched as JSON from parl.ca; the LEGISinfo bill pages for C-27, C-36 and C-8; the Justice Laws consolidation of PIPEDA and the 2026 annual statutes index; the Ontario e-Laws consolidated statute database; and the Legislative Assembly of Ontario's complete bill list for the 44th Parliament 1st Session (139 bills).
|
||||||
|
- *Outcome:* NO SUCH STATUTE EXISTS. Nothing enacted federally or in Ontario in 2025 or 2026 is a privacy statute. The nearest real things are (a) federal Bill C-36, introduced 15 June 2026, which WOULD enact the Protecting Privacy and Consumer Data Act — but it is at second reading with no royal assent; (b) federal Bill C-8 / S.C. 2026, c. 9, a cyber security statute, not privacy; and (c) Ontario O. Regs. 51/26 and 52/26 in force 1 July 2026, which are regulations under a 2024 Act, not a statute. Any public-page copy relying on "the 2026 privacy statute" as market context is asserting something that is not law. If the intent was "pending federal privacy reform", the accurate framing is Bill C-36 (45-1), first reading 15 June 2026, still before the House.
|
||||||
|
- **Did the Consumer Privacy Protection Act or the Artificial Intelligence and Data Act ever come into force in any form?**
|
||||||
|
- *Searched:* LEGISinfo C-27 page and JSON record (royal assent flags, reinstatement flags, session-ongoing flag); a scan of all 185 bills in the 45th Parliament 1st Session for any bill title containing "intellig", "artificial intelligence", "consumer privacy" or "data protection"; House of Commons Procedure and Practice 4th ed. on prorogation and dissolution.
|
||||||
|
- *Outcome:* No. C-27 died without royal assent and was not reinstated; no successor AI or CPPA bill has been introduced in the 45th Parliament. Canada has no federal AI statute as at 2026-08-29.
|
||||||
|
- **Is there any Canadian federal or Ontario legal requirement that personal data be stored in Canada?**
|
||||||
|
- *Searched:* Full-text greps of PIPEDA, Ontario FIPPA, PHIPA and O. Reg. 329/04 for "outside Canada", "within Canada", "stored in Canada" and "localiz"; the OPC's Guidelines for processing personal data across borders and its 2019 consultation conclusion; Treasury Board's SPIN 2017-01 and the Directive on Service and Digital.
|
||||||
|
- *Outcome:* No such requirement was found in any of them, and the OPC states the opposite for PIPEDA. NOT CHECKED, and outside the scope asked: the public-sector residency provisions in British Columbia's FIPPA and Nova Scotia's PIIDPA, which are the usual real source of the belief that "Canadian data must stay in Canada". Do not assert anything about those provinces from this artefact.
|
||||||
|
- **Does any Canadian arbitral institution publish formal guidance (as distinct from rules) on the use of AI in arbitration or mediation?**
|
||||||
|
- *Searched:* ADRIC's page sitemap (175 pages) grepped for ai/artificial/tech/rule/code/guideline/protocol; the ADRIC Rules & Codes index; the full text of the ADRIC Arbitration Rules effective 01 March 2025; VanIAC's Rules of Procedure page and site navigation.
|
||||||
|
- *Outcome:* None found. ADRIC's only AI material located is a 2023 commentary article and a 2026 conference session page ("The AI-Ready Neutral: Practical Essentials for Arbitrators and Mediators"), neither of which is institutional guidance. The conference page itself was NOT fetched — only its URL appeared in the sitemap — so nothing should be claimed about its content.
|
||||||
|
- **Do ICDR Canada or the Canadian Arbitration Association publish technology- or AI-specific rules?**
|
||||||
|
- *Searched:* Keyword web search naming ADRIC, VanIAC, CCAC and ICDR Canada together with AI guidance; their own sites were not individually fetched.
|
||||||
|
- *Outcome:* Not established either way. Neither icdr.org nor the Canadian Arbitration Association's site was retrieved, so no claim can be made about what they do or do not publish.
|
||||||
|
- **Coming-into-force status of the Critical Cyber Systems Protection Act (S.C. 2026, c. 9) — which of its provisions are actually operative.**
|
||||||
|
- *Searched:* The LEGISinfo C-8 page, the royal assent text summary, and the Justice Laws Annual Statutes 2026 c. 9 landing page.
|
||||||
|
- *Outcome:* Royal assent (15 June 2026) is confirmed, but the coming-into-force provisions were not read in full. Do not assert that the Critical Cyber Systems Protection Act is in force; assert only that it was enacted.
|
||||||
|
- **What S.O. 2026, c. 2, Sched. 7 (the most recent FIPPA amendment) actually changes.**
|
||||||
|
- *Searched:* Ontario e-Laws statute record for S.O. 2026, c. 2, identified as the Plan to Protect Ontario Act (Budget Measures), 2026 (Bill 97), assented to April 24, 2026; the schedule's text was not extracted.
|
||||||
|
- *Outcome:* Identified as a budget-measures omnibus amendment to FIPPA; its substance was not read and must not be characterised.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `WebSearch: Bill C-27 Digital Charter Implementation Act status LEGISinfo died on Order Paper prorogation`
|
||||||
|
- `WebSearch: PIPEDA Personal Information Protection and Electronic Documents Act S.C. 2000 c. 5 justice laws`
|
||||||
|
- `WebFetch: https://www.parl.ca/legisinfo/en/bill/44-1/c-27`
|
||||||
|
- `WebFetch: https://www.parl.ca/legisinfo/en/bill/44-1/c-27/json`
|
||||||
|
- `WebFetch: https://laws-lois.justice.gc.ca/eng/acts/P-8.6/`
|
||||||
|
- `curl: https://laws-lois.justice.gc.ca/eng/acts/P-8.6/FullText.html (then grep for residency terms; grep exit status read directly rather than through a pipe, after an initial `grep ... | head` gave a misleading exit code)`
|
||||||
|
- `curl: https://www.parl.ca/legisinfo/en/bills/json?parlsession=44-1 (412 bill records, keyword scan)`
|
||||||
|
- `curl: https://www.parl.ca/legisinfo/en/bills/json?parlsession=45-1 (185 bill records, keyword scan + 'intellig' scan + full royal-assent list)`
|
||||||
|
- `WebFetch: https://www.parl.ca/legisinfo/en/bill/45-1/c-36`
|
||||||
|
- `WebFetch: https://www.parl.ca/DocumentViewer/en/45-1/bill/C-36/first-reading`
|
||||||
|
- `WebFetch: https://www.parl.ca/legisinfo/en/bill/45-1/c-8`
|
||||||
|
- `WebFetch: https://www.parl.ca/DocumentViewer/en/45-1/bill/C-8/royal-assent`
|
||||||
|
- `WebFetch: https://laws-lois.justice.gc.ca/eng/AnnualStatutes/2026_9/`
|
||||||
|
- `WebSearch + WebFetch: https://www.ourcommons.ca/procedure/procedure-and-practice-4/ch08-7-e.html (prorogation and dissolution)`
|
||||||
|
- `Ontario e-Laws: discovered the JSON API behind the ontario.ca/laws SPA (the HTML pages return only a JS shell to any fetcher, and WebFetch got nothing) by reading /laws/static/js/main.dbd400db.js; base https://www.ontario.ca/laws/api/v2/legislation`
|
||||||
|
- `e-Laws API: /en/currency-date -> "August 26, 2026"`
|
||||||
|
- `e-Laws API: /en/act-content/statute/04p03 (PHIPA) + extraction of ss. 12, 13, 14, 50 and residency grep`
|
||||||
|
- `e-Laws API: /en/act-content/regulation/040329 (O. Reg. 329/04 under PHIPA) + residency grep`
|
||||||
|
- `e-Laws API: /en/act-content/statute/90f31 (FIPPA) + ss. 40, 40.1 + residency grep with exit status read`
|
||||||
|
- `e-Laws API: /en/act-content/statute/90m56 (MFIPPA)`
|
||||||
|
- `e-Laws API: /en/act-content/statute/24e24 (Enhancing Digital Security and Trust Act, 2024) + full text extraction`
|
||||||
|
- `e-Laws API: /en/act-reg/regulation?title=enhancing+digital+security+and+trust+act,+2024 (complete list of regulations made under EDSTA: 2 current, 0 revoked)`
|
||||||
|
- `e-Laws API: /en/act-content/regulation/260051 and /260052 (O. Reg. 51/26 and 52/26) + 'artificial intelligence' count`
|
||||||
|
- `e-Laws API: /en/act-content/statute/s26002 (S.O. 2026, c. 2 = Plan to Protect Ontario Act (Budget Measures), 2026, assented April 24, 2026)`
|
||||||
|
- `e-Laws API: /en/act-content/statute/25k25 and /s25025 (Kids' Online Safety and Privacy Month Act, 2025)`
|
||||||
|
- `WebSearch: Ontario Enhancing Digital Security and Trust Act 2024 in force FIPPA amendments Bill 194`
|
||||||
|
- `WebSearch: 'Enhancing Digital Security and Trust Act' Ontario regulation O. Reg. cyber security 2026 July 1 2026`
|
||||||
|
- `WebFetch: https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-194`
|
||||||
|
- `curl + scrape: https://www.ola.org/en/legislative-business/bills/parliament-44/session-1 (all 139 bills, unpaginated, keyword scan)`
|
||||||
|
- `WebFetch: ola.org bills 61, 66 and 137 (44-1)`
|
||||||
|
- `WebFetch: https://www.priv.gc.ca/en/privacy-topics/airports-and-borders/gl_dab_090127/ + curl to verify the page title and Date modified`
|
||||||
|
- `curl: https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/02_05_d_15/ (Summary of privacy laws in Canada)`
|
||||||
|
- `WebSearch (priv.gc.ca only) + WebFetch: https://www.priv.gc.ca/en/opc-news/news-and-announcements/2019/an_190923/`
|
||||||
|
- `WebSearch (canada.ca only) + curl: SPIN 2017-01 and https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=32601 (Directive on Service and Digital, s. 4.3.24)`
|
||||||
|
- `WebSearch: ADRIC artificial intelligence arbitration guideline rules technology disputes`
|
||||||
|
- `curl + pdftotext: https://adric.ca/rules/ADRIC-Arbitration-Rules-2025.pdf (103,811 chars extracted) + AI/technology grep`
|
||||||
|
- `curl: https://adric.ca/sitemap_index.xml and /page-sitemap.xml (175 pages) + ai/artificial/tech/rule/guideline grep`
|
||||||
|
- `curl: https://adric.ca/rules-codes/ and https://adric.ca/artificial-intelligence-and-arbitration-a-perfect-fit/`
|
||||||
|
- `WebSearch: VanIAC Vancouver International Arbitration Centre artificial intelligence guidelines rules 2025 2026`
|
||||||
|
- `curl: https://vaniac.org/arbitration/rules-of-procedure/ and https://vaniac.org/`
|
||||||
|
- `curl: https://ciidrc.org/ , /about-ciidrc/ , /domain-name-disputes/cdrp-policy/ , /domain-name-disputes/ciidrc-supplemental-rules/`
|
||||||
|
- `BLOCKED, recorded so a later reader does not mistake silence for absence: canlii.org returned HTTP 403 to WebFetch; cira.ca returned a Cloudflare HTTP 403 to both WebFetch and curl (the CDRP policy was therefore sourced from CIIDRC, a CIRA-approved provider, not from CIRA itself); adric.ca/rules/ returned HTTP 403 to curl although the rules PDF on the same host returned 200; canada.ca returned 403 to WebFetch but 200 to curl with a browser user-agent.`
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
# Deploy credential — verification output
|
||||||
|
|
||||||
|
**What this is.** The tool output behind `AGENTS.md` §7's *Deploy credential —
|
||||||
|
PROVISIONED* row and the two risk downgrades in `AGENTS.md` §10. It exists because the row
|
||||||
|
was written on evidence that lived only in a terminal, which is the
|
||||||
|
**Q24 / Q32 shape** R14 exists for: a claim whose supporting artefact is
|
||||||
|
unreachable is unverifiable by construction, not merely unverified.
|
||||||
|
`AGENTS.md` **Q52**, ruled 2026-08-28: *"YES — commit the simulate results, the
|
||||||
|
inline policy, and the `NoSuchBucketPolicy` response, access key ID redacted."*
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Subject | IAM user `adr-sml-deploy`, AWS account `327082975128` |
|
||||||
|
| Retrieved | **2026-08-28**, re-run from the repository root |
|
||||||
|
| Calling identity | `arn:aws:iam::327082975128:user/pouya` — the broadly-permissioned personal user, **at an interactive keyboard**. `AGENTS.md` §7 and §10: acceptable interactively, **never** as a CI credential |
|
||||||
|
| Calls | 18 — all **read-only**. No `create`, `put`, `attach`, `delete`, or `update` |
|
||||||
|
| Redaction | The access key ID is replaced with `AKIA…REDACTED` throughout. Nothing else is redacted; `UserId` (`AIDA…`) is retained because it is the durable principal identifier that appears in CloudTrail and grants nothing. The **secret** access key was never requested and cannot be retrieved from the API at all |
|
||||||
|
| Method | `aws-cli/2.34.53`. Every command's exit status was read, and **no `2>/dev/null` anywhere** — see the `NoSuchBucketPolicy` section, where the error *is* the result |
|
||||||
|
|
||||||
|
**Re-run it.** Every command below is copy-pasteable. Nothing here is
|
||||||
|
transcribed from a session; this file was generated from the captured output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The user
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws iam get-user --user-name adr-sml-deploy
|
||||||
|
{
|
||||||
|
"User": {
|
||||||
|
"Path": "/",
|
||||||
|
"UserName": "adr-sml-deploy",
|
||||||
|
"UserId": "AIDAUYJ5KD6MHAAC4BOKD",
|
||||||
|
"Arn": "arn:aws:iam::327082975128:user/adr-sml-deploy",
|
||||||
|
"CreateDate": "2026-08-26T15:45:18+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Created **2026-08-26T15:45:18+00:00**, path `/`, no tags.
|
||||||
|
|
||||||
|
## 2. Nothing attached, no groups, one inline policy
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws iam list-attached-user-policies --user-name adr-sml-deploy
|
||||||
|
{
|
||||||
|
"AttachedPolicies": []
|
||||||
|
}
|
||||||
|
|
||||||
|
$ aws iam list-groups-for-user --user-name adr-sml-deploy
|
||||||
|
{
|
||||||
|
"Groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
$ aws iam list-user-policies --user-name adr-sml-deploy
|
||||||
|
{
|
||||||
|
"PolicyNames": [
|
||||||
|
"adr-sml-deploy-minimal"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is the part policy-reading alone gets wrong.** An empty
|
||||||
|
`AttachedPolicies` proves nothing on its own — permissions can arrive through a
|
||||||
|
group, or through a resource-based policy that is invisible from the IAM side.
|
||||||
|
Both are checked: `Groups` is empty here, and **§6 below** checks the resource
|
||||||
|
side — §5 is the identity-side simulation and does not reach a bucket policy.
|
||||||
|
|
||||||
|
## 3. The inline policy — `adr-sml-deploy-minimal`
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws iam get-user-policy --user-name adr-sml-deploy \
|
||||||
|
--policy-name adr-sml-deploy-minimal
|
||||||
|
{
|
||||||
|
"UserName": "adr-sml-deploy",
|
||||||
|
"PolicyName": "adr-sml-deploy-minimal",
|
||||||
|
"PolicyDocument": {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "ListSiteBucket",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "s3:ListBucket",
|
||||||
|
"Resource": "arn:aws:s3:::adr-smlcompany-site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "WriteSiteObjects",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:DeleteObject"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:s3:::adr-smlcompany-site/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "InvalidateOneDistribution",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "cloudfront:CreateInvalidation",
|
||||||
|
"Resource": "arn:aws:cloudfront::327082975128:distribution/E1OK7G98KNKUTA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Four actions, one bucket, one distribution. **Never widen it** — `AGENTS.md` §10
|
||||||
|
records why
|
||||||
|
in terms: this is the control standing between a shared Gitea instance and an
|
||||||
|
AWS account that also holds `mlp-clientdb-prod-backups-327082975128`. Treat any
|
||||||
|
request to widen it as a security decision, not a convenience one.
|
||||||
|
|
||||||
|
## 4. The access key — issued, never used
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws iam list-access-keys --user-name adr-sml-deploy
|
||||||
|
{
|
||||||
|
"AccessKeyMetadata": [
|
||||||
|
{
|
||||||
|
"UserName": "adr-sml-deploy",
|
||||||
|
"AccessKeyId": "AKIA…REDACTED",
|
||||||
|
"Status": "Active",
|
||||||
|
"CreateDate": "2026-08-26T15:45:19+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
$ aws iam get-access-key-last-used --access-key-id AKIA…REDACTED
|
||||||
|
{
|
||||||
|
"UserName": "adr-sml-deploy",
|
||||||
|
"AccessKeyLastUsed": {
|
||||||
|
"ServiceName": "N/A",
|
||||||
|
"Region": "N/A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One key, `Active`, created **2026-08-26T15:45:19+00:00**.
|
||||||
|
|
||||||
|
⚠️ **A correction to `AGENTS.md` §7's wording, found by re-running the call.**
|
||||||
|
That row said
|
||||||
|
*"one active access key, `LastUsed` null"*. The API does not return null — it
|
||||||
|
returns `AccessKeyLastUsed` with **`ServiceName: "N/A"` and `Region: "N/A"`**,
|
||||||
|
and there is no `LastUsedDate` key at all. Same conclusion (the key has never
|
||||||
|
authenticated a request), different field shape, and a reader looking for a
|
||||||
|
literal `null` would not find one.
|
||||||
|
|
||||||
|
**Rotation is due 2026-11-26** — key created 2026-08-26, D3 commits to
|
||||||
|
quarterly. `AGENTS.md` §12 R17 carries the date; `docs/06-deployment.md` §Key rotation
|
||||||
|
carries the procedure. Create the second key, update the Gitea secrets, verify a
|
||||||
|
deploy, **then** delete the first.
|
||||||
|
|
||||||
|
## 5. `simulate-principal-policy` — nine checks, verified by execution
|
||||||
|
|
||||||
|
The point of this section is that it tests the **negative** half. A policy that
|
||||||
|
grants four actions is only a control if it can be shown not to grant the rest.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PRINCIPAL=arn:aws:iam::327082975128:user/adr-sml-deploy
|
||||||
|
sim() { aws iam simulate-principal-policy \
|
||||||
|
--policy-source-arn "$PRINCIPAL" --action-names "$1" --resource-arns "$2" ; }
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | Action | Resource | `EvalDecision` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `s3:PutObject` | `arn:aws:s3:::adr-smlcompany-site/index.html` | **allowed** |
|
||||||
|
| 2 | `s3:DeleteObject` | `arn:aws:s3:::adr-smlcompany-site/old.html` | **allowed** |
|
||||||
|
| 3 | `s3:ListBucket` | `arn:aws:s3:::adr-smlcompany-site` | **allowed** |
|
||||||
|
| 4 | `cloudfront:CreateInvalidation` | `arn:aws:cloudfront::327082975128:distribution/E1OK7G98KNKUTA` | **allowed** |
|
||||||
|
| 5 | `s3:ListBucket` | `arn:aws:s3:::mlp-clientdb-prod-backups-327082975128` | **implicitDeny** |
|
||||||
|
| 6 | `s3:GetObject` | `arn:aws:s3:::mlp-clientdb-prod-backups-327082975128/anything` | **implicitDeny** |
|
||||||
|
| 7 | `s3:DeleteBucket` | `arn:aws:s3:::adr-smlcompany-site` | **implicitDeny** |
|
||||||
|
| 8 | `s3:PutObject` | `arn:aws:s3:::meshkinilaw.ca/anything` | **implicitDeny** |
|
||||||
|
| 9 | `iam:CreateUser` | `*` | **implicitDeny** |
|
||||||
|
|
||||||
|
Four `allowed`, five `implicitDeny`. `implicitDeny` rather than `explicitDeny`
|
||||||
|
is the correct and expected shape: the policy contains no `Deny` statement, so
|
||||||
|
everything outside its three `Allow` statements falls through to IAM's
|
||||||
|
default-deny. An `explicitDeny` here would mean a *different* policy was also in
|
||||||
|
play.
|
||||||
|
|
||||||
|
Raw output for each check, including the full `MatchedStatements` and
|
||||||
|
`MissingContextValues`, follows in **§7 of this file**. *(Bare `§n` here means a
|
||||||
|
section of this document; references to the working file are written
|
||||||
|
`AGENTS.md` §n.)*
|
||||||
|
|
||||||
|
## 6. The resource side — `NoSuchBucketPolicy`, and why the contrast call matters
|
||||||
|
|
||||||
|
IAM simulation covers identity-based policy. It does **not** cover a
|
||||||
|
resource-based grant on the backup bucket, which would be invisible from the
|
||||||
|
principal's side. So:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws s3api get-bucket-policy --bucket mlp-clientdb-prod-backups-327082975128
|
||||||
|
# exit status: 254
|
||||||
|
# stdout: 0 bytes
|
||||||
|
# stderr:
|
||||||
|
# aws: [ERROR]: An error occurred (NoSuchBucketPolicy) when calling the GetBucketPolicy operation: The bucket policy does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
**An error is the result here, and that is only legible because stderr was not
|
||||||
|
suppressed.** `CLAUDE.md`'s convention, from Pouya's own 2026-08-28 correction:
|
||||||
|
`2>/dev/null` converts *"it failed"* into *"it found nothing"*, and those are
|
||||||
|
opposite results. The exit status is **254** and stdout is **empty** — read the
|
||||||
|
status, not just stdout.
|
||||||
|
|
||||||
|
**And an empty result needs a working instrument.** The same command against the
|
||||||
|
site bucket, to prove the call itself resolves a policy when one exists:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ aws s3api get-bucket-policy --bucket adr-smlcompany-site
|
||||||
|
{
|
||||||
|
"Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"AllowCloudFrontServicePrincipal\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudfront.amazonaws.com\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::adr-smlcompany-site/*\",\"Condition\":{\"StringEquals\":{\"AWS:SourceArn\":\"arn:aws:cloudfront::327082975128:distribution/E1OK7G98KNKUTA\"}}}]}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0**, a real policy returned — the CloudFront OAC grant, scoped to
|
||||||
|
distribution `E1OK7G98KNKUTA`. So `NoSuchBucketPolicy` on the backup bucket is a
|
||||||
|
**genuine absence**, not a command that failed to run. That contrast is the
|
||||||
|
whole reason this call is in the file: without it, an empty result from a broken
|
||||||
|
command looks exactly like an empty result from a bucket with no policy.
|
||||||
|
|
||||||
|
*(This contrast call was **not** part of the original Q22 verification. It was
|
||||||
|
added on re-run, and it closes the gap that made the original evidence weaker
|
||||||
|
than it read.)*
|
||||||
|
|
||||||
|
## 7. Raw `simulate-principal-policy` responses
|
||||||
|
|
||||||
|
### Check 1
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:PutObject",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site/index.html",
|
||||||
|
"EvalDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site/index.html",
|
||||||
|
"EvalResourceDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 2
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:DeleteObject",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site/old.html",
|
||||||
|
"EvalDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site/old.html",
|
||||||
|
"EvalResourceDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 3
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:ListBucket",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site",
|
||||||
|
"EvalDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 3,
|
||||||
|
"Column": 17
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site",
|
||||||
|
"EvalResourceDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 3,
|
||||||
|
"Column": 17
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 6,
|
||||||
|
"Column": 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 4
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "cloudfront:CreateInvalidation",
|
||||||
|
"EvalResourceName": "arn:aws:cloudfront::327082975128:distribution/E1OK7G98KNKUTA",
|
||||||
|
"EvalDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 12,
|
||||||
|
"Column": 83
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:cloudfront::327082975128:distribution/E1OK7G98KNKUTA",
|
||||||
|
"EvalResourceDecision": "allowed",
|
||||||
|
"MatchedStatements": [
|
||||||
|
{
|
||||||
|
"SourcePolicyId": "user_adr-sml-deploy_adr-sml-deploy-minimal",
|
||||||
|
"SourcePolicyType": "IAM Policy",
|
||||||
|
"StartPosition": {
|
||||||
|
"Line": 9,
|
||||||
|
"Column": 57
|
||||||
|
},
|
||||||
|
"EndPosition": {
|
||||||
|
"Line": 12,
|
||||||
|
"Column": 83
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 5
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:ListBucket",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::mlp-clientdb-prod-backups-327082975128",
|
||||||
|
"EvalDecision": "implicitDeny",
|
||||||
|
"MatchedStatements": [],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::mlp-clientdb-prod-backups-327082975128",
|
||||||
|
"EvalResourceDecision": "implicitDeny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 6
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:GetObject",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::mlp-clientdb-prod-backups-327082975128/anything",
|
||||||
|
"EvalDecision": "implicitDeny",
|
||||||
|
"MatchedStatements": [],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::mlp-clientdb-prod-backups-327082975128/anything",
|
||||||
|
"EvalResourceDecision": "implicitDeny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 7
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:DeleteBucket",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site",
|
||||||
|
"EvalDecision": "implicitDeny",
|
||||||
|
"MatchedStatements": [],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::adr-smlcompany-site",
|
||||||
|
"EvalResourceDecision": "implicitDeny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 8
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "s3:PutObject",
|
||||||
|
"EvalResourceName": "arn:aws:s3:::meshkinilaw.ca/anything",
|
||||||
|
"EvalDecision": "implicitDeny",
|
||||||
|
"MatchedStatements": [],
|
||||||
|
"MissingContextValues": [],
|
||||||
|
"EvalDecisionDetails": {},
|
||||||
|
"ResourceSpecificResults": [
|
||||||
|
{
|
||||||
|
"EvalResourceName": "arn:aws:s3:::meshkinilaw.ca/anything",
|
||||||
|
"EvalResourceDecision": "implicitDeny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check 9
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"EvaluationResults": [
|
||||||
|
{
|
||||||
|
"EvalActionName": "iam:CreateUser",
|
||||||
|
"EvalResourceName": "*",
|
||||||
|
"EvalDecision": "implicitDeny",
|
||||||
|
"MatchedStatements": [],
|
||||||
|
"MissingContextValues": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does and does not establish
|
||||||
|
|
||||||
|
**Establishes.** The user exists with the creation date `AGENTS.md` §7 records; it holds one
|
||||||
|
inline policy and nothing else; the policy grants four actions on one bucket and
|
||||||
|
one distribution; the four intended actions are `allowed`; five representative
|
||||||
|
out-of-scope actions — including two against the client-database backup bucket —
|
||||||
|
are denied; the backup bucket carries no resource-based policy that could grant
|
||||||
|
around IAM; and one key exists that has never authenticated a request.
|
||||||
|
|
||||||
|
**Does not establish.** That the key works (it has never been used, and testing
|
||||||
|
it would mean putting a real object in the bucket). That Gitea holds it —
|
||||||
|
`AGENTS.md` §7 records **`GITEA ACTIONS SECRETS: UNSET`**, and Q23 is open on whether a runner
|
||||||
|
exists at all. That the policy is sufficient for a deploy in practice: the
|
||||||
|
`s3:AbortMultipartUpload` omission is covered by asset sizes rather than by a
|
||||||
|
lifecycle rule, and `docs/06-deployment.md` records that with a revisit trigger.
|
||||||
|
|
||||||
|
**Not in scope of this file, and deliberately so.** The secret access key. It
|
||||||
|
must never reach the repository — D3, and `AGENTS.md` §10 on the jointly administered Gitea
|
||||||
|
instance, where an instance administrator can generally reach repository secrets
|
||||||
|
or register a runner that receives them.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Fonts — provenance
|
||||||
|
|
||||||
|
Self-hosted per `docs/02-design-system.md`. **No runtime Google Fonts request**:
|
||||||
|
the page collects legal inquiries, and a third-party font call costs a round trip
|
||||||
|
and adds a third party to that page.
|
||||||
|
|
||||||
|
These `.woff2` files are committed rather than pulled at build time so their
|
||||||
|
paths are stable — a `<link rel="preload">` needs a filename that does not change
|
||||||
|
between builds, and Astro's asset hashing would break that.
|
||||||
|
|
||||||
|
| File | Source package | Version | Licence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `instrument-serif-latin-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `instrument-serif-latin-ext-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `instrument-serif-latin-400-italic.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-latin-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-latin-ext-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-mono-latin-wght-normal.woff2` | `@fontsource-variable/geist-mono` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
|
||||||
|
**The `?v=1` on every font URL is load-bearing.** `scripts/deploy-local.sh`
|
||||||
|
serves `/fonts/*` with `max-age=31536000, immutable`, so a returning visitor
|
||||||
|
holds these bytes for a year and no CloudFront invalidation can reach their
|
||||||
|
browser cache. Replacing a file means bumping that query — in
|
||||||
|
`src/styles/global.css` **and** in the `<link rel="preload">` in
|
||||||
|
`BaseLayout.astro`, which must match character for character or the preload
|
||||||
|
fetches a second copy instead of warming the cache.
|
||||||
|
|
||||||
|
Fetched 2026-08-26 with `npm pack <pkg>@5.3.0` and extracted from `package/files/`.
|
||||||
|
Subsetting is Fontsource's, not ours — the `latin` and `latin-ext` cuts are
|
||||||
|
exactly the "Latin + Latin Extended-A" the design system asks for.
|
||||||
|
|
||||||
|
**123,804 bytes across all six**, of which only two — Instrument Serif 400 latin
|
||||||
|
(21,032) and Geist latin (29,400) — are preloaded, so first paint pulls about
|
||||||
|
50 kB. Quote the byte figure, not `du -sh`, which reports 136K because it counts
|
||||||
|
disk blocks rather than what crosses the wire.
|
||||||
|
|
||||||
|
**Not covered by `AGENTS.md` R11.** R11 re-checks npm pins for currency and
|
||||||
|
advisories; these are static binaries with no runtime and no dependency tree.
|
||||||
|
Refresh them deliberately — when a face gains glyphs the site needs — by
|
||||||
|
repeating the `npm pack` above, not on a currency schedule.
|
||||||
|
|
||||||
|
**Deliberately absent:** the `latin-ext` italic cut of Instrument Serif, and
|
||||||
|
every non-Latin cut of all three faces. `.display .it` is one italic phrase in a
|
||||||
|
headline (`docs/02`), and D4 makes the site English-only. Add a cut when a page
|
||||||
|
needs it; do not add all of them pre-emptively.
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#
|
#
|
||||||
# This file is kept because it is the better design: GitHub OIDC issues a
|
# This file is kept because it is the better design: GitHub OIDC issues a
|
||||||
# short-lived token per run instead of a static key. If the project ever moves
|
# short-lived token per run instead of a static key. If the project ever moves
|
||||||
# to GitHub or GitLab, use this and delete the static IAM user.
|
# to GitHub, use this and delete the static IAM user. GitLab also federates
|
||||||
|
# to AWS by OIDC, but with entirely different CI syntax — this file is the
|
||||||
|
# design there, not the implementation.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
name: Build and deploy
|
name: Build and deploy
|
||||||
|
|
||||||
@@ -13,8 +15,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# OIDC role assumption — no long-lived AWS credentials in this repository.
|
# OIDC role assumption — a short-lived token per run, no static key.
|
||||||
# See docs/06-deployment.md for the one-time IAM setup.
|
# NOT the current posture: this repository deploys with a static IAM key.
|
||||||
|
# See docs/06-deployment.md for the live procedure and the IAM policy.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -46,18 +49,28 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
||||||
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
# SUPERSEDED 2026-08-31 — do not copy these two lines. Build step 8
|
||||||
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
# 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 }}
|
||||||
|
|
||||||
# TODO(pouya): AGENTS.md Q9, Q10 — set these repository variables:
|
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
|
||||||
# AWS_DEPLOY_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID
|
# 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
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||||
aws-region: ${{ vars.AWS_REGION }}
|
aws-region: ${{ vars.AWS_REGION }}
|
||||||
|
|
||||||
# Two passes: hashed immutable assets first, HTML last. A visitor must
|
# Three passes: hashed immutable assets first, then images, HTML last.
|
||||||
|
# A visitor must
|
||||||
# never fetch a new page whose assets have not landed yet.
|
# never fetch a new page whose assets have not landed yet.
|
||||||
- name: Sync hashed assets
|
- name: Sync hashed assets
|
||||||
run: |
|
run: |
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
# Who can read `adr-intake-submissions` — verification extract
|
||||||
|
|
||||||
|
**Why this file exists.** `/legal/privacy/` makes a statement to the public about
|
||||||
|
who can see the contents of the intake table. `AGENTS.md` §4 admits no factual
|
||||||
|
claim that cannot be traced, and `CLAUDE.md`'s R14 rule is that anything a spec
|
||||||
|
claims about must be reachable from the repository — *"if the artefact lives only
|
||||||
|
in a console, no reviewer can compare the claim against it and the claim is
|
||||||
|
unverifiable by construction."* Until this file existed, that sentence was the
|
||||||
|
one claim on the site whose subject was entirely outside the repo.
|
||||||
|
|
||||||
|
Raised by `claims-auditor` in the D20 cutover audit, 2026-09-01, finding 8.
|
||||||
|
|
||||||
|
**Provenance.** Figures in the ORIGINAL sections were read from AWS on
|
||||||
|
**2026-09-01**; everything in the **2026-09-02 addendum** at the foot of this file
|
||||||
|
was read on 2026-09-02, and it supersedes the original role screen. Both were run
|
||||||
|
read-only as `arn:aws:iam::327082975128:user/pouya` with the commands listed at
|
||||||
|
the end.
|
||||||
|
No command in this file creates or changes anything. Re-run them rather than
|
||||||
|
trusting this file; it is dated for that reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ RULED AND APPLIED — 2026-09-02
|
||||||
|
|
||||||
|
**Pouya ruled `state the truth`, not `remove the access`** (§9 Q62, ruled
|
||||||
|
2026-09-01, applied 2026-09-02). Option 2 below is the one taken; option 1 was
|
||||||
|
declined. `lars`'s membership of `admins` is **unchanged**.
|
||||||
|
|
||||||
|
⚠️ **AND THEN RULED AGAIN THE SAME DAY — §9 Q63, and the second ruling is the
|
||||||
|
one this file most needs to carry, because THIS FILE SUPPLIED THE ERROR.** The
|
||||||
|
first shipped sentence was *"Two people can"*, and it was read straight off the
|
||||||
|
enumeration below. **Pouya's attestation, 2026-09-02:** *"two people is an
|
||||||
|
exaggeration… a handful is accurate — the simulation counts identities, not
|
||||||
|
humans, and the two are not the same claim."*
|
||||||
|
|
||||||
|
**The enumeration is exhaustive and the inference off it was not.** Every read
|
||||||
|
path terminates at `user/pouya` or `user/lars`; how many **people** can reach
|
||||||
|
those two credentials is not something `simulate-principal-policy` can see, so
|
||||||
|
the identity count is a **lower bound on people** and the page published it as an
|
||||||
|
exact count. **No numeric human headcount may ship.** The identity counts in this
|
||||||
|
file are unaffected and stay exactly as measured — this is a correction to what
|
||||||
|
may be *concluded* from them, not to any of them.
|
||||||
|
|
||||||
|
⚠️ **AND RULED A THIRD TIME, LATER THE SAME DAY: THE PAGE STATES WHO, AND THIS
|
||||||
|
FILE HOLDS THE METHOD.** Pouya, 2026-09-02: *"the page stays generic. It
|
||||||
|
over-explains technical mechanics that belong in the evidence file, not in front
|
||||||
|
of an inquirer."* §Who can see it is now **four short statements**. **Deleted from
|
||||||
|
the page:** the measurement paragraph, the root-credential sentence, the single-sign-on and federated-login enumeration, the resource-policy clause, the *"company that runs a database"* aside, the deploy-credential sentence and the three-copies summary. ⚠️ **THE SHARED-ACCOUNT CLAUSE WAS CUT WITH THEM AND THEN RESTORED — to §Where it is stored, where it belongs.** It is a storage disclosure rather than mechanics, the ruling did not name it, and without it no page told a reader their intake sits in an account that also runs unrelated systems (`adversarial-reviewer`, round 1). **These lists must stay identical — there were four of them and they named four different sets.** None of that was retracted and none of it is lost — it
|
||||||
|
is all still below, unchanged, and **that is now this file's job rather than a
|
||||||
|
supporting role.** ⚠️ **THE PAGE NO LONGER CITES THIS FILE'S CONTENT, SO THE
|
||||||
|
COMPARISON BELOW IS THE ONLY THING TYING THE TWO TOGETHER. Keep it in sync, and
|
||||||
|
do not restore a deleted sentence to the page on the strength of finding it
|
||||||
|
here** — the section comment in `src/pages/legal/privacy.astro` carries the same
|
||||||
|
bar.
|
||||||
|
|
||||||
|
**The shipped sentences as at 2026-09-02**, so this file can be compared against
|
||||||
|
the live page rather than against a struck one. All four, in order, complete:
|
||||||
|
|
||||||
|
> The record in the table: me, and the small number of people who administer the
|
||||||
|
> account it sits in with me.
|
||||||
|
|
||||||
|
> The system that receives what you send **can only add a record — it cannot read
|
||||||
|
> back what is stored.**
|
||||||
|
|
||||||
|
> The notification goes to the practice's mailbox, which is read by me and by
|
||||||
|
> administrative staff and is hosted on Google Workspace — so Google holds a copy
|
||||||
|
> of whatever you send me.
|
||||||
|
|
||||||
|
> The confirmation that went to you sits with whoever runs your email. That copy
|
||||||
|
> is in your hands rather than mine.
|
||||||
|
|
||||||
|
**What each rests on, because that mapping is the reason this file exists.**
|
||||||
|
Sentence 1: the enumeration below, plus Pouya's *"a handful"* attestation for the
|
||||||
|
human quantifier — **the measurement gives administrators, the attestation gives
|
||||||
|
the number, and neither gives the other.** Sentence 2: `adr-intake-lambda-role`
|
||||||
|
holds `PutItem` only, implicitDeny on all six read and modify actions.
|
||||||
|
Sentence 3: **an attestation, not a measurement** — `AGENTS.md` §7's
|
||||||
|
`info@smlcompany.ca` row; nothing in this repository or in AWS can check it.
|
||||||
|
Sentence 4: the handler's second `SendEmailCommand`.
|
||||||
|
|
||||||
|
**Three things the page deliberately does NOT say, and each was deleted by
|
||||||
|
ruling rather than being unsupported.** The **root credential** (§7 records it as
|
||||||
|
held by Pouya with no access key and MFA on — ⚠️ *held*, not *held only*, which
|
||||||
|
is why publishing it needed a question and why §9 Q64 is closed as **moot** and
|
||||||
|
not as answered). The **single-sign-on and resource-policy findings**. And the
|
||||||
|
**`33`** — deliberately withheld even while the paragraph stood, because a role
|
||||||
|
total moves when AWS creates a service-linked role by itself.
|
||||||
|
|
||||||
|
**The wording approval Pouya reserved is discharged by the read-through** — his
|
||||||
|
ruling, 2026-09-02: *"do not hold anything open waiting on a separate wording
|
||||||
|
approval; the read-through is the approval."* Q63(a) had approved a version, and
|
||||||
|
the version changed twice after it.
|
||||||
|
|
||||||
|
⚠️ **AND THE ENUMERATION BELOW WAS NOT ENOUGH TO SUPPORT THE COMPLETENESS CLAIM
|
||||||
|
— *"every user and every role in the account was simulated against this table"*,
|
||||||
|
which §7 still asserts and the page no longer carries. See the addendum at the
|
||||||
|
foot of this file**, which is what that claim actually rests on. Read it before
|
||||||
|
citing the five-row table. *(This pointer said "the second of those sentences"
|
||||||
|
until 2026-09-02: it indexed the quote block by POSITION, and the block changed
|
||||||
|
length under it. Name the claim, not its ordinal.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The claim being checked — ⚠️ STRUCK 2026-09-01, QUOTED HERE AS THE DEFECT
|
||||||
|
|
||||||
|
This is **no longer on the page.** It is kept because it is the string
|
||||||
|
`check-claims.mjs`'s `sole-administrator-q62` pattern permanently bars, and a
|
||||||
|
tripwire whose target is not recorded anywhere becomes unmaintainable.
|
||||||
|
|
||||||
|
`src/pages/legal/privacy.astro`, §Who can see it, **as it stood at `bd282aa`**:
|
||||||
|
|
||||||
|
> The table is reachable by the function that writes to it and by one
|
||||||
|
> administrative account, which is mine — nobody else has access to the table.
|
||||||
|
> There is no team, no assistant and no external administrator.
|
||||||
|
|
||||||
|
## The finding: it is false
|
||||||
|
|
||||||
|
The account has an IAM group **`admins`** carrying the AWS managed policy
|
||||||
|
**`AdministratorAccess`**, and it has **two members: `pouya` and `lars`.**
|
||||||
|
|
||||||
|
`iam simulate-principal-policy` for `dynamodb:GetItem`, `dynamodb:Query` and
|
||||||
|
`dynamodb:Scan` against
|
||||||
|
`arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions`, across
|
||||||
|
all five IAM users in the account:
|
||||||
|
|
||||||
|
| principal | GetItem | Query | Scan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `user/pouya` | **allowed** | **allowed** | **allowed** |
|
||||||
|
| `user/lars` | **allowed** | **allowed** | **allowed** |
|
||||||
|
| `user/adr-sml-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||||
|
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny | implicitDeny |
|
||||||
|
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||||
|
|
||||||
|
`lars` holds exactly the access `pouya` holds, by the same route: membership of
|
||||||
|
`admins`. The user's own attachments are only `IAMUserChangePassword`, so the
|
||||||
|
group is the whole of it.
|
||||||
|
|
||||||
|
So the published sentence is wrong on both of its halves — a second account has
|
||||||
|
access, and it belongs to a second administrator of a shared account.
|
||||||
|
|
||||||
|
## The rest of the surface, recorded so the check is complete rather than partial
|
||||||
|
|
||||||
|
- **5 IAM users**: `adr-sml-deploy`, `gitea-deploy-meshkinilaw`, `lars`,
|
||||||
|
`meshkini-backend-deploy`, `pouya`. The three deploy users are all
|
||||||
|
`implicitDeny` above. `adr-sml-deploy`'s scope is S3 + CloudFront and touches
|
||||||
|
no table (`docs/reference/deploy-credential-verification.md`).
|
||||||
|
- **1 IAM group**: `admins` — `AdministratorAccess` and `Billing`, two members.
|
||||||
|
- **33 IAM roles**, 26 of them not service-linked. Two carry
|
||||||
|
`AdministratorAccess`:
|
||||||
|
`cdk-hnb659fds-cfn-exec-role-327082975128-ca-central-1` and
|
||||||
|
`…-us-east-1`. These are **AWS CDK bootstrap CloudFormation execution roles**,
|
||||||
|
assumable by CloudFormation for stack deployment. They are a real path to the
|
||||||
|
table for anyone who can deploy a CDK stack in this account — which is the two
|
||||||
|
administrators above — rather than a third party.
|
||||||
|
- **`adr-intake-lambda-role`** is the writing principal: `dynamodb:PutItem` on
|
||||||
|
this table, `ses:SendEmail`/`SendRawEmail`, plus
|
||||||
|
`AWSLambdaBasicExecutionRole`. **`PutItem` only — it cannot read the table**,
|
||||||
|
which is worth stating because it is a stronger fact than the page currently
|
||||||
|
claims and it is the part of the sentence that is true.
|
||||||
|
- **No resource-based policy on the table.** DynamoDB supports one; this table
|
||||||
|
has none, so access is governed entirely by identity policies.
|
||||||
|
- The account is **not single-project** (`AGENTS.md` §10). `lars` and the two
|
||||||
|
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
|
||||||
|
in the S3 bucket listing §10 describes.
|
||||||
|
|
||||||
|
## What had to happen before `/legal/privacy/` went public — ✅ RESOLVED BY OPTION 2
|
||||||
|
|
||||||
|
Tracked as `AGENTS.md` §9 **Q62**, **closed 2026-09-02 on option 2.** Kept
|
||||||
|
unstruck because the reasoning is what makes the ruling re-readable, and because
|
||||||
|
option 1 remains live in one direction: if that access is ever actually removed,
|
||||||
|
the page and the tripwire both have to change, and `check-claims.mjs`'s `rule:`
|
||||||
|
line carries that instruction.
|
||||||
|
|
||||||
|
1. **Remove the access** — take `lars` out of `admins`, or replace that
|
||||||
|
membership with a policy that denies DynamoDB on this table — and then this
|
||||||
|
sentence becomes true. Note the likely collision: `AGENTS.md` Q23 records the
|
||||||
|
Gitea instance as *jointly administered* and blocked on *"its second
|
||||||
|
administrator"*, so this account is probably not the only thing that access
|
||||||
|
is for.
|
||||||
|
2. **Correct the sentence** to what is true. It is a privacy policy, so the
|
||||||
|
honest version is short and specific — the number of people with
|
||||||
|
administrative access, and that the function that writes cannot read.
|
||||||
|
|
||||||
|
**Do not resolve it by softening.** "Access is limited to authorised
|
||||||
|
administrators" is the shape §4 exists to bar: defensible, uninformative, and it
|
||||||
|
would replace a false specific with a true vacancy on the one page where a reader
|
||||||
|
is entitled to the specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run as `user/pouya`, `ca-central-1`, all read-only. Exit status read on each; no
|
||||||
|
stderr suppressed anywhere.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws iam list-users --query 'Users[].UserName'
|
||||||
|
aws iam list-groups --query 'Groups[].GroupName'
|
||||||
|
aws iam get-group --group-name admins --query 'Users[].UserName'
|
||||||
|
aws iam list-attached-group-policies --group-name admins --query 'AttachedPolicies[].PolicyName'
|
||||||
|
aws iam list-group-policies --group-name admins --query 'PolicyNames'
|
||||||
|
aws iam list-groups-for-user --user-name lars --query 'Groups[].GroupName'
|
||||||
|
aws iam list-attached-user-policies --user-name lars --query 'AttachedPolicies[].PolicyName'
|
||||||
|
aws iam list-user-policies --user-name lars --query 'PolicyNames'
|
||||||
|
|
||||||
|
TARN="arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions"
|
||||||
|
for U in pouya lars adr-sml-deploy gitea-deploy-meshkinilaw meshkini-backend-deploy; do
|
||||||
|
aws iam simulate-principal-policy \
|
||||||
|
--policy-source-arn "arn:aws:iam::327082975128:user/${U}" \
|
||||||
|
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||||
|
--resource-arns "$TARN" \
|
||||||
|
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||||
|
done
|
||||||
|
|
||||||
|
# Roles: 33 total, 26 non-service-linked; screened for broad policies.
|
||||||
|
for R in $(aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||||
|
| tr '\t' '\n' | grep -v '^AWSServiceRole'); do
|
||||||
|
aws iam list-attached-role-policies --role-name "$R" \
|
||||||
|
--query 'AttachedPolicies[].PolicyName' --output text
|
||||||
|
done
|
||||||
|
|
||||||
|
aws iam get-role-policy --role-name adr-intake-lambda-role \
|
||||||
|
--policy-name adr-intake-lambda-inline --query PolicyDocument
|
||||||
|
aws iam list-attached-role-policies --role-name adr-intake-lambda-role
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **`simulate-principal-policy` was the tool that produced a false negative on
|
||||||
|
this project once already** — `AGENTS.md` Q22, where eight checks returned empty
|
||||||
|
because `2>/dev/null` was hiding an `InvalidInput` error caused by a zsh
|
||||||
|
parameter-expansion bug (`$ACCT:user/` parses `:u` as a history modifier). The
|
||||||
|
loop above brace-quotes `${U}` for that reason, prints one line per principal so
|
||||||
|
a silently-skipped iteration is visible as a missing row, and suppresses nothing.
|
||||||
|
**Five rows, or the run did not happen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ ADDENDUM 2026-09-02 — THE ENUMERATION ABOVE WAS INCOMPLETE IN THREE WAYS, AND ITS CONCLUSION SURVIVES ANYWAY
|
||||||
|
|
||||||
|
**Why this addendum exists.** `/legal/privacy/` published a completeness claim
|
||||||
|
about who can read this table — *"every user and every role in the account was
|
||||||
|
simulated against this table"* — and, written as it stood, this file did not
|
||||||
|
support it. ⚠️ **THAT SENTENCE IS NO LONGER ON THE PAGE**: Pouya ruled later the
|
||||||
|
same day that the section states who and not how, and the measurement paragraph
|
||||||
|
was deleted (see **The shipped sentences** at the top of this file, which is now
|
||||||
|
four statements and does not include it). **This addendum is not thereby
|
||||||
|
obsolete — it is now load-bearing in a different place.** `AGENTS.md` §7's
|
||||||
|
`Intake table — who can read it` row still asserts the completeness, `docs/06`
|
||||||
|
and §12 **R21** still instruct an operator to re-run it before cutover, and the
|
||||||
|
page's first sentence still rests on its conclusion even though it no longer
|
||||||
|
recites the method. **A claim moved off a public page into a register is still a
|
||||||
|
claim.**
|
||||||
|
|
||||||
|
*(This paragraph quoted a different sentence until 2026-09-02 — round 1's
|
||||||
|
**pre-fix** wording, *"every account and role in this infrastructure…"*, which
|
||||||
|
the audit then struck. So this file briefly quoted two different sentences as the
|
||||||
|
live one, in the two halves of the same document, which defeats the comparison
|
||||||
|
R14 exists for. Fixed by pointing at the block above rather than re-quoting it:
|
||||||
|
one copy of a fact, in one place. `adversarial-reviewer`, round 2.)* Under R14 the artefact is what a reviewer compares the
|
||||||
|
claim against, so a claim stronger than its artefact is unverifiable by
|
||||||
|
construction even when it happens to be true.
|
||||||
|
|
||||||
|
**What was missing.**
|
||||||
|
|
||||||
|
1. **`aws iam list-role-policies` was never run.** The command block above lists
|
||||||
|
only `list-attached-role-policies`, which returns *managed* policies. **23 of
|
||||||
|
the 26 non-service-linked roles carry inline policies**, and none of them had
|
||||||
|
been read. The screen for "broad policies" could not have seen an inline grant.
|
||||||
|
2. **No role was ever simulated against the table.** Access was inferred from
|
||||||
|
policy *names* (`AdministratorAccess`) rather than measured as a decision.
|
||||||
|
3. **The five users were simulated for reads only** — `GetItem`, `Query`, `Scan`.
|
||||||
|
So the page's *(then-shipped; deleted by the mechanics ruling of 2026-09-02 and now §7's alone)* *"the credential that publishes this website has no access to the
|
||||||
|
table at all"* covered three read actions and said "at all".
|
||||||
|
|
||||||
|
**What the measurement found, and it changes the role count.** Simulating all 26
|
||||||
|
non-service-linked roles across **seven** actions — `GetItem`, `Query`, `Scan`,
|
||||||
|
`BatchGetItem`, `PutItem`, `UpdateItem`, `DeleteItem`:
|
||||||
|
|
||||||
|
| role | decision on the table | trust |
|
||||||
|
|---|---|---|
|
||||||
|
| `cdk-hnb659fds-cfn-exec-role-…-ca-central-1` | **allowed on all 7** | `cloudformation.amazonaws.com` only |
|
||||||
|
| `cdk-hnb659fds-cfn-exec-role-…-us-east-1` | **allowed on all 7** | `cloudformation.amazonaws.com` only |
|
||||||
|
| `cdk-hnb659fds-lookup-role-…-ca-central-1` | **allowed on the 4 READS**, denied on writes | `arn:aws:iam::327082975128:root` |
|
||||||
|
| `cdk-hnb659fds-lookup-role-…-us-east-1` | **allowed on the 4 READS**, denied on writes | `arn:aws:iam::327082975128:root` |
|
||||||
|
| `adr-intake-lambda-role` | `PutItem` **only**; implicitDeny on the other six | `lambda.amazonaws.com` |
|
||||||
|
| the other 21 | implicitDeny on all 7 | — |
|
||||||
|
|
||||||
|
**So FOUR roles can read the table, not the two this file recorded.** The two
|
||||||
|
`lookup` roles were missed by exactly the gap above: their grant is the inline
|
||||||
|
`LookupRolePolicy`, and `list-attached-role-policies` returns nothing for them.
|
||||||
|
|
||||||
|
**Why the published sentence is nevertheless correct.** The question is not how
|
||||||
|
many roles exist but which *people* they lead back to.
|
||||||
|
|
||||||
|
- The two **cfn-exec** roles trust `cloudformation.amazonaws.com` and nothing
|
||||||
|
else. No human can assume them; they are reachable only by deploying a
|
||||||
|
CloudFormation/CDK stack, which requires a principal who can deploy one.
|
||||||
|
- The two **lookup** roles trust the account root, which delegates the decision
|
||||||
|
to the caller's own identity policy. Simulated for `sts:AssumeRole` against
|
||||||
|
both role ARNs, for all five users, reading `ResourceSpecificResults` (ten
|
||||||
|
per-resource decisions, not five — `EvaluationResults` is one entry per
|
||||||
|
**action**, and an earlier pass here asserted the wrong count):
|
||||||
|
|
||||||
|
| principal | assume `lookup-…-ca-central-1` | assume `lookup-…-us-east-1` |
|
||||||
|
|---|---|---|
|
||||||
|
| `user/pouya` | **allowed** | **allowed** |
|
||||||
|
| `user/lars` | **allowed** | **allowed** |
|
||||||
|
| `user/adr-sml-deploy` | implicitDeny | implicitDeny |
|
||||||
|
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny |
|
||||||
|
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny |
|
||||||
|
|
||||||
|
- The **CloudFormation escalation path this file named and left untested** —
|
||||||
|
*"a real path to the table for anyone who can deploy a CDK stack"* — is now
|
||||||
|
measured. Simulated for `cloudformation:CreateStack`, `UpdateStack`,
|
||||||
|
`CreateChangeSet`, `ExecuteChangeSet`, `iam:PassRole` and `sts:AssumeRole`,
|
||||||
|
five users × six actions = **30 decisions**, count asserted:
|
||||||
|
|
||||||
|
| principal | the CDK / CloudFormation path |
|
||||||
|
|---|---|
|
||||||
|
| `user/pouya` | **allowed** on all six |
|
||||||
|
| `user/lars` | **allowed** on all six |
|
||||||
|
| `user/adr-sml-deploy` | implicitDeny on all six |
|
||||||
|
| `user/gitea-deploy-meshkinilaw` | implicitDeny on all six |
|
||||||
|
| `user/meshkini-backend-deploy` | implicitDeny on all six |
|
||||||
|
|
||||||
|
This is the finding that mattered most, because `meshkini-backend-deploy` is by
|
||||||
|
its name another project's backend-deploy credential and
|
||||||
|
`gitea-deploy-meshkinilaw` is held on a **jointly administered** Gitea instance
|
||||||
|
(Q23). Either one, had it been able to drive CloudFormation, would have read
|
||||||
|
the table without appearing in the five-row table above — and *"Two people
|
||||||
|
can"* would have been wrong. Neither can.
|
||||||
|
|
||||||
|
- **And the seven roles every sweep here had excluded BY CONSTRUCTION are now
|
||||||
|
measured too.** Every role loop in this file filters `grep -v
|
||||||
|
'^AWSServiceRole'`, and the assertion was written as *"twenty-six role rows"* —
|
||||||
|
so a service-linked role was outside the claim rather than inside it, which
|
||||||
|
matters because a service-linked role for a backup or migration service can
|
||||||
|
read table contents. All **7** (`APIGateway`, `CloudFrontLogger`,
|
||||||
|
`InternetMonitor`, `RDS`, `ResourceExplorer`, `Support`, `TrustedAdvisor`) are
|
||||||
|
**implicitDeny on all seven actions** — 49 decisions, count asserted
|
||||||
|
`[verified 2026-09-02]`. **So the enumeration is 33 of 33 roles, not 26 of
|
||||||
|
33**, and the page's *"every user and every role in the account"* is now
|
||||||
|
literally true. Raised by `adversarial-reviewer`, round 2.
|
||||||
|
|
||||||
|
- **There is no federated identity surface at all**: `list-saml-providers` **0**,
|
||||||
|
`list-open-id-connect-providers` **0**, `sso-admin list-instances` **0**
|
||||||
|
`[verified 2026-09-02]`. So "every user and every role" is not leaving out a
|
||||||
|
federated principal, because there is none to leave out.
|
||||||
|
|
||||||
|
- ⚠️ **AND THE `sso-admin` ZERO NEEDED A SECOND COMMAND TO MEAN ANYTHING, added
|
||||||
|
2026-09-02 (round 2).** `list-instances` answers about the account it is called
|
||||||
|
in, so a **member of an AWS Organization returns 0 while Identity Center runs in
|
||||||
|
the management account** — the zero would have been true and the conclusion
|
||||||
|
false. `aws organizations describe-organization` returns
|
||||||
|
**`AWSOrganizationsNotInUseException`: "Your account is not a member of an
|
||||||
|
organization"** `[verified 2026-09-02]`, so there is no management account above
|
||||||
|
this one and the zero is conclusive. Command 8.
|
||||||
|
|
||||||
|
- **The table carries NO RESOURCE-BASED POLICY OF ITS OWN**, and this is now a
|
||||||
|
command rather than an assertion. `aws dynamodb get-resource-policy` returns
|
||||||
|
**`PolicyNotFoundException`** `[verified 2026-09-02]` — exit **254**, the error
|
||||||
|
on stderr being the result. ⚠️ **A DynamoDB resource policy is invisible to
|
||||||
|
`describe-table`**, so no earlier command in this file could have seen one, and
|
||||||
|
`/legal/privacy/` publishes the claim (*"the table carries no policy of its own
|
||||||
|
granting access to anyone"*). It is the identity-policy enumeration's blind
|
||||||
|
spot: every simulation here asks what a **principal** may do, and a resource
|
||||||
|
policy grants from the other side. Found unbacked by `adversarial-reviewer`
|
||||||
|
round 2, on §10's own precedent — the client-backup bucket, where
|
||||||
|
`get-bucket-policy` returning `NoSuchBucketPolicy` was recorded because *"a
|
||||||
|
policy read alone could not have established the second half."* Command 7.
|
||||||
|
|
||||||
|
Every read path therefore terminates at `pouya` or `lars`. ⚠️ **AND THAT IS
|
||||||
|
WHERE THIS FILE WENT WRONG, SO THE CORRECTION SITS AT THE SENTENCE THAT CAUSED
|
||||||
|
IT.** This read *"The count of people is two, and it is now the result of an
|
||||||
|
enumeration rather than of a policy name"* until 2026-09-02, and `/legal/privacy/`
|
||||||
|
published that count. **It is a count of IDENTITIES.** Two credentials is a lower
|
||||||
|
bound on the number of people who can use them, and Pouya's attestation is that
|
||||||
|
*"a handful"* is the true figure (§9 Q63). The enumeration stands exactly as
|
||||||
|
measured; **an enumeration of principals is not a census.**
|
||||||
|
|
||||||
|
**The account root user, recorded because an enumeration that quietly omits it is
|
||||||
|
not an enumeration.** Root is not an IAM user and does not appear in
|
||||||
|
`list-users`, so it cannot be simulated and no policy constrains it — root can
|
||||||
|
always read the table. Two facts bound it: `get-account-summary` reports
|
||||||
|
`AccountAccessKeysPresent: 0`, so **there is no programmatic root credential**,
|
||||||
|
and `AccountMFAEnabled: 1`. Root access therefore requires the root password and
|
||||||
|
its MFA device.
|
||||||
|
|
||||||
|
⚠️ **THE PAGE SAYS NOTHING ABOUT ROOT, AND THIS PASSAGE HAS NOW BEEN THE REASON
|
||||||
|
FOR THAT TWICE ON OPPOSITE GROUNDS.** It first read *"The page does not mention
|
||||||
|
root and should not"*, reasoned from its own last clause — *"who holds the root
|
||||||
|
credentials is not established in this repository."* **Pouya then established it
|
||||||
|
(§9 Q63(c), 2026-09-02): he holds it** `[verified 2026-09-02 — Pouya]`, the page
|
||||||
|
published *"has no programmatic key, and I hold it"*, and the gap that opened
|
||||||
|
immediately was that *held by* is not *held only by* — a reader takes the
|
||||||
|
possessive as sole custody, which nothing measured or attested supports (§9
|
||||||
|
**Q64**). **His second ruling that day deleted the sentence** along with the rest
|
||||||
|
of the mechanics, so **Q64 is closed as MOOT rather than answered and the
|
||||||
|
underlying fact is exactly as unestablished as it was.**
|
||||||
|
|
||||||
|
**The consequence to carry, because it is not "nothing happened":** root custody
|
||||||
|
is now recorded in `AGENTS.md` §7 and nowhere public. ⚠️ **Nothing about it may
|
||||||
|
be published without asking him again**, and the question to ask is not *who
|
||||||
|
holds root* — that is answered — but *whether anyone else does*. The original
|
||||||
|
reasoning still holds and is why the page's first sentence is scoped as it is: an
|
||||||
|
account owner's own credential is inherent to every cloud account and is not a
|
||||||
|
third party who has been *granted* access, which is why the measured claim was
|
||||||
|
always scoped to *"every user and every role"* rather than to a bare "nobody else
|
||||||
|
can".
|
||||||
|
|
||||||
|
**Two claims are supported that were not before. One of them still ships; the
|
||||||
|
other was deleted from the page by ruling on 2026-09-02 and is kept here because
|
||||||
|
it remains true and remains §7's.**
|
||||||
|
|
||||||
|
- **SHIPS** — *"The system that receives what you send can only add a record — it
|
||||||
|
cannot read back what is stored"* — `adr-intake-lambda-role` returns `allowed`
|
||||||
|
for `PutItem` and `implicitDeny` for `GetItem`, `Query`, `Scan`,
|
||||||
|
`BatchGetItem`, `UpdateItem` and `DeleteItem`. Previously this rested on
|
||||||
|
reading the policy document; it is now the simulator's decision.
|
||||||
|
- **NO LONGER ON THE PAGE** — *"the credential that publishes this website has no
|
||||||
|
access to the table at all"* — `adr-sml-deploy` is `implicitDeny` on all
|
||||||
|
**seven**, so "at all" covers writes and deletes as well as reads. It went with
|
||||||
|
the mechanics cut, not because anything about it changed.
|
||||||
|
|
||||||
|
**And it corroborates §10 from the IAM surface.** Of the 26 non-service-linked
|
||||||
|
roles, **9 belong to CDK bootstrap** and **14 to four unrelated production
|
||||||
|
systems** in the same account. *(`/legal/privacy/` tells a reader this in as many words —
|
||||||
|
*"The table sits in an Amazon Web Services account that also runs systems
|
||||||
|
unrelated to this practice"* — in **§Where it is stored**, which is where the
|
||||||
|
sentence now lives: the mechanics cut removed it and it was restored there, as a
|
||||||
|
storage disclosure rather than a method. §10 is unaffected either way; it never
|
||||||
|
depended on the page saying so.)* *(Their role names were listed here until 2026-09-02 and
|
||||||
|
are not any more: this is a committed file, they are another project's IAM
|
||||||
|
surface, and the count carries the whole of the argument. `adversarial-reviewer`,
|
||||||
|
round 2.)*
|
||||||
|
|
||||||
|
⚠️ **THE INSTRUMENT FAILED FIRST, UNIFORMLY, AND IN THE DIRECTION THAT READS AS
|
||||||
|
CLEAN.** The role sweep was first run as `--action-names $ACTS` with the seven
|
||||||
|
actions in a shell variable. **zsh does not word-split parameter expansions**, so
|
||||||
|
`simulate-principal-policy` received **one** action name — the whole string — and
|
||||||
|
answered it: `implicitDeny` for 22 roles, and `allowed` for the four with a `*`
|
||||||
|
grant, because `*` matches a bogus action too. Twenty-two clean rows and a
|
||||||
|
plausible four. The tell was the shape of the output, not the verdict: one
|
||||||
|
decision per role where there should have been seven. **The fix is the assertion,
|
||||||
|
not the memory** — the loop now counts `EvaluationResults` per call and refuses a
|
||||||
|
row that does not carry exactly seven, and the users' assume check counts
|
||||||
|
`ResourceSpecificResults` and refuses a row that does not carry exactly two.
|
||||||
|
`CLAUDE.md` records this class five times over; this is the sixth, and it is the
|
||||||
|
"uniformly good" half.
|
||||||
|
|
||||||
|
### Commands — the ones this addendum rests on
|
||||||
|
|
||||||
|
Read-only, run as `user/pouya` in `ca-central-1`. No stderr suppressed, exit
|
||||||
|
status read on every call, and note the **literal** action lists: they are not in
|
||||||
|
a variable, which is the whole point above.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Inline policies — the command the original block never ran.
|
||||||
|
# ⚠️ THE `grep -v` IS THE ORIGINAL DEFECT AND IS KEPT ONLY TO SHOW IT. It
|
||||||
|
# excluded the 7 service-linked roles from a claim written as "every role", and a
|
||||||
|
# service-linked role for a backup or migration service CAN read table contents.
|
||||||
|
# For a full run, DELETE the grep -v — all 33 must be screened, which is what the
|
||||||
|
# 2026-09-02 addendum measured and what the page's "every user and every role"
|
||||||
|
# now rests on.
|
||||||
|
aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||||
|
| tr '\t' '\n' \
|
||||||
|
| while IFS= read -r R; do
|
||||||
|
aws iam list-role-policies --role-name "$R" --query 'PolicyNames' --output text
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Every non-service-linked role, seven actions, decision asserted per row.
|
||||||
|
# The count check is what makes a broken call loud instead of clean.
|
||||||
|
aws iam simulate-principal-policy \
|
||||||
|
--policy-source-arn "arn:aws:iam::327082975128:role/<ROLE>" \
|
||||||
|
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||||
|
dynamodb:BatchGetItem dynamodb:PutItem dynamodb:UpdateItem \
|
||||||
|
dynamodb:DeleteItem \
|
||||||
|
--resource-arns "arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions" \
|
||||||
|
--query 'length(EvaluationResults)' --output text # must print 7
|
||||||
|
|
||||||
|
# 3. Trust policies of the four roles that can read.
|
||||||
|
aws iam get-role --role-name <ROLE> --query 'Role.AssumeRolePolicyDocument'
|
||||||
|
|
||||||
|
# 4. Who can assume the two lookup roles — per RESOURCE, not per action.
|
||||||
|
aws iam simulate-principal-policy \
|
||||||
|
--policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
|
||||||
|
--action-names sts:AssumeRole \
|
||||||
|
--resource-arns "arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-ca-central-1" \
|
||||||
|
"arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-us-east-1" \
|
||||||
|
--query 'EvaluationResults[].ResourceSpecificResults[].{R:EvalResourceName,D:EvalResourceDecision}' \
|
||||||
|
--output text # must print 2 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. The CloudFormation / CDK escalation path, per user. Six actions, literal.
|
||||||
|
aws iam simulate-principal-policy \
|
||||||
|
--policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
|
||||||
|
--action-names cloudformation:CreateStack cloudformation:UpdateStack \
|
||||||
|
cloudformation:CreateChangeSet cloudformation:ExecuteChangeSet \
|
||||||
|
iam:PassRole sts:AssumeRole \
|
||||||
|
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||||
|
|
||||||
|
# 6. Federated identity surfaces, and root.
|
||||||
|
aws iam list-saml-providers --query 'length(SAMLProviderList)'
|
||||||
|
aws iam list-open-id-connect-providers --query 'length(OpenIDConnectProviderList)'
|
||||||
|
aws sso-admin list-instances --query 'length(Instances)'
|
||||||
|
aws iam get-account-summary \
|
||||||
|
--query 'SummaryMap.{AccessKeysPresentRoot:AccountAccessKeysPresent,MFA:AccountMFAEnabled}'
|
||||||
|
|
||||||
|
# 7. The table's OWN policy. ⚠️ NOT VISIBLE IN describe-table — a DynamoDB
|
||||||
|
# resource policy needs its own call, and the page publishes a claim about it.
|
||||||
|
# A "no policy" answer arrives as a NON-ZERO EXIT with PolicyNotFoundException
|
||||||
|
# on stderr, so do not suppress stderr and do not read exit 0 as the result.
|
||||||
|
aws dynamodb get-resource-policy \
|
||||||
|
--resource-arn 'arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions'
|
||||||
|
|
||||||
|
# 8. Is the account in an AWS Organization? ⚠️ THIS IS WHAT MAKES COMMAND 6's
|
||||||
|
# sso-admin ZERO CONCLUSIVE. `list-instances` answers about THIS account, so a
|
||||||
|
# member account returns 0 while Identity Center runs in the management
|
||||||
|
# account. Not-in-an-org means there is no such management account.
|
||||||
|
aws organizations describe-organization
|
||||||
|
```
|
||||||
|
|
||||||
|
**Five user rows and THIRTY-THREE role rows, or the run did not happen.** ⚠️
|
||||||
|
**This said "twenty-six" until 2026-09-02, which made an INCOMPLETE sweep pass
|
||||||
|
its own acceptance test** — the number matched command 1's `grep -v
|
||||||
|
'^AWSServiceRole'`, so an operator following §12 R21's instruction to re-run this
|
||||||
|
file would have reproduced the exclusion and got the pass line for it. The page's
|
||||||
|
*"every user and every role"* rests on 33 of 33. `adversarial-reviewer`, round 2.
|
||||||
|
And for every simulation: **seven decisions per role call, six per CDK-path call,
|
||||||
|
two per-resource decisions per assume call** — the counts are the assertion,
|
||||||
|
because a call that silently received one bogus action name answers
|
||||||
|
`implicitDeny` and reads exactly like a clean row. **Commands 7 and 8 are read by
|
||||||
|
their ERROR, not their output**: `PolicyNotFoundException` and
|
||||||
|
`AWSOrganizationsNotInUseException` are each the clean result, arriving on stderr
|
||||||
|
with a non-zero exit.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Reference — how the Licence Appeal Tribunal actually runs its pre-hearing step
|
||||||
|
|
||||||
|
**Why this file exists.** `AGENTS.md` Q41(c) asked what `LAT pre-hearing
|
||||||
|
mediation` means as an offering. Pouya's ruling of 2026-08-27: *"'LAT pre-hearing
|
||||||
|
mediation' is imprecise and must not imply appointment by the tribunal. Verify
|
||||||
|
against LAT's own materials how its case-conference process is conducted and who
|
||||||
|
conducts it."* This is that verification, committed rather than cited, under
|
||||||
|
`CLAUDE.md`'s rule that anything a spec makes a claim about must be reachable
|
||||||
|
from the repository (R14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance — read this before quoting anything below
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Source 1 | `https://tribunalsontario.ca/documents/lat/LAT-Rules.html` — *Licence Appeal Tribunal, Animal Care Review Board and Fire Safety Commission Rules of Practice and Procedure*, effective July 21, 2023 |
|
||||||
|
| Source 2 | `https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/` — LAT‑AABS, *Application and hearing process* |
|
||||||
|
| Retrieved | **2026-08-28** |
|
||||||
|
| Method | `curl -sS -o <file> '<url>'` — HTTP **200** both; **88,429 B** (rules) and **107,996 B** (AABS page) |
|
||||||
|
| Text extraction | script/style stripped, tags stripped, entities unescaped, whitespace collapsed → **66,593** and **33,696** characters |
|
||||||
|
|
||||||
|
> ⚠️ **NO HASHES, AND THE REASON IS THE POINT.** This table carried `sha256`
|
||||||
|
> prefixes `07d9c077e41cc8bd` and `08eff9a73b683cc5`. They are removed because
|
||||||
|
> **they cannot be reproduced, and a stamp that cannot be re-checked is worse
|
||||||
|
> than no stamp** — a future reader who re-fetches and gets a different digest
|
||||||
|
> would conclude the source had changed when it had not.
|
||||||
|
>
|
||||||
|
> Found by `adversarial-reviewer`, which re-fetched both URLs and got two
|
||||||
|
> different digests, then two more on two further fetches. Cause isolated by
|
||||||
|
> diffing consecutive responses: `LAT-Rules.html` carries a per-request
|
||||||
|
> bot-detection nonce (`__uzdbm_1`, `__uzdbm_2`), and the AABS page carries
|
||||||
|
> rotating WordPress `?ver=` cache-busters. **The sha256 of these URLs is not a
|
||||||
|
> stable quantity.**
|
||||||
|
>
|
||||||
|
> What *does* reproduce, and was independently reproduced: **both byte counts
|
||||||
|
> exactly**, **all ten verbatim quotes** with their rule numbers and headings, and
|
||||||
|
> **all four term counts**. So the substance of this file is verified twice over;
|
||||||
|
> only the hashes were spurious. Same family as the `1.23:1` bounding box and the
|
||||||
|
> `timeout 60 ls` in `CLAUDE.md` — a number that looks like verification, from a
|
||||||
|
> probe nobody validated.
|
||||||
|
|
||||||
|
**Instrument check, because `CLAUDE.md` requires one.** The word counts below were
|
||||||
|
taken from the **raw fetched bytes**, not from a `WebFetch` answer. `WebFetch`
|
||||||
|
answers through a summarising model, so a "quote" it returns may be a paraphrase
|
||||||
|
— and the first pass here did return a plausible-looking Rule 14.4 quote
|
||||||
|
(*"The case conference is an important opportunity to discuss settlement"*) that
|
||||||
|
turned out to be **correct**, and a Rule 14.6 gloss that was **not** how the rule
|
||||||
|
reads. Both were then checked against the literal text. Quotes in this file are
|
||||||
|
literal; where the two disagreed the literal text won.
|
||||||
|
|
||||||
|
A second instrument note: the rules document repeats every heading in a table of
|
||||||
|
contents before the body, so a naive "find the heading" extraction returns the
|
||||||
|
**TOC** and reports the rules as empty. The bodies are present, ~35 KB further in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finding 1 — the LAT's settlement step is a *case conference*, and a Tribunal Member conducts it
|
||||||
|
|
||||||
|
**Rule 2.4, verbatim:**
|
||||||
|
|
||||||
|
> "Case Conference" has the same meaning as "Pre-Hearing Conference" as defined
|
||||||
|
> in the SPPA.
|
||||||
|
|
||||||
|
So **"pre-hearing" is the LAT's own term, and what it names is a case
|
||||||
|
conference** — not a mediation.
|
||||||
|
|
||||||
|
**Rule 14.2 — Scope of case conference subject matter, verbatim opening:**
|
||||||
|
|
||||||
|
> The Tribunal may on its own initiative, or in response to a party's written
|
||||||
|
> request, direct the parties to participate in a case conference to consider:
|
||||||
|
> The settlement of any or all of the issues; […]
|
||||||
|
|
||||||
|
**Rule 14.3 — Member not to participate on a hearing panel, verbatim:**
|
||||||
|
|
||||||
|
> A Member who presides at or otherwise takes part in a case conference shall not
|
||||||
|
> participate as a Member of a panel at a subsequent hearing of the appeal except
|
||||||
|
> with the consent of the parties.
|
||||||
|
|
||||||
|
**Rule 14.4 — Settlement discussions, verbatim:**
|
||||||
|
|
||||||
|
> The case conference is an important opportunity to discuss settlement of the
|
||||||
|
> issues without the need for a hearing. The parties are expected to come to the
|
||||||
|
> case conference prepared to discuss settlement.
|
||||||
|
>
|
||||||
|
> All settlement discussions in a case conference and the documents put forward
|
||||||
|
> solely for the purpose of settlement are confidential. Settlement discussions
|
||||||
|
> are held on a "without prejudice" basis. Settlement discussions shall not be
|
||||||
|
> communicated to the Member that participates in the hearing or otherwise be
|
||||||
|
> relied on in a hearing before the Tribunal for any purpose unless the parties
|
||||||
|
> consent.
|
||||||
|
|
||||||
|
**Rule 14.6 — Party attendance, verbatim first sentence:**
|
||||||
|
|
||||||
|
> A party as defined under Rule 2.16 must attend their case conference.
|
||||||
|
|
||||||
|
**Rule 12 — Format, verbatim:**
|
||||||
|
|
||||||
|
> In accordance with applicable provisions of the SPPA, the Tribunal may hold a
|
||||||
|
> hearing or case conference in any of the following formats, as it considers
|
||||||
|
> appropriate: In-person; Electronic; Written; or Any combination of the above.
|
||||||
|
|
||||||
|
The public LAT‑AABS page adds, of the same step: *"A case conference is led by an
|
||||||
|
adjudicator whose role is to guide and support the parties in working to resolve
|
||||||
|
the dispute."*
|
||||||
|
|
||||||
|
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
|
||||||
|
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
|
||||||
|
mandatory, and the Member does not sit on the subsequent hearing panel **except
|
||||||
|
with the consent of the parties** (Rule 14.3, quoted verbatim above). ⚠️ **This
|
||||||
|
line read "the Member is disqualified from the subsequent hearing panel" until
|
||||||
|
2026-09-01** — an absolute, thirty lines below the quotation that qualifies it,
|
||||||
|
in this repository's own voice rather than the Tribunal's. `/practice/insurance/`
|
||||||
|
took the absolute from here and published it. Corrected in both places on the
|
||||||
|
same day; the page was corrected first and this file is where the defect would
|
||||||
|
otherwise have re-seeded, which is `CLAUDE.md`'s point about commentary around a
|
||||||
|
quotation being this repository speaking. A
|
||||||
|
privately retained neutral is not appointed to it and cannot be.
|
||||||
|
|
||||||
|
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
|
||||||
|
|
||||||
|
Counted on the literal extracted text, case-sensitively for both cases:
|
||||||
|
|
||||||
|
```
|
||||||
|
lat-rules.html 66,593 chars 'mediat' 0 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
|
||||||
|
lat-aabs.html 33,696 chars 'mediat' 1 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zero** in the Rules. There is no rule providing for the Tribunal to appoint an
|
||||||
|
external mediator, and no rule about a party retaining a private neutral —
|
||||||
|
because the Rules do not contemplate the concept at all.
|
||||||
|
|
||||||
|
## Finding 3 — the single match, read rather than counted
|
||||||
|
|
||||||
|
`CLAUDE.md`: *a grep that matches is not a finding until you read what it
|
||||||
|
matched.* The one `mediat` on the AABS page, printed with its heading, is this —
|
||||||
|
and it is the affirmative basis for the offering rather than a problem for it:
|
||||||
|
|
||||||
|
> **4. Consider other ways to resolve your dispute**
|
||||||
|
>
|
||||||
|
> Before you apply to the LAT‑AABS, you may want to consider negotiation or
|
||||||
|
> mediation services. Parties are encouraged to attempt to negotiate the claim
|
||||||
|
> at all times, including before filing at the LAT‑AABS, and continuing
|
||||||
|
> negotiation discussions after a claim has been filed.
|
||||||
|
|
||||||
|
> ⚠️ **CORRECTED 2026-08-29. THE GLOSS THAT STOOD HERE WAS WRONG, AND IT WAS
|
||||||
|
> WRONG ABOUT THE QUOTATION THREE LINES ABOVE IT.** It read: *"The Tribunal
|
||||||
|
> itself points parties at private mediation, **before filing and continuing
|
||||||
|
> after filing.** That is exactly the space a privately retained mediator
|
||||||
|
> occupies, and it is the Tribunal's own words for it."*
|
||||||
|
>
|
||||||
|
> **Read the passage again.** Sentence 1 names **mediation**, and names it for
|
||||||
|
> one moment only — *"Before you apply."* Sentence 2 is about **negotiation**:
|
||||||
|
> *"Parties are encouraged to attempt to **negotiate** the claim at all times,
|
||||||
|
> including before filing at the LAT-AABS, and continuing **negotiation**
|
||||||
|
> discussions after a claim has been filed."* The word appears twice. **The
|
||||||
|
> "continuing after filing" frame belongs to negotiation, and the gloss carried
|
||||||
|
> it across onto mediation.** The word **"private"** is not the Tribunal's
|
||||||
|
> either — the sentence names no provider and draws no public/private
|
||||||
|
> distinction.
|
||||||
|
>
|
||||||
|
> **What the passage does support:** the Tribunal names mediation as something a
|
||||||
|
> party may consider **before applying**, and encourages negotiated settlement
|
||||||
|
> at every stage. That is an affirmative basis for the offering. It is not a
|
||||||
|
> Tribunal statement about mediating a claim that is already filed.
|
||||||
|
>
|
||||||
|
> **How this got past three checks.** The quotation was fetched, pasted
|
||||||
|
> verbatim, and is correct. The overreach is in the sentence *underneath* it —
|
||||||
|
> and that sentence, not the quote, is what propagated into `docs/01`, into
|
||||||
|
> `src/data/site.ts`, and from there into published copy on three pages. Two
|
||||||
|
> review rounds and a claims audit read this file and did not catch it, because
|
||||||
|
> the quote sat right there and appeared to say it. Caught 2026-08-29 by an
|
||||||
|
> independent re-fetch that read the sentence structure rather than the gloss.
|
||||||
|
> **Same shape as Q39's struck universal: the source was fine and the sentence
|
||||||
|
> drawn from it was wider than the source.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes, and what it does not
|
||||||
|
|
||||||
|
**Establishes:**
|
||||||
|
|
||||||
|
1. The LAT's pre-hearing settlement step is a **case conference conducted by a
|
||||||
|
Tribunal Member**. `LAT pre-hearing mediation` therefore describes a thing
|
||||||
|
that does not exist, and the half a reader would recognise — *pre-hearing* —
|
||||||
|
is the Tribunal's own label for a step nobody outside the Tribunal conducts.
|
||||||
|
2. The Tribunal names **mediation** as an option a party may consider **before
|
||||||
|
applying**, and encourages negotiated settlement at all stages including
|
||||||
|
after filing. *(Corrected 2026-08-29. This item read: "Private mediation of
|
||||||
|
accident-benefits and SABS disputes is **compatible with a LAT application,
|
||||||
|
before filing or after**, and the Tribunal says so." The last five words were
|
||||||
|
false — see the correction box above. Whether a filed claim can also be
|
||||||
|
privately mediated is not something this source addresses either way, and
|
||||||
|
nothing here should be cited for it.)*
|
||||||
|
|
||||||
|
**Does not establish:**
|
||||||
|
|
||||||
|
- Anything about whether Pouya holds a roster position with the LAT or Tribunals
|
||||||
|
Ontario. Nothing here bears on that. §4 has no such row, so the site claims
|
||||||
|
none — per Pouya's ruling: *"If Pouya holds a roster position that makes more
|
||||||
|
than that true, it is a §4 addition — absent a row, it isn't."*
|
||||||
|
- Anything about *commercial* arbitration gating. Same caution as
|
||||||
|
`ontario-family-arbitration-training.md`: a source about one process is not
|
||||||
|
authority about another. These documents do not mention arbitration at all.
|
||||||
|
|
||||||
|
## The wording that follows from it
|
||||||
|
|
||||||
|
**Never publish** `LAT pre-hearing mediation`, or any phrasing in which a LAT
|
||||||
|
proceeding appears to appoint or host the mediator.
|
||||||
|
|
||||||
|
**Published instead** — `src/data/site.ts`, `PRACTICE_AREAS` → `insurance`:
|
||||||
|
|
||||||
|
> Accident benefits and SABS entitlement, MIG disputes, and private mediation
|
||||||
|
> retained by the parties, not the Tribunal's case conference.
|
||||||
|
|
||||||
|
*(Corrected 2026-08-29. The blurb read "…and private mediation alongside a LAT
|
||||||
|
application, before filing or after", and the "before filing or after" half
|
||||||
|
rested entirely on the gloss corrected above. The replacement carries the
|
||||||
|
distinction `docs/01` actually requires — **private, retained by the parties,
|
||||||
|
not the Tribunal's case conference** — which is the high-value half and is fully
|
||||||
|
supported. The Tribunal's own sentence about mediation before applying is quoted
|
||||||
|
on the page rather than compressed into a card.)*
|
||||||
|
|
||||||
|
`docs/01` keeps `LAT pre-hearing mediation` as a **search intent** — people do
|
||||||
|
type it — with a note that it must never be lifted into copy. That lift is
|
||||||
|
exactly what happened once already.
|
||||||
|
|
||||||
|
`/practice/insurance/` at build step 5 must state that the mediation offered is
|
||||||
|
**private**, retained by the parties, and **not the Tribunal's case conference**.
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# Ontario Construction Act — adjudication, prompt payment, liens; and the two nuclear programmes named in docs/01
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.** Fetched from the primary sources listed below and
|
||||||
|
extracted with quotations pasted verbatim. This file is the artefact; the pages
|
||||||
|
cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a regulation and a tribunal page all move.** Every consolidation
|
||||||
|
> date and effective date below is as at the retrieval date and nothing more.
|
||||||
|
> Re-check before cutover, and before any republish that turns on one of them.
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
|
||||||
|
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
|
||||||
|
> Five of seven extracts were stamped and two were not, so a reader could
|
||||||
|
> not tell whether this one was considered and found non-volatile or simply
|
||||||
|
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
|
||||||
|
> control-fires-over-part-of-its-scope defect as R18 having no checklist
|
||||||
|
> item, one notch smaller. **Nothing here was re-retrieved.**
|
||||||
|
>
|
||||||
|
> - **No R18 limb, but ONE CANDIDATE LIMB, and it is flagged rather than
|
||||||
|
> silently adopted.** `/practice/construction/` publishes: *"Ontario Power
|
||||||
|
> Generation … applied in March 2026 for a licence to operate it."* That is a
|
||||||
|
> **pending application**, so it moves the way limb (a) moves. It is not
|
||||||
|
> false today — the application was made, and a completed past act stays
|
||||||
|
> true — which is why this is a note and not a blocker. But a reader takes it
|
||||||
|
> as current status, and unlike limb (a) the sentence is **not time-anchored**
|
||||||
|
> ("when this page was written"). **For Pouya at the next re-check: adopt it
|
||||||
|
> as R18 limb (h), or time-anchor the sentence and drop it.**
|
||||||
|
> - The rest is stable: Part II.1 in force since 2019-10-01, and ODACC's own
|
||||||
|
> statement that it is the Authorized Nominating Authority. The ODACC 2025
|
||||||
|
> Annual Report is cited for institutional facts rather than for figures, so
|
||||||
|
> it does not carry limb (d)'s exposure `[verified 2026-09-02 — swept
|
||||||
|
> `dist/practice/construction/index.html`]`.
|
||||||
|
|
||||||
|
**Topic as researched:** Ontario construction dispute resolution — the statutory machinery (Construction Act, R.S.O. 1990, c. C.30); plus verification of the OPG Darlington New Nuclear Project and Bruce Power's Bruce C Project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| statute | Construction Act, R.S.O. 1990, c. C.30 — Ontario e-Laws (current consolidation). NOTE ON RETRIEVAL: the e-Laws page is a JavaScript single-page app; a plain fetch returns an empty shell. The statute text quoted here was retrieved from the JSON API that backs that page: https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30 (HTTP 200, 374,864 bytes). | <https://www.ontario.ca/laws/statute/90c30> |
|
||||||
|
| statute | Ontario e-Laws — version list for R.S.O. 1990, c. C.30 (JSON API backing the 'Versions' tab of https://www.ontario.ca/laws/statute/90c30). Shows the title carried by each historical consolidation of the same chapter. | <https://www.ontario.ca/laws/api/v2/legislation/en/act-versions/statute/90c30> |
|
||||||
|
| statute | Construction Lien Act, R.S.O. 1990, c. C.30 — historical e-Laws consolidation, version 8 (period 14 December 2017 to 30 June 2018). Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30/v8 | <https://www.ontario.ca/laws/statute/90c30/v8> |
|
||||||
|
| statute | Construction Act, R.S.O. 1990, c. C.30 — historical e-Laws consolidation, version 9 (period beginning 1 July 2018). Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30/v9 | <https://www.ontario.ca/laws/statute/90c30/v9> |
|
||||||
|
| statute | Construction Lien Amendment Act, 2017, S.O. 2017, c. 24 - Bill 142 — Ontario e-Laws. Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/S17024 | <https://www.ontario.ca/laws/statute/S17024> |
|
||||||
|
| regulation | O. Reg. 264/25 — ADJUDICATIONS UNDER PART II.1 OF THE ACT (made under the Construction Act). Ontario e-Laws; consolidation period from January 1, 2026; 'No amendments.' Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/regulation/250264 | <https://www.ontario.ca/laws/regulation/250264> |
|
||||||
|
| institution | Ontario Dispute Adjudication for Construction Contracts (ODACC) — home page | <https://odacc.ca/en/> |
|
||||||
|
| institution | ODACC — About Us | <https://odacc.ca/en/aboutus/> |
|
||||||
|
| institution | ODACC — Adjudication Process | <https://odacc.ca/en/adjudication-process/> |
|
||||||
|
| institution | ODACC 2025 Annual Report (PDF, 37 pages) — the annual report ODACC is required to publish as Authorized Nominating Authority | <https://odacc.ca/wp-content/uploads/2021/07/2025-ODACC-Annual-Report-Final.pdf> |
|
||||||
|
| proponent | Small modular reactors / Darlington SMR – Ontario Power Generation (proponent's own page). NOTE ON RETRIEVAL: opg.com returned HTTP 403 (Cloudflare bot block) to both direct curl and WebFetch on 2026-08-29; the page text quoted here was obtained through the r.jina.ai HTML-to-text reader proxy (https://r.jina.ai/https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/, HTTP 200). Key facts are independently corroborated by the CNSC page listed below. A human should re-read the OPG page in a browser before any of this wording is published. | <https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/> |
|
||||||
|
| regulator | Darlington New Nuclear Project — Canadian Nuclear Safety Commission (federal nuclear regulator) | <https://www.cnsc-ccsn.gc.ca/eng/reactors/new-reactor-power-plant-projects/new-reactor-power-plant-facilities/darlington-new-nuclear-project/> |
|
||||||
|
| proponent | The Bruce C Project — Bruce Power (proponent's own page) | <https://www.brucepower.com/the-bruce-c-project/> |
|
||||||
|
| proponent | Bruce C Project — Engage Bruce Power (Bruce Power's own engagement platform). NOTE: WebFetch returned 403; retrieved with curl sending a browser User-Agent (HTTP 200, 62,728 bytes). | <https://engage.brucepower.com/brucec> |
|
||||||
|
| proponent | Planning phase of Integrated Impact Assessment completed for Bruce Power's potential Bruce C project — Bruce Power newsroom, dated 2025-08-21 (proponent's own release) | <https://www.brucepower.com/2025/08/21/planning-phase-of-integrated-impact-assessment-completed-for-bruce-powers-potential-bruce-c-project/> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### Construction Act, R.S.O. 1990, c. C.30 — Ontario e-Laws (current consolidation). NOTE ON RETRIEVAL: the e-Laws page is a JavaScript single-page app; a plain fetch returns an empty shell. The statute text quoted here was retrieved from the JSON API that backs that page: https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30 (HTTP 200, 374,864 bytes).
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90c30> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Construction Act, R.S.O. 1990, c. C.30
|
||||||
|
|
||||||
|
> R.S.O. 1990, Chapter C.30
|
||||||
|
|
||||||
|
> Consolidation Period: From January 1, 2026 to the e-Laws currency date.
|
||||||
|
|
||||||
|
> Last amendment: 2025, c. 14, Sched. 2.
|
||||||
|
|
||||||
|
> PART I.1 PROMPT PAYMENT
|
||||||
|
|
||||||
|
> PART II.1 CONSTRUCTION DISPUTE INTERIM ADJUDICATION
|
||||||
|
|
||||||
|
> PART V EXPIRY, PRESERVATION AND PERFECTION OF LIENS
|
||||||
|
|
||||||
|
> Expiry of liens 31 (1) Unless preserved under section 34, the liens arising from the supply of services or materials to an improvement expire as provided in this section. R.S.O. 1990, c. C.30, s. 31 (1); 2017, c. 24, s. 67.
|
||||||
|
|
||||||
|
> Contractor’s liens (2) Subject to subsection (4), the lien of a contractor, (a) for services or materials supplied to an improvement on or before the date certified or declared to be the date of the substantial performance of the contract, expires at the conclusion of the 60-day period next following the occurrence of the earlier of, (i) the date on which a copy of the certificate or declaration of the substantial performance of the contract is published as provided in section 32, and (ii) the date the contract is completed, abandoned or terminated; and (b) for services or materials supplied to the improvement where there is no certification or declaration of the substantial performance of the contract, or for services or materials supplied to the improvement after the date certified or declared to be the date of substantial performance, expires at the conclusion of the 60-day period next following the occurrence of the earlier of, (i) the date the contract is completed, and (ii) the date the contract is abandoned or terminated. R.S.O. 1990, c. C.30, s. 31 (2); 2017, c. 24, s. 26 (1-5), 66.
|
||||||
|
|
||||||
|
> Liens of other persons (3) Subject to subsection (4), the lien of any other person, (a) for services or materials supplied to an improvement on or before the date certified or declared to be the date of the substantial performance of the contract, expires at the conclusion of the 60-day period next following the occurrence of the earliest of, (i) the date on which a copy of the certificate or declaration of the substantial performance of the contract is published, as provided in section 32, (ii) the date on which the person last supplies services or materials to the improvement, (ii.1) the date the contract is completed, abandoned or terminated, and (iii) the date a subcontract is certified to be completed under section 33, where the services or materials were supplied under or in respect of that subcontract
|
||||||
|
|
||||||
|
> Notice of termination (6) No later than seven days after a contract is terminated, either the owner or the contractor or other person whose lien is subject to expiry shall publish a notice of the termination in the prescribed form and manner. 2025, c. 14, Sched. 2, s. 6.
|
||||||
|
|
||||||
|
> 34 (1) A lien may be preserved during the supplying of services or materials or at any time before it expires, (a) where the lien attaches to the premises, by the registration in the proper land registry office of a claim for lien on the title of the premises in accordance with this Part; and (b) where the lien does not attach to the premises, by giving to the owner a copy of the claim for lien. R.S.O. 1990, c. C.30, s. 34 (1); 2010, c. 16, Sched. 2, s. 2 (5); 2017, c. 24, s. 63, 64, 68, 70, 71.
|
||||||
|
|
||||||
|
> What liens may be perfected 36 (1) A lien may not be perfected unless it is preserved. R.S.O. 1990, c. C.30, s. 36 (1).
|
||||||
|
|
||||||
|
> Expiry of preserved lien (2) A lien that has been preserved expires unless it is perfected prior to the end of the 90-day period next following the last day, under section 31, on which the lien could have been preserved. R.S.O. 1990, c. C.30, s. 36 (2); 2017, c. 24, s. 31 (1).
|
||||||
|
|
||||||
|
> How lien perfected (3) A lien claimant perfects the lien claimant’s preserved lien, (a) where the lien attaches to the premises, when the lien claimant commences an action to enforce the lien and, except where an order to vacate the registration of the lien is made, the lien claimant registers a certificate of action in the prescribed form on the title of the premises; or (b) where the lien does not attach to the premises, when the lien claimant commences an action to enforce the lien.
|
||||||
|
|
||||||
|
> Expiry of perfected lien 37 (1) A perfected lien expires immediately after the second anniversary of the commencement of the action that perfected the lien, unless one of the following occurs on or before that anniversary: 1. An order is made for the trial of an action in which the lien may be enforced. 2. An action in which the lien may be enforced is set down for trial. 1994, c. 27, s. 42 (1).
|
||||||
|
|
||||||
|
> Giving of proper invoices 6.3 (1) Proper invoices shall be given to an owner on a monthly basis, unless the contract provides otherwise. 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> Payment deadline, owner to contractor 6.4 (1) Subject to the giving of a notice of non-payment under subsection (2), an owner shall pay the amount payable under a proper invoice no later than 28 days after receiving the proper invoice from the contractor. 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> Exception, notice of non-payment if dispute (2) An owner who disputes a proper invoice may refuse to pay all or any portion of the amount payable under the proper invoice within the time specified in subsection (1) if, no later than 14 days after receiving the proper invoice from the contractor, the owner gives to the contractor a notice of non-payment, in the prescribed form and manner, specifying the amount of the proper invoice that is not being paid and detailing all of the reasons for non-payment. 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> Payment deadlines, contractor to subcontractor Full payment 6.5 (1) Subject to the giving of a notice of non-payment under subsection (6), a contractor who receives full payment of a proper invoice within the time specified in subsection 6.4 (1) shall, no later than seven days after receiving payment, pay each subcontractor who supplied services or materials under a subcontract with the contractor that were included in the proper invoice the amount payable to the subcontractor. 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> Non or partial payment, unpaid amount (4) Subject to the giving of a notice of non-payment under subsection (5) or (6), if the owner does not pay some or all of a proper invoice within the time specified in subsection 6.4 (1), the contractor shall, no later than 35 days after giving the proper invoice to the owner, pay each subcontractor who supplied services or materials under a subcontract with the contractor that were included in the proper invoice the amount payable to the subcontractor, to the extent that he or she was not paid fully under subsection (2). 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> (iii) providing an undertaking to refer the matter to adjudication under Part II.1 no later than 21 days after giving the notice to the subcontractor
|
||||||
|
|
||||||
|
> 6.6 (1) Subject to the giving of a notice of non-payment under subsection (7), a subcontractor who receives full payment from a contractor in respect of a proper invoice within the time specified in subsection 6.5 (1) shall, no later than seven days after receiving payment, pay each subcontractor who supplied services or materials under a subcontract between them that were included in the proper invoice the amount payable to the subcontractor. 2017, c. 24, s. 7.
|
||||||
|
|
||||||
|
> Section Amendments with date in force (d/m/y) 2017, c. 24, s. 7 - 01/10/2019
|
||||||
|
|
||||||
|
> Definitions 13.1 In this Part, “adjudication” means construction dispute interim adjudication under this Part; (“arbitrage intérimaire”) “adjudicator” means a registry adjudicator or a private adjudicator; (“arbitre intérimaire”) “Authority” means the Authorized Nominating Authority designated under section 13.2; (“Autorité”)
|
||||||
|
|
||||||
|
> Authorized Nominating Authority 13.2 (1) The Minister may designate an entity to act as Authorized Nominating Authority for the purposes of this Part. 2017, c. 24, s. 11 (1); 2025, c. 14, Sched. 2, s. 2.
|
||||||
|
|
||||||
|
> Duties and powers of Authority Duties 13.3 (1) The Authority shall, (a) develop and oversee programs for the training of persons as registry adjudicators and as private adjudicators; (b) qualify persons who meet the prescribed requirements as registry adjudicators and as private adjudicators; (c) establish and maintain a publicly available registry of registry adjudicators; (d) appoint registry adjudicators for the purposes of subsection 13.9 (5); and (e) perform any other duties of the Authority set out in this Part or that may be prescribed for the purposes of this Part.
|
||||||
|
|
||||||
|
> Availability of adjudication Contract 13.5 (1) Subject to subsection (3), a party to a contract may refer a dispute with the other party to the contract respecting any prescribed matter or any matter agreed to by the parties to adjudication. 2024, c. 20, Sched. 4, s. 12 (1).
|
||||||
|
|
||||||
|
> Subcontract (2) Subject to subsection (3.1), a party to a subcontract may refer a dispute with the other party to the subcontract respecting any prescribed matter or any matter agreed to by the parties to adjudication. 2024, c. 20, Sched. 4, s. 12 (1).
|
||||||
|
|
||||||
|
> Expiry of adjudication period, contract (3) An adjudication in respect of a contract may not be commenced if the notice of adjudication is given more than 90 days after the date on which the contract is completed, abandoned or terminated, unless the parties to the adjudication agree otherwise. 2024, c. 20, Sched. 4, s. 12 (1).
|
||||||
|
|
||||||
|
> Multiple disputes only on consent (4) An adjudication may only address a single dispute, unless the parties to the adjudication and the adjudicator agree otherwise.
|
||||||
|
|
||||||
|
> Application despite other proceeding (5) A party may refer a dispute to adjudication under this Part even if the dispute is the subject of a court action or of an arbitration under the Arbitration Act, 1991, unless the action or arbitration has been finally determined. 2017, c. 24, s. 11 (1); 2024, c. 20, Sched. 4, s. 12 (3).
|
||||||
|
|
||||||
|
> 13.11 No later than five days after an adjudicator agrees or is appointed to conduct the adjudication, the party who gave the notice of adjudication shall, (a) provide to the adjudicator a copy of the notice; and (b) provide to the adjudicator and to the other party a copy of the contract or subcontract and any documents the party intends to rely on during the adjudication. 2018, c. 17, Sched. 8, s. 6.
|
||||||
|
|
||||||
|
> Determination 13.13 (1) Subject to subsection (2), an adjudicator shall make a determination of the matter that is the subject of an adjudication no later than 30 days after receiving the documents required by section 13.11. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Extension (2) The deadline for an adjudicator’s determination may be extended, at any time before its expiry and after the provision of documents to the adjudicator under section 13.11, (a) on the adjudicator’s request, with the written consent of the parties to the adjudication, for a period of no more than 14 days; or (b) on the written agreement of the parties to the adjudication, subject to the adjudicator’s consent, for the period specified in the agreement. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Delayed determination (5) A determination made by an adjudicator after the date determined under subsection (1) or (2) is of no force or effect. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Written reasons (6) The adjudicator’s determination shall be in writing and shall include reasons for the determination. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Effect of determination 13.15 (1) The determination of a matter by an adjudicator is binding on the parties to the adjudication until a determination of the matter by a court, a determination of the matter by way of an arbitration conducted under the Arbitration Act, 1991, or a written agreement between the parties respecting the matter. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Authority of court, arbitrator (2) Subject to section 13.18, nothing in this Part restricts the authority of a court or of an arbitrator acting under the Arbitration Act, 1991 to consider the merits of a matter determined by an adjudicator. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> 13.18 (1) An application for judicial review of a determination of an adjudicator may only be made with leave of the Divisional Court in accordance with this section and the rules of court. 2017, c. 24, s. 11 (1).
|
||||||
|
|
||||||
|
> Enforcement of amounts payable (2) A party who is required under the determination of an adjudicator to pay an amount to another person shall pay the amount no later than 15 days after the determination has been communicated to the parties to the adjudication. 2017, c. 24, s. 11 (1); 2024, c. 20, Sched. 4, s. 23 (1).
|
||||||
|
|
||||||
|
> Section Amendments with date in force (d/m/y) 2017, c. 24, s. 11 (1) - 01/10/2019
|
||||||
|
|
||||||
|
> Persons who may be let in (6) The court may allow any person with a perfected lien, (a) who was not served with a notice of trial; or (b) whose action was stayed by reason of an order under the Arbitration Act, 1991, to be let in to prove the claim at any time before the amount realized in the action for the satisfaction of the lien has been distributed
|
||||||
|
|
||||||
|
> Transition, Construction Lien Amendment Act, 2017 87.3 (1) This Act and the regulations, as they read on June 29, 2018, continue to apply with respect to an improvement if,
|
||||||
|
|
||||||
|
### Ontario e-Laws — version list for R.S.O. 1990, c. C.30 (JSON API backing the 'Versions' tab of https://www.ontario.ca/laws/statute/90c30). Shows the title carried by each historical consolidation of the same chapter.
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/api/v2/legislation/en/act-versions/statute/90c30> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> "title": {"en": "Construction Lien Act, R.S.O. 1990, c. C.30"}, "dateFrom": {"en": "2017-12-14T05:00:00.000Z"}, "dateTo": {"en": "2018-06-30T04:00:00.000Z"}, "alias": {"en": "statute/90c30/v8"}
|
||||||
|
|
||||||
|
> "title": {"en": "Construction Act, R.S.O. 1990, c. C.30"}, "dateFrom": {"en": "2018-07-01T04:00:00.000Z"}, "dateTo": {"en": "2018-12-05T05:00:00.000Z"}, "alias": {"en": "statute/90c30/v9"}
|
||||||
|
|
||||||
|
> "title": {"en": "Construction Act, R.S.O. 1990, c. C.30"}, "dateFrom": {"en": "2026-01-01T05:00:00.000Z"}, "state": {"en": "current"}, "alias": {"en": "statute/90c30"}
|
||||||
|
|
||||||
|
### Construction Lien Act, R.S.O. 1990, c. C.30 — historical e-Laws consolidation, version 8 (period 14 December 2017 to 30 June 2018). Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30/v8
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90c30/v8> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> title: Construction Lien Act, R.S.O. 1990, c. C.30
|
||||||
|
|
||||||
|
> shortTitle: Construction Lien Act
|
||||||
|
|
||||||
|
> chapter: R.S.O. 1990, Chapter C.30
|
||||||
|
|
||||||
|
### Construction Act, R.S.O. 1990, c. C.30 — historical e-Laws consolidation, version 9 (period beginning 1 July 2018). Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30/v9
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90c30/v9> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> title: Construction Act, R.S.O. 1990, c. C.30
|
||||||
|
|
||||||
|
> shortTitle: Construction Act
|
||||||
|
|
||||||
|
> chapter: R.S.O. 1990, Chapter C.30
|
||||||
|
|
||||||
|
### Construction Lien Amendment Act, 2017, S.O. 2017, c. 24 - Bill 142 — Ontario e-Laws. Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/S17024
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/S17024> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Construction Lien Amendment Act, 2017, S.O. 2017, c. 24 - Bill 142
|
||||||
|
|
||||||
|
> Assented to December 12, 2017
|
||||||
|
|
||||||
|
> 1 The short title of the Construction Lien Act is repealed and the following substituted: Construction Act
|
||||||
|
|
||||||
|
> Commencement 86 (1) Subject to subsections (2), (3), (4), (5) and (6), this Act comes into force on the day it receives Royal Assent. (2) The following provisions come into force on a day to be named by proclamation of the Lieutenant Governor: 1. Section 1.
|
||||||
|
|
||||||
|
> 75 Subsection 103 (3) of the Courts of Justice Act is amended by striking out “Construction Lien Act” at the end and substituting “Construction Act”.
|
||||||
|
|
||||||
|
> 77 (1) The Schedule to the Limitations Act, 2002 is amended by striking out “Construction Lien Act” in the column titled “Act” and substituting “Construction Act”.
|
||||||
|
|
||||||
|
> 87 The short title of this Act is the Construction Lien Amendment Act, 2017.
|
||||||
|
|
||||||
|
### O. Reg. 264/25 — ADJUDICATIONS UNDER PART II.1 OF THE ACT (made under the Construction Act). Ontario e-Laws; consolidation period from January 1, 2026; 'No amendments.' Retrieved via https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/regulation/250264
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/regulation/250264> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ADJUDICATIONS UNDER PART II.1 OF THE ACT
|
||||||
|
|
||||||
|
> Adjudication Availability 19. (1) The following matters are prescribed for the purposes of subsection 13.5 (1) of the Act: 1. The valuation of services or materials provided under the contract. 2. Payment under the contract, including in respect of a change order, whether approved or not, or a proposed change order. 3. A dispute that is the subject of a notice of non-payment under Part I.1 of the Act. 4. Amounts retained under section 12 of the Act (set-off by trustee) or under subsection 17 (3) of the Act (lien set-off). 5. Payment of a holdback under section 26 of the Act. 6. Any of the following matters, if it is reasonably necessary to resolve a dispute respecting the matter in order to make a determination on any other matter that may be adjudicated: i. The scope of work required to be performed under the contract. ii. A request for a change in the contract price. iii. A request for an extension of time in the completion of work required to be performed under the contract.
|
||||||
|
|
||||||
|
> (2) The matters listed in subsection (1), with necessary modifications, are prescribed for the purposes of subsection 13.5 (2) of the Act.
|
||||||
|
|
||||||
|
> Designation 2. (1) To be eligible to be designated to act as Authorized Nominating Authority, an entity must, (a) submit an application to the Minister in the time and manner specified by the Minister; and (b) agree in writing to abide by any conditions of designation specified by the Minister, including any conditions respecting the term or termination of any such designation.
|
||||||
|
|
||||||
|
> Notice of adjudication, copy to Authority 20. A party to a contract or subcontract who gives a notice of adjudication under subsection 13.7 (1) of the Act shall, on the same day, provide a copy of the notice in electronic format to the Authority.
|
||||||
|
|
||||||
|
### Ontario Dispute Adjudication for Construction Contracts (ODACC) — home page
|
||||||
|
|
||||||
|
<https://odacc.ca/en/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Ontario Dispute Adjudication for Construction Contracts (“ODACC”) is the Authorized Nominating Authority (“ANA”) under the Construction Act. As the ANA, ODACC is responsible for administering construction-related adjudications and for training and qualifying Adjudicators. ODACC derives its powers from the Construction Act and Ontario Regulation 264/25
|
||||||
|
|
||||||
|
> Adjudication and prompt payment provisions of the Construction Act came into force on October 1, 2019. Further amendments to the Construction Act came into force on January 1, 2026. ODACC is responsible for administering adjudications and for training and certifying adjudicators.
|
||||||
|
|
||||||
|
> Only Adjudicators listed in the Adjudicator Registry are permitted to conduct adjudications and make Determinations under the Construction Act.
|
||||||
|
|
||||||
|
> The adjudication process commences when the Claimant provides the Respondent with a Notice of Adjudication (and sends ODACC an electronic copy).
|
||||||
|
|
||||||
|
### ODACC — About Us
|
||||||
|
|
||||||
|
<https://odacc.ca/en/aboutus/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Ontario Dispute Adjudication for Construction Contracts (“ODACC”) is the Authorized Nominating Authority (“ANA”) under the Construction Act. As the ANA, ODACC is responsible for administering construction-related adjudications and for training and qualifying Adjudicators.
|
||||||
|
|
||||||
|
> Further duties and powers of ODACC are listed in sections 6 through 18 of Ontario Regulation 264/25.
|
||||||
|
|
||||||
|
### ODACC — Adjudication Process
|
||||||
|
|
||||||
|
<https://odacc.ca/en/adjudication-process/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Adjudication is a dispute resolution process that allows Parties to present their dispute to an independent third party for a decision.
|
||||||
|
|
||||||
|
> The Adjudicator will consider the evidence and make a decision (a “Determination”) within thirty days of the Claimant submitting its supporting documents. If the Adjudicator orders a Party to pay the other Party, the payment must be made within fifteen days of the issuing of the Determination.
|
||||||
|
|
||||||
|
> Adjudications are quick. After an Adjudicator receives the Claimant’s documents, the Adjudicator must render a Determination no later than 30 days after receiving the Claimant’s documents (unless the Determination due date is extended);
|
||||||
|
|
||||||
|
> Determinations are binding only until a decision is made in a subsequent proceeding. Either Party can commence a proceeding in court or through arbitration. The Determination is binding until a final decision is made in the subsequent proceeding;
|
||||||
|
|
||||||
|
> Adjudication is available as a right. A Party to a construction contract can commence an adjudication without the other Party’s consent
|
||||||
|
|
||||||
|
> An adjudication may only be conducted by a certified ODACC Adjudicator.
|
||||||
|
|
||||||
|
### ODACC 2025 Annual Report (PDF, 37 pages) — the annual report ODACC is required to publish as Authorized Nominating Authority
|
||||||
|
|
||||||
|
<https://odacc.ca/wp-content/uploads/2021/07/2025-ODACC-Annual-Report-Final.pdf> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> ODACC is the Authorized Nominating Authority (the “ANA”) under the Ontario Construction Act and is responsible for administering construction-related Adjudications and for training and qualifying Adjudicators.
|
||||||
|
|
||||||
|
> The Adjudication and Prompt Payment provisions of the Construction Act came into force on October 1, 2019.
|
||||||
|
|
||||||
|
> ODACC is pleased to provide this report on the sixth year of its operation for the fiscal year August 1, 2024, to July 31, 2025 (the “2025 Fiscal Year”).
|
||||||
|
|
||||||
|
### Small modular reactors | Darlington SMR – Ontario Power Generation (proponent's own page). NOTE ON RETRIEVAL: opg.com returned HTTP 403 (Cloudflare bot block) to both direct curl and WebFetch on 2026-08-29; the page text quoted here was obtained through the r.jina.ai HTML-to-text reader proxy (https://r.jina.ai/https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/, HTTP 200). Key facts are independently corroborated by the CNSC page listed below. A human should re-read the OPG page in a browser before any of this wording is published.
|
||||||
|
|
||||||
|
<https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The Darlington New Nuclear Project is leading the way in the advancement of Small Modular Reactor (SMR) technology in Canada – the future of nuclear power generation.
|
||||||
|
|
||||||
|
> On July 7, 2023, the Ontario government announced it will work with Ontario Power Generation (OPG) to commence planning and licensing for three additional SMRs, for a total of four SMRs at the Darlington new nuclear site.
|
||||||
|
|
||||||
|
> In March 2026, OPG applied to the Canadian Nuclear Safety Commission (CNSC) for a Licence to Operate (LTO) the first SMR. The LTO is the regulatory approval required to complete commissioning and safely operate the reactor, once construction is complete.
|
||||||
|
|
||||||
|
> OPG’s application for this licence is comprised of a number of packages, submitted to the CNSC over the course of several months. This application process will culminate in a public hearing where the regulator will hear comments from members of the public regarding our application.
|
||||||
|
|
||||||
|
> Site construction progress - Summer 2026
|
||||||
|
|
||||||
|
> The construction of the reactor building is now progressing upwards towards grade following the successful basemat (the foundation of the Unit 1 reactor building) installation earlier this year.
|
||||||
|
|
||||||
|
> Additional regulatory approvals will be required prior to construction and operation of additional units.
|
||||||
|
|
||||||
|
> Our planning goal is to complete construction of the first SMR by the end of this decade, and connect to the grid by the end of 2030.
|
||||||
|
|
||||||
|
### Darlington New Nuclear Project — Canadian Nuclear Safety Commission (federal nuclear regulator)
|
||||||
|
|
||||||
|
<https://www.cnsc-ccsn.gc.ca/eng/reactors/new-reactor-power-plant-projects/new-reactor-power-plant-facilities/darlington-new-nuclear-project/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The site is owned by Ontario Power Generation (OPG).
|
||||||
|
|
||||||
|
> The BWRX-300 is a 300 MWe water-cooled, natural circulation small modular reactor.
|
||||||
|
|
||||||
|
> OPG applied for a licence to construct 1 BWRX-300 reactor in October 2022 and was granted the licence in April 2025.
|
||||||
|
|
||||||
|
> In March 2026, OPG applied for a 20-year licence to operate 1 BWRX-300 reactor and an associated low- and intermediate-level waste storage structure.
|
||||||
|
|
||||||
|
> Current status: 1 unit under construction
|
||||||
|
|
||||||
|
> The CNSC has verified that the commitments for RHP-1 (for installation of the reactor building foundation) were met, and the hold point was removed on March 30, 2026.
|
||||||
|
|
||||||
|
### The Bruce C Project — Bruce Power (proponent's own page)
|
||||||
|
|
||||||
|
<https://www.brucepower.com/the-bruce-c-project/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> As Ontario prepares for the future through its Integrated Energy Plan, Bruce Power has initiated a federal Impact Assessment (IA) for the Bruce C Project. The project aims to create an option to build up to 4,800 megawatts of nuclear capacity at the Bruce Power site, located within the Territory of the Saugeen Ojibway Nation, in the Municipality of Kincardine, Ontario.
|
||||||
|
|
||||||
|
> Bruce Power is advancing the IA process in a proactive, open and transparent manner to engage Indigenous Peoples, local communities, interested parties and the public early on.
|
||||||
|
|
||||||
|
### Bruce C Project — Engage Bruce Power (Bruce Power's own engagement platform). NOTE: WebFetch returned 403; retrieved with curl sending a browser User-Agent (HTTP 200, 62,728 bytes).
|
||||||
|
|
||||||
|
<https://engage.brucepower.com/brucec> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The Bruce C Project is creating the option to build up to 4,800 MW of nuclear capacity on the existing Bruce Power site.
|
||||||
|
|
||||||
|
> Through the federal integrated Impact Assessment (IA) process led by the Impact Assessment Agency of Canada (IAAC) alongside the Canadian Nuclear Safety Commission (CNSC), Bruce Power is studying the potential environmental, economic, social and health impacts of a new nuclear build.
|
||||||
|
|
||||||
|
> IN PROGRESS — In the Impact Statement phase, the Bruce C Project team will prepare the Impact Statement
|
||||||
|
|
||||||
|
> UPCOMING — In the Impact Assessment Phase phase, the Review Panel will prepare hearing materials and public hearings will be held.
|
||||||
|
|
||||||
|
> COMPLETED — During the Planning phase, the Bruce C Project team: Engaged with Indigenous Nations and Communities, local municipalities and the public on project information.
|
||||||
|
|
||||||
|
> At the completion of the Planning Phase, Bruce Power received the Integrated Tailored Impact Statement Guidelines and Planning Phase documents from the IAAC and CNSC, which defines the requirements for Bruce Power to include in the Impact Statement and Licence to Prepare Site Application.
|
||||||
|
|
||||||
|
> Reactor technology has not been selected at this time, and the Impact Assessment for the Bruce C Project will be technology neutral. This approach considers multiple technologies to provide optionality to the province in long-term electricity system planning.
|
||||||
|
|
||||||
|
> Bruce Power has commenced a siting assessment to understand potential constraints and opportunities on the Bruce Power site, support conceptual layout development, and evaluate suitable areas for potential development.
|
||||||
|
|
||||||
|
### Planning phase of Integrated Impact Assessment completed for Bruce Power's potential Bruce C project — Bruce Power newsroom, dated 2025-08-21 (proponent's own release)
|
||||||
|
|
||||||
|
<https://www.brucepower.com/2025/08/21/planning-phase-of-integrated-impact-assessment-completed-for-bruce-powers-potential-bruce-c-project/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The planning phase of the federal Integrated Impact Assessment process has been completed for Bruce Power’s Bruce C project.
|
||||||
|
|
||||||
|
> On August 19, the Impact Assessment Agency of Canada (IAAC), in collaboration with the Canadian Nuclear Safety Commission (CNSC), issued the formal Notice of Commencement of Impact Assessment under the Impact Assessment Act. This notice initiates the next stage of the process, the development of the Impact Statement, and is accompanied by Tailored Impact Statement Guidelines (TISG) and documents that will shape the scope and depth of the assessment moving forward.
|
||||||
|
|
||||||
|
> The planning phase is the first of the five phases in Impact Assessment process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
Each item names the source it rests on. An item here that no quotation above
|
||||||
|
supports is a defect in this file, not a fact.
|
||||||
|
|
||||||
|
- The statute's current name and citation is the Construction Act, R.S.O. 1990, c. C.30. The e-Laws consolidation retrieved on 2026-08-29 states 'Consolidation Period: From January 1, 2026 to the e-Laws currency date' and 'Last amendment: 2025, c. 14, Sched. 2.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- The Construction Act did not replace the Construction Lien Act with a new statute — it IS the same statute renamed. Section 1 of the Construction Lien Amendment Act, 2017, S.O. 2017, c. 24 (assented to December 12, 2017) reads: '1 The short title of the Construction Lien Act is repealed and the following substituted: Construction Act'. The chapter number is unchanged (R.S.O. 1990, c. C.30) across the rename.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/S17024>
|
||||||
|
- The rename took effect on 1 July 2018. e-Laws' own version list for R.S.O. 1990, c. C.30 shows version 8 titled 'Construction Lien Act, R.S.O. 1990, c. C.30' running to 2018-06-30, and version 9 titled 'Construction Act, R.S.O. 1990, c. C.30' beginning 2018-07-01.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/api/v2/legislation/en/act-versions/statute/90c30>
|
||||||
|
- The historical e-Laws consolidation at /laws/statute/90c30/v8 carries the short title 'Construction Lien Act' and the chapter 'R.S.O. 1990, Chapter C.30'; the next consolidation, /laws/statute/90c30/v9, carries the short title 'Construction Act' and the same chapter.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30/v9>
|
||||||
|
- LIEN PRESERVATION — the deadline is set by s. 31 (headed 'Expiry of liens'), read with s. 34 ('A lien may be preserved during the supplying of services or materials or at any time before it expires'). Under s. 31(2) a contractor's lien 'expires at the conclusion of the 60-day period next following' publication of the certificate/declaration of substantial performance or, where earlier, completion, abandonment or termination of the contract; where there is no certification or declaration, the 60-day period runs from the earlier of completion and abandonment or termination. Section 31(3) applies the same 60-day period to the lien of 'any other person' (running from the earliest of publication, last supply, completion/abandonment/termination, or certification of the subcontract as complete under s. 33). Section 31(2.1) applies the same 60-day period to a workers' trust fund lien.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- HOW A LIEN IS PRESERVED — s. 34(1): where the lien attaches to the premises, 'by the registration in the proper land registry office of a claim for lien on the title of the premises'; where it does not attach, 'by giving to the owner a copy of the claim for lien.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- LIEN PERFECTION — s. 36(1): 'A lien may not be perfected unless it is preserved.' Section 36(2): 'A lien that has been preserved expires unless it is perfected prior to the end of the 90-day period next following the last day, under section 31, on which the lien could have been preserved.' Section 36(3) defines perfection as commencing an action to enforce the lien and (where the lien attaches to the premises) registering a certificate of action on title.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- A perfected lien has a further limit: s. 37(1) provides that it 'expires immediately after the second anniversary of the commencement of the action that perfected the lien' unless an order is made for the trial of an action in which the lien may be enforced, or such an action is set down for trial, on or before that anniversary.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- As of the 1 January 2026 consolidation, s. 31(6) (added by 2025, c. 14, Sched. 2, s. 6) requires that 'No later than seven days after a contract is terminated, either the owner or the contractor or other person whose lien is subject to expiry shall publish a notice of the termination in the prescribed form and manner', and s. 31(7) fixes the publication date as the termination date for the purposes of s. 31.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- INTERIM ADJUDICATION — Ontario does have statutory construction adjudication. It sits in Part II.1 of the Construction Act, headed 'CONSTRUCTION DISPUTE INTERIM ADJUDICATION' (ss. 13.1–13.23). Section 13.1 defines 'adjudication' as 'construction dispute interim adjudication under this Part'.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- Section 13.2(1) provides that 'The Minister may designate an entity to act as Authorized Nominating Authority for the purposes of this Part', and s. 13.1 defines 'Authority' as 'the Authorized Nominating Authority designated under section 13.2'. The Act itself does not name the designated entity.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- The Authorized Nominating Authority is Ontario Dispute Adjudication for Construction Contracts (ODACC). ODACC states on its own site: 'Ontario Dispute Adjudication for Construction Contracts ("ODACC") is the Authorized Nominating Authority ("ANA") under the Construction Act. As the ANA, ODACC is responsible for administering construction-related adjudications and for training and qualifying Adjudicators.'
|
||||||
|
*Source:* <https://odacc.ca/en/aboutus/>
|
||||||
|
- ODACC repeats the same statement in its own 2025 Annual Report (the report it is required to publish as ANA), which describes the fiscal year 1 August 2024 to 31 July 2025 as 'the sixth year of its operation'.
|
||||||
|
*Source:* <https://odacc.ca/wp-content/uploads/2021/07/2025-ODACC-Annual-Report-Final.pdf>
|
||||||
|
- The Part II.1 (adjudication) and Part I.1 (prompt payment) provisions came into force on 1 October 2019. The Act's own in-force notes record '2017, c. 24, s. 11 (1) - 01/10/2019' against the Part II.1 sections and '2017, c. 24, s. 7 - 01/10/2019' against the Part I.1 sections. ODACC states the same date: 'Adjudication and prompt payment provisions of the Construction Act came into force on October 1, 2019.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- WHAT MAY BE ADJUDICATED — s. 13.5(1) (as amended effective 1 January 2026) permits a party to a contract to refer a dispute 'respecting any prescribed matter or any matter agreed to by the parties' to adjudication; s. 13.5(2) does the same for subcontracts. The prescribed matters are set out in O. Reg. 264/25, s. 19(1): the valuation of services or materials provided under the contract; payment under the contract, including in respect of a change order (approved or not) or a proposed change order; a dispute that is the subject of a notice of non-payment under Part I.1; amounts retained under s. 12 (set-off by trustee) or s. 17(3) (lien set-off); payment of a holdback under s. 26; and — only where reasonably necessary to resolve another adjudicable matter — the scope of work, a request for a change in the contract price, and a request for an extension of time.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/250264>
|
||||||
|
- Adjudication is time-limited at the front end: s. 13.5(3) bars an adjudication in respect of a contract 'if the notice of adjudication is given more than 90 days after the date on which the contract is completed, abandoned or terminated, unless the parties to the adjudication agree otherwise', with a parallel rule for subcontracts in s. 13.5(3.1). Section 13.5(4) limits an adjudication to 'a single dispute, unless the parties to the adjudication and the adjudicator agree otherwise.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- STATUTORY TIMELINE FOR A DETERMINATION — s. 13.13(1): 'an adjudicator shall make a determination of the matter that is the subject of an adjudication no later than 30 days after receiving the documents required by section 13.11.' Section 13.11 requires the party who gave the notice of adjudication to provide those documents 'No later than five days after an adjudicator agrees or is appointed to conduct the adjudication'. Section 13.13(2) permits extension — up to 14 days on the adjudicator's request with the parties' written consent, or for a period agreed in writing by the parties with the adjudicator's consent. Section 13.13(5): 'A determination made by an adjudicator after the date determined under subsection (1) or (2) is of no force or effect.' Section 13.13(6) requires the determination to be in writing with reasons.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- A determined amount must be paid 'no later than 15 days after the determination has been communicated to the parties to the adjudication' (s. 13.19(2)). Judicial review of a determination is available only with leave of the Divisional Court (s. 13.18(1)).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- PROMPT PAYMENT (Part I.1) — proper invoices 'shall be given to an owner on a monthly basis, unless the contract provides otherwise' (s. 6.3(1)). Owner to contractor: payment 'no later than 28 days after receiving the proper invoice from the contractor' (s. 6.4(1)), unless the owner gives a notice of non-payment 'no later than 14 days after receiving the proper invoice' (s. 6.4(2)).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- PROMPT PAYMENT, down the chain — contractor to subcontractor: where the contractor is paid in full within the s. 6.4(1) time, it must pay each subcontractor 'no later than seven days after receiving payment' (s. 6.5(1)), and the same seven days applies to partial payment received (s. 6.5(2)). Where the owner does not pay, the contractor must pay its subcontractors 'no later than 35 days after giving the proper invoice to the owner' (s. 6.5(4)) unless it gives a notice of non-payment; that notice must include 'an undertaking to refer the matter to adjudication under Part II.1 no later than 21 days after giving the notice to the subcontractor' (s. 6.5(5)(a)(iii)). Subcontractor to sub-subcontractor: seven days after receiving payment (s. 6.6(1)).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- ARBITRATION — the Act contemplates arbitration under the Arbitration Act, 1991 in four places, all of them treating arbitration as the forum that supersedes an interim adjudication rather than as a process the Act itself creates: s. 13.5(5) (a dispute may be referred to adjudication 'even if the dispute is the subject of a court action or of an arbitration under the Arbitration Act, 1991, unless the action or arbitration has been finally determined'); s. 13.15(1) (an adjudicator's determination 'is binding on the parties to the adjudication until a determination of the matter by a court, a determination of the matter by way of an arbitration conducted under the Arbitration Act, 1991, or a written agreement between the parties'); s. 13.15(2) (nothing in Part II.1 restricts 'the authority of a court or of an arbitrator acting under the Arbitration Act, 1991 to consider the merits of a matter determined by an adjudicator'); and s. 62(6)(b) (a lien claimant 'whose action was stayed by reason of an order under the Arbitration Act, 1991' may be let in to prove the claim).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90c30>
|
||||||
|
- DARLINGTON — the Darlington New Nuclear Project is real and is Ontario Power Generation's. OPG's own page describes it as 'leading the way in the advancement of Small Modular Reactor (SMR) technology in Canada' and states that on 7 July 2023 the Ontario government announced it would work with OPG 'to commence planning and licensing for three additional SMRs, for a total of four SMRs at the Darlington new nuclear site.'
|
||||||
|
*Source:* <https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/>
|
||||||
|
- DARLINGTON, status in the proponent's own words as at 2026-08-29: OPG's page states 'In March 2026, OPG applied to the Canadian Nuclear Safety Commission (CNSC) for a Licence to Operate (LTO) the first SMR', that the application 'is comprised of a number of packages, submitted to the CNSC over the course of several months' and 'will culminate in a public hearing'. Under the heading 'Site construction progress - Summer 2026' it states 'The construction of the reactor building is now progressing upwards towards grade following the successful basemat (the foundation of the Unit 1 reactor building) installation earlier this year.' It adds that 'Additional regulatory approvals will be required prior to construction and operation of additional units.'
|
||||||
|
*Source:* <https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/>
|
||||||
|
- DARLINGTON, corroborated by the regulator: the CNSC page states 'The site is owned by Ontario Power Generation (OPG)', 'The BWRX-300 is a 300 MWe water-cooled, natural circulation small modular reactor', 'OPG applied for a licence to construct 1 BWRX-300 reactor in October 2022 and was granted the licence in April 2025', 'In March 2026, OPG applied for a 20-year licence to operate 1 BWRX-300 reactor and an associated low- and intermediate-level waste storage structure', and 'Current status: 1 unit under construction'.
|
||||||
|
*Source:* <https://www.cnsc-ccsn.gc.ca/eng/reactors/new-reactor-power-plant-projects/new-reactor-power-plant-facilities/darlington-new-nuclear-project/>
|
||||||
|
- BRUCE C — the project is real and is Bruce Power's. Bruce Power's own page states 'Bruce Power has initiated a federal Impact Assessment (IA) for the Bruce C Project. The project aims to create an option to build up to 4,800 megawatts of nuclear capacity at the Bruce Power site, located within the Territory of the Saugeen Ojibway Nation, in the Municipality of Kincardine, Ontario.'
|
||||||
|
*Source:* <https://www.brucepower.com/the-bruce-c-project/>
|
||||||
|
- BRUCE C, status in the proponent's own words as at 2026-08-29: Bruce Power's engagement site marks the Planning phase 'COMPLETED', the Impact Statement phase 'IN PROGRESS' ('In the Impact Statement phase, the Bruce C Project team will prepare the Impact Statement'), and the Impact Assessment phase 'UPCOMING' (Review Panel hearing materials and public hearings). It states 'At the completion of the Planning Phase, Bruce Power received the Integrated Tailored Impact Statement Guidelines and Planning Phase documents from the IAAC and CNSC'. Bruce Power's newsroom release of 2025-08-21 states 'The planning phase of the federal Integrated Impact Assessment process has been completed for Bruce Power's Bruce C project' and that on 19 August the IAAC with the CNSC 'issued the formal Notice of Commencement of Impact Assessment under the Impact Assessment Act'.
|
||||||
|
*Source:* <https://engage.brucepower.com/brucec>
|
||||||
|
- BRUCE C — no reactor technology has been chosen. Bruce Power states: 'Reactor technology has not been selected at this time, and the Impact Assessment for the Bruce C Project will be technology neutral.' It also states it 'has commenced a siting assessment' on the existing site. Bruce C is therefore at an assessment/optioning stage, not a construction stage — unlike Darlington, no construction licence exists.
|
||||||
|
*Source:* <https://engage.brucepower.com/brucec>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.** It is the half that keeps a page
|
||||||
|
honest, and on this project it is the half that has twice been skipped.
|
||||||
|
|
||||||
|
- **Does the Construction Act contemplate MEDIATION of construction disputes, and where?**
|
||||||
|
- *Searched:* Case-insensitive regex sweep for 'mediat' over the complete fetched text of the current consolidation (the full JSON payload from https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30, 374,864 bytes, tags stripped), plus a read of the Part headings list and of Part VIII (Jurisdiction and Procedure).
|
||||||
|
- *Outcome:* NO MEDIATION PROVISION FOUND. The sweep returned exactly 2 matches and both were false positives — 'in the immediate vicinity of the premises' (s. 1 definition of 'improvement') and 'the year immediately preceding the anniversary' (s. 26.1(4), annual holdback release). Both matches were printed and read. On the text retrieved, the Construction Act contains no mediation provisions at all: no mediation Part, no mandatory or court-annexed mediation step, no mediator role. There is nothing to quote because there is nothing there. This is a confirmed negative, not an unsearched gap — but it is stated as an absence in the statute only. Whether mediation of construction disputes is required or available by some OTHER instrument (for example the Rules of Civil Procedure, O. Reg. 194/90, or a standard-form contract such as CCDC 2) was NOT searched and must not be inferred from this.
|
||||||
|
- **On what date, and by what instrument, was ODACC designated as the Authorized Nominating Authority?**
|
||||||
|
- *Searched:* O. Reg. 264/25 in full (searched for 'ODACC' and 'Ontario Dispute Adjudication' — zero matches; s. 2 sets designation eligibility criteria but names no entity); ODACC's home page, About Us page and 2025 Annual Report PDF (searched for 'designat', 'named', '2019'); s. 13.2 of the Act.
|
||||||
|
- *Outcome:* NOT ESTABLISHED. ODACC's designation is made by the Minister under s. 13.2(1), not by regulation, and no primary or institutional source retrieved states the designation date. ODACC's own pages assert only that it IS the ANA, with no date. A third-party law-firm/news source surfaced in search asserts 18 July 2019, but that was not fetched or verified and is NOT relied on here. Do not publish a designation date.
|
||||||
|
- **Verbatim confirmation of OPG's Darlington page by direct fetch of opg.com.**
|
||||||
|
- *Searched:* curl with a browser User-Agent and full navigation headers (HTTP 403, Cloudflare block, on both www.opg.com and opg.com); WebFetch on the same URL (HTTP 403). Text was then obtained through the r.jina.ai reader proxy (HTTP 200).
|
||||||
|
- *Outcome:* PARTIAL. The OPG content quoted above came through a third-party reader proxy rather than a direct fetch, so the exact wording carries one hop of risk. Every load-bearing fact in it (proponent, BWRX-300 technology, construction licence April 2025, operating licence application March 2026, one unit under construction) is independently confirmed by the CNSC regulator page, which WAS fetched directly. Before any OPG wording is quoted on a public page, open the OPG URL in a browser and confirm the sentence.
|
||||||
|
- **Whether the Darlington and Bruce C projects have any adjudication, lien, or prompt-payment activity under the Construction Act.**
|
||||||
|
- *Searched:* Not searched — outside the scope of the sources fetched.
|
||||||
|
- *Outcome:* NOT ESTABLISHED, and nothing above supports connecting these two projects to the Construction Act machinery. The projects were verified as real and their status recorded; no source retrieved links either project to any dispute, adjudication, lien or payment proceeding. Do not use them as examples of ADR work, of the practitioner's involvement, or of anything else beyond 'these projects exist and are at these stages'.
|
||||||
|
- **Whether the Construction Act's Part II.1 provisions quoted are affected by any not-yet-in-force amendment.**
|
||||||
|
- *Searched:* Searched the raw e-Laws payload for not-in-force markers ('not in force', 'ynote', NYIF classes). The consolidation's metadata flags "Contains 'not yet in force' provision sections".
|
||||||
|
- *Outcome:* MOSTLY CLEARED, with one caveat. Two not-in-force items were found, both amendments from 2017, c. 33, Sched. 2, s. 76 (1) and (2), attached to s. 1 and s. 34 — neither touches the periods or the Part II.1 text quoted above. However, the full NYIF set was not exhaustively mapped section by section. Anything published from this artefact should be re-checked against the live e-Laws page on the day of publication, since this consolidation is current only 'to the e-Laws currency date'.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `WebFetch https://www.ontario.ca/laws/statute/90c30 — returned an empty SPA shell ('e-Laws | Ontario.ca'), no statute text; recorded as a failed instrument rather than a null result`
|
||||||
|
- `curl https://www.ontario.ca/laws/statute/90c30 — HTTP 200, 54,243 bytes, but 0 matches for 'Construction'; confirmed the page is a React SPA`
|
||||||
|
- `curl https://www.canlii.org/en/on/laws/stat/rso-1990-c-c30/latest/rso-1990-c-c30.html — HTTP 403, blocked; CanLII not used`
|
||||||
|
- `Read the e-Laws JS bundle (/laws/static/js/main.dbd400db.js) to recover the API route pattern: legislation/{lang}/doc-search/{type}/{code}[/{version}]`
|
||||||
|
- `Probed five candidate e-Laws API shapes (all 404) before locating the correct route`
|
||||||
|
- `curl https://www.ontario.ca/laws/api/v2/legislation/en/doc-search/statute/90c30 — HTTP 200, 374,864 bytes, full current Construction Act`
|
||||||
|
- `curl .../doc-search/statute/S17024 — Construction Lien Amendment Act, 2017`
|
||||||
|
- `curl .../doc-search/statute/90c30/v8 and /v9 — the last 'Construction Lien Act' consolidation and the first 'Construction Act' consolidation`
|
||||||
|
- `curl .../act-versions/statute/90c30 — all 22 historical versions with titles and date ranges`
|
||||||
|
- `curl .../doc-search/regulation/250264 (O. Reg. 264/25) and /250384 (O. Reg. 384/25)`
|
||||||
|
- `grep of the Act text for sections 31, 34, 36, 37, 6.1–6.6, 13.1–13.19, and for all Part headings`
|
||||||
|
- `Case-insensitive sweep of the full Act payload for 'mediat' (2 hits, both false positives, both printed and read) and for 'arbitrat' (4 hits, all printed and read)`
|
||||||
|
- `Sweep of the raw Act payload for not-yet-in-force markers`
|
||||||
|
- `WebFetch https://odacc.ca/en/ — ODACC home page`
|
||||||
|
- `curl https://odacc.ca/en/ , /en/aboutus/ , /en/adjudication-process/ , /en/annual-report/`
|
||||||
|
- `curl + pdftotext https://odacc.ca/wp-content/uploads/2021/07/2025-ODACC-Annual-Report-Final.pdf (37 pages)`
|
||||||
|
- `WebFetch https://odacc.ca/en/about-us/ — HTTP 404 (wrong slug; correct slug is /en/aboutus/)`
|
||||||
|
- `WebSearch 'Ontario Ministry of the Attorney General designated ODACC Authorized Nominating Authority Construction Act' — only third-party sources for the designation date; not relied on`
|
||||||
|
- `WebSearch 'ontario.ca Construction Act renamed Construction Lien Act July 1 2018' — no primary source returned; the rename date was instead established from the e-Laws version list`
|
||||||
|
- `WebSearch 'OPG Darlington New Nuclear Project small modular reactor opg.com official page'`
|
||||||
|
- `curl and WebFetch https://www.opg.com/projects-services/projects/nuclear/smr/darlington-smr/ — HTTP 403 both, Cloudflare; retried via curl on bare opg.com (403) before falling back to r.jina.ai (HTTP 200)`
|
||||||
|
- `WebFetch https://www.cnsc-ccsn.gc.ca/.../darlington-new-nuclear-project/ — regulator corroboration`
|
||||||
|
- `WebSearch 'Bruce Power Bruce C project site brucepower.com'`
|
||||||
|
- `curl https://www.brucepower.com/the-bruce-c-project/`
|
||||||
|
- `WebFetch https://engage.brucepower.com/brucec — HTTP 403; refetched with curl and a browser User-Agent, HTTP 200`
|
||||||
|
- `curl https://www.brucepower.com/2025/08/21/planning-phase-of-integrated-impact-assessment-completed-for-bruce-powers-potential-bruce-c-project/`
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
# Ontario energy — OEB leave to construct, IESO connection assessment, Bill 40 and the data-centre regime
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.** Fetched from the primary sources listed below and
|
||||||
|
extracted with quotations pasted verbatim. This file is the artefact; the pages
|
||||||
|
cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a regulation and a tribunal page all move.** Every consolidation
|
||||||
|
> date and effective date below is as at the retrieval date and nothing more.
|
||||||
|
> Re-check before cutover, and before any republish that turns on one of them.
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||||
|
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||||
|
> are still the original retrieval and were not re-fetched** — what was
|
||||||
|
> re-checked is whether the *facts* they support have moved.
|
||||||
|
>
|
||||||
|
> - **(c) ERO 026-0853 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 —
|
||||||
|
> Pouya]`. The comment period to 2026-09-12 is still open.
|
||||||
|
> - **(b) the regulation under `Electricity Act` s. 28.1 — NOT RE-RETRIEVED.**
|
||||||
|
> Held unchanged on a cadence judgement rather than a fresh retrieval
|
||||||
|
> `[assumed 2026-09-01 — Pouya: unchanged by its nature at this cadence]`.
|
||||||
|
> That is a weaker stamp than (c) and is written weaker on purpose.
|
||||||
|
|
||||||
|
**Topic as researched:** Ontario electricity/energy regulatory processes that generate disputes: OEB leave to construct (OEB Act, 1998 ss. 90/92/95/96), the IESO and market participation, the IESO connection assessment process (SIA/CIA), large-load and data-centre grid connection (Electricity Act, 1998 s. 28.1), and Ontario Bill 40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| statute | Ontario Energy Board Act, 1998, S.O. 1998, c. 15, Sched. B — e-Laws (ontario.ca), Government of Ontario | <https://www.ontario.ca/laws/statute/98o15> |
|
||||||
|
| statute | Electricity Act, 1998, S.O. 1998, c. 15, Sched. A — e-Laws (ontario.ca), Government of Ontario | <https://www.ontario.ca/laws/statute/98e15> |
|
||||||
|
| regulation | O. Reg. 161/99: DEFINITIONS AND EXEMPTIONS, under the Ontario Energy Board Act, 1998 — e-Laws (ontario.ca) | <https://www.ontario.ca/laws/regulation/990161> |
|
||||||
|
| regulator | Leave to construct applications for priority transmission projects — Ontario Energy Board | <https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects> |
|
||||||
|
| regulator | Natural Gas Facilities Handbook — Ontario Energy Board | <https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/natural-gas-facilities-handbook> |
|
||||||
|
| institution | Managing the Power System — IESO (Independent Electricity System Operator). Requested as https://www.ieso.ca/en/Learn/About-the-IESO/What-We-Do, which redirects here. | <https://ieso.ca/Learn/Ontario-Electricity-Grid/Managing-the-Power-System> |
|
||||||
|
| institution | Overview of the Connection Process — IESO | <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview> |
|
||||||
|
| institution | Stage 2 – Obtain conditional approval to connect — IESO | <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval> |
|
||||||
|
| institution | Stage 4 – Authorize market and program participation — IESO | <https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation> |
|
||||||
|
| institution | Frequently Asked Questions - Connection Process — IESO | <https://www.ieso.ca/Sector-Participants/Connection-Process/Frequently-Asked-Questions> |
|
||||||
|
| statute | Bill 40, Protect Ontario by Securing Affordable Energy for Generations Act, 2025 — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40> |
|
||||||
|
| statute | Bill 40 (44th Parliament, 1st Session) — Status — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40/status> |
|
||||||
|
| statute | Bill 40, Moving Ontarians Safely Act, 2023 — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-40> |
|
||||||
|
| statute | Bill 40, Support for Adults in Need of Assistance Act, 2021 — Legislative Assembly of Ontario | <https://www.ola.org/en/legislative-business/bills/parliament-42/session-2/bill-40> |
|
||||||
|
| regulator | Economic and Strategic Assessment Framework for New Data Centres — Environmental Registry of Ontario (ERO 026-0853), Ministry of Energy and Mines | <https://ero.ontario.ca/notice/026-0853> |
|
||||||
|
| regulator | New Requirements for Data Centres Seeking to Connect to the Electricity Grid in Ontario — Environmental Registry of Ontario (ERO 025-1001), Ministry of Energy and Mines | <https://ero.ontario.ca/notice/025-1001> |
|
||||||
|
| regulator | Distribution System Code (DSC) — Ontario Energy Board | <https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/distribution-system-code-dsc> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### Ontario Energy Board Act, 1998, S.O. 1998, c. 15, Sched. B — e-Laws (ontario.ca), Government of Ontario
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/98o15> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Ontario Energy Board Act, 1998 / S.O. 1998, CHAPTER 15 / Schedule B
|
||||||
|
|
||||||
|
> Consolidation Period: From December 11, 2025 to the e-Laws currency date.
|
||||||
|
|
||||||
|
> Last amendment: 2025, c. 22, Sched. 3.
|
||||||
|
|
||||||
|
> PART VI — TRANSMISSION AND DISTRIBUTION LINES
|
||||||
|
|
||||||
|
> Definitions, Part VI — 89 In this Part, "electricity distribution line" means a line, transformers, plant or equipment used for conveying electricity at voltages of 50 kilovolts or less; ("ligne de distribution d'électricité")
|
||||||
|
|
||||||
|
> "electricity transmission line" means a line, transformers, plant or equipment used for conveying electricity at voltages higher than 50 kilovolts; ("ligne de transport d'électricité")
|
||||||
|
|
||||||
|
> "hydrocarbon line" means a pipe line carrying any hydrocarbon, other than a pipe line within an oil refinery, oil or petroleum storage depot, chemical processing plant or pipe line terminal or station; ("ligne pour hydrocarbures")
|
||||||
|
|
||||||
|
> "work" means a hydrocarbon line, electricity distribution line, electricity transmission line, interconnection or station. ("ouvrage") 1998, c. 15, Sched. B, s. 89; 2003, c. 3, s. 62.
|
||||||
|
|
||||||
|
> Leave to construct hydrocarbon line — 90 (1) No person shall construct a hydrocarbon line without first obtaining from the Board an order granting leave to construct the hydrocarbon line if, (a) the proposed hydrocarbon line is more than 20 kilometres in length; (b) the proposed hydrocarbon line is projected to cost more than the amount prescribed by the regulations; (c) any part of the proposed hydrocarbon line, (i) uses pipe that has a nominal pipe size of 12 inches or more, and (ii) has an operating pressure of 2,000 kilopascals or more; or (d) criteria prescribed by the regulations are met. 2003, c. 3, s. 63 (1).
|
||||||
|
|
||||||
|
> Exception — 90 (2) Subsection (1) applies to the relocation or reconstruction of a hydrocarbon line only if the conditions prescribed by the regulations are met. 2024, c. 16, Sched. 11, s. 1.
|
||||||
|
|
||||||
|
> Application for leave to construct hydrocarbon line or station — 91 (1) Any person may, before constructing a hydrocarbon line to which subsection 90 (1) does not apply or a station, apply to the Board for an order granting leave to construct the hydrocarbon line or station. 2024, c. 10, s. 6.
|
||||||
|
|
||||||
|
> Leave to construct, etc., electricity transmission or distribution line — 92 (1) No person shall construct, expand or reinforce an electricity transmission line or an electricity distribution line or make an interconnection without first obtaining from the Board an order granting leave to construct, expand or reinforce such line or interconnection. 1998, c. 15, Sched. B, s. 92 (1).
|
||||||
|
|
||||||
|
> Exception — 92 (2) Subsection (1) applies to the relocation or reconstruction of an existing electricity transmission line or electricity distribution line or interconnection where no expansion or reinforcement is involved only if the acquisition of additional land or authority to use additional land is necessary. 2024, c. 16, Sched. 11, s. 2.
|
||||||
|
|
||||||
|
> Route map — 94 An applicant for an order granting leave under this Part shall file with the application a map showing the general location of the proposed work and the municipalities, highways, railways, utility lines and navigable waters through, under, over, upon or across which the proposed work is to pass. 1998, c. 15, Sched. B, s. 94.
|
||||||
|
|
||||||
|
> Exemption, subs. 90 (1) or 92 (1) — 95 (1) The Board may, if in its opinion special circumstances of a particular case so require, make an order exempting any person from the requirements of subsection 90 (1) or 92 (1) without a hearing. 2024, c. 10, s. 7.
|
||||||
|
|
||||||
|
> Same, prescribed circumstances — 95 (2) The Board shall, with or without a hearing, make an order exempting a person from the requirements of subsection 90 (1) or 92 (1) if the Board is satisfied that the circumstances prescribed by the regulations have been met. 2024, c. 10, s. 7.
|
||||||
|
|
||||||
|
> Order allowing work to be carried out — 96 (1) If, after considering an application under section 90, 91 or 92 the Board is of the opinion that the construction, expansion or reinforcement of the proposed work is in the public interest, it shall make an order granting leave to carry out the work. 1998, c. 15, Sched. B, s. 96.
|
||||||
|
|
||||||
|
> Applications under s. 92 — 96 (2) In an application under section 92, the Board shall only consider the following when, under subsection (1), it considers whether the construction, expansion or reinforcement of the electricity transmission line or electricity distribution line, or the making of the interconnection, is in the public interest: 1. The interests of consumers with respect to prices and the reliability and quality of electricity service. 2. Supporting economic growth in a manner consistent with the policies of the Government of Ontario. 2009, c. 12, Sched. D, s. 16; 2021, c. 25, Sched. 19, s. 2; 2025, c. 22, Sched. 3, s. 9 (1).
|
||||||
|
|
||||||
|
> Same — 96 (3) In an application under section 92, the Board shall consider such reports, documents or other information as may be prescribed by the regulations. 2025, c. 22, Sched. 3, s. 9 (2).
|
||||||
|
|
||||||
|
> Section Amendments with date in force (d/m/y) — 2025, c. 22, Sched. 3, s. 9 (1, 2) - 11/12/2025
|
||||||
|
|
||||||
|
> Lieutenant Governor in Council, order re electricity transmission line — 96.1 (1) The Lieutenant Governor in Council may make an order declaring that the construction, expansion or reinforcement of an electricity transmission line specified in the order is needed as a priority project. 2015, c. 29, s. 16.
|
||||||
|
|
||||||
|
### Electricity Act, 1998, S.O. 1998, c. 15, Sched. A — e-Laws (ontario.ca), Government of Ontario
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/98e15> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Electricity Act, 1998 / S.O. 1998, CHAPTER 15 / Schedule A
|
||||||
|
|
||||||
|
> Consolidation Period: From April 24, 2026 to the e-Laws currency date.
|
||||||
|
|
||||||
|
> Last amendment: 2026, c. 2, Sched. 5, s. 9.
|
||||||
|
|
||||||
|
> "market participant" means a person who is authorized by the market rules to participate in the IESO-administered markets or to cause or permit electricity to be conveyed into, through or out of the IESO-controlled grid; ("intervenant du marché")
|
||||||
|
|
||||||
|
> "market rules" means the rules made under section 32; ("règles du marché")
|
||||||
|
|
||||||
|
> Objects — 6 (1) The objects of the IESO are, (a) to exercise the powers and perform the duties assigned to it under this Act, the regulations, directions, the market rules and its licence;
|
||||||
|
|
||||||
|
> (c) to direct the operation and maintain the reliability of the IESO-controlled grid to promote the purposes of this Act;
|
||||||
|
|
||||||
|
> (g) to operate the IESO-administered markets to promote the purposes of this Act;
|
||||||
|
|
||||||
|
> (l) to conduct independent planning for electricity generation, demand management, conservation and transmission;
|
||||||
|
|
||||||
|
> (l.1) to support economic growth in a manner that protects the interests of consumers;
|
||||||
|
|
||||||
|
> Prohibition — (5) The IESO shall not conduct the operations of the IESO-administered markets in any manner that, (a) unjustly advantages or disadvantages any market participant or class of market participants; or (b) is inconsistent with this Act. 2014, c. 7, Sched. 7, s. 3 (1).
|
||||||
|
|
||||||
|
> Exceptions for specified load facilities — 28.1 (1) In this section, "specified connection requirements" means, in relation to a specified load facility, (a) any conditions, requirements or limitations that the regulations specify must be met for the specified load facility to be connected or reconnected to a transmission system or distribution system, as applicable, (b) any approvals that the regulations specify must be obtained for the specified load facility to be connected or reconnected to a transmission system or distribution system, as applicable, and (c) any conditions, requirements or limitations of an approval described in clause (b); ("exigences précisées en matière de raccordement")
|
||||||
|
|
||||||
|
> "specified load facility" means a facility or class of facilities, (a) that is a data centre and that meets any criteria that may be set out in the regulations, or (b) that, (i) withdraws or would, if it were connected to a transmission system or distribution system, be expected to withdraw electricity from the IESO-controlled grid or from the distribution system of a distributor who is licensed under Part V of the Ontario Energy Board Act, 1998, (ii) has or would, if it were connected to a transmission system or distribution system, be expected to have a demand for electricity at the point of connection to the transmission system or distribution system, as applicable, that exceeds the amount prescribed by the regulations, and (iii) meets any other criteria that may be set out in the regulations. ("installation de charge précisée") 2025, c. 22, Sched. 1, s. 7.
|
||||||
|
|
||||||
|
> Connection or reconnection of specified load facilities — (2) Unless a transmitter or distributor is satisfied that the specified connection requirements have been complied with, the transmitter or distributor shall not, (a) connect a specified load facility to its transmission system or distribution system, as applicable; or (b) reconnect a specified load facility to the transmission or distribution system after the specified load facility was disconnected, in accordance with the regulations, as a result of the nonfulfillment or breach of any of the specified connection requirements. 2025, c. 22, Sched. 1, s. 7.
|
||||||
|
|
||||||
|
> Transition — (6) This section does not apply to a specified load facility in respect of which a connection request made in accordance with the Transmission System Code or the Distribution System Code issued under the Ontario Energy Board Act, 1998 was submitted to a transmitter or distributor, as applicable, before June 3, 2025. 2025, c. 22, Sched. 1, s. 7.
|
||||||
|
|
||||||
|
> Section Amendments with date in force (d/m/y) — 2025, c. 22, Sched. 1, s. 7 - 11/12/2025
|
||||||
|
|
||||||
|
> (f.0.1) defining "data centre" for the purposes of section 28.1;
|
||||||
|
|
||||||
|
> (f.0.2) prescribing criteria for the purposes of the definition of "specified load facility" in section 28.1, which may include, but are not limited to, criteria related to, (i) the geographic area in which the specified load facility is or may be located, (ii) the maximum volume of electricity that the specified load facility may withdraw at the point of connection to the transmission system or have distributed to it at the point of connection to the distribution system, as applicable, or (iii) the maximum demand for electricity that the specified load facility may have at the point of connection to the transmission system or distribution system, as applicable;
|
||||||
|
|
||||||
|
### O. Reg. 161/99: DEFINITIONS AND EXEMPTIONS, under the Ontario Energy Board Act, 1998 — e-Laws (ontario.ca)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/regulation/990161> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> O. Reg. 161/99: DEFINITIONS AND EXEMPTIONS, Under: Ontario Energy Board Act, 1998, S.O. 1998, c. 15, Sched. B
|
||||||
|
|
||||||
|
> ONTARIO REGULATION 161/99 — DEFINITIONS AND EXEMPTIONS — Consolidation Period: From January 1, 2024 to the e-Laws currency date. Last amendment: 376/23.
|
||||||
|
|
||||||
|
> 6.2 (1) Subsection 92 (1) of the Act does not apply to, (a) a person that constructs, expands or reinforces an electricity distribution line; (b) a person that makes an interconnection linking a distribution system with an adjacent distribution system; (c) a person that constructs or reinforces an electricity transmission line that is two kilometres or less in length; (d) a person that expands an electricity transmission line in length by two kilometres or less;
|
||||||
|
|
||||||
|
> (e) a person, other than a licensed transmitter or licensed distributor, that constructs, expands or reinforces an electricity transmission line, if the cost of the construction, expansion or reinforcement of the line is to be exclusively paid for by the person;
|
||||||
|
|
||||||
|
> (f) a person that makes an interconnection linking a transmission system with an adjacent transmission system in Ontario; or (g) a person that makes an interconnection linking a distribution system with an adjacent transmission system. O. Reg. 365/00, s. 4; O. Reg. 72/02, s. 6; O. Reg. 511/22, s. 1 (1).
|
||||||
|
|
||||||
|
### Leave to construct applications for priority transmission projects — Ontario Energy Board
|
||||||
|
|
||||||
|
<https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Section 92 of the Ontario Energy Board Act, 1998 (Act) requires transmitters and distributors to obtain approval from the OEB for the construction, expansion, or reinforcement of electricity transmission and distribution lines or interconnections.
|
||||||
|
|
||||||
|
> Section 96 (1) of the Act sets out that after the OEB considers an application under section 92, if it is of the opinion that the construction, expansion or reinforcement of the proposed work is in the public interest, it shall make an order granting leave to carry out the work.
|
||||||
|
|
||||||
|
> Under section 96.1 (1) of the Act, the Lieutenant Governor in Council may make an order declaring that the construction, expansion or reinforcement of an electricity transmission line specified in the order is needed as a priority project. Even if a transmission line is declared to be a priority project, OEB approval to build the line under section 92 of the Act is still required. However, in these cases the OEB must accept that the project is needed when forming its opinion under section 96 of the Act.
|
||||||
|
|
||||||
|
> This page identifies any leave to construct applications for priority transmission projects currently in process at the OEB.
|
||||||
|
|
||||||
|
> There are currently no leave to construct applications before the OEB for approval to build a transmission line that has been declared to be a priority project under section 96.1 of the Act.
|
||||||
|
|
||||||
|
### Natural Gas Facilities Handbook — Ontario Energy Board
|
||||||
|
|
||||||
|
<https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/natural-gas-facilities-handbook> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The Natural Gas Facilities Handbook provides guidance related to the following application types:
|
||||||
|
|
||||||
|
> 2. Under the Ontario Energy Board Act — Section 38, Designated Storage Area Applications; Section 40, DSA Well Drilling Licence Application Referrals; Section 90 and 91, Leave to Construct Applications; Section 95, Exemption from the Requirements of Section 90 Applications; Section 99, Expropriation Applications related to Leave to Construct Approvals
|
||||||
|
|
||||||
|
> On September 24, 2024, the OEB issued new Filing Requirements applicable to applications for exemption from leave to construct applications contemplated in sections 90(2) and 95(2) of the Ontario Energy Board Act, 1998.
|
||||||
|
|
||||||
|
> On April 3, 2024, the OEB issued a letter to natural gas distributors regarding minor revisions that it has made to the standard conditions of approval typically attached to the OEB's decisions approving natural gas leave to construct applications.
|
||||||
|
|
||||||
|
### Managing the Power System — IESO (Independent Electricity System Operator). Requested as https://www.ieso.ca/en/Learn/About-the-IESO/What-We-Do, which redirects here.
|
||||||
|
|
||||||
|
<https://ieso.ca/Learn/Ontario-Electricity-Grid/Managing-the-Power-System> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The IESO is the coordinator and integrator of Ontario's electricity system. Our system operators monitor the energy needs of the province in real time – 24 hours a day, 7 days a week – balancing supply and demand and directing the flow of electricity across Ontario's transmission lines.
|
||||||
|
|
||||||
|
> The IESO works with our partners in the electricity sector, as well as our Canadian and U.S. neighbours, to ensure that Ontario's grid operates reliably and that everyone in Ontario has access to the energy they need, when and where they need it.
|
||||||
|
|
||||||
|
> The IESO is responsible for ensuring these five pillars of electricity system reliability are met: Capacity: The ability to produce enough electricity to meet demand; Energy: The ability to produce electricity consistently over time; Transmission: The ability to distribute and deliver electricity; Operability: The ability to respond to changes in demand; Ancillary Services: Technical support that fine-tunes the electricity grid to adapt to second-by-second changes in demand.
|
||||||
|
|
||||||
|
> Because the IESO controls all dispatch instructions for the province, our system operators can ensure that Ontario's electricity suppliers are not over or under producing at any given time and that the system is running as efficiently and cost-effectively as possible.
|
||||||
|
|
||||||
|
> Copyright © 2026 Independent Electricity System Operator
|
||||||
|
|
||||||
|
### Overview of the Connection Process — IESO
|
||||||
|
|
||||||
|
<https://www.ieso.ca/Sector-Participants/Connection-Process/Overview> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The process to connect a new facility or to modify an existing facility involves up to six stages as described below.
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
> You are required to successfully complete all applicable stages to receive final approval to connect your new or modified facility to the electricity system and begin commercial operation.
|
||||||
|
|
||||||
|
> The entire 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.
|
||||||
|
|
||||||
|
> 1. Prepare application — Planned connection of new facilities and modifications to existing facilities must be assessed to identify and mitigate any potential adverse effect on the reliability of the electricity grid and its existing customers. To ensure that the applicable processes are initiated, as a first step, please contact your transmitter or distributor.
|
||||||
|
|
||||||
|
> 2. Obtain conditional approval to connect — Your organization is required to obtain conditional approval for new or modified connections through the appropriate processes. Successful completion of the connection assessment process results in a conditional approval to connect. This stage typically takes one year.
|
||||||
|
|
||||||
|
> Transmission system connections — 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).
|
||||||
|
|
||||||
|
> Distribution system connections — New connections or modifications to facilities connected to a distributor's system must participate in the distributor's connection assessment process. As part of this process, distributors may need to participate in the IESO's and transmitter's connection assessment processes to obtain their conditional approval to connect on your behalf.
|
||||||
|
|
||||||
|
> 4. Authorize market and program participation — To participate in the IESO-controlled grid, IESO-administered markets or programs, you must register your organization with the IESO to authorize it as a market or program participant. This stage typically takes about one month.
|
||||||
|
|
||||||
|
> 5. Register equipment — The equipment, telemetry, and metering installed at your facility must be registered and tested with the IESO. The IESO uses the data provided by market participants for the systems and models that are necessary to maintain the reliability of the IESO-controlled grid and to operate the IESO-administered markets. This stage takes at least three months.
|
||||||
|
|
||||||
|
> Successful completion of all six stages, to the satisfaction of the IESO, is required to obtain final approval to connect to the IESO-controlled grid, and start commercial operation in the IESO-administered markets.
|
||||||
|
|
||||||
|
> If you're exploring connection options and aren't ready to submit a formal request, start with our Major Projects Identification Committees (MPIC) process.
|
||||||
|
|
||||||
|
> Whether your organization is an existing or potential market participant, you can explore options for a new or modified connection with the IESO's technical feasibility study (TFS). This optional, confidential service is provided on a cost-recovery basis to identify and mitigate potential issues with various connection options, and help participants select a final connection option.
|
||||||
|
|
||||||
|
### Stage 2 – Obtain conditional approval to connect — IESO
|
||||||
|
|
||||||
|
<https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> If you determined during the previous stage that your organization is required to participate in the IESO's and transmitter's connection assessment and approval (CAA) process, please contact the IESO for a pre-application meeting.
|
||||||
|
|
||||||
|
> Upon receipt, the IESO will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID.
|
||||||
|
|
||||||
|
> Step 2 – Execute the system impact assessment (SIA) agreement — After receiving your application and associated deposit for a connection assessment, the IESO will prepare an SIA agreement in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules for execution by your authorized representative.
|
||||||
|
|
||||||
|
> Step 3 – IESO completes SIA studies and produces draft report — Once you have provided all of the required information, the IESO will assess the impact of your proposed new or modified connection on the reliability of the integrated power system and issue a draft SIA report to your organization and the transmitter for review and comments.
|
||||||
|
|
||||||
|
> The transmitter generally initiates the customer impact assessment (CIA) after the draft SIA report from the IESO. A CIA agreement between the connection applicant and transmitter is also needed as part of the transmitter's CIA process.
|
||||||
|
|
||||||
|
> Step 4 – IESO issues final SIA report — After addressing comments received from your organization and the transmitter on the draft or revised draft SIA report, the IESO sends the final SIA report and one of the following to both parties: Notification of conditional approval (NoCA); Notification of disapproval with reasons (NoDR)
|
||||||
|
|
||||||
|
> The final SIA report will be published on the IESO website in the Application Status table at the end of the month in which it was finalized.
|
||||||
|
|
||||||
|
### Stage 4 – Authorize market and program participation — IESO
|
||||||
|
|
||||||
|
<https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Your organization must be authorized by the IESO to participate in the IESO-administered markets or programs, or to connect a physical facility to the IESO-controlled grid.
|
||||||
|
|
||||||
|
> Step 1 – Register organization — If your organization is new to the IESO, you must register prior to receiving authorization. To register, your organization must submit company information and appoint mandatory contact roles using the IESO's online application form... It may take up to three weeks to complete the registration process.
|
||||||
|
|
||||||
|
> Step 2 - Authorize as a market participant — The authorized representative appoints other roles, including the applicant representative, who will be responsible for authorizing your newly registered organization as a market participant using Online IESO (the IESO's web-based registration system).
|
||||||
|
|
||||||
|
> Paying the market registration application fee $1,130 ($1,000 + 13% HST)
|
||||||
|
|
||||||
|
> Providing the following: Ontario Energy Board (OEB) licence; National Energy Permit (for energy trader – exporter only); Prudential support if your organization plans to participate in the real-time IESO-administered markets
|
||||||
|
|
||||||
|
> Step 4 – IESO issues registration approval notification — Once the above authorization tasks are completed, the IESO will issue a registration approval notification (RAN) confirming that your organization has been approved by the IESO to participate in the IESO-administered markets and programs.
|
||||||
|
|
||||||
|
### Frequently Asked Questions - Connection Process — IESO
|
||||||
|
|
||||||
|
<https://www.ieso.ca/Sector-Participants/Connection-Process/Frequently-Asked-Questions> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> FAQ - Connection Assessments — What tools and models do the IESO use for System Impact Assessments (SIAs)? The IESO uses DSA and PSSE tools to conduct SIA studies.
|
||||||
|
|
||||||
|
> Is there an expedited process available in Ontario to skip the interconnection queue, similar to the US market? The IESO is not using an "interconnection queue", instead it adopted the concept of "committed projects" that is defined in Section 3.3 of Market Manual 1.4: Connection Assessment and Approval. As such, there is no option to "skip the interconnection queue", each assessment follows the timelines described in Section 5.8 of Market Manual 1.4: Connection Assessment and Approval.
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
> Where can I find the required application forms and data requirements for submitting a connection application to the IESO? You'll need to complete Form 128 and send it via email to connection.assessments@ieso.ca to initiate the SIA process.
|
||||||
|
|
||||||
|
### Bill 40, Protect Ontario by Securing Affordable Energy for Generations Act, 2025 — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Bill 40, Protect Ontario by Securing Affordable Energy for Generations Act, 2025
|
||||||
|
|
||||||
|
> Lecce, Hon. Stephen (Minister of Energy and Mines)
|
||||||
|
|
||||||
|
> Current status: Royal Assent received. Statutes of Ontario 2025, chapter 22
|
||||||
|
|
||||||
|
> Bill 40 has been enacted as Chapter 22 of the Statutes of Ontario, 2025.
|
||||||
|
|
||||||
|
> Bill 40 2025 — An Act to amend various statutes with respect to energy, the electrical sector and public utilities
|
||||||
|
|
||||||
|
> CONTENTS — Preamble; 1. Contents of this Act; 2. Commencement; 3. Short title; Schedule 1 Electricity Act, 1998; Schedule 2 Municipal Franchises Act; Schedule 3 Ontario Energy Board Act, 1998
|
||||||
|
|
||||||
|
> Preamble — The Government of Ontario is committed to: Building an affordable, secure, reliable and clean energy system to power the strongest economy in the G7. Prioritizing economic growth, job creation and strong governance as objectives of Ontario's energy system. Supporting the responsible growth of energy-intensive industries like data centres that align with Ontario's economic priorities and benefit local communities. Keeping our energy supply secure by supporting the sector with the implementation of limitations on foreign participation in Ontario's energy sector. Delivering affordable and clean energy for generations to power our economy and peoples' lives.
|
||||||
|
|
||||||
|
> Short title — 3 The short title of this Act is the Protect Ontario by Securing Affordable Energy for Generations Act, 2025.
|
||||||
|
|
||||||
|
> SCHEDULE 1 ELECTRICITY ACT, 1998 ... 4. New section 28.1 is added to the Act. It provides that transmitters or distributors shall not connect or reconnect certain load facilities from its transmission system or distribution system unless connection requirements that are specified in the regulations are met. Complementary amendments are made to the regulation-making authority in section 114.
|
||||||
|
|
||||||
|
> SCHEDULE 3 ONTARIO ENERGY BOARD ACT, 1998 ... 5. Section 96 of the Act is amended in respect of applications under section 92 of the Act (leave to construct, etc., electricity transmission or distribution line). Economic growth is added to the list of matters the Board is permitted to consider when deciding whether granting leave is in the public interest. As well, the Board is required to consider reports, documents or other information that may be specified by the regulations made under the Act in considering an application under section 92.
|
||||||
|
|
||||||
|
> SCHEDULE 3 ... 1. Subsection 1 (1) of the Act is amended to add a new objective of the Ontario Energy Board respecting economic growth in relation to the regulation of the electricity sector. 2. A new section 13.1 of the Act authorizes the Board's chief executive officer to issue internal policies respecting various procedural matters in relation to hearings and determinations.
|
||||||
|
|
||||||
|
> SCHEDULE 2 MUNICIPAL FRANCHISES ACT — ... Section 3 is re-enacted to remove the requirement for the municipal electors to assent to such a by-law and instead to require that a municipality pass a by-law setting out the terms and conditions.
|
||||||
|
|
||||||
|
### Bill 40 (44th Parliament, 1st Session) — Status — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40/status> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Date | Bill stage | Event | Outcome | Committee
|
||||||
|
|
||||||
|
> December 11, 2025 | Royal Assent | Royal Assent received | - | -
|
||||||
|
|
||||||
|
> December 8, 2025 | Third Reading | Vote | Carried on division | -
|
||||||
|
|
||||||
|
> December 3, 2025 | Second Reading | Reported as amended | - | Standing Committee on the Interior
|
||||||
|
|
||||||
|
> November 17, 2025 | Second Reading | Ordered referred to Standing Committee pursuant to the Order of the House | - | Standing Committee on the Interior
|
||||||
|
|
||||||
|
> June 3, 2025 | First Reading | Ordered for Second Reading | - | -
|
||||||
|
|
||||||
|
> June 3, 2025 | First Reading | Vote | Carried | -
|
||||||
|
|
||||||
|
### Bill 40, Moving Ontarians Safely Act, 2023 — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-40> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Parliament 43, Session 1 — Bill 40, Moving Ontarians Safely Act, 2023
|
||||||
|
|
||||||
|
> The Bill amends the Highway Traffic Act. It is about the legal consequences of a collision that seriously injures or kills a pedestrian, a cyclist, a mobility device user, a roadway worker, an emergency responder outside their motor vehicle or another individual listed in the Bill.
|
||||||
|
|
||||||
|
### Bill 40, Support for Adults in Need of Assistance Act, 2021 — Legislative Assembly of Ontario
|
||||||
|
|
||||||
|
<https://www.ola.org/en/legislative-business/bills/parliament-42/session-2/bill-40> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Parliament 42, Session 2 — Bill 40, Support for Adults in Need of Assistance Act, 2021
|
||||||
|
|
||||||
|
> The Bill enacts the Support for Adults in Need of Assistance Act, 2021. The Act requires regulated health professionals to report to a board of health if they have reasonable suspicion that an individual who is 16 years of age or older is being abused or neglected.
|
||||||
|
|
||||||
|
### Economic and Strategic Assessment Framework for New Data Centres — Environmental Registry of Ontario (ERO 026-0853), Ministry of Energy and Mines
|
||||||
|
|
||||||
|
<https://ero.ontario.ca/notice/026-0853> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Economic and Strategic Assessment Framework for New Data Centres — ERO number 026-0853 — Notice type: Regulation — Posted by: Ministry of Energy and Mines — Notice stage: Proposal — Proposal posted: August 13, 2026 — Comment period: August 13, 2026 - September 12, 2026 (30 days) Open — Last updated: August 13, 2026
|
||||||
|
|
||||||
|
> This consultation closes at 11:59 p.m. on: September 12, 2026
|
||||||
|
|
||||||
|
> To ensure responsible data centre growth, the government introduced legislative amendments to the Electricity Act, 1998 that provide the Lieutenant Governor in Council the authority to set out in regulation requirements that data centres covered by the regulation must meet before connecting or re-connecting to the electricity grid.
|
||||||
|
|
||||||
|
> Data centres that do not meet these requirements would not be able to connect (or re-connect) to the electricity grid. The province is considering drafting a proposed regulation that, if approved, would require new large data centres to obtain the approval of the government to connect or reconnect to the electricity grid.
|
||||||
|
|
||||||
|
> To guide such approvals and attract the best data centre investments that drive economic growth, ensure Canadians' data remains in Canada and deliver significant and meaningful benefits to local communities, Ontario is proposing a Data Centre Playbook.
|
||||||
|
|
||||||
|
> Assessing proposed data centres against these pillars could form part of the broader proposed regulatory data centre connection process requiring connection approval by the government, alongside other considerations related to electricity system reliability, technical feasibility and cost responsibility.
|
||||||
|
|
||||||
|
> Pillar 1: Advance Economic Development ... Pillar 2: Protect Data Security and Digital Sovereignty ... Pillar 3: Invest in Communities and Earn Public Trust
|
||||||
|
|
||||||
|
> The government is therefore exploring amendments to Ontario Regulation 429/04 under the Electricity Act, 1998 that would establish a new separate rate Class (e.g., Class C) for new data centres above a specific demand threshold (e.g., 1 MW). Facilities in this rate class would not be eligible to participate in the Industrial Conservation Initiative (ICI), a demand response program for large electricity customers.
|
||||||
|
|
||||||
|
> In addition, there are a significant number of data centres that have applied for connections. MEM estimates these proposals could total more than 10,000 MW cumulatively.
|
||||||
|
|
||||||
|
### New Requirements for Data Centres Seeking to Connect to the Electricity Grid in Ontario — Environmental Registry of Ontario (ERO 025-1001), Ministry of Energy and Mines
|
||||||
|
|
||||||
|
<https://ero.ontario.ca/notice/025-1001> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> New Requirements for Data Centres Seeking to Connect to the Electricity Grid in Ontario — ERO number 025-1001 — Notice type: Regulation — Posted by: Ministry of Energy and Mines — Notice stage: Proposal Updated — Proposal posted: September 5, 2025 — Comment period: September 5, 2025 - November 4, 2025 (60 days) Closed — Last updated: September 8, 2025
|
||||||
|
|
||||||
|
> The Ministry of Energy and Mines (MEM) introduced amendments to the Electricity Act, 1998 as part of Bill 40. If passed, the government is proposing to set out in regulation a process that will allow the Minister of Energy and Mines to prioritize and approve connection requests from data centre projects that serve the province's economic interests.
|
||||||
|
|
||||||
|
> The proposal contemplates implementing an approval process for connection requests of data centres covered by the proposed regulation.
|
||||||
|
|
||||||
|
> If the legislation passes, the proposed regulation will require covered data centre projects to receive approval from the Minister of Energy and Mines to connect to the provincial electricity grid.
|
||||||
|
|
||||||
|
> The data centre sector is forecast to represent about 13 per cent of new electricity demand in the province by 2035.
|
||||||
|
|
||||||
|
> The government is seeking input on the types of data centres that would be subject to the proposed requirement, including the electrica[l]
|
||||||
|
|
||||||
|
### Distribution System Code (DSC) — Ontario Energy Board
|
||||||
|
|
||||||
|
<https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/distribution-system-code-dsc> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Distribution System Code (DSC) — Sets out the minimum obligations that a licensed electricity distributor must meet in carrying out its obligations to distribute electricity within its service area under its licence.
|
||||||
|
|
||||||
|
> Last revised August 1, 2026
|
||||||
|
|
||||||
|
> Appendix I - Methodology for Implementing a Capacity Allocation Model (pdf)
|
||||||
|
|
||||||
|
> 40. Amendment to introduce Capacity Allocation Model (CAM) to facilitate housing development connections and to create Appendix I to set out the CAM methodology. This change came into force on September 16, 2025 (EB- 2024-0092).
|
||||||
|
|
||||||
|
> DER Connection Procedures (DERCP) - Version 3.0 (issued December 18, 2025, came into force on May 1, 2026)
|
||||||
|
|
||||||
|
> Electric Vehicle Charging Connection Procedures (EVCCP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
Each item names the source it rests on. An item here that no quotation above
|
||||||
|
supports is a defect in this file, not a fact.
|
||||||
|
|
||||||
|
- LEAVE TO CONSTRUCT — electricity: s. 92 (1) of the Ontario Energy Board Act, 1998 is the leave-to-construct provision for electricity lines. Operative words: "No person shall construct, expand or reinforce an electricity transmission line or an electricity distribution line or make an interconnection without first obtaining from the Board an order granting leave to construct, expand or reinforce such line or interconnection."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- LEAVE TO CONSTRUCT — hydrocarbon pipeline: s. 90 (1) is the separate leave-to-construct provision for hydrocarbon lines. Operative words: "No person shall construct a hydrocarbon line without first obtaining from the Board an order granting leave to construct the hydrocarbon line if," followed by four triggers — more than 20 km in length; projected cost above the prescribed amount; any part using pipe of nominal size 12 inches or more AND operating at 2,000 kilopascals or more; or criteria prescribed by the regulations. So s. 90 is threshold-based while s. 92 is not.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- Both provisions sit in Part VI of the Act ("TRANSMISSION AND DISTRIBUTION LINES"). Section 89 defines "electricity transmission line" as conveying electricity above 50 kilovolts, "electricity distribution line" as 50 kilovolts or less, and "hydrocarbon line" as a pipe line carrying any hydrocarbon other than one within a refinery, storage depot, chemical processing plant or pipe line terminal or station.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- The public-interest test is in s. 96 (1): if the Board "is of the opinion that the construction, expansion or reinforcement of the proposed work is in the public interest, it shall make an order granting leave to carry out the work." For s. 92 applications, s. 96 (2) confines what "public interest" may mean to two enumerated matters: "1. The interests of consumers with respect to prices and the reliability and quality of electricity service. 2. Supporting economic growth in a manner consistent with the policies of the Government of Ontario."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- The second branch of the s. 96 (2) test — "Supporting economic growth in a manner consistent with the policies of the Government of Ontario" — and the new s. 96 (3) duty to consider prescribed reports were added by 2025, c. 22, Sched. 3, s. 9, in force 11/12/2025. The e-Laws consolidation period for the Act begins December 11, 2025 and its last amendment is 2025, c. 22, Sched. 3.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- Exemptions from leave to construct are available under s. 95: the Board "may, if in its opinion special circumstances of a particular case so require, make an order exempting any person from the requirements of subsection 90 (1) or 92 (1) without a hearing" (s. 95 (1)), and "shall" exempt where prescribed circumstances are met (s. 95 (2)). Section 94 requires a route map to be filed with the application.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98o15>
|
||||||
|
- Section 96.1 (1) lets the Lieutenant Governor in Council declare a transmission line a "priority project". The OEB's own page states that even then, "OEB approval to build the line under section 92 of the Act is still required. However, in these cases the OEB must accept that the project is needed when forming its opinion under section 96 of the Act."
|
||||||
|
*Source:* <https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects>
|
||||||
|
- OEB's own description of the s. 92 process: "Section 92 of the Ontario Energy Board Act, 1998 (Act) requires transmitters and distributors to obtain approval from the OEB for the construction, expansion, or reinforcement of electricity transmission and distribution lines or interconnections."
|
||||||
|
*Source:* <https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects>
|
||||||
|
- As of retrieval on 2026-08-29, the OEB's priority-transmission page states: "There are currently no leave to construct applications before the OEB for approval to build a transmission line that has been declared to be a priority project under section 96.1 of the Act." (This is a point-in-time statement and will change.)
|
||||||
|
*Source:* <https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects>
|
||||||
|
- On the natural-gas side, the OEB publishes a Natural Gas Facilities Handbook giving guidance on, among others, "Section 90 and 91, Leave to Construct Applications", "Section 95, Exemption from the Requirements of Section 90 Applications", and "Section 99, Expropriation Applications related to Leave to Construct Approvals". On September 24, 2024 the OEB issued new filing requirements for exemption applications under ss. 90(2) and 95(2).
|
||||||
|
*Source:* <https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/natural-gas-facilities-handbook>
|
||||||
|
- O. Reg. 161/99 (Definitions and Exemptions) under the OEB Act, s. 6.2 (1), exempts several categories from s. 92 (1) — including "a person that constructs, expands or reinforces an electricity distribution line" and "a person that constructs or reinforces an electricity transmission line that is two kilometres or less in length". This is the source of the commonly cited 2 km transmission threshold; distribution lines are exempt outright by regulation notwithstanding their inclusion in s. 92 (1).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/990161>
|
||||||
|
- THE IESO, in its own words: "The IESO is the coordinator and integrator of Ontario's electricity system. Our system operators monitor the energy needs of the province in real time – 24 hours a day, 7 days a week – balancing supply and demand and directing the flow of electricity across Ontario's transmission lines." It names five reliability pillars: Capacity, Energy, Transmission, Operability and Ancillary Services.
|
||||||
|
*Source:* <https://ieso.ca/Learn/Ontario-Electricity-Grid/Managing-the-Power-System>
|
||||||
|
- The IESO's statutory objects are in s. 6 (1) of the Electricity Act, 1998 and include directing the operation and maintaining the reliability of the IESO-controlled grid (cl. c), operating the IESO-administered markets (cl. g), conducting independent planning (cl. l), and — added by Bill 40 — "to support economic growth in a manner that protects the interests of consumers" (cl. l.1).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||||
|
- MARKET PARTICIPATION — statutory definition: "market participant" means "a person who is authorized by the market rules to participate in the IESO-administered markets or to cause or permit electricity to be conveyed into, through or out of the IESO-controlled grid" (Electricity Act, 1998, s. 2 definitions). Section 6 (5) forbids the IESO from operating the markets in a way that "unjustly advantages or disadvantages any market participant or class of market participants".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||||
|
- MARKET PARTICIPATION — operationally, per the IESO: "To participate in the IESO-controlled grid, IESO-administered markets or programs, you must register your organization with the IESO to authorize it as a market or program participant." Registration runs through Online IESO, requires an OEB licence, prudential support for real-time market participation, and a market registration application fee of $1,130; it ends with the IESO issuing a "registration approval notification (RAN)".
|
||||||
|
*Source:* <https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation>
|
||||||
|
- CONNECTION PROCESS — the IESO's published connection process runs to **up to six** stages (⚠️ this read *"the IESO runs a six-stage connection process"* until 2026-09-03 — it gave the process to the IESO alone and stated the count unscoped, which are the two things the note below corrects; the quotation it rests on is the Overview's *"involves up to six stages"*): (1) Prepare application; (2) Obtain conditional approval to connect; (3) Design and build; (4) Authorize market and program participation; (5) Register equipment; (6) Commission equipment and validate performance. "New or modified connections to a transmitter's system are generally subject to all six stages, while new or modified connections to a distributor's system may only be subject to the first three."
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
|
||||||
|
- "System Impact Assessment" IS the IESO's real term, confirmed on multiple IESO pages. The IESO: "New connections or modifications to facilities connected to a transmitter's system are subject to the IESO's system impact assessment (SIA) and the transmitter's customer impact assessment (CIA)." The IESO conducts the SIA; the transmitter conducts the CIA.
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
|
||||||
|
- ⚠️ **CORRECTED 2026-09-03 — THIS LINE IS COMMENTARY AND IT MISATTRIBUTED THE PROCESS.** It read *"The umbrella name for the process is the 'connection assessment and approval (CAA)' process"*, and the pages took that from here: `/practice/energy/` published *"The IESO operates a six-stage connection process and calls it connection assessment and approval"* and `docs/01` directed *"CAA is the umbrella"*. **The IESO's own words, quoted above at the Stage 2 heading, are "the IESO's **and transmitter's** connection assessment and approval (CAA) process"**, and the Overview says the process *"involves **up to** six stages"*, scoped by connection type. Both were corrected on the pages the same day. This is `CLAUDE.md`'s point exactly — the quotations here are evidence, the prose around them is this repository's voice, and it is where a corrected page re-seeds if the commentary is left standing. The original line follows. The umbrella name for the process is the "connection assessment and approval (CAA)" process. On application the IESO "will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID". The SIA agreement is prepared "in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules". The IESO then "will assess the impact of your proposed new or modified connection on the reliability of the integrated power system" and issues a draft, then final, SIA report accompanied by either a "Notification of conditional approval (NoCA)" or a "Notification of disapproval with reasons (NoDR)".
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
|
||||||
|
- The transmitter "generally initiates the customer impact assessment (CIA) after the draft SIA report from the IESO", and a CIA agreement between the connection applicant and the transmitter is required as part of the transmitter's CIA process.
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
|
||||||
|
- Ontario does NOT operate an "interconnection queue". The IESO: "The IESO is not using an 'interconnection queue', instead it adopted the concept of 'committed projects' that is defined in Section 3.3 of Market Manual 1.4: Connection Assessment and Approval. As such, there is no option to 'skip the interconnection queue'."
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Frequently-Asked-Questions>
|
||||||
|
- Connection timelines, per the IESO: stage 2 (conditional approval) "typically takes one year"; stage 4 "typically takes about one month"; stage 5 "takes 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."
|
||||||
|
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
|
||||||
|
- LARGE LOADS / DATA CENTRES — there IS a real, in-force Ontario statutory process. Section 28.1 of the Electricity Act, 1998 ("Exceptions for specified load facilities") came into force 11/12/2025 (added by 2025, c. 22, Sched. 1, s. 7). It provides: "Unless a transmitter or distributor is satisfied that the specified connection requirements have been complied with, the transmitter or distributor shall not, (a) connect a specified load facility to its transmission system or distribution system... or (b) reconnect a specified load facility..."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||||
|
- "Specified load facility" is defined in s. 28.1 (1) as a facility or class of facilities "(a) that is a data centre and that meets any criteria that may be set out in the regulations", OR (b) one that withdraws electricity from the IESO-controlled grid or a licensed distributor's system, has demand at the point of connection "that exceeds the amount prescribed by the regulations", and meets any other prescribed criteria. So the section is expressly aimed at data centres and at large loads defined by a regulated demand threshold.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||||
|
- Section 28.1 (6) is a transition rule: the section "does not apply to a specified load facility in respect of which a connection request made in accordance with the Transmission System Code or the Distribution System Code issued under the Ontario Energy Board Act, 1998 was submitted to a transmitter or distributor, as applicable, before June 3, 2025" — i.e. the date Bill 40 received First Reading.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/98e15>
|
||||||
|
- ⚠️ **CORRECTED 2026-08-29 — "two LIVE consultations" was wrong, and the quoted notice data three sections above says so.** ERO 025-1001's comment period ran 5 September – **4 November 2025** and the notice reads **"Closed"**; only ERO 026-0853 (13 August – 12 September 2026) was open on the retrieval date. The overreach reached `src/data/practice-pages.ts` and was caught on a self-audit against this file before it shipped. The original wording of this item follows.
|
||||||
|
- ~~There are two live Ontario government consultations specifically about connecting data centres to the grid.~~ There are two Ontario government consultations specifically about connecting data centres to the grid, **one of them closed.** ERO 025-1001, "New Requirements for Data Centres Seeking to Connect to the Electricity Grid in Ontario" (Ministry of Energy and Mines, Regulation notice, Proposal, posted September 5, 2025, comment period closed November 4, 2025): "the proposed regulation will require covered data centre projects to receive approval from the Minister of Energy and Mines to connect to the provincial electricity grid."
|
||||||
|
*Source:* <https://ero.ontario.ca/notice/025-1001>
|
||||||
|
- ERO 026-0853, "Economic and Strategic Assessment Framework for New Data Centres" (Ministry of Energy and Mines, Regulation notice, Proposal, posted August 13, 2026, comment period August 13 – September 12, 2026, OPEN as at 2026-08-29). It proposes a "Data Centre Playbook" and a three-pillar assessment (Advance Economic Development; Protect Data Security and Digital Sovereignty; Invest in Communities and Earn Public Trust), and separately proposes "amendments to Ontario Regulation 429/04 under the Electricity Act, 1998 that would establish a new separate rate Class (e.g., Class C) for new data centres above a specific demand threshold (e.g., 1 MW)", whose facilities "would not be eligible to participate in the Industrial Conservation Initiative (ICI)".
|
||||||
|
*Source:* <https://ero.ontario.ca/notice/026-0853>
|
||||||
|
- As at August 13, 2026 the connection-approval regulation under s. 28.1 was still prospective in the Ministry's own words: "The province is considering drafting a proposed regulation that, if approved, would require new large data centres to obtain the approval of the government to connect or reconnect to the electricity grid."
|
||||||
|
*Source:* <https://ero.ontario.ca/notice/026-0853>
|
||||||
|
- BILL 40 — the energy Bill 40 is Bill 40 of the 44th Parliament, 1st Session: "Protect Ontario by Securing Affordable Energy for Generations Act, 2025", sponsored by Hon. Stephen Lecce (Minister of Energy and Mines). Long title: "An Act to amend various statutes with respect to energy, the electrical sector and public utilities". Status: "Royal Assent received. Statutes of Ontario 2025, chapter 22". It has three schedules: Electricity Act, 1998; Municipal Franchises Act; Ontario Energy Board Act, 1998.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40>
|
||||||
|
- Bill 40 (44-1) timeline: First Reading June 3, 2025; referred to the Standing Committee on the Interior November 17, 2025; reported as amended December 3, 2025; Third Reading vote carried on division December 8, 2025; Royal Assent December 11, 2025.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40/status>
|
||||||
|
- Bill 40's preamble names data centres expressly: the Government of Ontario is committed to "Supporting the responsible growth of energy-intensive industries like data centres that align with Ontario's economic priorities and benefit local communities."
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40>
|
||||||
|
- Bill 40 is the direct link between the two topics above: its Schedule 1 added Electricity Act s. 28.1 (data centre / large load connection requirements) and its Schedule 3 amended OEB Act s. 96 so that "Economic growth is added to the list of matters the Board is permitted to consider when deciding whether granting leave is in the public interest" on a s. 92 leave-to-construct application, and required the Board to consider prescribed reports and documents.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40>
|
||||||
|
- THE TRAP CONFIRMED — "Bill 40" is reused every parliament and most Bill 40s are unrelated to energy. Two verified non-energy examples: Bill 40 of the 43rd Parliament, 1st Session is the "Moving Ontarians Safely Act, 2023", amending the Highway Traffic Act; and Bill 40 of the 42nd Parliament, 2nd Session is the "Support for Adults in Need of Assistance Act, 2021", on reporting abuse or neglect of adults. Only the 44-1 Bill 40 is the energy bill. Any reference to "Bill 40" in an Ontario energy context must be cited as Bill 40, 44th Parliament, 1st Session / S.O. 2025, c. 22.
|
||||||
|
*Source:* <https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-40>
|
||||||
|
- A real Ontario term adjacent to "connection allocation" is the OEB's "Capacity Allocation Model (CAM)" in the Distribution System Code: DSC amendment 40 "introduce[d] Capacity Allocation Model (CAM) to facilitate housing development connections and to create Appendix I to set out the CAM methodology. This change came into force on September 16, 2025 (EB- 2024-0092)." The DSC also carries DER Connection Procedures (DERCP) and Electric Vehicle Charging Connection Procedures (EVCCP).
|
||||||
|
*Source:* <https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/distribution-system-code-dsc>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.** It is the half that keeps a page
|
||||||
|
honest, and on this project it is the half that has twice been skipped.
|
||||||
|
|
||||||
|
- **Is "connection allocation" an actual Ontario term of art for the process by which a generator or large load gets grid access?**
|
||||||
|
- *Searched:* Full-text grep for "allocation" and "connection allocation" across all four IESO pages fetched (Connection Process Overview, Stage 2 Obtain Approval, Stage 4 Authorize Participation, Connection Process FAQ); plus WebSearch for '"connection allocation" Ontario electricity IESO OEB'.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — zero hits for "allocation" of any kind in the IESO connection-process pages. Do not use "connection allocation" as an Ontario term. The correct terms, all verified above, are: connection assessment and approval (CAA), System Impact Assessment (SIA, by the IESO), Customer Impact Assessment (CIA, by the transmitter), and Notification of Conditional Approval (NoCA). A distinct and real OEB term is "Capacity Allocation Model (CAM)" in the Distribution System Code, but it is about distribution capacity for housing developments, not transmission connection generally.
|
||||||
|
- **Has the implementing regulation under Electricity Act s. 28.1 (defining "data centre", the demand threshold for a "specified load facility", and the "specified connection requirements") actually been made?**
|
||||||
|
- *Searched:* e-Laws act-reg API listing of current regulations under the Electricity Act, 1998 (returned 50 current regulations, none titled for data centres or specified load facilities); ERO notices 025-1001 and 026-0853 fetched in full; WebSearch for '"O. Reg." Ontario "data centre" Electricity Act 1998 connection regulation'.
|
||||||
|
- *Outcome:* NOT ESTABLISHED either way, and DO NOT ASSERT ITS ABSENCE. What IS established is that as at August 13, 2026 the Ministry of Energy and Mines described the connection-approval regulation as something "the province is considering drafting". The 50-item regulation list may have been truncated by a page cap, and criteria could in principle be added to an existing regulation rather than a new one. Safe wording: "the enabling section is in force since 11 December 2025; the Ministry's August 2026 consultation still described the connection-approval regulation as under consideration." Re-verify before publishing anything about the regulation's status.
|
||||||
|
- **Is there a dedicated IESO connection process, page, or queue specifically for large loads or data centres (as distinct from the general six-stage process)?**
|
||||||
|
- *Searched:* WebSearch restricted to ieso.ca for '"data centre" OR "large load" connection IESO Ontario'; plus review of the four IESO connection-process pages fetched.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — no IESO page describing a data-centre- or large-load-specific connection process was found. The general six-stage CAA/SIA/CIA process appears to apply to load facilities as it does to generation (the IESO's CAA application form has a "Load Facilities" variant). An IESO planning paper titled "Demand & Conservation Planning Technical Paper: Large Step Loads" appeared in search results but was NOT fetched and is a planning/forecasting document, not a connection process. Note that several search results for "large load connection process" were about the AESO in Alberta, not Ontario — do not confuse the two.
|
||||||
|
- **Does the OEB publish a general (non-priority-project) explainer page for the electricity leave-to-construct process?**
|
||||||
|
- *Searched:* WebSearch on oeb.ca for leave-to-construct application/filing-requirements pages; fetched the OEB priority-transmission leave-to-construct page and the Natural Gas Facilities Handbook page.
|
||||||
|
- *Outcome:* PARTIALLY ESTABLISHED. The OEB page fetched and quoted is specifically the priority-transmission-projects page, though its text describes ss. 92, 96(1) and 96.1(1) generally. An 'OEB-Electricity-Leave-to-Construct-Filing-Requirements-20230316.pdf' and an 'issues-list-LTC-electricity.pdf' appeared in search results but were NOT fetched, and the 2023 filing requirements would predate the 2024 and 2025 statutory amendments verified above. Do not cite them.
|
||||||
|
- **Typical duration or cost of an OEB section 92 leave-to-construct hearing.**
|
||||||
|
- *Searched:* WebSearch for the OEB section 92 application process (a snippet asserted 'several months'); no primary OEB page fetched that states a timeline.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — the 'several months' figure came only from a search-result snippet, not from a fetched OEB source. Do not publish any duration or cost figure for a leave-to-construct proceeding. The only timeline figures verified in this research are the IESO connection-process stage timings, which are a different process.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `WebSearch: IESO "System Impact Assessment" connection assessment procedure ieso.ca`
|
||||||
|
- `WebSearch: Ontario Energy Board "leave to construct" section 92 application process oeb.ca`
|
||||||
|
- `WebSearch: Ontario Bill 40 site:ola.org`
|
||||||
|
- `WebSearch: Ontario regulation "specified load facility" data centre connection Electricity Act 28.1 consultation`
|
||||||
|
- `WebSearch: "O. Reg." Ontario "data centre" Electricity Act 1998 connection regulation ontario.ca/laws 2026`
|
||||||
|
- `WebSearch: IESO large load data centre connection process ieso.ca`
|
||||||
|
- `WebSearch: oeb.ca "leave to construct" natural gas pipeline application filing requirements process page`
|
||||||
|
- `WebSearch: IESO "market registration" become a market participant registration process ieso.ca overview`
|
||||||
|
- `WebSearch (allowed_domains restricted to ieso.ca): "data centre" OR "large load" connection IESO Ontario`
|
||||||
|
- `WebSearch: "connection allocation" Ontario electricity IESO OEB`
|
||||||
|
- `WebFetch: https://www.ontario.ca/laws/statute/98e15 — FAILED, returned only the SPA shell with no statute text`
|
||||||
|
- `WebFetch: https://www.canlii.org/en/on/laws/stat/so-1998-c-15-sch-b/latest/so-1998-c-15-sch-b.html — FAILED, HTTP 403 (CanLII blocks both WebFetch and curl)`
|
||||||
|
- `e-Laws API: GET https://www.ontario.ca/laws/api/v2/laws/autocomplete?term=Ontario%20Energy%20Board%20Act — used to establish the correct e-Laws alias`
|
||||||
|
- `e-Laws API: GET https://www.ontario.ca/laws/api/v2/laws/autocomplete?term=Definitions%20and%20Exemptions — used to find the alias for O. Reg. 161/99`
|
||||||
|
- `e-Laws API: GET https://www.ontario.ca/laws/api/v2/legislation/en/act-reg/statute?title=electricity%20act,%201998&sort=citation — listed 50 current regulations under the Electricity Act, 1998`
|
||||||
|
- `curl (Googlebot UA, required for e-Laws prerender): https://www.ontario.ca/laws/statute/98o15 — Ontario Energy Board Act, 1998`
|
||||||
|
- `curl (Googlebot UA): https://www.ontario.ca/laws/statute/98e15 — Electricity Act, 1998`
|
||||||
|
- `curl (Googlebot UA): https://www.ontario.ca/laws/regulation/990161 — O. Reg. 161/99`
|
||||||
|
- `curl: https://www.oeb.ca/applications/applications-oeb/leave-construct-applications-priority-transmission-projects`
|
||||||
|
- `curl: https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/natural-gas-facilities-handbook`
|
||||||
|
- `curl: https://www.oeb.ca/regulatory-rules-and-documents/rules-codes-and-requirements/distribution-system-code-dsc`
|
||||||
|
- `curl: https://www.ieso.ca/en/Learn/About-the-IESO/What-We-Do (redirects to https://ieso.ca/Learn/Ontario-Electricity-Grid/Managing-the-Power-System)`
|
||||||
|
- `curl: https://www.ieso.ca/Sector-Participants/Connection-Process/Overview`
|
||||||
|
- `curl: https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval`
|
||||||
|
- `curl: https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation`
|
||||||
|
- `curl: https://www.ieso.ca/Sector-Participants/Connection-Process/Frequently-Asked-Questions`
|
||||||
|
- `curl: https://www.ola.org/en/legislative-business/bills/parliament-44/session-1/bill-40 and /status`
|
||||||
|
- `curl: https://www.ola.org/en/legislative-business/bills/parliament-43/session-1/bill-40`
|
||||||
|
- `curl: https://www.ola.org/en/legislative-business/bills/parliament-42/session-2/bill-40`
|
||||||
|
- `curl: https://ero.ontario.ca/notice/026-0853`
|
||||||
|
- `curl: https://ero.ontario.ca/notice/025-1001`
|
||||||
|
- `METHOD NOTE 1 — e-Laws (ontario.ca/laws) is a React SPA. A normal fetch returns a 54 KB shell containing ZERO statute text and no error. Fetching with a Googlebot user-agent returns the full prerendered HTML (672 KB for the OEB Act, 909 KB for the Electricity Act). Anyone re-verifying these quotes must use the bot UA or the quotes will appear unverifiable.`
|
||||||
|
- `METHOD NOTE 2 — the e-Laws alias for the Ontario Energy Board Act, 1998 is 98o15 (S.O. 1998, c. 15, Sched. B). 98e15 is the Electricity Act, 1998 (Sched. A). My first fetch used 98e15 for the OEB Act and would have sourced the wrong statute; the autocomplete API caught it. Both statutes are needed here and they are easy to transpose.`
|
||||||
|
- `ARTEFACTS — all fetched HTML and extracted text saved under /private/tmp/claude-501/-Users-pouya-Dev-Websites-adr-sml/2e628a52-3cc2-46e4-a4c9-dc1e5273a175/scratchpad/ : g.html + g.txt (OEB Act), ea.html + ea.txt (Electricity Act), r161.html + r161.txt (O. Reg. 161/99), oeb_ltc.*, oeb_ngfh.*, oeb_dsc.*, ieso_Overview.*, ieso_Obtain-Approval.*, ieso_stage4.*, ieso_faq.*, ieso_What-We-Do.*, bill40.*, b40status.*, b40_43.html, b40_42.html, ero_026-0853.*, ero_025-1001.*. These are in a scratchpad, NOT in the repo — under the project's own rule that a claim's supporting artefact must be reachable from the repository, they must be committed (or a faithful extract with provenance committed) before any of these facts goes on a public page.`
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Reference — Ontario's training requirements for family arbitrators
|
||||||
|
|
||||||
|
**Why this file exists.** `AGENTS.md` §4 Offerings rests on a proposition about
|
||||||
|
what Ontario law does and does not gate. R14: *anything a spec makes a claim
|
||||||
|
about must be reachable from the repository* — a claim whose source lives only
|
||||||
|
at a URL is one a reviewer can be asked to trust rather than check. This is the
|
||||||
|
extract, with its provenance and the command that produced it.
|
||||||
|
|
||||||
|
It is **not** legal advice and it is not a substitute for the instruments
|
||||||
|
themselves. It records what one government page said on one day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Source | `https://www.ontario.ca/page/training-family-arbitrators` |
|
||||||
|
| Retrieved | **2026-08-27** |
|
||||||
|
| Method | `WebFetch` — page converted to markdown, then queried for verbatim requirements, hour figures, the lawyer / non-lawyer distinction, ongoing-training period, and every statute or regulation named |
|
||||||
|
| Retrieved by | Claude Code, on Pouya's instruction of 2026-08-27 (Q39) |
|
||||||
|
| Cited by Pouya | Yes — this is the source named in his Q39 ruling, with the same three hour figures |
|
||||||
|
|
||||||
|
**Re-derive it:** fetch the URL and read it. If the page has changed, record the
|
||||||
|
change here rather than editing the extract — a stale extract with a date is
|
||||||
|
useful; a silently updated one is not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the page states
|
||||||
|
|
||||||
|
Quoted as retrieved. Where the page's own wording is reproduced it is in
|
||||||
|
quotation marks.
|
||||||
|
|
||||||
|
**Screening for domestic violence and power imbalances.** "at least 14 hours
|
||||||
|
(within one week) to learn about screening parties for domestic violence and
|
||||||
|
power imbalances".
|
||||||
|
|
||||||
|
**Ontario family law — non-lawyers only.** "All family law arbitrators who are
|
||||||
|
not a part of the Ontario Bar, or another Canadian bar, must complete 30 hours
|
||||||
|
of training about Ontario family law." The page adds that "You do not need to
|
||||||
|
complete this training all at once."
|
||||||
|
|
||||||
|
**Members of the Ontario Bar.** No hour figure. The page states instead that
|
||||||
|
"you should ensure you are familiar with family law to fulfil your professional
|
||||||
|
obligation to provide services competently."
|
||||||
|
|
||||||
|
**Ongoing training.** "10 hours over any two-year period. Five of these hours
|
||||||
|
must be related to domestic violence or power imbalance issues".
|
||||||
|
|
||||||
|
**Statute named on the page.** *Arbitration Act, 1991*. **No section number and
|
||||||
|
no regulation (`O. Reg.`) number appears on the page.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the page does NOT state — and this half matters more
|
||||||
|
|
||||||
|
**It says nothing about commercial arbitration.** Commercial arbitration is
|
||||||
|
neither mentioned nor excluded. The page does not state which arbitrations the
|
||||||
|
requirements apply to beyond describing them as family arbitration.
|
||||||
|
|
||||||
|
So the source establishes the **family** half of §4's scoped proposition
|
||||||
|
directly, and the **commercial** half only by *absence* — a page about family
|
||||||
|
arbitrators is not authority for what commercial arbitrators need. §4 records
|
||||||
|
the commercial half as **Pouya's stated position**, attributed to him and
|
||||||
|
deliberately unstamped, for exactly that reason.
|
||||||
|
|
||||||
|
**Nothing on the site turns on the gated activity.** Pouya has confirmed he does
|
||||||
|
not accept family arbitration under the *Family Law Act* (§4 Offerings, scope
|
||||||
|
exclusion). The requirements above are recorded because the register reasoned
|
||||||
|
from a false universal for a day and must not do so again — not because the
|
||||||
|
practice sits anywhere near them.
|
||||||
|
|
||||||
|
**Do not upgrade this file into an authority it is not.** If a stronger source
|
||||||
|
is ever wanted — the *Family Law Act* provisions and the regulation made under
|
||||||
|
it — fetch and extract those, name them by number, and date them. Do not write
|
||||||
|
a section number from memory.
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# Ontario SABS and the Licence Appeal Tribunal — the regulation, the MIG, the forum, and published caseload
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.** Fetched from the primary sources listed below and
|
||||||
|
extracted with quotations pasted verbatim. This file is the artefact; the pages
|
||||||
|
cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a regulation and a tribunal page all move.** Every consolidation
|
||||||
|
> date and effective date below is as at the retrieval date and nothing more.
|
||||||
|
> Re-check before cutover, and before any republish that turns on one of them.
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
|
||||||
|
> the sources; this stamp does not restate them. **The quoted bytes below
|
||||||
|
> are still the original retrieval and were not re-fetched** — what was
|
||||||
|
> re-checked is whether the *facts* they support have moved.
|
||||||
|
>
|
||||||
|
> - **(d) the Tribunals Ontario annual report — RE-VERIFIED UNMOVED**
|
||||||
|
> `[re-checked 2026-09-01 — Pouya,
|
||||||
|
> <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`.
|
||||||
|
> **No 2025-26 report is published; FY2024-25 remains current**, so the
|
||||||
|
> figures `/practice/insurance/` publishes are still the latest. This closes
|
||||||
|
> the open item at the foot of this file, which asked exactly that question.
|
||||||
|
> - **(e) the SABS, amended with effect 2026-07-01 — NOT RE-RETRIEVED.** Held
|
||||||
|
> unchanged on a cadence judgement `[assumed 2026-09-01 — Pouya]`.
|
||||||
|
|
||||||
|
**Topic as researched:** Ontario accident benefits (SABS) disputes — regulation, Minor Injury Guideline, the LAT-AABS dispute route, tribunal caseload volume, mediation references in tribunal materials, and FSRA's role
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| regulation | O. Reg. 34/10: STATUTORY ACCIDENT BENEFITS SCHEDULE - EFFECTIVE SEPTEMBER 1, 2010 — Ontario e-Laws | <https://www.ontario.ca/laws/regulation/100034> |
|
||||||
|
| statute | Insurance Act, R.S.O. 1990, c. I.8 — Ontario e-Laws | <https://www.ontario.ca/laws/statute/90i08> |
|
||||||
|
| statute | Licence Appeal Tribunal Act, 1999, S.O. 1999, c. 12, Sched. G — Ontario e-Laws | <https://www.ontario.ca/laws/statute/99l12> |
|
||||||
|
| tribunal | Licence Appeal Tribunal - Automobile Accident Benefits Service (LAT-AABS) — Tribunals Ontario | <https://tribunalsontario.ca/lat-aabs/> |
|
||||||
|
| tribunal | Laws, rules and decisions — LAT-AABS, Tribunals Ontario | <https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/> |
|
||||||
|
| tribunal | Application and hearing process — LAT-AABS, Tribunals Ontario | <https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/> |
|
||||||
|
| tribunal | Tribunals Ontario 2024-25 Annual Report | <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html> |
|
||||||
|
| tribunal | Licence Appeal Tribunal Rules (2025 consolidation) — Tribunals Ontario | <https://tribunalsontario.ca/documents/lat/LAT-Rules_2025.html> |
|
||||||
|
| statute | Financial Services Regulatory Authority of Ontario Act, 2016, S.O. 2016, c. 37, Sched. 8 — Ontario e-Laws | <https://www.ontario.ca/laws/statute/16f37> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### O. Reg. 34/10: STATUTORY ACCIDENT BENEFITS SCHEDULE - EFFECTIVE SEPTEMBER 1, 2010 — Ontario e-Laws
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/regulation/100034> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> O. Reg. 34/10: STATUTORY ACCIDENT BENEFITS SCHEDULE - EFFECTIVE SEPTEMBER 1, 2010
|
||||||
|
|
||||||
|
> <meta property="og:actTitle" content="Insurance Act" />
|
||||||
|
|
||||||
|
> ONTARIO REGULATION 34/10
|
||||||
|
|
||||||
|
> Consolidation Period: From July 1, 2026 to the e-Laws currency date.
|
||||||
|
|
||||||
|
> Last amendment: 58/25.
|
||||||
|
|
||||||
|
> Definitions and interpretation
|
||||||
|
|
||||||
|
> 3. (1) In this Regulation,
|
||||||
|
|
||||||
|
> “minor injury” means one or more of a sprain, strain, whiplash associated disorder, contusion, abrasion, laceration or subluxation and includes any clinically associated sequelae to such an injury; (“blessure légère”)
|
||||||
|
|
||||||
|
> “Minor Injury Guideline” means a guideline, (a) that is issued by the Chief Executive Officer under subsection 268.3 (1.1) of the Act and published in The Ontario Gazette, and (b) that establishes a treatment framework in respect of one or more minor injuries; (“Directive sur les blessures légères”)
|
||||||
|
|
||||||
|
> “Guideline” means, (a) a guideline, including the Minor Injury Guideline, issued by the Chief Executive Officer under subsection 268.3 (1) of the Act and published in The Ontario Gazette,
|
||||||
|
|
||||||
|
> Monetary limits re medical and rehabilitation benefits
|
||||||
|
|
||||||
|
> 18. (1) The sum of the medical and rehabilitation benefits payable in respect of an insured person who sustains an impairment that is predominantly a minor injury shall not exceed $3,500 plus the amount of any applicable harmonized sales tax payable under Part IX of the Excise Tax Act (Canada) for accidents that occur on or after June 3, 2019 for any one accident, less the sum of all amounts paid in respect of the insured person in accordance with the Minor Injury Guideline. O. Reg. 34/10, s. 18 (1); O. Reg. 123/19, s. 2 (1).
|
||||||
|
|
||||||
|
> (2) Despite subsection (1), the limit in that subsection does not apply to an insured person if his or her health practitioner determines and provides compelling evidence that the insured person has a pre-existing medical condition that was documented by a health practitioner before the accident and that will prevent the insured person from achieving maximal recovery from the minor injury if the insured person is subject to the limit or is limited to the goods and services authorized under the Minor Injury Guideline. O. Reg. 34/10, s. 18 (2); O. Reg. 347/13, s. 1; O. Reg. 123/19, s. 2 (2).
|
||||||
|
|
||||||
|
> (3) The sum of the medical, rehabilitation and attendant care benefits paid in respect of an insured person who is not subject to the financial limit in subsection (1) shall not exceed, for any one accident, (a) $65,000 plus the amount of any applicable harmonized sales tax payable under Part IX of the Excise Tax Act (Canada) for accidents that occur on or after June 3, 2019; or (b) if the insured person sustained a catastrophic impairment as a result of the accident, $1,000,000
|
||||||
|
|
||||||
|
> “Old Regulation” means Ontario Regulation 403/96 (Statutory Accident Benefits Schedule — Accidents on or After November 1, 1996), made under the Act; (“ancien règlement”)
|
||||||
|
|
||||||
|
> (a) that are authorized by, and calculated by applying the rates set out in, the most recent transportation expense guideline published by the Financial Services Regulatory Authority of Ontario, and
|
||||||
|
|
||||||
|
> An application under subsection 280 (2) of the Act in respect of a benefit shall be commenced within two years after the insurer’s refusal to pay the amount claimed. O. Reg. 44/16, s. 6.
|
||||||
|
|
||||||
|
### Insurance Act, R.S.O. 1990, c. I.8 — Ontario e-Laws
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90i08> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Dispute Resolution — Statutory Accident Benefits
|
||||||
|
|
||||||
|
> 279 For the purposes of sections 280 to 283, “insured person” includes a person who is claiming funeral expenses or a death benefit under the Statutory Accident Benefits Schedule; (“personne assurée”)
|
||||||
|
|
||||||
|
> “Licence Appeal Tribunal” means the Licence Appeal Tribunal established under the Licence Appeal Tribunal Act, 1999. (“Tribunal d’appel en matière de permis”) 2014, c. 9, Sched. 3, s. 14.
|
||||||
|
|
||||||
|
> Resolution of disputes
|
||||||
|
|
||||||
|
> 280 (1) This section applies with respect to the resolution of disputes in respect of an insured person’s entitlement to statutory accident benefits or in respect of the amount of statutory accident benefits to which an insured person is entitled. 2014, c. 9, Sched. 3, s. 14.
|
||||||
|
|
||||||
|
> Application to Tribunal (2) The insured person or the insurer may apply to the Licence Appeal Tribunal to resolve a dispute described in subsection (1). 2014, c. 9, Sched. 3, s. 14.
|
||||||
|
|
||||||
|
> Limit on court proceedings (3) No person may bring a proceeding in any court with respect to a dispute described in subsection (1), other than an appeal from a decision of the Licence Appeal Tribunal or an application for judicial review. 2014, c. 9, Sched. 3, s. 14.
|
||||||
|
|
||||||
|
> Resolution in accordance with Schedule (4) The dispute shall be resolved in accordance with the Statutory Accident Benefits Schedule. 2014, c. 9, Sched. 3, s. 14.
|
||||||
|
|
||||||
|
> 2014, c. 9, Sched. 3, s. 14 - 01/04/2016
|
||||||
|
|
||||||
|
> “Chief Executive Officer” means the Chief Executive Officer appointed under subsection 10 (2) of the Financial Services Regulatory Authority of Ontario Act, 2016; (“directeur général de l’Autorité”)
|
||||||
|
|
||||||
|
> 268.3 (1) The Chief Executive Officer may issue guidelines on the interpretation and operation of the Statutory Accident Benefits Schedule or any provision of that Schedule. 1993, c. 10, s. 27; 1997, c. 28, s. 116; 2018, c. 8, Sched. 13, s. 22.
|
||||||
|
|
||||||
|
> Same (1.1) The Chief Executive Officer may issue guidelines setting out the treatment, services, measures or goods applicable in respect of types of impairments for the purposes of payment of a medical or rehabilitation benefit provided under the Statutory Accident Benefits Schedule, and such guidelines may include conditions, restrictions and limits with respect to such treatment, services, measures or goods. 2002, c. 22, s. 125; 2018, c. 8, Sched. 13, s. 22.
|
||||||
|
|
||||||
|
> Effect of guideline (2) Subject to section 268.2, a guideline shall be considered in any determination involving the interpretation of the Statutory Accident Benefits Schedule. 1993, c. 10, s. 27.
|
||||||
|
|
||||||
|
### Licence Appeal Tribunal Act, 1999, S.O. 1999, c. 12, Sched. G — Ontario e-Laws
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/99l12> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Licence Appeal Tribunal Act, 1999, S.O. 1999, c. 12, Sched. G
|
||||||
|
|
||||||
|
> S.O. 1999, CHAPTER 12 Schedule G
|
||||||
|
|
||||||
|
> Consolidation Period: From January 1, 2025 to the e-Laws currency date.
|
||||||
|
|
||||||
|
> Tribunal established 2 (1) There is hereby established a tribunal to be known in English as the Licence Appeal Tribunal and in French as Tribunal d’appel en matière de permis. 1999, c. 12, Sched. G, s. 2 (1).
|
||||||
|
|
||||||
|
> Members (2) The Tribunal shall consist of not fewer than three members. 1999, c. 12, Sched. G, s. 2 (2).
|
||||||
|
|
||||||
|
### Licence Appeal Tribunal - Automobile Accident Benefits Service (LAT-AABS) — Tribunals Ontario
|
||||||
|
|
||||||
|
<https://tribunalsontario.ca/lat-aabs/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Resolve a dispute about an insured person’s entitlement to, or amount of, statutory motor vehicle accident benefits
|
||||||
|
|
||||||
|
> A person who is injured in an automobile accident can apply to LAT-AABS if there is a disagreement about their entitlement to accident benefits or the amount of benefits that should be paid.
|
||||||
|
|
||||||
|
> If an insurance company believes an individual has been paid too much under their policy, the company can file a LAT-AABS application to have the money returned.
|
||||||
|
|
||||||
|
> The LAT has two divisions – LAT-AABS and LAT-GS (General Service). LAT-GS resolves a variety of appeals including vehicle impoundments, licence suspensions, monetary orders, licensing, consumer compensation claims, and compliance orders.
|
||||||
|
|
||||||
|
### Laws, rules and decisions — LAT-AABS, Tribunals Ontario
|
||||||
|
|
||||||
|
<https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> The Insurance Act and the Statutory Accident Benefits Schedule (SABS) make the LAT-AABS responsible for resolving disputes over automobile accident benefits:
|
||||||
|
|
||||||
|
> Section 280 of the Insurance Act says that a person or insurance company may apply to the LAT-AABS to resolve a dispute about an insured person’s entitlement to, or amount of, a statutory motor vehicle accident benefit.
|
||||||
|
|
||||||
|
> Section 268 of the Insurance Act says that every motor vehicle liability policy provides specified benefits which are set out in the Statutory Accident Benefits Schedule (SABS), a regulation made under the Insurance Act.
|
||||||
|
|
||||||
|
> Sections 279-288 of the Insurance Act deal with disputes about motor vehicle accident insurance benefits.
|
||||||
|
|
||||||
|
> Related laws Licence Appeal Tribunal Act Insurance Act Statutory Powers Procedure Act Compulsory Automobile Insurance Act Motor Vehicle Accident Claims Act Auto Insurance Rate Stabilization Act
|
||||||
|
|
||||||
|
> Related regulations Statutory Accident Benefits Schedule, O. Reg. 34/10 (Effective September 1, 2010)
|
||||||
|
|
||||||
|
> Rules of Practice Licence Appeal Tribunal Rules, 2023 (effective November 17, 2025)
|
||||||
|
|
||||||
|
### Application and hearing process — LAT-AABS, Tribunals Ontario
|
||||||
|
|
||||||
|
<https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> 4. Consider other ways to resolve your dispute
|
||||||
|
|
||||||
|
> Before you apply to the LAT-AABS, you may want to consider negotiation or mediation services. Parties are encouraged to attempt to negotiate the claim at all times, including before filing at the LAT-AABS, and continuing negotiation discussions after a claim has been filed.
|
||||||
|
|
||||||
|
> The SABS is the framework that the LAT-AABS uses to help you and your motor vehicle insurance company reach a settlement. The SABS is a regulation under the Insurance Act that sets out the benefits and compensation that may be available to you as a driver, passenger, or pedestrian if you have been hurt in a motor vehicle accident.
|
||||||
|
|
||||||
|
> You are encouraged to talk to your insurance company and try to settle your dispute before starting the LAT-AABS process. Settling your dispute with your insurance company is the fastest and least costly way to resolve your claim.
|
||||||
|
|
||||||
|
> You must file an application within two years after receiving this notice from the insurance company.
|
||||||
|
|
||||||
|
> A case conference is an opportunity for parties to attempt to settle their cases and, if a settlement is not reached, an adjudicator from the
|
||||||
|
|
||||||
|
> The goals of the case conference are to: help the parties reach a settlement;
|
||||||
|
|
||||||
|
> A case conference is led by an adjudicator whose role is to guide and support the parties in working to resolve the dispute. The adjudicator is trained to understand accident benefits disputes and will provide his or her view on what could happen if the case went to a hearing.
|
||||||
|
|
||||||
|
> the LAT-AABS will schedule a two-hour case conference for the parties to meet with an adjudicator. Case conferences will usually take place within 45-60 days of the response being received. The case conference will usually take place by telephone.
|
||||||
|
|
||||||
|
> Changes to the Statutory Accident Benefits Schedule effective July 1, 2026 may impact your insurance benefits and the rules that apply to them.
|
||||||
|
|
||||||
|
### Tribunals Ontario 2024-25 Annual Report
|
||||||
|
|
||||||
|
<https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Tribunals Ontario 2024-25 Annual Report
|
||||||
|
|
||||||
|
> This report reflects the agency's accomplishments for the fiscal year ending March 31, 2025.
|
||||||
|
|
||||||
|
> ISBN 978-1-4868-8848-1 © King's Printer for Ontario, 2025
|
||||||
|
|
||||||
|
> Table 2: LAT-AABS Caseload Overview
|
||||||
|
|
||||||
|
> Caseload 2024-2025 2023-2024 2022-2023 2021-2022
|
||||||
|
|
||||||
|
> Appeals received 16,002 16,142 13,983 15,800
|
||||||
|
|
||||||
|
> Appeals closed 18,884 18,016 16,257 11,668
|
||||||
|
|
||||||
|
> Active appeals at fiscal year end 9,191 12,016 13,903 16,204
|
||||||
|
|
||||||
|
> Case conferences held 12,081 11,556 11,411 7,752
|
||||||
|
|
||||||
|
> Decisions rendered 1,104 1,088 587 555
|
||||||
|
|
||||||
|
> Applications settled/withdrawn 17,603 16,941 15,337 11,116
|
||||||
|
|
||||||
|
> In 2024–2025, the LAT maintained strong service delivery while managing 16,776 new matters—the second-highest annual intake on record. This included 4,559 matters in Q4 alone, marking the highest quarterly appeal and application volume ever recorded at the LAT.
|
||||||
|
|
||||||
|
> In the last fiscal year, the LAT continued to reduce its active caseload, resolving a record 19,627 files despite the increased intake volume. As of March 31, 2025, the active caseload stood at 9,367 files down from a peak caseload of 17,465 in August 2022.
|
||||||
|
|
||||||
|
> To sustain service improvements, the LAT-AABS significantly reduced the time between application intake and the first case conference. In 2024-2025, the average timeline dropped to three months and three weeks, down from six months the previous year.
|
||||||
|
|
||||||
|
> The LAT-AABS also improved scheduling efficiency for merits hearings. The average time from application to oral hearing decreased from 437 to 332 days, while written hearing timelines decreased from 528 to 421 days.
|
||||||
|
|
||||||
|
> The LAT adjudicates applications and resolves disputes concerning compensation claims and licensing activities regulated by the provincial government, including the activities of delegated administrative authorities. The LAT is comprised of two main divisions: General Service (LAT-GS) and Automobile Accident Benefits Service (LAT-AABS).
|
||||||
|
|
||||||
|
> Table 1: LAT-GS Caseload Overview ... Appeals received 774 645 625 595
|
||||||
|
|
||||||
|
### Licence Appeal Tribunal Rules (2025 consolidation) — Tribunals Ontario
|
||||||
|
|
||||||
|
<https://tribunalsontario.ca/documents/lat/LAT-Rules_2025.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> 2.4 “CASE CONFERENCE” “Case Conference” has the same meaning as “Pre-Hearing Conference” as defined in the
|
||||||
|
|
||||||
|
> 14. Case Conferences 14.1 Directions And Orders At Case Conferences 14.2 Scope Of Case Conferences 14.4 Settlement Discussions 14.5 Case Conference Not Public 14.6 Party Attendance At Case Conferences
|
||||||
|
|
||||||
|
> 20.5 Settlement At Case Conferences
|
||||||
|
|
||||||
|
> Rule 20.4 provides that at least 10 days before a scheduled case conference, each party must file a case conference summary in such form as required by the Tribunal.
|
||||||
|
|
||||||
|
### Financial Services Regulatory Authority of Ontario Act, 2016, S.O. 2016, c. 37, Sched. 8 — Ontario e-Laws
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/16f37> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Financial Services Regulatory Authority of Ontario Act, 2016, S.O. 2016, c. 37, Sched. 8
|
||||||
|
|
||||||
|
> “Authority” means the Financial Services Regulatory Authority of Ontario continued under subsection 2 (1); (“Autorité”)
|
||||||
|
|
||||||
|
> “regulated sector” means a sector that is subject to, (a) the Credit Unions and Caisses Populaires Act, 2020, (a.1) the Financial Professionals Title Protection Act, 2019, (b) the Insurance Act, (c) the Loan and Trust Corporations Act, (d) the Mortgage Brokerages, Lenders and Administrators Act, 2006, (e) the Pension Benefits Act, (f) the Pooled Registered Pension Plans Act, 2015, or (g) such other legislation as may be prescribed; (“secteur réglementé”)
|
||||||
|
|
||||||
|
> 2 (1) The predecessor Authority and DICO are amalgamated and shall continue as one corporation without share capital under the name Financial Services Regulatory Authority of Ontario in English and Autorité ontarienne de réglementation des services financiers in French. 2018, c. 17, Sched. 17, s. 2.
|
||||||
|
|
||||||
|
> Crown agency (3) The Authority is an agent of the Crown in right of Ontario.
|
||||||
|
|
||||||
|
> Objects of the Authority 3 (1) The objects of the Authority are, (a) to regulate and generally supervise the regulated sectors; (b) to contribute to public confidence in the regulated sectors; (c) to monitor and evaluate developments and trends in the regulated sectors; (d) to cooperate and collaborate with other regulators where appropriate; (e) to promote public education and knowledge about the regulated sectors; (f) to promote transparency and disclosure of information by the regulated sectors; (g) to deter deceptive or fraudulent conduct, practices and activities by the regulated sectors; and (h) to carry out such other objects as may be prescribed. 2017, c. 34, Sched. 16, s. 2.
|
||||||
|
|
||||||
|
> Same, financial services sectors (2) In addition to the objects set out in subsection (1), the objects of the Authority in respect of the financial services sectors are, (a) to promote high standards of business conduct; (b) to protect the rights and interests of consumers; and (c) to foster strong, sustainable, competitive and innovative financial services sectors. 2017, c. 34, Sched. 16, s. 2.
|
||||||
|
|
||||||
|
> “FSCO” means the former Financial Services Commission of Ontario that was established under the repealed Financial Services Commission of Ontario Act, 1997; (“CSFO”)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
Each item names the source it rests on. An item here that no quotation above
|
||||||
|
supports is a defect in this file, not a fact.
|
||||||
|
|
||||||
|
- The SABS is Ontario Regulation 34/10, titled "Statutory Accident Benefits Schedule — Effective September 1, 2010". Ontario e-Laws publishes it as "O. Reg. 34/10: STATUTORY ACCIDENT BENEFITS SCHEDULE - EFFECTIVE SEPTEMBER 1, 2010". (Note: the e-Laws body heading renders in small-caps markup, which flattens to the text string "Statutory Accident bEnefits Schedule" — this is a rendering artefact, not the regulation's title.)
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- O. Reg. 34/10 is made under the Insurance Act. The e-Laws page carries the machine-readable field og:actTitle = "Insurance Act", and the regulation's own text refers to "the Act" throughout while defining its predecessor O. Reg. 403/96 as "made under the Act".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- The tribunal independently confirms the enabling statute: "Section 268 of the Insurance Act says that every motor vehicle liability policy provides specified benefits which are set out in the Statutory Accident Benefits Schedule (SABS), a regulation made under the Insurance Act."
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/>
|
||||||
|
- The version of O. Reg. 34/10 retrieved on 2026-08-29 carries "Consolidation Period: From July 1, 2026 to the e-Laws currency date" and "Last amendment: 58/25". A SABS amendment took effect 1 July 2026; the tribunal notes "Changes to the Statutory Accident Benefits Schedule effective July 1, 2026 may impact your insurance benefits and the rules that apply to them." Any SABS copy predating that date is stale.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- "Minor Injury Guideline" is defined in section 3(1) of O. Reg. 34/10 (heading: "Definitions and interpretation") as "a guideline, (a) that is issued by the Chief Executive Officer under subsection 268.3 (1.1) of the Act and published in The Ontario Gazette, and (b) that establishes a treatment framework in respect of one or more minor injuries".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- "minor injury" is separately defined in section 3(1) of O. Reg. 34/10 as "one or more of a sprain, strain, whiplash associated disorder, contusion, abrasion, laceration or subluxation and includes any clinically associated sequelae to such an injury".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- The $3,500 monetary limit is set by section 18(1) of the SABS itself — NOT by the Minor Injury Guideline. Section 18 is headed "Monetary limits re medical and rehabilitation benefits" and s. 18(1) reads: "The sum of the medical and rehabilitation benefits payable in respect of an insured person who sustains an impairment that is predominantly a minor injury shall not exceed $3,500 plus the amount of any applicable harmonized sales tax payable under Part IX of the Excise Tax Act (Canada) for accidents that occur on or after June 3, 2019 for any one accident, less the sum of all amounts paid in respect of the insured person in accordance with the Minor Injury Guideline."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- Section 18(2) of the SABS creates an exception to the $3,500 limit: it "does not apply to an insured person if his or her health practitioner determines and provides compelling evidence that the insured person has a pre-existing medical condition that was documented by a health practitioner before the accident and that will prevent the insured person from achieving maximal recovery from the minor injury" if subject to the limit or to the MIG's authorized goods and services.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- For an insured person NOT subject to the s. 18(1) minor-injury limit, s. 18(3) caps medical, rehabilitation and attendant care benefits at $65,000 per accident (plus applicable HST, for accidents on or after June 3, 2019), or $1,000,000 where the person sustained a catastrophic impairment.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- The Minor Injury Guideline is issued by the Chief Executive Officer under s. 268.3(1.1) of the Insurance Act, which empowers the CEO to "issue guidelines setting out the treatment, services, measures or goods applicable in respect of types of impairments for the purposes of payment of a medical or rehabilitation benefit provided under the Statutory Accident Benefits Schedule". Under s. 268.3(2), "a guideline shall be considered in any determination involving the interpretation of the Statutory Accident Benefits Schedule."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90i08>
|
||||||
|
- SABS disputes are heard by the Licence Appeal Tribunal. Insurance Act s. 280(1) applies "with respect to the resolution of disputes in respect of an insured person's entitlement to statutory accident benefits or in respect of the amount of statutory accident benefits to which an insured person is entitled", and s. 280(2) provides: "The insured person or the insurer may apply to the Licence Appeal Tribunal to resolve a dispute described in subsection (1)."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90i08>
|
||||||
|
- Insurance Act s. 280(3) bars the courts: "No person may bring a proceeding in any court with respect to a dispute described in subsection (1), other than an appeal from a decision of the Licence Appeal Tribunal or an application for judicial review." Section 280(4) adds that "The dispute shall be resolved in accordance with the Statutory Accident Benefits Schedule."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90i08>
|
||||||
|
- Insurance Act s. 279 defines "Licence Appeal Tribunal" as "the Licence Appeal Tribunal established under the Licence Appeal Tribunal Act, 1999". The e-Laws amendment history records the s. 279–280 scheme (2014, c. 9, Sched. 3, s. 14) as in force 01/04/2016.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90i08>
|
||||||
|
- The Licence Appeal Tribunal is established by s. 2(1) of the Licence Appeal Tribunal Act, 1999, S.O. 1999, c. 12, Sched. G: "There is hereby established a tribunal to be known in English as the Licence Appeal Tribunal and in French as Tribunal d'appel en matière de permis."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/99l12>
|
||||||
|
- The LAT's accident-benefits division is the Automobile Accident Benefits Service (LAT-AABS), which exists to "Resolve a dispute about an insured person's entitlement to, or amount of, statutory motor vehicle accident benefits". The LAT has two divisions, LAT-AABS and LAT-GS (General Service).
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/>
|
||||||
|
- Either side may apply: an injured person may apply "if there is a disagreement about their entitlement to accident benefits or the amount of benefits that should be paid", and "If an insurance company believes an individual has been paid too much under their policy, the company can file a LAT-AABS application to have the money returned."
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/>
|
||||||
|
- The tribunal lists the governing instruments as the Licence Appeal Tribunal Act, the Insurance Act, the Statutory Powers Procedure Act, the Compulsory Automobile Insurance Act, the Motor Vehicle Accident Claims Act and the Auto Insurance Rate Stabilization Act, with the Statutory Accident Benefits Schedule, O. Reg. 34/10 among the related regulations. Procedure is governed by the Licence Appeal Tribunal Rules, 2023 (effective November 17, 2025).
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/>
|
||||||
|
- A SABS application to the LAT must be commenced within two years: SABS s. 56 provides that "An application under subsection 280 (2) of the Act in respect of a benefit shall be commenced within two years after the insurer's refusal to pay the amount claimed." The tribunal states the same rule as "You must file an application within two years after receiving this notice from the insurance company."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
- YES — the LAT publishes accident-benefit caseload volume figures. The Tribunals Ontario 2024-25 Annual Report contains "Table 2: LAT-AABS Caseload Overview", reporting for the fiscal year ending March 31, 2025 and three prior years.
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS appeals RECEIVED, by fiscal year: 16,002 (2024-2025); 16,142 (2023-2024); 13,983 (2022-2023); 15,800 (2021-2022). Reporting period for the most recent column is the fiscal year ending March 31, 2025.
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS appeals CLOSED, by fiscal year: 18,884 (2024-2025); 18,016 (2023-2024); 16,257 (2022-2023); 11,668 (2021-2022).
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS active appeals at fiscal year end: 9,191 (2024-2025); 12,016 (2023-2024); 13,903 (2022-2023); 16,204 (2021-2022).
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS case conferences held: 12,081 (2024-2025); 11,556 (2023-2024); 11,411 (2022-2023); 7,752 (2021-2022). Decisions rendered: 1,104 (2024-2025); 1,088 (2023-2024); 587 (2022-2023); 555 (2021-2022).
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS "Applications settled/withdrawn" (a single combined row — settlements are NOT reported separately from withdrawals): 17,603 (2024-2025); 16,941 (2023-2024); 15,337 (2022-2023); 11,116 (2021-2022).
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- Tribunal-wide LAT context for 2024-2025: the LAT managed "16,776 new matters—the second-highest annual intake on record", including "4,559 matters in Q4 alone, marking the highest quarterly appeal and application volume ever recorded at the LAT", and resolved "a record 19,627 files". Active caseload as at March 31, 2025 was 9,367, "down from a peak caseload of 17,465 in August 2022". (These totals cross-check the AABS table: 16,002 + 774 GS = 16,776; 18,884 + 743 GS = 19,627; 9,191 + 176 GS = 9,367.)
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- LAT-AABS timelines for 2024-2025: average time from application intake to first case conference "dropped to three months and three weeks, down from six months the previous year"; average time from application to oral hearing "decreased from 437 to 332 days, while written hearing timelines decreased from 528 to 421 days".
|
||||||
|
*Source:* <https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html>
|
||||||
|
- YES — the tribunal's own materials expressly point parties to mediation before applying. Under the heading "4. Consider other ways to resolve your dispute", the LAT-AABS Application and hearing process page states verbatim: "Before you apply to the LAT-AABS, you may want to consider negotiation or mediation services. Parties are encouraged to attempt to negotiate the claim at all times, including before filing at the LAT-AABS, and continuing negotiation discussions after a claim has been filed."
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/>
|
||||||
|
- PRECISION LIMIT on the mediation passage: the sentence does not use the word "private", does not name any provider, and does not say mediation may be used DURING a pending application. Mediation is mentioned only in the "before you apply" clause; the clause about continuing after filing refers specifically to "negotiation discussions", not to mediation. Any public copy should not extend it beyond that.
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/>
|
||||||
|
- The tribunal generally encourages pre-application settlement: "You are encouraged to talk to your insurance company and try to settle your dispute before starting the LAT-AABS process. Settling your dispute with your insurance company is the fastest and least costly way to resolve your claim."
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/>
|
||||||
|
- The LAT's own in-process settlement mechanism is the adjudicator-led case conference, not mediation. "A case conference is an opportunity for parties to attempt to settle their cases"; its goals include "help the parties reach a settlement"; and it is "led by an adjudicator whose role is to guide and support the parties in working to resolve the dispute", who "will provide his or her view on what could happen if the case went to a hearing". Case conferences are scheduled for two hours, usually within 45-60 days of the response, usually by telephone.
|
||||||
|
*Source:* <https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/>
|
||||||
|
- FSRA is the Financial Services Regulatory Authority of Ontario, a corporation without share capital continued under s. 2(1) of the Financial Services Regulatory Authority of Ontario Act, 2016, S.O. 2016, c. 37, Sched. 8, and "an agent of the Crown in right of Ontario" (s. 2(3)). It succeeded the former Financial Services Commission of Ontario (FSCO).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/16f37>
|
||||||
|
- FSRA's statutory objects (s. 3(1)) are "(a) to regulate and generally supervise the regulated sectors; (b) to contribute to public confidence in the regulated sectors; (c) to monitor and evaluate developments and trends in the regulated sectors; (d) to cooperate and collaborate with other regulators where appropriate; (e) to promote public education and knowledge about the regulated sectors; (f) to promote transparency and disclosure of information by the regulated sectors; (g) to deter deceptive or fraudulent conduct, practices and activities by the regulated sectors". For financial services sectors, s. 3(2) adds "(a) to promote high standards of business conduct; (b) to protect the rights and interests of consumers; and (c) to foster strong, sustainable, competitive and innovative financial services sectors."
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/16f37>
|
||||||
|
- Insurance — and therefore auto insurance — falls inside FSRA's mandate because "regulated sector" is defined in s. 1(1) of the FSRA Act to mean a sector subject to, among other statutes, "(b) the Insurance Act".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/16f37>
|
||||||
|
- FSRA's specific operative role in the accident-benefits scheme is exercised through its Chief Executive Officer: the Insurance Act defines "Chief Executive Officer" as "the Chief Executive Officer appointed under subsection 10 (2) of the Financial Services Regulatory Authority of Ontario Act, 2016", and s. 268.3(1) and (1.1) empower that officer to issue the SABS guidelines — including the Minor Injury Guideline.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90i08>
|
||||||
|
- The SABS itself names FSRA as the publisher of operative guidelines, e.g. the definition of "authorized transportation expense" turns on "the most recent transportation expense guideline published by the Financial Services Regulatory Authority of Ontario".
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/100034>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.** It is the half that keeps a page
|
||||||
|
honest, and on this project it is the half that has twice been skipped.
|
||||||
|
|
||||||
|
- **FSRA's description of itself and its auto-insurance role IN ITS OWN WORDS (from fsrao.ca).**
|
||||||
|
- *Searched:* WebSearch for FSRA/fsrao.ca auto insurance role; then direct retrieval of https://www.fsrao.ca/about-fsra/who-we-are and https://www.fsrao.ca/consumers/auto-insurance via WebFetch and via curl — plain, then with a Chrome UA, then with a full Safari UA plus Accept/Accept-Language/Sec-Fetch-* headers; also tried the media endpoint https://www.fsrao.ca/media/26151/download.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — fsrao.ca is behind a Cloudflare interstitial. Every attempt returned HTTP 403 with a body whose title is "Just a moment..." (verified by reading the returned bytes, not by assuming an empty result). No FSRA-authored sentence was retrieved, so none is quoted. FSRA's identity and role are instead established above from primary statute (FSRA Act, 2016 s. 1(1), 2, 3; Insurance Act s. 268.3 and the "Chief Executive Officer" definition), which is stronger sourcing than a marketing page — but it is NOT FSRA speaking in its own voice. If a public page needs FSRA's own wording, someone must open fsrao.ca in a browser and commit the extract.
|
||||||
|
- **The text and contents of the Minor Injury Guideline document itself — what treatments it authorizes, its version number, and its effective date.**
|
||||||
|
- *Searched:* The MIG is issued by FSRA's CEO and published on fsrao.ca / in The Ontario Gazette. fsrao.ca was unreachable (Cloudflare 403, as above). The SABS text on e-Laws defines the MIG and incorporates it by reference but does not reproduce it.
|
||||||
|
- *Outcome:* NOT ESTABLISHED. What IS established is (a) the MIG's legal definition (SABS s. 3(1)), (b) its enabling power (Insurance Act s. 268.3(1.1)), and (c) the $3,500 limit, which sits in SABS s. 18(1) rather than in the Guideline. Do not describe the MIG's clinical contents, its treatment blocks, or its dollar figures as coming from the Guideline itself on the strength of this research.
|
||||||
|
- **Whether the Licence Appeal Tribunal Rules contain any mediation provision, or whether the LAT offers a mediation service of its own.**
|
||||||
|
- *Searched:* Case-insensitive grep for 'mediat' across four fetched tribunal artefacts: the LAT Rules (tribunalsontario.ca/documents/lat/LAT-Rules_2025.html), the LAT-AABS landing page, the Laws/rules/decisions page, and the Application and hearing process page. Matches were printed with context and read, not counted.
|
||||||
|
- *Outcome:* RESULT — NEGATIVE, and this is a finding rather than a gap. 'mediat' occurs exactly ONCE across all four documents: the single sentence on the Application and hearing process page quoted above. The LAT Rules contain no mediation rule; their settlement machinery is the case conference (Rules 14, 14.4 'Settlement Discussions', 20.5 'Settlement At Case Conferences'). The LAT does not appear to offer mediation itself. Note this was a sweep whose output was read; it covers only those four documents, not every page on tribunalsontario.ca.
|
||||||
|
- **Whether the LAT publishes a breakdown of how many AABS applications settle (as opposed to being withdrawn).**
|
||||||
|
- *Searched:* Read Table 2 (LAT-AABS Caseload Overview) in the Tribunals Ontario 2024-25 Annual Report in full.
|
||||||
|
- *Outcome:* NOT ESTABLISHED — the report publishes a single combined row, 'Applications settled/withdrawn' (17,603 in 2024-2025). Settlements are not separated from withdrawals. Do NOT characterise that figure as a settlement rate or as a number of mediated/settled cases.
|
||||||
|
- **Whether a more recent annual report than 2024-25 exists (i.e. a 2025-26 report covering the year ending March 31, 2026).**
|
||||||
|
- *Searched:* WebSearch for Tribunals Ontario annual report LAT AABS caseload; retrieved the 2024-25 report, which is dated June 30, 2025 and is the most recent surfaced.
|
||||||
|
- *Outcome:* NOT CONFIRMED either way. The 2024-25 report (fiscal year ending March 31, 2025) is the latest located as of 2026-08-29, but no search was run specifically to rule out a 2025-26 edition. Given today's date, one may well have been published. Re-check before publishing any 'most recent' or 'latest available' framing around these figures.
|
||||||
|
- *Outcome, R18 re-check:* **CONFIRMED — no 2025-26 edition is published, and FY2024-25 remains current** `[re-checked 2026-09-01 — Pouya, <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`. This closes the item as originally posed. The caution in the line above is retained rather than struck, because it is about a FRAMING and not about this fact: nothing on `/practice/insurance/` calls these figures the most recent or the latest available, and nothing should start to — a 2025-26 edition will appear eventually and a bare year label goes stale gracefully where a superlative does not.
|
||||||
|
- **What the SABS amendments effective July 1, 2026 actually changed.**
|
||||||
|
- *Searched:* Noted the consolidation banner on the e-Laws SABS page ('From July 1, 2026') and the tribunal's warning that 'Changes to the Statutory Accident Benefits Schedule effective July 1, 2026 may impact your insurance benefits'. Did not fetch O. Reg. 383/24 or the amending instruments.
|
||||||
|
- *Outcome:* NOT ESTABLISHED. Search-result snippets suggested that certain benefits became optional, but no primary amending regulation was fetched, so nothing about the substance of the July 2026 changes is asserted here. The s. 3(1) definitions and the s. 18(1) $3,500 limit quoted above ARE from the post-July-2026 consolidation and are current as retrieved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adversarial check on this extract
|
||||||
|
|
||||||
|
An independent pass was run over the items above with one instruction: decide
|
||||||
|
whether the pasted quotations actually support each one, and flag anything
|
||||||
|
broader than its quote. `PARTLY` means the wording overreaches the source.
|
||||||
|
|
||||||
|
**Overreach found:** YES — see below
|
||||||
|
|
||||||
|
| Verdict | Claim | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **PARTLY** | 1. SABS is O. Reg. 34/10, titled as e-Laws publishes it — including the parenthetical note that the body heading is small-caps markup flattening to "Statutory Accident bEnefits Sch | The title strings are verbatim ("O. Reg. 34/10: STATUTORY ACCIDENT BENEFITS SCHEDULE - EFFECTIVE SEPTEMBER 1, 2010", "ONTARIO REGULATION 34/10"), and S5 independently quotes "Statutory Accident Benefits Schedule, O. Reg. 34/10 (Effective September 1, 2010)". OVERREACH: the entire parenthetical — "the e-Laws body heading renders in small-caps markup, which flattens to the text string 'Statutory Acc |
|
||||||
|
| **PARTLY** | 4. Retrieved version carries the July 1 2026 consolidation banner and "Last amendment: 58/25"; a SABS amendment took effect 1 July 2026; any SABS copy predating that date is stale | The banner, the last-amendment line, and the tribunal's "Changes to the Statutory Accident Benefits Schedule effective July 1, 2026" warning are all verbatim. OVERREACH: "Any SABS copy predating that date is stale" is a general editorial rule no quote states. "A SABS amendment took effect 1 July 2026" is also inferred — from a consolidation-period start date plus the tribunal's caution — with no a |
|
||||||
|
| **PARTLY** | 7. The $3,500 limit is set by SABS s. 18(1) — NOT by the Minor Injury Guideline | The s. 18 heading and the whole of s. 18(1) are verbatim, so "the limit sits in the regulation" is fully carried. OVERREACH: the words "NOT by the Minor Injury Guideline" are a statement about the Guideline's contents, and the extract's own notEstablished entry records that the MIG text was never retrieved. The quotes support only the positive form — the limit appears in the regulation's own text, |
|
||||||
|
| **PARTLY** | 10. MIG issued by the CEO under Insurance Act s. 268.3(1.1); under s. 268.3(2) "a guideline shall be considered in any determination..." | The 268.3(1.1) power and the SABS's own "issued by the Chief Executive Officer under subsection 268.3 (1.1) of the Act" are verbatim. OVERREACH: the s. 268.3(2) quotation silently drops the opening qualifier that is present in the pasted source quote — "Subject to section 268.2, a guideline shall be considered ..." — and presents an unconditional rule. Section 268.2 is not in evidence, so the scop |
|
||||||
|
| **PARTLY** | 13. s. 279 defines "Licence Appeal Tribunal"; the amendment history records the s. 279–280 scheme (2014, c. 9, Sched. 3, s. 14) as in force 01/04/2016 | The s. 279 definition is verbatim. OVERREACH: "records the s. 279–280 scheme ... as in force 01/04/2016" reads a bare e-Laws amendment-table line — "2014, c. 9, Sched. 3, s. 14 - 01/04/2016" — as a commencement statement. No quoted words say "in force", and the line names no sections. Minor, but it is an inference from page formatting rather than from text. |
|
||||||
|
| **PARTLY** | 17. The tribunal lists the governing instruments (LAT Act, Insurance Act, SPPA, Compulsory Automobile Insurance Act, MVACA, AIRSA; O. Reg. 34/10); procedure governed by the LAT Rul | The lists and the Rules line are verbatim. OVERREACH: the source heading is "Related laws", and calling those items "the governing instruments" upgrades a related-reading list into a claim that each governs LAT-AABS proceedings — the Auto Insurance Rate Stabilization Act being the clearest example the quote will not carry. "Procedure is governed by" for the quoted heading "Rules of Practice" is an |
|
||||||
|
| **PARTLY** | 18. Two-year limitation: "SABS s. 56" provides ...; the tribunal states the same rule | The limitation sentence is verbatim. Two overreaches. (a) The section number "s. 56" appears in NO pasted quote — the quoted line carries only "O. Reg. 44/16, s. 6", which is an amending citation, not the section of the SABS. Drop the number or re-verify it. (b) "The tribunal states the same rule" equates "two years after the insurer's refusal to pay the amount claimed" with "two years after recei |
|
||||||
|
| **PARTLY** | 24. "Applications settled/withdrawn" is a single combined row — settlements are NOT reported separately from withdrawals | The combined row and its four figures are verbatim, so "Table 2 reports one combined row" is fully carried. OVERREACH: "settlements are NOT reported separately from withdrawals" is a negative about the report as a whole, while the pasted quotes cover only Table 2's rows. Keep it to the table: nothing pasted shows what the rest of the report does or does not break out. |
|
||||||
|
| **PARTLY** | 25. Tribunal-wide 2024-25 context (16,776 new matters; 4,559 in Q4; 19,627 resolved; 9,367 active; peak 17,465 Aug 2022) with an arithmetic cross-check against the AABS table | Every narrative figure is verbatim. OVERREACH is in the parenthetical cross-check: two of its three reconciliations use LAT-GS numbers that appear in NO pasted quote — GS closed 743 and GS active 176. The only GS quote is "Appeals received 774 645 625 595". So 16,002 + 774 = 16,776 is checkable from the quotes; 18,884 + 743 and 9,191 + 176 are not, and "all three reconcile" cannot be verified from |
|
||||||
|
| **PARTLY** | 27. YES — the tribunal's own materials expressly point parties to mediation before applying | The heading and the sentence are verbatim and do mention mediation. OVERREACH in the framing, not the quote: "expressly point parties to mediation" overstates "you may want to consider negotiation or mediation services" — permissive, unranked, and paired with negotiation, with no referral or direction. "the tribunal's own materials" (plural, a class) rests on ONE sentence on ONE page; the extract' |
|
||||||
|
| **PARTLY** | 30. The LAT's own in-process settlement mechanism is the adjudicator-led case conference, not mediation | Every descriptive element — settlement opportunity, "help the parties reach a settlement", adjudicator-led, the adjudicator's view of a hearing outcome, two hours, 45-60 days of the response, usually by telephone — is verbatim. OVERREACH: "is ... not mediation" and the exclusivity of "THE LAT's own in-process settlement mechanism" rest on an absence found by a grep over four documents (recorded in |
|
||||||
|
| **PARTLY** | 31. FSRA continued under s. 2(1) of the FSRA Act, 2016 as a corporation without share capital, an agent of the Crown (s. 2(3)); it succeeded the former FSCO | "Corporation without share capital", the s. 2(1) continuation and "an agent of the Crown in right of Ontario" are verbatim. OVERREACH: "It succeeded the former Financial Services Commission of Ontario (FSCO)". The pasted s. 2(1) says FSRA continues from "the predecessor Authority and DICO" — neither term defined in any pasted quote — and the FSCO quote only defines FSCO as "the former ... Commissi |
|
||||||
|
| **PARTLY** | 32. FSRA's statutory objects, s. 3(1)(a)–(g) and s. 3(2)(a)–(c) | Everything quoted is verbatim. OVERREACH by omission: the pasted quote continues "(h) to carry out such other objects as may be prescribed", and the fact presents (a)–(g) as what the objects "are", with no ellipsis and no mention of (h). An enumerated list presented as complete when the source shows one more item. Add (h) or mark the truncation. |
|
||||||
|
| **PARTLY** | 33. Insurance — and therefore auto insurance — falls inside FSRA's mandate because "regulated sector" is defined in s. 1(1) to include "(b) the Insurance Act" | The "regulated sector" definition including "(b) the Insurance Act" is verbatim. Three things go beyond it: the section number "s. 1(1)" appears in no quote (the definition is pasted with no section reference); "and therefore auto insurance" is an inferential step absent from every FSRA Act quote, borrowed from the tribunal's separate s. 268 sentence; and "falls inside FSRA's mandate" is a conclus |
|
||||||
|
| **PARTLY** | 34. FSRA's specific operative role in the accident-benefits scheme is exercised through its CEO, who issues the SABS guidelines including the MIG | The CEO definition, s. 268.3(1) and (1.1), and the SABS's own "issued by the Chief Executive Officer under subsection 268.3 (1.1)" are all verbatim, so "the CEO issues the SABS guidelines, including the MIG" is fully carried. OVERREACH: "FSRA's specific operative role in the accident-benefits scheme IS exercised through its Chief Executive Officer" reads as exclusive. No quote surveys FSRA's role |
|
||||||
|
| **PARTLY** | 35. The SABS names FSRA as the publisher of operative guidelines, e.g. the definition of "authorized transportation expense" turns on the FSRA-published transportation expense guid | THIS IS THE CLASS-FROM-ONE-INSTANCE SHAPE the audit was looking for. The only evidence is a mid-sentence fragment: "(a) that are authorized by, and calculated by applying the rates set out in, the most recent transportation expense guideline published by the Financial Services Regulatory Authority of Ontario, and". Two overreaches. (a) "publisher of operative guidelines" — plural, a class — is dra |
|
||||||
|
|
||||||
|
*19 of 35 items were found fully supported; only the
|
||||||
|
others are tabled above.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `WebSearch: Statutory Accident Benefits Schedule O. Reg. 34/10 ontario.ca laws`
|
||||||
|
- `WebSearch: Licence Appeal Tribunal Automobile Accident Benefits Service tribunalsontario.ca`
|
||||||
|
- `WebSearch: Tribunals Ontario annual report Licence Appeal Tribunal AABS caseload applications received`
|
||||||
|
- `WebSearch: FSRA Financial Services Regulatory Authority of Ontario auto insurance role "about us" fsrao.ca`
|
||||||
|
- `WebFetch: https://www.ontario.ca/laws/regulation/100034 (returned page shell only — superseded by curl)`
|
||||||
|
- `WebFetch: https://www.canlii.org/en/on/laws/regu/o-reg-34-10/latest/o-reg-34-10.html (HTTP 403)`
|
||||||
|
- `WebFetch: https://tribunalsontario.ca/lat-aabs/`
|
||||||
|
- `WebFetch: https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/`
|
||||||
|
- `WebFetch: https://www.fsrao.ca/about-fsra/who-we-are (HTTP 403)`
|
||||||
|
- `WebFetch: https://www.fsrao.ca/consumers/auto-insurance (HTTP 403)`
|
||||||
|
- `curl: https://www.ontario.ca/laws/regulation/100034 (HTTP 200, 260,313 bytes — full SABS text)`
|
||||||
|
- `curl: https://www.ontario.ca/laws/statute/90i08 (HTTP 200, 1,310,429 bytes — full Insurance Act)`
|
||||||
|
- `curl: https://www.ontario.ca/laws/statute/99l12 (HTTP 200 — Licence Appeal Tribunal Act, 1999)`
|
||||||
|
- `curl: https://www.ontario.ca/laws/statute/16f37 (HTTP 200 — FSRA Act, 2016)`
|
||||||
|
- `curl: https://tribunalsontario.ca/lat-aabs/ (HTTP 200)`
|
||||||
|
- `curl: https://tribunalsontario.ca/lat-aabs/laws-rules-and-decisions/ (HTTP 200)`
|
||||||
|
- `curl: https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/ (HTTP 200)`
|
||||||
|
- `curl: https://tribunalsontario.ca/documents/lat/LAT-Rules_2025.html (HTTP 200)`
|
||||||
|
- `curl: https://tribunalsontario.ca/documents/TO/Tribunals_Ontario_2024-2025_Annual_Report.html (HTTP 200, 506,740 bytes)`
|
||||||
|
- `curl: https://www.fsrao.ca/about-fsra/who-we-are — HTTP 403 plain, 403 with Chrome UA, 403 with full Safari UA + Sec-Fetch headers (Cloudflare 'Just a moment...' interstitial)`
|
||||||
|
- `curl: https://www.fsrao.ca/media/26151/download (HTTP 403, same Cloudflare interstitial)`
|
||||||
|
- `grep (read with context): 'Minor Injury Guideline' x28 and '3,500' x1 in the SABS text`
|
||||||
|
- `grep (read with context): case-insensitive 'mediat|settle|alternative dispute|case conference' across LAT-AABS landing, laws, process pages and the LAT Rules`
|
||||||
|
- `grep (read with context): 'Chief Executive Officer means' and '268.3' in the Insurance Act`
|
||||||
|
- `grep (read with context): 'regulated sector' and 'Objects of the Authority' in the FSRA Act`
|
||||||
|
- `Arithmetic cross-check of the annual-report table alignment against the report's own narrative totals (16,002+774=16,776; 18,884+743=19,627; 9,191+176=9,367 — all three reconcile)`
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
# Ontario and federal shareholder, partnership and closely-held-business remedies; family-arbitration training re-checked
|
||||||
|
|
||||||
|
Committed under AGENTS.md R14 and the CLAUDE.md rule it encodes: **anything a
|
||||||
|
spec makes a claim about must be reachable from the repository.** Every fact
|
||||||
|
the six `/practice/*` pages state about the world is checkable here or it is
|
||||||
|
not published.
|
||||||
|
|
||||||
|
**Retrieved 2026-08-29.** Fetched from the primary sources listed below and
|
||||||
|
extracted with quotations pasted verbatim. This file is the artefact; the pages
|
||||||
|
cite it. Do not paraphrase a fact into a page that is not stated here.
|
||||||
|
|
||||||
|
> ⚠️ **A statute, a regulation and a tribunal page all move.** Every consolidation
|
||||||
|
> date and effective date below is as at the retrieval date and nothing more.
|
||||||
|
> Re-check before cutover, and before any republish that turns on one of them.
|
||||||
|
|
||||||
|
> ### R18 re-check — cutover pass, 2026-09-01
|
||||||
|
>
|
||||||
|
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
|
||||||
|
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
|
||||||
|
> Five of seven extracts were stamped and two were not, so a reader could
|
||||||
|
> not tell whether this one was considered and found non-volatile or simply
|
||||||
|
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
|
||||||
|
> control-fires-over-part-of-its-scope defect as R18 having no checklist
|
||||||
|
> item, one notch smaller. **Nothing here was re-retrieved.**
|
||||||
|
>
|
||||||
|
> - **No volatile limb at this cutover.** The facts this file supports on
|
||||||
|
> `/practice/shareholder/` are OBCA sections and case law, which do not move
|
||||||
|
> at this cadence, and a sweep of the built page for dates, statuses and
|
||||||
|
> "as of" framings returned nothing time-anchored `[verified 2026-09-02 —
|
||||||
|
> swept `dist/practice/shareholder/index.html`]`.
|
||||||
|
|
||||||
|
**Topic as researched:** Ontario/Canada shareholder, partnership and closely-held business disputes — statutory remedies (oppression, dissent/appraisal, winding up), the Partnerships Act, arbitration references in the corporations statutes; plus an independent re-check of Ontario family arbitration training requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Kind | Source | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| statute | Business Corporations Act, R.S.O. 1990, c. B.16 (Ontario e-Laws, full text) | <https://www.ontario.ca/laws/statute/90b16> |
|
||||||
|
| statute | Canada Business Corporations Act, R.S.C., 1985, c. C-44 (Justice Laws, full text) | <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html> |
|
||||||
|
| statute | Partnerships Act, R.S.O. 1990, c. P.5 (Ontario e-Laws, full text) | <https://www.ontario.ca/laws/statute/90p05> |
|
||||||
|
| regulation | O. Reg. 134/07: Family Arbitration, under the Arbitration Act, 1991 (Ontario e-Laws, current consolidation) | <https://www.ontario.ca/laws/regulation/070134> |
|
||||||
|
| regulation | O. Reg. 134/07: Family Arbitration — original (v1) version, Ontario e-Laws source law | <https://www.ontario.ca/laws/regulation/070134/v1> |
|
||||||
|
| regulator | Training for family arbitrators — Government of Ontario (ontario.ca) | <https://www.ontario.ca/page/training-family-arbitrators> |
|
||||||
|
| statute | Family Law Act, R.S.O. 1990, c. F.3 (Ontario e-Laws, full text) | <https://www.ontario.ca/laws/statute/90f03> |
|
||||||
|
| statute | Arbitration Act, 1991, S.O. 1991, c. 17 (Ontario e-Laws, full text) | <https://www.ontario.ca/laws/statute/91a17> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbatim quotations
|
||||||
|
|
||||||
|
### Business Corporations Act, R.S.O. 1990, c. B.16 (Ontario e-Laws, full text)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90b16> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Business Corporations Act
|
||||||
|
> R.S.O. 1990, CHAPTER B.16
|
||||||
|
> Consolidation Period: From October 1, 2025 to the e-Laws currency date .
|
||||||
|
> Last amendment: 2024, c. 15, Sched. 1, s. 111 .
|
||||||
|
|
||||||
|
> Oppression remedy
|
||||||
|
> 248 (1) A complainant and, in the case of an offering corporation, the Commission may apply to the court for an order under this section. 1994, c. 27, s. 71 (33).
|
||||||
|
|
||||||
|
> Idem
|
||||||
|
> (2) Where, upon an application under subsection (1), the court is satisfied that in respect of a corporation or any of its affiliates,
|
||||||
|
> (a) any act or omission of the corporation or any of its affiliates effects or threatens to effect a result;
|
||||||
|
> (b) the business or affairs of the corporation or any of its affiliates are, have been or are threatened to be carried on or conducted in a manner; or
|
||||||
|
> (c) the powers of the directors of the corporation or any of its affiliates are, have been or are threatened to be exercised in a manner,
|
||||||
|
> that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer of the corporation, the court may make an order to rectify the matters complained of. R.S.O. 1990, c. B.16, s. 248 (2).
|
||||||
|
|
||||||
|
> Court order
|
||||||
|
> (3) In connection with an application under this section, the court may make any interim or final order it thinks fit including, without limiting the generality of the foregoing,
|
||||||
|
> (a) an order restraining the conduct complained of;
|
||||||
|
> (b) an order appointing a receiver or receiver-manager;
|
||||||
|
> (c) an order to regulate a corporation’s affairs by amending the articles or by-laws or creating or amending a unanimous shareholder agreement;
|
||||||
|
> ... (f) an order directing a corporation, subject to subsection (6), or any other person, to purchase securities of a security holder;
|
||||||
|
> ... (l) an order winding up the corporation under section 207;
|
||||||
|
|
||||||
|
> 245 In this Part,
|
||||||
|
> “action” means an action under this Act; (“action”)
|
||||||
|
> “complainant” means,
|
||||||
|
> (a) a registered holder or beneficial owner, and a former registered holder or beneficial owner, of a security of a corporation or any of its affiliates,
|
||||||
|
> (b) a director or an officer or a former director or officer of a corporation or of any of its affiliates,
|
||||||
|
> (c) any other person who, in the discretion of the court, is a proper person to make an application under this Part. (“plaignant”) R.S.O. 1990, c. B.16, s. 245.
|
||||||
|
|
||||||
|
> Rights of dissenting shareholders
|
||||||
|
> 185 (1) Subject to subsection (3) and to sections 186 and 248, if a corporation resolves to,
|
||||||
|
> (a) amend its articles under section 168 to add, remove or change restrictions on the issue, transfer or ownership of shares of a class or series of the shares of the corporation;
|
||||||
|
> ... (c) amalgamate with another corporation under sections 175 and 176;
|
||||||
|
> (d) be continued under the laws of another jurisdiction under section 181;
|
||||||
|
> ... (e) sell, lease or exchange all or substantially all its property under subsection 184 (3),
|
||||||
|
> a holder of shares of any class or series entitled to vote on the resolution may dissent. R.S.O. 1990, c. B.16, s. 185 (1); 2017, c. 20, Sched. 6, s. 24.
|
||||||
|
|
||||||
|
> Winding up by court
|
||||||
|
> 207 (1) A corporation may be wound up by order of the court,
|
||||||
|
> (a) where the court is satisfied that in respect of the corporation or any of its affiliates,
|
||||||
|
> (i) any act or omission of the corporation or any of its affiliates effects a result,
|
||||||
|
> (ii) the business or affairs of the corporation or any of its affiliates are or have been carried on or conducted in a manner, or
|
||||||
|
> (iii) the powers of the directors of the corporation or any of its affiliates are or have been exercised in a manner,
|
||||||
|
> that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer; or
|
||||||
|
|
||||||
|
> (b) where the court is satisfied that,
|
||||||
|
> (i) a unanimous shareholder agreement entitled a complaining shareholder to demand dissolution of the corporation after the occurrence of a specified event and that event has occurred,
|
||||||
|
> ... (iv) it is just and equitable for some reason, other than the bankruptcy or insolvency of the corporation, that it should be wound up; or
|
||||||
|
> (c) where the shareholders by special resolution authorize an application to be made to the court to wind up the corporation. R.S.O. 1990, c. B.16, s. 207 (1).
|
||||||
|
|
||||||
|
> Matter that a unanimous shareholder agreement may provide
|
||||||
|
> (6) A unanimous shareholder agreement may, without restricting the generality of subsection (2), provide that,
|
||||||
|
> (a) any amendment of the unanimous shareholder agreement may be effected in the manner specified therein; and
|
||||||
|
> (b) in the event that shareholders who are parties to the unanimous shareholder agreement are unable to agree on or resolve any matter pertaining to the agreement, the matter may be referred to arbitration under such procedures and conditions as are specified in the unanimous shareholder agreement. R.S.O. 1990, c. B.16, s. 108 (6).
|
||||||
|
|
||||||
|
### Canada Business Corporations Act, R.S.C., 1985, c. C-44 (Justice Laws, full text)
|
||||||
|
|
||||||
|
<https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Canada Business Corporations Act ( R.S.C. , 1985, c. C-44)
|
||||||
|
|
||||||
|
> Marginal note: Application to court re oppression
|
||||||
|
> 241 (1) A complainant may apply to a court for an order under this section.
|
||||||
|
|
||||||
|
> Marginal note: Grounds
|
||||||
|
> (2) If, on an application under subsection (1), the court is satisfied that in respect of a corporation or any of its affiliates
|
||||||
|
> (a) any act or omission of the corporation or any of its affiliates effects a result,
|
||||||
|
> (b) the business or affairs of the corporation or any of its affiliates are or have been carried on or conducted in a manner, or
|
||||||
|
> (c) the powers of the directors of the corporation or any of its affiliates are or have been exercised in a manner
|
||||||
|
> that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer, the court may make an order to rectify the matters complained of.
|
||||||
|
|
||||||
|
> Marginal note: Powers of court
|
||||||
|
> (3) In connection with an application under this section, the court may make any interim or final order it thinks fit including, without limiting the generality of the foregoing,
|
||||||
|
> (a) an order restraining the conduct complained of;
|
||||||
|
> (b) an order appointing a receiver or receiver-manager;
|
||||||
|
> (c) an order to regulate a corporation’s affairs by amending the articles or by-laws or creating or amending a unanimous shareholder agreement;
|
||||||
|
> ... (f) an order directing a corporation, subject to subsection (6), or any other person, to purchase securities of a security holder;
|
||||||
|
> ... (l) an order liquidating and dissolving the corporation;
|
||||||
|
|
||||||
|
> Marginal note: Alternative order
|
||||||
|
> (7) An applicant under this section may apply in the alternative for an order under section 214.
|
||||||
|
|
||||||
|
> 238 In this Part,
|
||||||
|
> complainant means
|
||||||
|
> (a) a registered holder or beneficial owner, and a former registered holder or beneficial owner, of a security of a corporation or any of its affiliates,
|
||||||
|
> (b) a director or an officer or a former director or officer of a corporation or any of its affiliates,
|
||||||
|
> (c) the Director, or
|
||||||
|
> (d) any other person who, in the discretion of a court, is a proper person to make an application under this Part. ( plaignant )
|
||||||
|
|
||||||
|
> Marginal note: Right to dissent
|
||||||
|
> 190 (1) Subject to sections 191 and 241, a holder of shares of any class of a corporation may dissent if the corporation is subject to an order under paragraph 192(4)(d) that affects the holder or if the corporation resolves to
|
||||||
|
> (a) amend its articles under section 173 or 174 to add, change or remove any provisions restricting or constraining the issue, transfer or ownership of shares of that class;
|
||||||
|
> ... (c) amalgamate otherwise than under section 184;
|
||||||
|
> (d) be continued under section 188;
|
||||||
|
> (e) sell, lease or exchange all or substantially all its property under subsection 189(3); or
|
||||||
|
> (f) carry out a going-private transaction or a squeeze-out transaction.
|
||||||
|
|
||||||
|
> Marginal note: Payment for shares
|
||||||
|
> (3) In addition to any other right the shareholder may have, but subject to subsection (26), a shareholder who complies with this section is entitled, when the action approved by the resolution from which the shareholder dissents or an order made under subsection 192(4) becomes effective, to be paid by the corporation the fair value of the shares in respect of which the shareholder dissents, determined as of the close of business on the day before the resolution was adopted or the order was made.
|
||||||
|
|
||||||
|
> Marginal note: Further grounds
|
||||||
|
> 214 (1) A court may order the liquidation and dissolution of a corporation or any of its affiliated corporations on the application of a shareholder,
|
||||||
|
> (a) if the court is satisfied that in respect of a corporation or any of its affiliates
|
||||||
|
> (i) any act or omission of the corporation or any of its affiliates effects a result,
|
||||||
|
> (ii) the business or affairs of the corporation or any of its affiliates are or have been carried on or conducted in a manner, or
|
||||||
|
> (iii) the powers of the directors of the corporation or any of its affiliates are or have been exercised in a manner
|
||||||
|
> that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer; or
|
||||||
|
> (b) if the court is satisfied that
|
||||||
|
> (i) a unanimous shareholder agreement entitles a complaining shareholder to demand dissolution of the corporation after the occurrence of a specified event and that event has occurred, or
|
||||||
|
> (ii) it is just and equitable that the corporation should be liquidated and dissolved.
|
||||||
|
|
||||||
|
> 146 (1) An otherwise lawful written agreement among all the shareholders of a corporation, or among all the shareholders and one or more persons who are not shareholders, that restricts, in whole or in part, the powers of the directors to manage, or supervise the management of, the business and affairs of the corporation is valid.
|
||||||
|
|
||||||
|
### Partnerships Act, R.S.O. 1990, c. P.5 (Ontario e-Laws, full text)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90p05> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Partnerships Act
|
||||||
|
> R.S.O. 1990, Chapter P.5
|
||||||
|
> Consolidation Period: From October 1, 2023 to the e-Laws currency date .
|
||||||
|
> Last amendment: 2023, c. 9, Sched. 26 .
|
||||||
|
|
||||||
|
> Dissolution by expiry of term or notice
|
||||||
|
> 32 Subject to any agreement between the partners, a partnership is dissolved,
|
||||||
|
> (a) if entered into for a fixed term, by the expiration of that term;
|
||||||
|
> (b) if entered into for a single adventure or undertaking, by the termination of that adventure or undertaking; or
|
||||||
|
> (c) if entered into for an undefined time, by a partner giving notice to the other or others of his or her intention to dissolve the partnership, in which case the partnership is dissolved as from the date mentioned in the notice as the date of dissolution, or, if no date is so mentioned, as from the date of the communication of the notice. R.S.O. 1990, c. P.5, s. 32.
|
||||||
|
|
||||||
|
> Dissolution by death or insolvency of partner
|
||||||
|
> 33 (1) Subject to any agreement between the partners, every partnership is dissolved as regards all the partners by the death or insolvency of a partner. R.S.O. 1990, c. P.5, s. 33 (1).
|
||||||
|
> Where partner’s share charged for separate debt
|
||||||
|
> (2) A partnership may, at the option of the other partners, be dissolved if any partner suffers that partner’s share of the partnership property to be charged under this Act for that partner’s separate debt. R.S.O. 1990, c. P.5, s. 33 (2).
|
||||||
|
|
||||||
|
> By illegality of business
|
||||||
|
> 34 A partnership is in every case dissolved by the happening of any event that makes it unlawful for the business of the firm to be carried on or for the members of the firm to carry it on in partnership. R.S.O. 1990, c. P.5, s. 34.
|
||||||
|
|
||||||
|
> By the court
|
||||||
|
> 35 (1) On application by a partner, the court may order a dissolution of the partnership,
|
||||||
|
> (a) when a partner is found to be incapable as defined in the Substitute Decisions Act, 1992 ;
|
||||||
|
> (b) when a partner, other than the partner suing, becomes in any other way permanently incapable of performing the partner’s part of the partnership contract;
|
||||||
|
> (c) when a partner, other than the partner suing, has been guilty of such conduct as, in the opinion of the court, regard being had to the nature of the business, is calculated to prejudicially affect the carrying on of the business;
|
||||||
|
> (d) when a partner, other than the partner suing, wilfully or persistently commits a breach of the partnership agreement, or otherwise so conducts himself or herself in matters relating to the partnership business that it is not reasonably practicable for the other partner or partners to carry on the business in partnership with the partner;
|
||||||
|
> (e) when the business of the partnership can only be carried on at a loss; or
|
||||||
|
> (f) when in any case circumstances have arisen that in the opinion of the court render it just and equitable that the partnership be dissolved. R.S.O. 1990, c. P.5, s. 35; 2009, c. 33, Sched. 2, s. 57 (1).
|
||||||
|
|
||||||
|
> 39 On the dissolution of a partnership every partner is entitled, as against the other partners in the firm and all persons claiming through them in respect of their interests as partners, to have the property of the partnership applied in payment of the debts and liabilities of the firm and to have the surplus assets after such payment applied in payment of what may be due to the partners respectively after deducting what may be due from them as partners to the firm, and for that purpose any partner or the partner’s representative may, on the termination of the partnership, apply to the court to wind up the business and affairs of the firm. R.S.O. 1990, c. P.5, s. 39.
|
||||||
|
|
||||||
|
### O. Reg. 134/07: Family Arbitration, under the Arbitration Act, 1991 (Ontario e-Laws, current consolidation)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/regulation/070134> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> O. Reg. 134/07: FAMILY ARBITRATION, Under: Arbitration Act, 1991, S.O. 1991, c. 17
|
||||||
|
> ONTARIO REGULATION 134/07
|
||||||
|
> family arbitration
|
||||||
|
> Consolidation Period: From June 4, 2021 to the e-Laws currency date .
|
||||||
|
> Last amendment: 411/21 .
|
||||||
|
|
||||||
|
> Qualifications of arbitrator
|
||||||
|
> 3. Every arbitrator who conducts a family arbitration shall have received the training approved by the Attorney General for the arbitrator or class of arbitrators, as set out on a Government of Ontario website. O. Reg. 134/07, s. 3; O. Reg. 411/21, s. 1.
|
||||||
|
|
||||||
|
> 5. I, ......................... (print name of arbitrator ) , confirm the following matters:
|
||||||
|
> i. I will treat the parties equally and fairly in the arbitration, as subsection 19 (1) of the Arbitration Act, 1991 requires.
|
||||||
|
> ii. I have received the appropriate training approved by the Attorney General.
|
||||||
|
> iii. The parties were separately screened for power imbalances and domestic violence and I have considered the results of the screening and will do so throughout the arbitration, if I conduct one.
|
||||||
|
> iv. The parties were separately screened for power imbalances and domestic violence by someone other than me and I have considered his or her report on the results of the screening and will do so throughout the arbitration.
|
||||||
|
|
||||||
|
> 1. In this Regulation,
|
||||||
|
> “mediation-arbitration agreement” means a family arbitration agreement that provides that,
|
||||||
|
> (a) a mediation between the parties is to be conducted before any arbitration is conducted, and
|
||||||
|
> (b) if the mediation fails, the mediator shall arbitrate the dispute and make a binding resolution of it; (“convention de médiation-arbitrage”)
|
||||||
|
|
||||||
|
> (3) The arbitrator shall keep the record for at least 10 years after the date of the award. O. Reg. 134/07, s. 4 (3).
|
||||||
|
|
||||||
|
### O. Reg. 134/07: Family Arbitration — original (v1) version, Ontario e-Laws source law
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/regulation/070134/v1> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Qualifications of arbitrator 3. Every arbitrator who conducts a family arbitration shall have received the training approved by the Attorney General for the arbitrator or class of arbitrators, as set out on the Ministry’s website. O. Reg. 134/07, s. 3.
|
||||||
|
|
||||||
|
### Training for family arbitrators — Government of Ontario (ontario.ca)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/page/training-family-arbitrators> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> In Ontario, family arbitrators must complete training approved by the Attorney General. Arbitration awards made by family arbitrators who have not completed the required training are not enforceable by the court.
|
||||||
|
|
||||||
|
> You need to complete a training program of at least 14 hours (within one week) to learn about screening parties for domestic violence and power imbalances. You should ensure your training covers most of or all the following elements:
|
||||||
|
|
||||||
|
> Your training must follow the principles outlined in the Ontario Association for Family Mediation’s Policy on Domestic Violence and Power Imbalances , adjusted for arbitration.
|
||||||
|
|
||||||
|
> All family law arbitrators who are not a part of the Ontario Bar, or another Canadian bar, must complete 30 hours of training about Ontario family law. You do not need to complete this training all at once, but there are certain areas of family law that would be best to learn together.
|
||||||
|
|
||||||
|
> Your training must have been done within five years of an arbitration where you certified that you were trained, unless you have done on average at least two family arbitrations per year, during those five years.
|
||||||
|
|
||||||
|
> Ongoing training
|
||||||
|
> As a family arbitrator, you will also need to take ongoing training of 10 hours over any two-year period. Five of these hours must be related to domestic violence or power imbalance issues.
|
||||||
|
|
||||||
|
> Updated: August 12, 2021
|
||||||
|
> Published: May 04, 2021
|
||||||
|
|
||||||
|
### Family Law Act, R.S.O. 1990, c. F.3 (Ontario e-Laws, full text)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/90f03> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> “family arbitration” means an arbitration that,
|
||||||
|
> (a) deals with matters that could be dealt with in a marriage contract, separation agreement, cohabitation agreement or paternity agreement under this Part, and
|
||||||
|
> (b) is conducted exclusively in accordance with the law of Ontario or of another Canadian jurisdiction; (“arbitrage familial”)
|
||||||
|
|
||||||
|
> 59.1 (1) Family arbitrations, family arbitration agreements and family arbitration awards are governed by this Act and by the Arbitration Act, 1991 . 2006, c. 1, s. 5 (10).
|
||||||
|
|
||||||
|
> Conditions for enforceability
|
||||||
|
> 59.6 (1) A family arbitration award is enforceable only if,
|
||||||
|
> (a) the family arbitration agreement under which the award is made is made in writing and complies with any regulations made under the Arbitration Act, 1991 ;
|
||||||
|
> (b) each of the parties to the agreement receives independent legal advice before making the agreement;
|
||||||
|
> (c) the requirements of section 38 of the Arbitration Act, 1991 are met (formal requirements, writing, reasons, delivery to parties); and
|
||||||
|
> (d) the arbitrator complies with any regulations made under the Arbitration Act, 1991. 2006, c. 1, s. 5 (10).
|
||||||
|
|
||||||
|
> 59.4 A family arbitration agreement and an award made under it are unenforceable unless the family arbitration agreement is entered into after the dispute to be arbitrated has arisen. 2006, c. 1, s. 5 (10).
|
||||||
|
|
||||||
|
### Arbitration Act, 1991, S.O. 1991, c. 17 (Ontario e-Laws, full text)
|
||||||
|
|
||||||
|
<https://www.ontario.ca/laws/statute/91a17> — retrieved 2026-08-29
|
||||||
|
|
||||||
|
> Arbitration Act, 1991
|
||||||
|
> S.O. 1991, Chapter 17
|
||||||
|
> Consolidation Period: From March 22, 2017 to the e-Laws currency date .
|
||||||
|
> Last amendment: 2017, c. 2, Sched. 5, s. 13 .
|
||||||
|
|
||||||
|
> Family arbitrations, agreements and awards
|
||||||
|
> 2.1 (1) Family arbitrations, family arbitration agreements and family arbitration awards are governed by this Act and by the Family Law Act . 2006, c. 1, s. 1 (2).
|
||||||
|
> Conflict
|
||||||
|
> (2) In the event of conflict between this Act and the Family Law Act, the Family Law Act prevails. 2006, c. 1, s. 1 (2).
|
||||||
|
|
||||||
|
> Other third-party decision-making processes in family matters
|
||||||
|
> 2.2 (1) When a decision about a matter described in clause (a) of the definition of “family arbitration” in section 1 is made by a third person in a process that is not conducted exclusively in accordance with the law of Ontario or of another Canadian jurisdiction,
|
||||||
|
> (a) the process is not a family arbitration; and
|
||||||
|
> (b) the decision is not a family arbitration award and has no legal effect. 2006, c. 1, s. 1 (2).
|
||||||
|
|
||||||
|
> Family arbitration awards
|
||||||
|
> 50.1 Family arbitration awards are enforceable only under the Family Law Act . 2006, c. 1, s. 1 (10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes
|
||||||
|
|
||||||
|
Each item names the source it rests on. An item here that no quotation above
|
||||||
|
supports is a defect in this file, not a fact.
|
||||||
|
|
||||||
|
- OPPRESSION REMEDY (Ontario): the oppression remedy is s. 248 of the Business Corporations Act, R.S.O. 1990, c. B.16. Section 248(1) gives a 'complainant' (and, for an offering corporation, the Commission) standing to apply to the court. The operative test in s. 248(2) is conduct 'that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer of the corporation', on which 'the court may make an order to rectify the matters complained of.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- The OBCA oppression provision is broader than its federal counterpart in one respect visible on the face of the text: s. 248(2) reaches conduct that 'effects or threatens to effect a result' and affairs 'are, have been or are threatened to be carried on', i.e. it expressly captures threatened conduct. The CBCA equivalent (s. 241(2)) uses only 'effects a result' and 'are or have been carried on', with no 'threatened' language.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- OBCA s. 248(3) lists the remedial orders available, including '(f) an order directing a corporation, subject to subsection (6), or any other person, to purchase securities of a security holder' (the buy-out order) and '(l) an order winding up the corporation under section 207'. Payment under (3)(f) or (g) is barred by s. 248(6) where the corporation is or would become unable to pay its liabilities as they become due.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- 'Complainant' is defined in OBCA s. 245 to include a registered holder or beneficial owner (and former holder/owner) of a security, a director or officer (or former director or officer), and '(c) any other person who, in the discretion of the court, is a proper person to make an application under this Part.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- OPPRESSION REMEDY (federal): the equivalent is s. 241 of the Canada Business Corporations Act, R.S.C., 1985, c. C-44, marginal note 'Application to court re oppression'. Section 241(1) provides 'A complainant may apply to a court for an order under this section', and the s. 241(2) test is conduct 'that is oppressive or unfairly prejudicial to or that unfairly disregards the interests of any security holder, creditor, director or officer', on which 'the court may make an order to rectify the matters complained of.'
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- CBCA s. 241(3) lists the same remedial menu as the OBCA, including a buy-out order at (3)(f) and, at (3)(l), 'an order liquidating and dissolving the corporation'. CBCA s. 241(7) expressly permits an oppression applicant to 'apply in the alternative for an order under section 214'.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- 'Complainant' is defined in CBCA s. 238 and, unlike the Ontario definition, expressly includes '(c) the Director' alongside security holders, former security holders, directors, officers, and any other person a court in its discretion finds a proper person.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- APPRAISAL / DISSENT RIGHTS (Ontario): OBCA s. 185, headed 'Rights of dissenting shareholders'. Section 185(1) is expressed 'Subject to subsection (3) and to sections 186 and 248' and lists the triggering resolutions (certain article amendments, amalgamation, continuance out of the jurisdiction, and sale/lease/exchange of all or substantially all property under s. 184(3)).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- APPRAISAL / DISSENT RIGHTS (federal): CBCA s. 190, marginal note 'Right to dissent'. Section 190(3) states the remedy: a complying shareholder 'is entitled ... to be paid by the corporation the fair value of the shares in respect of which the shareholder dissents, determined as of the close of business on the day before the resolution was adopted or the order was made.' The CBCA list of triggers includes, at s. 190(1)(f), 'carry out a going-private transaction or a squeeze-out transaction'.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- A shareholder cannot use both remedies on the same amendment: OBCA s. 248(5) and CBCA s. 241(5) each provide that a shareholder is not entitled to dissent (under s. 185 / s. 190 respectively) if an amendment to the articles is effected under the oppression section.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- WINDING UP / JUST AND EQUITABLE (Ontario): OBCA s. 207, headed 'Winding up by court'. Section 207(1)(a) reproduces the oppression grounds; s. 207(1)(b)(iv) is the just-and-equitable ground — 'it is just and equitable for some reason, other than the bankruptcy or insolvency of the corporation, that it should be wound up'. Section 207(1)(b)(i) separately covers a unanimous shareholder agreement that entitled a shareholder to demand dissolution on a specified event that has occurred. By s. 207(2), on such an application 'the court may make such order under this section or section 248 as it thinks fit.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- LIQUIDATION AND DISSOLUTION / JUST AND EQUITABLE (federal): CBCA s. 214, marginal note 'Further grounds'. A court may order liquidation and dissolution 'on the application of a shareholder' on the oppression grounds in s. 214(1)(a) or, under s. 214(1)(b)(ii), where 'it is just and equitable that the corporation should be liquidated and dissolved.' Section 214(2) mirrors the Ontario cross-reference: 'a court may make such order under this section or section 241 as it thinks fit.'
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- PARTNERSHIPS ACT (Ontario) citation: Partnerships Act, R.S.O. 1990, Chapter P.5. The e-Laws consolidation retrieved was 'From October 1, 2023 to the e-Laws currency date', last amendment 2023, c. 9, Sched. 26.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- Partnerships Act dissolution provisions run from s. 32 under the heading 'Dissolution of Partnership': s. 32 (expiry of term, completion of the adventure, or notice by a partner where the partnership is for an undefined time); s. 33 (death or insolvency of a partner; and, at the option of the other partners, where a partner's share is charged for a separate debt); s. 34 (illegality of the business); and s. 35 (dissolution by the court).
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- Sections 32 and 33 of the Partnerships Act are each expressed 'Subject to any agreement between the partners' — so a partnership agreement can displace them. Section 34 (illegality) is not so qualified: 'A partnership is in every case dissolved by the happening of any event that makes it unlawful for the business of the firm to be carried on'.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- Partnerships Act s. 35(1) sets out six grounds on which, 'On application by a partner, the court may order a dissolution of the partnership', including (d) wilful or persistent breach of the partnership agreement or conduct such 'that it is not reasonably practicable for the other partner or partners to carry on the business in partnership with the partner'; (e) 'when the business of the partnership can only be carried on at a loss'; and (f) the just-and-equitable ground — 'when in any case circumstances have arisen that in the opinion of the court render it just and equitable that the partnership be dissolved.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- Partnerships Act s. 39 provides that on dissolution every partner is entitled to have partnership property applied to the firm's debts and the surplus distributed, and that 'for that purpose any partner or the partner's representative may, on the termination of the partnership, apply to the court to wind up the business and affairs of the firm.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- ARBITRATION IN THE CORPORATIONS STATUTES — Ontario: YES, in exactly one place. OBCA s. 108(6)(b) provides that a unanimous shareholder agreement may provide that 'in the event that shareholders who are parties to the unanimous shareholder agreement are unable to agree on or resolve any matter pertaining to the agreement, the matter may be referred to arbitration under such procedures and conditions as are specified in the unanimous shareholder agreement.' This is the only occurrence of the string 'arbitrat' in the whole Act — a case-insensitive search returned exactly 1 hit in both the converted text and the raw HTML, and the hit was read in full context.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90b16>
|
||||||
|
- ARBITRATION IN THE CORPORATIONS STATUTES — federal: NO. A case-insensitive search of the complete CBCA full text for 'arbitrat' returned zero occurrences, in both the converted text and the raw HTML. The instrument was validated on the same file in the same run ('oppressive' returned 3 hits, 'unanimous shareholder agreement' 26), and the retrieved document was confirmed complete (it runs through s. 267, the Schedule of Offences, and the Related Provisions). The CBCA does validate unanimous shareholder agreements at s. 146(1) but, unlike OBCA s. 108(6)(b), says nothing about referring a dispute under one to arbitration.
|
||||||
|
*Source:* <https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html>
|
||||||
|
- Neither corporations statute mentions mediation. A search for 'mediat' returned 23 hits in the CBCA and 25 in the OBCA; every one was read and all are 'immediately' or 'immediate'. The Ontario Partnerships Act likewise contains zero occurrences of 'arbitrat', and its only 'mediat' hits (4) are 'immediately'.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90p05>
|
||||||
|
- FAMILY ARBITRATION — the 14-hour screening requirement is CONFIRMED. The Government of Ontario page states: 'You need to complete a training program of at least 14 hours (within one week) to learn about screening parties for domestic violence and power imbalances.' This independently reproduces the figure in the repo's existing extract at docs/reference/ontario-family-arbitration-training.md.
|
||||||
|
*Source:* <https://www.ontario.ca/page/training-family-arbitrators>
|
||||||
|
- FAMILY ARBITRATION — the 30-hour Ontario family law requirement is CONFIRMED, and it applies only to non-lawyers: 'All family law arbitrators who are not a part of the Ontario Bar, or another Canadian bar, must complete 30 hours of training about Ontario family law.'
|
||||||
|
*Source:* <https://www.ontario.ca/page/training-family-arbitrators>
|
||||||
|
- FAMILY ARBITRATION — the ongoing-training requirement is CONFIRMED: 'As a family arbitrator, you will also need to take ongoing training of 10 hours over any two-year period. Five of these hours must be related to domestic violence or power imbalance issues.'
|
||||||
|
*Source:* <https://www.ontario.ca/page/training-family-arbitrators>
|
||||||
|
- FAMILY ARBITRATION — a currency requirement the repo's existing extract does NOT record: 'Your training must have been done within five years of an arbitration where you certified that you were trained, unless you have done on average at least two family arbitrations per year, during those five years.' This is a fourth requirement alongside the three hour figures.
|
||||||
|
*Source:* <https://www.ontario.ca/page/training-family-arbitrators>
|
||||||
|
- FAMILY ARBITRATION — the enforcement consequence, stated by the government page itself: 'In Ontario, family arbitrators must complete training approved by the Attorney General. Arbitration awards made by family arbitrators who have not completed the required training are not enforceable by the court.'
|
||||||
|
*Source:* <https://www.ontario.ca/page/training-family-arbitrators>
|
||||||
|
- IMPORTANT QUALIFICATION on the word 'prescribed': the hour figures are NOT set out in the regulation. O. Reg. 134/07, s. 3 ('Qualifications of arbitrator') requires only that 'Every arbitrator who conducts a family arbitration shall have received the training approved by the Attorney General for the arbitrator or class of arbitrators, as set out on a Government of Ontario website.' The regulation makes the training mandatory by reference; the content and the hours live on the ontario.ca page, which the Attorney General can change without amending the regulation.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/070134>
|
||||||
|
- The hour figures have NEVER appeared in O. Reg. 134/07. The original (v1) version of s. 3 reads identically except that it referred to 'the Ministry's website'; O. Reg. 411/21, s. 1 changed only that phrase to 'a Government of Ontario website'. So the delegation to a web page has been the mechanism since the regulation was made.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/070134/v1>
|
||||||
|
- The screening requirement has a second, harder anchor than the government web page: the arbitrator's certificate prescribed by O. Reg. 134/07, s. 2(4) para. 5 requires the arbitrator to confirm in the agreement itself both 'ii. I have received the appropriate training approved by the Attorney General' and that 'The parties were separately screened for power imbalances and domestic violence and I have considered the results of the screening and will do so throughout the arbitration'. This text IS in the regulation.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/070134>
|
||||||
|
- The statutory chain for family arbitration: Family Law Act, R.S.O. 1990, c. F.3, s. 51 defines 'family arbitration'; s. 59.1(1) provides that family arbitrations 'are governed by this Act and by the Arbitration Act, 1991'; and s. 59.6(1) makes an award 'enforceable only if' the agreement complies with the regulations, each party 'receives independent legal advice before making the agreement', s. 38 of the Arbitration Act, 1991 is met, and '(d) the arbitrator complies with any regulations made under the Arbitration Act, 1991' — which is the provision that makes O. Reg. 134/07 s. 3 training bite on enforceability.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90f03>
|
||||||
|
- Family Law Act s. 59.4 requires that a family arbitration agreement be entered into after the dispute has arisen: 'A family arbitration agreement and an award made under it are unenforceable unless the family arbitration agreement is entered into after the dispute to be arbitrated has arisen.' This is a structural difference from commercial arbitration, where pre-dispute clauses are the norm.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/90f03>
|
||||||
|
- Arbitration Act, 1991, S.O. 1991, c. 17, s. 2.1(1) states that family arbitrations 'are governed by this Act and by the Family Law Act', and s. 2.1(2) that 'In the event of conflict between this Act and the Family Law Act, the Family Law Act prevails.' Section 2.2(1) provides that a family-matter decision made in a process 'not conducted exclusively in accordance with the law of Ontario or of another Canadian jurisdiction' is not a family arbitration and 'has no legal effect'. Section 50.1: 'Family arbitration awards are enforceable only under the Family Law Act.'
|
||||||
|
*Source:* <https://www.ontario.ca/laws/statute/91a17>
|
||||||
|
- O. Reg. 134/07 defines 'mediation-arbitration agreement' for family matters as a family arbitration agreement providing that '(a) a mediation between the parties is to be conducted before any arbitration is conducted, and (b) if the mediation fails, the mediator shall arbitrate the dispute and make a binding resolution of it' — a definition of med-arb that exists in Ontario law only in the family context.
|
||||||
|
*Source:* <https://www.ontario.ca/laws/regulation/070134>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does NOT establish
|
||||||
|
|
||||||
|
**Read this section before writing copy.** It is the half that keeps a page
|
||||||
|
honest, and on this project it is the half that has twice been skipped.
|
||||||
|
|
||||||
|
- **Does the word 'prescribed' accurately describe Ontario's family arbitrator training hours, as AGENTS.md §9 Q39 and line 1952 currently put it?**
|
||||||
|
- *Searched:* Fetched the current consolidation of O. Reg. 134/07 and its original (v1) version from ontario.ca, and read s. 3 in full in both.
|
||||||
|
- *Outcome:* NOT CONFIRMED — and this looks like a wording defect in the repo, not a sourcing gap. No hour figure appears anywhere in the regulation, in any version. The regulation requires 'the training approved by the Attorney General ... as set out on a Government of Ontario website'; the 14/30/10 figures are administratively approved training published on a web page the Attorney General can revise without amending the regulation. The requirement is mandatory and enforceability-bearing, so 'required' or 'approved by the Attorney General' is accurate; 'prescribed' implies the numbers sit in the instrument, and they do not. Flagging for Pouya rather than fixing — AGENTS.md is his record.
|
||||||
|
- **Is the ontario.ca training page still the operative statement of approved training as at 2026-08-29, given it carries 'Updated: August 12, 2021'?**
|
||||||
|
- *Searched:* Fetched the live page today (HTTP 200) and read its own date stamps; also fetched the current e-Laws consolidation of O. Reg. 134/07 to confirm s. 3 still points to a Government of Ontario website.
|
||||||
|
- *Outcome:* PARTLY. The page is live today and the regulation still delegates to it, so it is the operative source by the regulation's own terms. But its content stamp is 'Updated: August 12, 2021 / Published: May 04, 2021'. I found no separate published register of Attorney General training approvals against which to cross-check, so I cannot independently confirm the figures have not been superseded by an approval not reflected on the page. Treat the hours as 'what the government page states as at 2026-08-29'.
|
||||||
|
- **Do the regulations made under the CBCA (as distinct from the Act) say anything about arbitration?**
|
||||||
|
- *Searched:* Only the CBCA Act full text at laws-lois.justice.gc.ca was retrieved and searched. The Canada Business Corporations Regulations, 2001 (SOR/2001-512) were not fetched.
|
||||||
|
- *Outcome:* NOT SEARCHED. The negative finding above is scoped to the Act only. If the site is going to say anything about federal corporate arbitration, the regulations should be checked too before that claim is written.
|
||||||
|
- **Do any of these sources support a claim about what qualifications a COMMERCIAL arbitrator or mediator in Ontario needs?**
|
||||||
|
- *Searched:* All eight sources above were searched for arbitration/mediation references; the family arbitration chain (FLA ss. 51, 59.1-59.7; Arbitration Act ss. 2.1, 2.2, 50.1; O. Reg. 134/07) was read in full.
|
||||||
|
- *Outcome:* NOT ESTABLISHED, and this confirms the honest limit the repo's own extract already records. Every training and qualification requirement found is expressly tied to 'family arbitration' as defined in FLA s. 51. Nothing retrieved states, either way, what a commercial arbitrator or mediator requires. The absence of a commercial requirement in family-specific instruments is not authority that none exists — AGENTS.md §4 is right to carry the commercial half as Pouya's stated position rather than as a sourced fact, and nothing in this pass changes that.
|
||||||
|
- **Is there a statutory buy-out or shotgun mechanism for closely-held Ontario corporations outside the oppression remedy?**
|
||||||
|
- *Searched:* Read OBCA ss. 108, 185, 207, 245-250 and CBCA ss. 146, 190, 214, 238-241 in full text.
|
||||||
|
- *Outcome:* NOT FOUND as a freestanding mechanism. The buy-out order exists only as a discretionary remedy the court may grant under OBCA s. 248(3)(f) / CBCA s. 241(3)(f), subject to the solvency limit in s. 248(6) / s. 241(6). Shotgun and buy-sell clauses are creatures of the shareholders' agreement, not of either statute. Do not let site copy imply a statutory buy-out right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adversarial check on this extract
|
||||||
|
|
||||||
|
An independent pass was run over the items above with one instruction: decide
|
||||||
|
whether the pasted quotations actually support each one, and flag anything
|
||||||
|
broader than its quote. `PARTLY` means the wording overreaches the source.
|
||||||
|
|
||||||
|
**Overreach found:** YES — see below
|
||||||
|
|
||||||
|
| Verdict | Claim | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **PARTLY** | [3] OBCA s. 248(3) remedial orders incl. (3)(f) buy-out and (3)(l) winding up; plus 'Payment under (3)(f) or (g) is barred by s. 248(6) where the corporation is or would become una | The first sentence is verbatim in the quote. The second sentence overreaches: no quote of s. 248(6) is pasted anywhere, and the words 'is or would become unable to pay its liabilities as they become due' appear in no source. The quote's own words go only as far as 'subject to subsection (6)' — it does not disclose what subsection (6) says. Paragraph '(g)' also appears in no pasted quote at all. Ov |
|
||||||
|
| **PARTLY** | [6] 'CBCA s. 241(3) lists the same remedial menu as the OBCA', incl. (3)(f) buy-out and (3)(l) liquidation; s. 241(7) alternative application under s. 214 | The (3)(f), (3)(l) and 241(7) elements are verbatim in the quotes. 'lists the same remedial menu as the OBCA' overreaches: both pasted s. 248(3) and s. 241(3) quotes are elided (each contains '...'), so the full menus were never put side by side, and the one paragraph the fact itself compares differs textually — OBCA (l) is 'an order winding up the corporation under section 207', CBCA (l) is 'an o |
|
||||||
|
| **PARTLY** | [8] OBCA s. 185 dissent rights — 'Subject to subsection (3) and to sections 186 and 248' and the triggering resolutions | The heading, the 'Subject to' clause and the four named triggers are in the quote. But the pasted s. 185(1) is elided ('... (c) amalgamate') so at least paragraph (b) is missing from the source text, while the fact presents its parenthetical as 'the triggering resolutions' — a closed list a copywriter would reproduce as complete. Overreaching words: 'lists the triggering resolutions (…)' read as e |
|
||||||
|
| **NOT** | [10] OBCA s. 248(5) and CBCA s. 241(5) each bar dissent where an article amendment is effected under the oppression section | No quote of s. 248(5) or s. 241(5) appears anywhere in the source set — the OBCA and CBCA quote blocks jump from (3) to other sections. The proposition may well be true in the world, but nothing pasted supports any part of it, including the characterisation 'A shareholder cannot use both remedies on the same amendment'. |
|
||||||
|
| **PARTLY** | [11] OBCA s. 207 winding up — 207(1)(a) oppression grounds, (b)(iv) just and equitable, (b)(i) USA dissolution trigger; and 'By s. 207(2) … the court may make such order under this | Everything up to and including s. 207(1)(b)(iv) and (b)(i) is verbatim in the two pasted 207(1) quotes. The final sentence is not: no quote of s. 207(2) exists in the source set, yet the fact renders it inside quotation marks as source text. Overreaching words: "By s. 207(2), on such an application 'the court may make such order under this section or section 248 as it thinks fit.'" — presented as |
|
||||||
|
| **PARTLY** | [12] CBCA s. 214 liquidation/dissolution — 214(1)(a) and (b)(ii); and 'Section 214(2) mirrors the Ontario cross-reference: a court may make such order under this section or section | The marginal note 'Further grounds', 'on the application of a shareholder', the oppression grounds and the just-and-equitable ground are verbatim in the quote. The s. 214(2) sentence is not supported by any pasted quote and is again presented in quotation marks; the word 'mirrors' additionally rests on the equally unquoted OBCA s. 207(2). Overreaching words: the whole final sentence 'Section 214(2 |
|
||||||
|
| **PARTLY** | [14] Partnerships Act dissolution provisions 'run from s. 32 under the heading Dissolution of Partnership'; content of ss. 32, 33, 34, 35 | The substance of ss. 32, 33(1), 33(2), 34 and 35 is verbatim in the quotes. The structural claim is not: the string 'Dissolution of Partnership' appears in no pasted quote — the quoted headings are 'Dissolution by expiry of term or notice', 'Dissolution by death or insolvency of partner', 'By illegality of business' and 'By the court'. Nor does anything quoted show where the dissolution provisions |
|
||||||
|
| **PARTLY** | [15] 'Sections 32 and 33 are each expressed Subject to any agreement between the partners — so a partnership agreement can displace them'; s. 34 not so qualified | s. 32 and s. 33(1) do carry the phrase in the quotes, and the s. 34 quote demonstrably lacks it. But the quoted s. 33(2) — 'A partnership may, at the option of the other partners, be dissolved if any partner suffers that partner's share … to be charged' — carries no 'subject to any agreement' qualifier, so a section-level statement about 's. 33' is broader than the pasted text. 'so a partnership a |
|
||||||
|
| **PARTLY** | [18] Arbitration in the OBCA: 'YES, in exactly one place' — s. 108(6)(b), 'the only occurrence of the string arbitrat in the whole Act' | The text of s. 108(6)(b) is pasted verbatim and fully supports the affirmative half. The exhaustive half cannot be supported by any quote: a pasted excerpt cannot establish that a string occurs nowhere else in a 732 KB statute — that claim rests on a grep reported in searchesRun, not on quoted source text, and it is the kind of statement that would appear on a public page as a bare assertion about |
|
||||||
|
| **PARTLY** | [19] Arbitration in the CBCA: 'NO' — zero occurrences of 'arbitrat'; document confirmed complete; CBCA validates USAs at s. 146(1) but says nothing about arbitration | Only the last clause is quote-backed: s. 146(1) is pasted and does validate unanimous shareholder agreements without mentioning arbitration. Everything else is an absence claim about a whole statute that no pasted quote can carry — the zero-hit counts, the control-hit validation, and the completeness assertion ('runs through s. 267, the Schedule of Offences, and the Related Provisions') appear in |
|
||||||
|
| **NOT** | [20] 'Neither corporations statute mentions mediation' (23 CBCA / 25 OBCA hits, all 'immediately'); Partnerships Act has zero 'arbitrat' and 4 'mediat' hits, all 'immediately' | No pasted quote supports any part of this. It is entirely a report of grep output listed in searchesRun, and the sources contain no quoted text in which the word does or does not appear. The scope also runs past the cited source: the item is filed against the Partnerships Act URL while asserting negatives about both the OBCA and the CBCA. This is a three-statute class statement with zero quoted in |
|
||||||
|
| **PARTLY** | [22] The 30-hour Ontario family law requirement 'applies only to non-lawyers' | The 30-hour sentence is verbatim, but the class it describes is 'family law arbitrators who are not a part of the Ontario Bar, or another Canadian bar' — membership of a Canadian bar, not lawyer status. A foreign-qualified lawyer is a lawyer and is caught by the quoted words, so 'non-lawyers' widens (and misdescribes) the quoted class. 'only' also converts a positive requirement on one class into |
|
||||||
|
| **PARTLY** | [26] 'Prescribed' is inaccurate: the hour figures are NOT in O. Reg. 134/07; s. 3 delegates to a Government of Ontario website the AG can change without amending the regulation | The s. 3 quote fully supports the delegation mechanism and the exact words 'as set out on a Government of Ontario website'. The absence half is not quote-provable: the pasted regulation excerpts are four fragments (ss. 1, 2(4) para 5, 3, 4(3)), which cannot show that no hour figure appears anywhere in the instrument. 'which the Attorney General can change without amending the regulation' is also a |
|
||||||
|
| **PARTLY** | [27] 'The hour figures have NEVER appeared in O. Reg. 134/07'; v1 s. 3 identical but for 'the Ministry's website'; O. Reg. 411/21 s. 1 changed only that phrase | Comparing the two pasted texts of s. 3 supports the wording change and its amending citation. It does not support an all-versions, whole-regulation negative: only s. 3 is quoted from v1, and nothing at all is quoted from v2 or v3 despite searchesRun saying they were fetched. Overreaching words: 'The hour figures have NEVER appeared in O. Reg. 134/07' and 'changed only that phrase' as a statement a |
|
||||||
|
| **PARTLY** | [28] The certificate 'prescribed by O. Reg. 134/07, s. 2(4) para. 5' requires the arbitrator to confirm 'in the agreement itself' both item ii (training) and item iii (screening) | The certificate text is pasted and does contain items ii and iii verbatim. Three things exceed it. The pinpoint: the quote begins bare, at '5.', with nothing showing it sits in s. 2(4). The location: nothing quoted says the certificate goes 'in the agreement itself'. And the conjunction: the quote sets out iii (screened by me) and iv (screened by someone other than me) as apparent alternatives, so |
|
||||||
|
| **PARTLY** | [29] Statutory chain: FLA s. 51 defines 'family arbitration'; s. 59.1(1) governance; s. 59.6(1) enforceability conditions, (d) making O. Reg. 134/07 s. 3 bite | The definition, s. 59.1(1) and all four limbs of s. 59.6(1) are pasted verbatim, and the closing synthesis follows directly from quoted 59.6(1)(d) plus the quoted regulation heading ('Under: Arbitration Act, 1991'). The one thing outside the quotes is the pinpoint: the definition quote carries no section number, so 's. 51' is supplied from outside the source. Overreaching words: 's. 51' as a pinpo |
|
||||||
|
| **PARTLY** | [30] FLA s. 59.4 requires a post-dispute family arbitration agreement; 'This is a structural difference from commercial arbitration, where pre-dispute clauses are the norm.' | The s. 59.4 quote is verbatim and supports the first sentence entirely. The second sentence is supported by nothing: no source in the set says anything about commercial arbitration practice, and the extract's own notEstablished section concedes that nothing retrieved speaks to commercial arbitration. Overreaching words: 'This is a structural difference from commercial arbitration, where pre-disput |
|
||||||
|
| **PARTLY** | [32] O. Reg. 134/07 definition of 'mediation-arbitration agreement' — 'a definition of med-arb that exists in Ontario law only in the family context' | The definition itself is pasted verbatim and fully supports the first half. The trailing clause is a negative about the whole of Ontario law drawn from a family-specific regulation — precisely the class statement from a single instance this audit is looking for. The greps reported in searchesRun covered three corporations/partnership statutes and did not search for 'med-arb' or for the definition |
|
||||||
|
|
||||||
|
*14 of 32 items were found fully supported; only the
|
||||||
|
others are tabled above.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Searches run
|
||||||
|
|
||||||
|
- `curl https://www.ontario.ca/laws/statute/90b16 (OBCA full text, HTTP 200, 732,644 bytes)`
|
||||||
|
- `grep -c -i 'arbitrat' obca.txt / obca.html — 1 hit each, read in full context (s. 108(6)(b))`
|
||||||
|
- `grep -o -i '[a-z]*mediat[a-z]*' obca.txt | sort | uniq -c — 25 hits, all 'immediately'`
|
||||||
|
- `curl https://laws-lois.justice.gc.ca/eng/acts/C-44/FullText.html (CBCA full text, HTTP 200, 883,143 bytes)`
|
||||||
|
- `grep -c -i 'arbitrat' cbca.txt / cbca.html — 0 hits in both; instrument validated with controls 'oppressive' (3), 'unanimous shareholder agreement' (26); document completeness confirmed to s. 267 + Schedule + Related Provisions`
|
||||||
|
- `grep -o -i '[a-z]*mediat[a-z]*' cbca.txt | sort | uniq -c — 23 hits, all 'immediately'/'immediate'`
|
||||||
|
- `curl https://www.ontario.ca/laws/statute/90p05 (Partnerships Act, HTTP 200); grep 'arbitrat' — 0 hits in text and raw HTML, control 'partnership' 127 hits`
|
||||||
|
- `curl https://www.ontario.ca/laws/regulation/070134 (O. Reg. 134/07 current consolidation, HTTP 200)`
|
||||||
|
- `curl https://www.ontario.ca/laws/regulation/070134/v1, /v2, /v3 — historical versions of s. 3, to test whether hour figures were ever in the regulation`
|
||||||
|
- `WebSearch (allowed_domains: ontario.ca): 'Ontario family arbitrator training approved by the Attorney General hours screening domestic violence' — used only to locate the URL; snippets not relied on`
|
||||||
|
- `curl https://www.ontario.ca/page/training-family-arbitrators (HTTP 200) — primary source fetched and quoted directly rather than via search snippet`
|
||||||
|
- `curl https://www.ontario.ca/laws/statute/90f03 (Family Law Act, HTTP 200) — ss. 51, 59.1, 59.4, 59.6, 59.7`
|
||||||
|
- `curl https://www.ontario.ca/laws/statute/91a17 (Arbitration Act, 1991, HTTP 200) — ss. 2.1, 2.2, 50.1`
|
||||||
|
- `git grep -n -i '14 hours|30 hours|10 hours|screening' — located the repo's existing extract at docs/reference/ontario-family-arbitration-training.md and the AGENTS.md Q39 entry, for independent comparison`
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// ESLint 10 flat config. Scope is deliberately small: this project targets zero
|
||||||
|
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
|
||||||
|
// configuration, site data, and the occasional island. Rules exist to catch
|
||||||
|
// mistakes, not to impose style — Prettier owns formatting.
|
||||||
|
//
|
||||||
|
// `typescript-eslint` is here because .astro frontmatter IS TypeScript, so the
|
||||||
|
// plugin cannot parse a single component without it. It runs unconfigured for
|
||||||
|
// type-awareness on purpose: `astro check` already does the type checking, and
|
||||||
|
// duplicating it here would be slower and would disagree at the edges.
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import astro from 'eslint-plugin-astro';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', '.astro/**', 'docs/reference/**'] },
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...astro.configs.recommended,
|
||||||
|
...astro.configs['flat/jsx-a11y-recommended'],
|
||||||
|
|
||||||
|
// `no-undef` off for TYPESCRIPT ONLY, on typescript-eslint's own advice: it
|
||||||
|
// has no type information, so every ambient global is a false positive —
|
||||||
|
// Astro declares `ImageMetadata`, `astroHTML.JSX` and friends globally, and
|
||||||
|
// .astro frontmatter IS TypeScript. tsc catches a real undefined reference,
|
||||||
|
// which is what `npm run check` is for.
|
||||||
|
//
|
||||||
|
// NOT applied to .js/.mjs. `tsconfig.json` sets `allowJs` without `checkJs`,
|
||||||
|
// so plain JS is not type-checked by anything — turning the rule off there
|
||||||
|
// meant a typo like `procss.env.X` in astro.config.mjs passed lint silently.
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.astro'],
|
||||||
|
rules: { 'no-undef': 'off' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// A stray console.log in a static build is dead weight shipped to nobody.
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
|
||||||
|
// `role="list"` on a <ul> is redundant to a spec reader and load-bearing
|
||||||
|
// in a browser: Safari drops list semantics from any list styled
|
||||||
|
// `list-style: none`, so VoiceOver stops announcing "list, 6 items".
|
||||||
|
// src/styles/global.css keys its own reset off `ul[role='list']` for
|
||||||
|
// exactly this reason. The rule is right in general; this is the one
|
||||||
|
// documented exception, and it is scoped to that single pairing.
|
||||||
|
'astro/jsx-a11y/no-redundant-roles': [
|
||||||
|
'error',
|
||||||
|
{ ul: ['list'], ol: ['list'] },
|
||||||
|
],
|
||||||
|
eqeqeq: ['error', 'always'],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// `scripts/` ARE CLI TOOLS, AND PRINTING IS THEIR OUTPUT. The `no-console`
|
||||||
|
// rule above is justified in this config as "a stray console.log in a static
|
||||||
|
// build is dead weight shipped to nobody" — which is a statement about the
|
||||||
|
// shipped bundle, and nothing in `scripts/` reaches it. `check-claims.mjs`
|
||||||
|
// exists to print what it matched: CLAUDE.md's rule is that a grep is not a
|
||||||
|
// finding until you read what it matched, so suppressing its output would
|
||||||
|
// defeat the tool. Scoped to this directory rather than disabled globally.
|
||||||
|
//
|
||||||
|
// ⚠️ IT MUST SIT AFTER THE BLOCK IT OVERRIDES. Flat config applies matching
|
||||||
|
// blocks in order, last one wins — placed above, this had no effect at all
|
||||||
|
// and `npm run lint` still reported all six warnings. Measured, not assumed.
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.{js,mjs}'],
|
||||||
|
rules: { 'no-console': 'off' },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* `infra/cloudfront/` IS NOT A NODE MODULE AND NOT A BROWSER SCRIPT. A
|
||||||
|
CloudFront Function's entry point is a bare `function handler(event)` that
|
||||||
|
the runtime calls **by name** — it has no `export` (the runtime rejects
|
||||||
|
module syntax) and nothing in the file references it, so
|
||||||
|
`no-unused-vars` fires on the one declaration that is the whole point of
|
||||||
|
the file. `argsIgnorePattern` cannot reach a function declaration, so the
|
||||||
|
rule is scoped off here rather than silenced with a comment at the
|
||||||
|
declaration, which would read as though the name were incidental.
|
||||||
|
The test beside it is a CLI tool and prints, exactly as `scripts/` does.
|
||||||
|
|
||||||
|
⚠️ LIKE THE BLOCK ABOVE, THIS MUST STAY LAST. Flat config applies matching
|
||||||
|
blocks in order and the last one wins. */
|
||||||
|
{
|
||||||
|
files: ['infra/cloudfront/**/*.{js,mjs}'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* THE BACKEND TEST FILE ONLY — NOT `backend/intake/**`. `handler.mjs` runs in
|
||||||
|
Lambda, where `console.log` is a line in CloudWatch that nobody reads and
|
||||||
|
`console.warn`/`console.error` are the two that signal, so the rule stays on
|
||||||
|
for it deliberately. The test beside it is a CLI tool and prints its verdict,
|
||||||
|
exactly as `scripts/` and the router test do.
|
||||||
|
|
||||||
|
⚠️ LAST, LIKE THE TWO ABOVE. Flat config applies matching blocks in order
|
||||||
|
and the last one wins. */
|
||||||
|
{
|
||||||
|
files: ['backend/**/*.test.mjs'],
|
||||||
|
rules: { 'no-console': 'off' },
|
||||||
|
},
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Shape helpers for the CloudFront policy configs `configure.mjs` builds.
|
||||||
|
*
|
||||||
|
* ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.**
|
||||||
|
* `get-response-headers-policy` returns `{}` for a member the source does not
|
||||||
|
* define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy`
|
||||||
|
* — and sending that back fails `create-response-headers-policy` on
|
||||||
|
* ParamValidation before the call leaves the machine. `docs/09` Part 3 carries
|
||||||
|
* the incident and the exact error.
|
||||||
|
*
|
||||||
|
* **Dropping an empty member is safe at every depth, and that is a measurement
|
||||||
|
* rather than a hope.** Of the 16 structures reachable from
|
||||||
|
* `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at
|
||||||
|
* least one required field** — so `{}` is not a legal value there and can only
|
||||||
|
* be the placeholder. The single exception is `SecurityHeadersConfig` itself,
|
||||||
|
* and `configure.mjs` skips before it can build one of those empty, because a
|
||||||
|
* PDF policy cloning no security headers is the thing that section exists to
|
||||||
|
* avoid.
|
||||||
|
*
|
||||||
|
* They live in their own module so they can be tested: `configure.mjs` reads
|
||||||
|
* argv and calls AWS at import time, so importing THAT to reach two pure
|
||||||
|
* functions is not possible. Same reason `fields.mjs` sits beside
|
||||||
|
* `handler.mjs`. See `policy-shapes.test.mjs`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every empty-object member removed, at every depth, bottom-up — so a member
|
||||||
|
* left empty by stripping its own children is removed in turn.
|
||||||
|
*
|
||||||
|
* Arrays are recursed into but never have elements removed: an element index is
|
||||||
|
* load-bearing against its `Quantity` sibling, and an empty object inside one
|
||||||
|
* would be this script's own construction rather than an AWS placeholder. That
|
||||||
|
* case is left for `emptyObjectPaths` to report.
|
||||||
|
*/
|
||||||
|
export const withoutEmptyMembers = (value) => {
|
||||||
|
if (Array.isArray(value)) return value.map(withoutEmptyMembers);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
const cleaned = withoutEmptyMembers(v);
|
||||||
|
const isEmptyObject =
|
||||||
|
cleaned &&
|
||||||
|
typeof cleaned === 'object' &&
|
||||||
|
!Array.isArray(cleaned) &&
|
||||||
|
Object.keys(cleaned).length === 0;
|
||||||
|
if (!isEmptyObject) out[k] = cleaned;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True for `{}` — the value AWS accepts nowhere in these configs. */
|
||||||
|
export const isEmptyObject = (v) =>
|
||||||
|
Boolean(v) &&
|
||||||
|
typeof v === 'object' &&
|
||||||
|
!Array.isArray(v) &&
|
||||||
|
Object.keys(v).length === 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dotted path of every empty object left in a config. A post-condition on
|
||||||
|
* the strip above, not a filter: if this returns anything, the strip did not do
|
||||||
|
* what this module claims it does.
|
||||||
|
*
|
||||||
|
* Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and
|
||||||
|
* common, while an empty object is valid nowhere.
|
||||||
|
*/
|
||||||
|
export function emptyObjectPaths(value, path = '') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`));
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if (Object.keys(value).length === 0) return [path || '(root)'];
|
||||||
|
return Object.entries(value).flatMap(([k, v]) =>
|
||||||
|
emptyObjectPaths(v, path ? `${path}.${k}` : k),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ **NO VALIDATOR I COULD READ ENFORCES ANY OF THESE, WHICH IS WHY THIS TABLE
|
||||||
|
* EXISTS.** Measured 2026-09-04 against **`aws-cli/2.34.53`'s bundled
|
||||||
|
* `botocore/validate.py`**: it checks **neither `max` nor `pattern`** —
|
||||||
|
* `range_check()` reads only `min`, and the word `pattern` does not appear in
|
||||||
|
* the file. And the caps that matter are not modelled as constraints anyway: on
|
||||||
|
* both policy configs `Comment` is a bare `string`, and the 128 lives in the
|
||||||
|
* shape's **`documentation` prose**.
|
||||||
|
*
|
||||||
|
* ⚠️ **THAT IS ONE INSTRUMENT, NAMED, NOT A CLAIM ABOUT EVERY MECHANISM.** This
|
||||||
|
* machine also carries `aws-cli/2.11.15`, whose install is PyInstaller-frozen
|
||||||
|
* and whose `validate.py` cannot be read, so it is **unchecked rather than
|
||||||
|
* confirmed**. `CLAUDE.md`: *"no mechanism can X" is a claim about every
|
||||||
|
* mechanism, including the ones you did not enumerate* — so the honest form is
|
||||||
|
* this one. What is **direct evidence** either way: the 182-character `Comment`
|
||||||
|
* reached the API and came back `InvalidArgument`, so nothing stopped it on the
|
||||||
|
* CLI that actually ran. `docs/09` Part 3 carries both attempts.
|
||||||
|
*
|
||||||
|
* Every entry below **with a cited AWS source** is therefore enforced by the
|
||||||
|
* service and by nothing local. The two `[assumed]` entries are not known to be
|
||||||
|
* enforced at all.
|
||||||
|
*
|
||||||
|
* ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES, NOT LITERALS THIS
|
||||||
|
* FILE AUTHORS.** A literal we write is reviewed when it is written; a value
|
||||||
|
* copied out of the default behaviour's policy changes without anyone here
|
||||||
|
* touching it, and `docs/05` already specifies a Content-Security-Policy that
|
||||||
|
* would land there. `CreateResponseHeadersPolicy` declares a dedicated error
|
||||||
|
* for exactly that — `TooLongCSPInResponseHeadersPolicy`.
|
||||||
|
*
|
||||||
|
* ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED: `RemoveHeadersConfig` is cloned
|
||||||
|
* too and its count cap is not published.** The operation declares
|
||||||
|
* `TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists; the quotas
|
||||||
|
* page states no number and inventing one would be worse than the gap. A breach
|
||||||
|
* there surfaces as that error at the write, not as a pre-flight skip.
|
||||||
|
*
|
||||||
|
* ⚠️ **ABSENCE FROM A SOURCE IS NOT ABSENCE OF A LIMIT.** Entries marked
|
||||||
|
* `[assumed]` have no AWS source at all; they are kept because they cost nothing
|
||||||
|
* and constrain nothing this script sends.
|
||||||
|
*/
|
||||||
|
export const PAYLOAD_LIMITS = {
|
||||||
|
'response-headers-policy': [
|
||||||
|
{
|
||||||
|
path: 'Name',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 128,
|
||||||
|
source:
|
||||||
|
'[assumed] — no AWS source states a policy name length; the documented Name rule is uniqueness. Pouya, 2026-09-04',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'Comment',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 128,
|
||||||
|
source:
|
||||||
|
'service model, ResponseHeadersPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
/* CLONED, not authored here — see the header. */
|
||||||
|
path: 'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 1783,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas, Quotas on headers: "Maximum length of the Content-Security-Policy header value | 1,783 characters"; error shape TooLongCSPInResponseHeadersPolicy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'CustomHeadersConfig.Items[].Header',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 256,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header name | 256 characters"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'CustomHeadersConfig.Items[].Value',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 1783,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header value | 1,783 characters"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'CustomHeadersConfig.Items[]',
|
||||||
|
rule: 'maxCount',
|
||||||
|
limit: 10,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas: "maximum number of custom headers that you can add to a response headers policy | 10" (adjustable); error shape TooManyCustomHeadersInResponseHeadersPolicy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
'CustomHeadersConfig.Items[].Header',
|
||||||
|
'CustomHeadersConfig.Items[].Value',
|
||||||
|
],
|
||||||
|
rule: 'maxCombinedLength',
|
||||||
|
limit: 10240,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas: "Custom headers: maximum length of all header values and names combined | 10,240 characters"',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'origin-request-policy': [
|
||||||
|
{
|
||||||
|
path: 'Name',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 128,
|
||||||
|
source: '[assumed] — see the response-headers-policy Name entry',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'Comment',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 128,
|
||||||
|
source:
|
||||||
|
'service model, OriginRequestPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters". This is the one that failed on 2026-09-04 at 182',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'HeadersConfig.Headers.Items[]',
|
||||||
|
rule: 'maxCount',
|
||||||
|
limit: 10,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas: "Headers per origin request policy | 10" (adjustable); error shape TooManyHeadersInOriginRequestPolicy. We send 5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
paths: ['HeadersConfig.Headers.Items[]'],
|
||||||
|
rule: 'maxCombinedLength',
|
||||||
|
limit: 1024,
|
||||||
|
source:
|
||||||
|
'CloudFront quotas: "Total combined length of all query string, header, and cookie names in an origin request policy | 1024". We contribute header names only',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
/* Checked as a flag before any AWS call, because by the time a distribution
|
||||||
|
payload exists sections 4 and 5 may already have created policies.
|
||||||
|
|
||||||
|
⚠️ NOT DECORATION. `aws cloudfront list-functions --output text` returns the
|
||||||
|
ARN twice, tab-joined, because the function exists in a DEVELOPMENT and a
|
||||||
|
LIVE stage — 113 characters, and it fails the pattern too. Staging that
|
||||||
|
replaces a working `router.js` association with a value CloudFront will not
|
||||||
|
accept, and `router.js` keeps 22 of 23 pages off S3's AccessDenied.
|
||||||
|
`docs/09` Part 2 derives it correctly with `describe-function --stage LIVE`. */
|
||||||
|
'function-association': [
|
||||||
|
{
|
||||||
|
path: 'FunctionARN',
|
||||||
|
rule: 'maxLength',
|
||||||
|
limit: 108,
|
||||||
|
source: "service model, shape FunctionARN: {'max': 108}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'FunctionARN',
|
||||||
|
rule: 'pattern',
|
||||||
|
limit: 'arn:aws:cloudfront::[0-9]{12}:function\\/[a-zA-Z0-9-_]{1,64}',
|
||||||
|
source: 'service model, shape FunctionARN: pattern',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a dotted path, where `[]` means "every element of this array". Always
|
||||||
|
* returns `{path, value}` pairs with the index substituted, so a violation
|
||||||
|
* names the element rather than the collection.
|
||||||
|
*/
|
||||||
|
function resolvePath(root, path) {
|
||||||
|
let frontier = [{ path: '', value: root }];
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
const next = [];
|
||||||
|
const isArray = segment.endsWith('[]');
|
||||||
|
const key = isArray ? segment.slice(0, -2) : segment;
|
||||||
|
for (const { path: p, value } of frontier) {
|
||||||
|
const child = value?.[key];
|
||||||
|
const here = p ? `${p}.${key}` : key;
|
||||||
|
if (child === undefined || child === null) continue;
|
||||||
|
if (isArray) {
|
||||||
|
if (!Array.isArray(child)) continue;
|
||||||
|
child.forEach((v, i) => next.push({ path: `${here}[${i}]`, value: v }));
|
||||||
|
} else {
|
||||||
|
next.push({ path: here, value: child });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return frontier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every limit the given payload breaches. Empty means it is safe to send as far
|
||||||
|
* as this table knows — which is a claim about the table, not about AWS.
|
||||||
|
*/
|
||||||
|
export function limitViolations(kind, payload) {
|
||||||
|
const rules = PAYLOAD_LIMITS[kind];
|
||||||
|
if (!rules) throw new Error(`no limit table for payload kind '${kind}'`);
|
||||||
|
const out = [];
|
||||||
|
const add = (v) => out.push(v);
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
/* `paths` (plural) is for the aggregate rules, where AWS caps a total
|
||||||
|
across more than one field — header names AND values combined. */
|
||||||
|
const paths = rule.paths ?? [rule.path];
|
||||||
|
const label = paths.join(' + ');
|
||||||
|
const resolved = paths.flatMap((one) => resolvePath(payload, one));
|
||||||
|
|
||||||
|
if (rule.rule === 'maxCount') {
|
||||||
|
if (resolved.length > rule.limit) {
|
||||||
|
add({
|
||||||
|
path: label,
|
||||||
|
rule: 'maxCount',
|
||||||
|
actual: resolved.length,
|
||||||
|
limit: rule.limit,
|
||||||
|
message: `${label} has ${resolved.length} entries; the limit is ${rule.limit} (${rule.source})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.rule === 'maxCombinedLength') {
|
||||||
|
const total = resolved.reduce(
|
||||||
|
(n, { value }) => n + (typeof value === 'string' ? value.length : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (total > rule.limit) {
|
||||||
|
add({
|
||||||
|
path: label,
|
||||||
|
rule: 'maxCombinedLength',
|
||||||
|
actual: total,
|
||||||
|
limit: rule.limit,
|
||||||
|
message: `${label} totals ${total} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { path, value } of resolved) {
|
||||||
|
if (typeof value !== 'string') continue;
|
||||||
|
if (rule.rule === 'maxLength' && value.length > rule.limit) {
|
||||||
|
add({
|
||||||
|
path,
|
||||||
|
rule: 'maxLength',
|
||||||
|
actual: value.length,
|
||||||
|
limit: rule.limit,
|
||||||
|
message: `${path} is ${value.length} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.rule === 'pattern' &&
|
||||||
|
!new RegExp(`^(?:${rule.limit})$`).test(value)
|
||||||
|
) {
|
||||||
|
add({
|
||||||
|
path,
|
||||||
|
rule: 'pattern',
|
||||||
|
actual: JSON.stringify(value),
|
||||||
|
limit: rule.limit,
|
||||||
|
message: `${path} does not match ${rule.limit} (${rule.source})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04
|
||||||
|
* `--apply` failure recorded in `docs/09` Part 3.
|
||||||
|
*
|
||||||
|
* The first case is that failure verbatim: the `SecurityHeadersConfig` the live
|
||||||
|
* `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and
|
||||||
|
* all, which is what `create-response-headers-policy` rejected.
|
||||||
|
*
|
||||||
|
* node infra/cloudfront/policy-shapes.test.mjs
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
withoutEmptyMembers,
|
||||||
|
emptyObjectPaths,
|
||||||
|
isEmptyObject,
|
||||||
|
limitViolations,
|
||||||
|
PAYLOAD_LIMITS,
|
||||||
|
} from './policy-shapes.mjs';
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
const failures = [];
|
||||||
|
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const t = (name, got, want) => {
|
||||||
|
if (eq(got, want)) pass += 1;
|
||||||
|
else
|
||||||
|
failures.push(
|
||||||
|
`${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The live source policy, copied from `get-response-headers-policy` on
|
||||||
|
67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */
|
||||||
|
const LIVE_SECURITY_HEADERS = {
|
||||||
|
XSSProtection: { Override: false, Protection: true, ModeBlock: true },
|
||||||
|
FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' },
|
||||||
|
ReferrerPolicy: {
|
||||||
|
Override: false,
|
||||||
|
ReferrerPolicy: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
ContentSecurityPolicy: {},
|
||||||
|
ContentTypeOptions: { Override: true },
|
||||||
|
StrictTransportSecurity: {
|
||||||
|
Override: false,
|
||||||
|
AccessControlMaxAgeSec: 31536000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- the incident itself ------------------------------------------------ */
|
||||||
|
const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS);
|
||||||
|
t(
|
||||||
|
'the 2026-09-04 breach: ContentSecurityPolicy is dropped',
|
||||||
|
Object.keys(stripped).sort(),
|
||||||
|
[
|
||||||
|
'ContentTypeOptions',
|
||||||
|
'FrameOptions',
|
||||||
|
'ReferrerPolicy',
|
||||||
|
'StrictTransportSecurity',
|
||||||
|
'XSSProtection',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'and five survive — the count docs/09 Part 3 tells the operator to read',
|
||||||
|
Object.keys(stripped).length,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the surviving members are untouched',
|
||||||
|
stripped.StrictTransportSecurity,
|
||||||
|
LIVE_SECURITY_HEADERS.StrictTransportSecurity,
|
||||||
|
);
|
||||||
|
t('nothing empty is left behind', emptyObjectPaths(stripped), []);
|
||||||
|
|
||||||
|
/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip
|
||||||
|
turned into a hard abort (adversarial-reviewer, round 1) ------------- */
|
||||||
|
t(
|
||||||
|
'a top-level policy-config member is dropped',
|
||||||
|
withoutEmptyMembers({
|
||||||
|
Name: 'p',
|
||||||
|
CorsConfig: {},
|
||||||
|
SecurityHeadersConfig: stripped,
|
||||||
|
}),
|
||||||
|
{ Name: 'p', SecurityHeadersConfig: stripped },
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- and the one BELOW that, which the first repair still aborted on
|
||||||
|
(adversarial-reviewer, round 2) -------------------------------------- */
|
||||||
|
t(
|
||||||
|
'a CorsConfig member is dropped, and the emptied CorsConfig with it',
|
||||||
|
withoutEmptyMembers({
|
||||||
|
Name: 'p',
|
||||||
|
CorsConfig: { AccessControlExposeHeaders: {} },
|
||||||
|
}),
|
||||||
|
{ Name: 'p' },
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'but a CorsConfig that still has content survives',
|
||||||
|
withoutEmptyMembers({
|
||||||
|
CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false },
|
||||||
|
}),
|
||||||
|
{ CorsConfig: { OriginOverride: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- things that must NOT be discarded ---------------------------------- */
|
||||||
|
t(
|
||||||
|
'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common',
|
||||||
|
withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }),
|
||||||
|
{ RemoveHeadersConfig: { Quantity: 0, Items: [] } },
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'false, 0, null and empty string are kept',
|
||||||
|
withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }),
|
||||||
|
{ a: false, b: 0, c: null, d: '' },
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'array elements are recursed into but never removed',
|
||||||
|
withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }),
|
||||||
|
{ Items: [{ Header: 'X' }, {}] },
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the custom-headers list the script builds is untouched',
|
||||||
|
withoutEmptyMembers({
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 1,
|
||||||
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 1,
|
||||||
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
t('stripping is idempotent', withoutEmptyMembers(stripped), stripped);
|
||||||
|
|
||||||
|
/* ---- the drift comparison: {} and absent must normalise alike ------------
|
||||||
|
Round 1's repair stripped children but left `norm({})` as "{}" against
|
||||||
|
`norm(undefined)` as "null", which reported permanent, unrepairable drift on
|
||||||
|
the intake form's own path. */
|
||||||
|
const norm = (o) => {
|
||||||
|
const v = withoutEmptyMembers(o);
|
||||||
|
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
|
||||||
|
};
|
||||||
|
t('norm({}) equals norm(undefined)', norm({}), norm(undefined));
|
||||||
|
t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({}));
|
||||||
|
t(
|
||||||
|
'but a real difference still differs',
|
||||||
|
norm({ a: 1 }) === norm({ a: 2 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- emptyObjectPaths, the post-condition ------------------------------- */
|
||||||
|
t(
|
||||||
|
'reports the incident path',
|
||||||
|
emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }),
|
||||||
|
['SecurityHeadersConfig.ContentSecurityPolicy'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'reports round 2s deeper path',
|
||||||
|
emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }),
|
||||||
|
['CorsConfig.AccessControlExposeHeaders'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'reports an empty object inside an array, with its index',
|
||||||
|
emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }),
|
||||||
|
['Items[1]'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'reports every one, not just the first',
|
||||||
|
emptyObjectPaths({ a: {}, b: { c: {} } }),
|
||||||
|
['a', 'b.c'],
|
||||||
|
);
|
||||||
|
t('silent on an empty array', emptyObjectPaths({ a: [] }), []);
|
||||||
|
t(
|
||||||
|
'silent on null, undefined and primitives',
|
||||||
|
emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t('names the root when the whole config is empty', emptyObjectPaths({}), [
|
||||||
|
'(root)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ---- the invariant the two functions exist to hold together ------------- */
|
||||||
|
t(
|
||||||
|
'THE INVARIANT: nothing survives the strip that the assertion would report',
|
||||||
|
emptyObjectPaths(
|
||||||
|
withoutEmptyMembers({
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
SecurityHeadersConfig: LIVE_SECURITY_HEADERS,
|
||||||
|
CorsConfig: { AccessControlExposeHeaders: {} },
|
||||||
|
ServerTimingHeadersConfig: {},
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 1,
|
||||||
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- PAYLOAD_LIMITS: the 2026-09-04 second failure -----------------------
|
||||||
|
InvalidArgument, "The parameter Comment is too big", from
|
||||||
|
create-origin-request-policy. The model types Comment as a bare `string`, so
|
||||||
|
ParamValidation could not see it and the dry run was the only place it could
|
||||||
|
have been caught. */
|
||||||
|
const ORP = (comment) => ({
|
||||||
|
Name: 'adr-sml-api-viewer-address',
|
||||||
|
Comment: comment,
|
||||||
|
HeadersConfig: {
|
||||||
|
HeaderBehavior: 'whitelist',
|
||||||
|
Headers: { Quantity: 1, Items: ['Origin'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t(
|
||||||
|
'the 182-character Comment that failed is reported',
|
||||||
|
limitViolations('origin-request-policy', ORP('x'.repeat(182))).map((v) => [
|
||||||
|
v.path,
|
||||||
|
v.actual,
|
||||||
|
v.limit,
|
||||||
|
]),
|
||||||
|
[['Comment', 182, 128]],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the shipped Comment passes',
|
||||||
|
limitViolations(
|
||||||
|
'origin-request-policy',
|
||||||
|
ORP(
|
||||||
|
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'128 exactly is allowed — the cap is inclusive',
|
||||||
|
limitViolations('origin-request-policy', ORP('x'.repeat(128))),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'129 is not',
|
||||||
|
limitViolations('origin-request-policy', ORP('x'.repeat(129))).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the 118-character Comment AWS accepted on 2026-09-04 passes',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment:
|
||||||
|
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Section 4's payload needs its OWN over-cap cases: asserting only that the
|
||||||
|
accepted 118 passes leaves the cap free to be wrong in the loose direction,
|
||||||
|
which a mutation raising it to 1280 proved by surviving. */
|
||||||
|
t(
|
||||||
|
'a 129-character response-headers Comment is caught',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment: 'x'.repeat(129),
|
||||||
|
}).map((v) => [v.path, v.actual, v.limit]),
|
||||||
|
[['Comment', 129, 128]],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'and 128 exactly is allowed',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment: 'x'.repeat(128),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'an over-long policy Name is caught on both policy kinds',
|
||||||
|
[
|
||||||
|
limitViolations('response-headers-policy', { Name: 'n'.repeat(129) })
|
||||||
|
.length,
|
||||||
|
limitViolations('origin-request-policy', { Name: 'n'.repeat(129) }).length,
|
||||||
|
],
|
||||||
|
[1, 1],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'and both shipped names pass',
|
||||||
|
[
|
||||||
|
limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex' })
|
||||||
|
.length,
|
||||||
|
limitViolations('origin-request-policy', {
|
||||||
|
Name: 'adr-sml-api-viewer-address',
|
||||||
|
}).length,
|
||||||
|
],
|
||||||
|
[0, 0],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- the function ARN, the one limit the service model does give us ------ */
|
||||||
|
const GOOD_ARN = 'arn:aws:cloudfront::327082975128:function/adr-sml-router';
|
||||||
|
t(
|
||||||
|
'a correctly derived function ARN passes',
|
||||||
|
limitViolations('function-association', { FunctionARN: GOOD_ARN }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the tab-doubled ARN that `list-functions --output text` returns breaks both rules',
|
||||||
|
limitViolations('function-association', {
|
||||||
|
FunctionARN: `${GOOD_ARN}\t${GOOD_ARN}`,
|
||||||
|
})
|
||||||
|
.map((v) => v.rule)
|
||||||
|
.sort(),
|
||||||
|
['maxLength', 'pattern'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'a Lambda@Edge ARN is not a CloudFront function ARN',
|
||||||
|
limitViolations('function-association', {
|
||||||
|
FunctionARN: 'arn:aws:lambda:us-east-1:327082975128:function:edge',
|
||||||
|
}).some((v) => v.rule === 'pattern'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- the aggregate rules, both documented on the CloudFront quotas page -- */
|
||||||
|
const HDRS = (items) => ({
|
||||||
|
Name: 'adr-sml-api-viewer-address',
|
||||||
|
Comment: 'c',
|
||||||
|
HeadersConfig: {
|
||||||
|
HeaderBehavior: 'whitelist',
|
||||||
|
Headers: { Quantity: items.length, Items: items },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const SHIPPED_HEADERS = [
|
||||||
|
'CloudFront-Viewer-Address',
|
||||||
|
'Content-Type',
|
||||||
|
'Origin',
|
||||||
|
'Referer',
|
||||||
|
'User-Agent',
|
||||||
|
];
|
||||||
|
t(
|
||||||
|
'the five headers we actually whitelist pass every rule',
|
||||||
|
limitViolations('origin-request-policy', HDRS(SHIPPED_HEADERS)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'an 11th whitelisted header breaches "Headers per origin request policy | 10"',
|
||||||
|
limitViolations(
|
||||||
|
'origin-request-policy',
|
||||||
|
HDRS(Array.from({ length: 11 }, (_, i) => `X-H${i}`)),
|
||||||
|
).map((v) => [v.rule, v.actual, v.limit]),
|
||||||
|
[['maxCount', 11, 10]],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'ten is allowed',
|
||||||
|
limitViolations(
|
||||||
|
'origin-request-policy',
|
||||||
|
HDRS(Array.from({ length: 10 }, (_, i) => `X-H${i}`)),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'header names totalling over 1024 breach the combined-length quota',
|
||||||
|
limitViolations(
|
||||||
|
'origin-request-policy',
|
||||||
|
HDRS(Array.from({ length: 9 }, () => 'X'.repeat(120))),
|
||||||
|
)
|
||||||
|
.map((v) => v.rule)
|
||||||
|
.sort(),
|
||||||
|
['maxCombinedLength'],
|
||||||
|
);
|
||||||
|
|
||||||
|
t(
|
||||||
|
'an 11th custom response header breaches its own count quota',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'n',
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 11,
|
||||||
|
Items: Array.from({ length: 11 }, (_, i) => ({
|
||||||
|
Header: `X-${i}`,
|
||||||
|
Value: 'v',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}).map((v) => v.rule),
|
||||||
|
['maxCount'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the single X-Robots-Tag header we add passes',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment:
|
||||||
|
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 1,
|
||||||
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'names and values combined over 10,240 are caught',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'n',
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 8,
|
||||||
|
Items: Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
Header: `X-${i}`,
|
||||||
|
Value: 'v'.repeat(1500),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}).some((v) => v.rule === 'maxCombinedLength'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- the walker -------------------------------------------------------- */
|
||||||
|
t(
|
||||||
|
'[] resolves every element and the violation names the index',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'n',
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 2,
|
||||||
|
Items: [
|
||||||
|
{ Header: 'X-Robots-Tag', Value: 'noindex' },
|
||||||
|
{ Header: 'X'.repeat(300), Value: 'v' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}).map((v) => v.path),
|
||||||
|
['CustomHeadersConfig.Items[1].Header'],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'an absent field is not a violation',
|
||||||
|
limitViolations('response-headers-policy', { Name: 'n' }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'a non-string value is skipped rather than crashing',
|
||||||
|
limitViolations('response-headers-policy', { Name: 'n', Comment: 12345 }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'a null along the path is skipped',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'n',
|
||||||
|
CustomHeadersConfig: null,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'an unknown payload kind throws rather than passing silently',
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
limitViolations('nope', {});
|
||||||
|
return 'no throw';
|
||||||
|
} catch (e) {
|
||||||
|
return e.message.includes('nope');
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'every limit entry carries a source',
|
||||||
|
Object.values(PAYLOAD_LIMITS)
|
||||||
|
.flat()
|
||||||
|
.every((l) => typeof l.source === 'string' && l.source.length > 0),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'every entry addresses exactly one of path / paths',
|
||||||
|
Object.values(PAYLOAD_LIMITS)
|
||||||
|
.flat()
|
||||||
|
.every((l) => (l.path === undefined) !== (l.paths === undefined)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- the CLONED values, which change without this file being touched.
|
||||||
|
`docs/05` specifies a Content-Security-Policy that would land on the default
|
||||||
|
behaviour's policy and be copied straight into ours; the API declares
|
||||||
|
TooLongCSPInResponseHeadersPolicy for exactly that. */
|
||||||
|
const withCsp = (csp) => ({
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment: 'c',
|
||||||
|
SecurityHeadersConfig: {
|
||||||
|
ContentTypeOptions: { Override: true },
|
||||||
|
ContentSecurityPolicy: { Override: false, ContentSecurityPolicy: csp },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t(
|
||||||
|
'a cloned CSP over 1,783 characters is caught before the create',
|
||||||
|
limitViolations('response-headers-policy', withCsp('x'.repeat(1784))).map(
|
||||||
|
(v) => [v.path, v.actual, v.limit],
|
||||||
|
),
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||||
|
1784,
|
||||||
|
1783,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'1,783 exactly is allowed',
|
||||||
|
limitViolations('response-headers-policy', withCsp('x'.repeat(1783))),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'a realistic CSP passes',
|
||||||
|
limitViolations(
|
||||||
|
'response-headers-policy',
|
||||||
|
withCsp("default-src 'self'; img-src 'self' data:; style-src 'self'"),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
t(
|
||||||
|
'the live source policy, which defines no CSP at all, passes whole',
|
||||||
|
limitViolations('response-headers-policy', {
|
||||||
|
Name: 'adr-sml-pdf-noindex',
|
||||||
|
Comment:
|
||||||
|
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||||
|
SecurityHeadersConfig: stripped,
|
||||||
|
CustomHeadersConfig: {
|
||||||
|
Quantity: 1,
|
||||||
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error(
|
||||||
|
`policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`policy-shapes: ${pass} of ${pass} cases pass`);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* CloudFront Function, VIEWER REQUEST, on the DEFAULT behaviour and on `*.pdf`.
|
||||||
|
* Not on `/api/*` — see the rule below, which is the one that matters.
|
||||||
|
*
|
||||||
|
* `*.pdf` has it because this function is NOT a no-op on file paths: it
|
||||||
|
* normalises `\` to `/` and collapses a leading `//` run BEFORE the extension
|
||||||
|
* test, and 301s when that changed anything. Measured live 2026-09-03:
|
||||||
|
* `//pouya-lajevardi-bio.pdf` returns 301. Dropping the association there hands
|
||||||
|
* S3 the doubled key and returns 404 instead.
|
||||||
|
*
|
||||||
|
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
|
||||||
|
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
|
||||||
|
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
|
||||||
|
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
|
||||||
|
* distribution 2026-09-01, before this function existed: `/about/` and
|
||||||
|
* `/definitely-not-a-page/` both returned **403 with an 111-byte
|
||||||
|
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
|
||||||
|
* `/` worked, via the distribution's default root object. That is 22 of the 23
|
||||||
|
* pages.
|
||||||
|
*
|
||||||
|
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
|
||||||
|
* `/api/intake` has no extension and no trailing slash, so the redirect branch
|
||||||
|
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
|
||||||
|
* which would lose the submission body silently. The association is per
|
||||||
|
* behaviour and `/api/*` gets none.
|
||||||
|
*
|
||||||
|
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
|
||||||
|
*
|
||||||
|
* /about/ -> rewrite to /about/index.html (the origin has that key)
|
||||||
|
* /about -> 301 to /about/ (one canonical URL per page)
|
||||||
|
*
|
||||||
|
* Anything with a file extension in its last segment is left alone —
|
||||||
|
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
|
||||||
|
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
|
||||||
|
*
|
||||||
|
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
|
||||||
|
* arrow functions, no `String.prototype.endsWith`, no template literals. The
|
||||||
|
* runtime supports more than this; a viewer-request function runs on every
|
||||||
|
* request to the site and is the wrong place to be clever.
|
||||||
|
*/
|
||||||
|
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
|
||||||
|
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
|
||||||
|
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
|
||||||
|
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
|
||||||
|
raw and `|` is routine in tracking values. Strip rather than encode — these
|
||||||
|
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
|
||||||
|
function safe(part) {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler(event) {
|
||||||
|
var request = event.request;
|
||||||
|
|
||||||
|
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
|
||||||
|
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
|
||||||
|
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
|
||||||
|
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
|
||||||
|
the same, because the URL Standard maps it to `/` in special schemes.
|
||||||
|
Redirect rather than rewrite, or `//about/` serves the About page at a second
|
||||||
|
URL with a 200. Only the leading run: an interior `//` is a key that does not
|
||||||
|
exist. */
|
||||||
|
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
|
||||||
|
var normalised = uri !== request.uri;
|
||||||
|
var lastSlash = uri.lastIndexOf('/');
|
||||||
|
var lastSegment = uri.substring(lastSlash + 1);
|
||||||
|
|
||||||
|
// A file, not a route.
|
||||||
|
if (lastSegment.indexOf('.') !== -1) {
|
||||||
|
if (normalised) return moved(uri, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory-style route: hand the origin the key it actually holds.
|
||||||
|
if (lastSegment === '') {
|
||||||
|
if (normalised) return moved(uri, request);
|
||||||
|
request.uri = uri + 'index.html';
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
|
||||||
|
page has ONE address: serving it at both would put two indexable URLs on the
|
||||||
|
same content, which `docs/04` treats as its primary concern. */
|
||||||
|
return moved(uri + '/', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 301 to a path on this origin, carrying the query string. `location` is always
|
||||||
|
* built from an already-normalised path, which is what keeps it same-origin.
|
||||||
|
*/
|
||||||
|
function moved(path, request) {
|
||||||
|
var qs = '';
|
||||||
|
var names = Object.keys(request.querystring);
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
var name = names[i];
|
||||||
|
var value = request.querystring[name];
|
||||||
|
if (value.multiValue) {
|
||||||
|
for (var j = 0; j < value.multiValue.length; j++) {
|
||||||
|
qs +=
|
||||||
|
(qs === '' ? '' : '&') +
|
||||||
|
safe(name) +
|
||||||
|
'=' +
|
||||||
|
safe(value.multiValue[j].value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
|
||||||
|
than the function guessing which the viewer meant. */
|
||||||
|
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 301,
|
||||||
|
statusDescription: 'Moved Permanently',
|
||||||
|
headers: {
|
||||||
|
location: { value: path + (qs === '' ? '' : '?' + qs) },
|
||||||
|
'cache-control': { value: 'public, max-age=0, must-revalidate' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Unit test for the viewer-request router. `node infra/cloudfront/router.test.mjs`.
|
||||||
|
*
|
||||||
|
* The function file cannot use module syntax — CloudFront's runtime has no
|
||||||
|
* `export` — so it is read and evaluated rather than imported. `aws cloudfront
|
||||||
|
* test-function` is the authoritative check because it runs the real runtime;
|
||||||
|
* this one runs in a second, catches the branch mistakes, and costs nothing.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const src = readFileSync(join(here, 'router.js'), 'utf8');
|
||||||
|
const handler = new Function(`${src}; return handler;`)();
|
||||||
|
|
||||||
|
const req = (uri, querystring = {}) => ({ request: { uri, querystring } });
|
||||||
|
|
||||||
|
const CASES = [
|
||||||
|
// [uri, querystring, expected] — expected is {uri} for a rewrite/passthrough
|
||||||
|
// or {status, location} for a redirect.
|
||||||
|
['/', {}, { uri: '/index.html' }],
|
||||||
|
['/about/', {}, { uri: '/about/index.html' }],
|
||||||
|
['/practice/construction/', {}, { uri: '/practice/construction/index.html' }],
|
||||||
|
['/contact/received/', {}, { uri: '/contact/received/index.html' }],
|
||||||
|
['/about', {}, { status: 301, location: '/about/' }],
|
||||||
|
['/practice/energy', {}, { status: 301, location: '/practice/energy/' }],
|
||||||
|
// Files are untouched — every one of these is a real object in dist/.
|
||||||
|
['/robots.txt', {}, { uri: '/robots.txt' }],
|
||||||
|
['/sitemap-index.xml', {}, { uri: '/sitemap-index.xml' }],
|
||||||
|
['/404.html', {}, { uri: '/404.html' }],
|
||||||
|
['/favicon.ico', {}, { uri: '/favicon.ico' }],
|
||||||
|
['/pouya-lajevardi-bio.pdf', {}, { uri: '/pouya-lajevardi-bio.pdf' }],
|
||||||
|
['/_astro/schema.Cm5su60K.css', {}, { uri: '/_astro/schema.Cm5su60K.css' }],
|
||||||
|
['/og/mediation.jpg', {}, { uri: '/og/mediation.jpg' }],
|
||||||
|
// The query string survives the redirect, normalised to `name=value`.
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ utm_source: { value: 'linkedin' }, ref: { value: '' } },
|
||||||
|
{ status: 301, location: '/fees/?utm_source=linkedin&ref=' },
|
||||||
|
],
|
||||||
|
/* ⚠️ THE OPEN-REDIRECT CASES. CloudFront forwards duplicate leading slashes
|
||||||
|
verbatim (it collapses dot-segments but not `//`), so without normalisation
|
||||||
|
`//evil.example.com/x` produced `Location: //evil.example.com/x/` — a
|
||||||
|
network-path reference that sends the viewer to another host from this
|
||||||
|
domain's own URL. The backslash form defeats a `startsWith('//')` guard,
|
||||||
|
because the URL Standard maps `\` to `/` in special schemes. Both must stay
|
||||||
|
same-origin, and both must keep a SINGLE leading slash. */
|
||||||
|
[
|
||||||
|
'//evil.example.com/x',
|
||||||
|
{},
|
||||||
|
{ status: 301, location: '/evil.example.com/x/' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'///evil.example.com/x',
|
||||||
|
{},
|
||||||
|
{ status: 301, location: '/evil.example.com/x/' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/\\evil.example.com/x',
|
||||||
|
{},
|
||||||
|
{ status: 301, location: '/evil.example.com/x/' },
|
||||||
|
],
|
||||||
|
/* ⚠️ A NORMALISED PATH IS REDIRECTED, NOT REWRITTEN — this asserted a 200 for
|
||||||
|
one revision, which closed the redirect and opened an unbounded family of
|
||||||
|
duplicate URLs for every page on the site. */
|
||||||
|
[
|
||||||
|
'//evil.example.com/x/',
|
||||||
|
{},
|
||||||
|
{ status: 301, location: '/evil.example.com/x/' },
|
||||||
|
],
|
||||||
|
['//about/', {}, { status: 301, location: '/about/' }],
|
||||||
|
['///about/', {}, { status: 301, location: '/about/' }],
|
||||||
|
['/\\about/', {}, { status: 301, location: '/about/' }],
|
||||||
|
/* A file is normalised too. This branch returned `request` untouched for one
|
||||||
|
revision, so `//robots.txt` reached S3 with the doubled slash and 404'd. */
|
||||||
|
['//robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||||
|
['/\\robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||||
|
/* An interior `//` is left alone on purpose: it is a key that does not exist,
|
||||||
|
so it resolves to the 404 page. Only the leading run is a security question. */
|
||||||
|
['/a//b/', {}, { uri: '/a//b/index.html' }],
|
||||||
|
/* Header-injection surface: CR, LF, space and the delimiters browsers disagree
|
||||||
|
about are stripped rather than re-encoded — an already-encoded value must not
|
||||||
|
be encoded twice. `%20` therefore passes through untouched. */
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ q: { value: 'a b"><x' }, utm: { value: 'a%20b' } },
|
||||||
|
{ status: 301, location: '/fees/?q=abx&utm=a%20b' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ evil: { value: 'x\r\nSet-Cookie: a=b' } },
|
||||||
|
{ status: 301, location: '/fees/?evil=xSet-Cookie:a=b' },
|
||||||
|
],
|
||||||
|
/* `#` changes the STRUCTURE of the Location — without stripping it, `&b=y`
|
||||||
|
lands in a fragment and the parameter is silently lost. */
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ a: { value: 'x#&b=y' } },
|
||||||
|
{ status: 301, location: '/fees/?a=x&b=y' },
|
||||||
|
],
|
||||||
|
/* ⚠️ AND THESE MUST SURVIVE. `| ^ ` { }` are not in WHATWG's query
|
||||||
|
percent-encode set, so a browser sends them raw — and `|` is routine in
|
||||||
|
ad-platform tracking values. One revision of `safe()` stripped all of them,
|
||||||
|
silently corrupting exactly the campaign links the 301 exists to preserve. */
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ utm_content: { value: 'banner|top' }, k: { value: 'a{b}c^d`e' } },
|
||||||
|
{ status: 301, location: '/fees/?utm_content=banner|top&k=a{b}c^d`e' },
|
||||||
|
],
|
||||||
|
// multiValue, which no case exercised before.
|
||||||
|
[
|
||||||
|
'/fees',
|
||||||
|
{ tag: { value: 'a', multiValue: [{ value: 'a' }, { value: 'b' }] } },
|
||||||
|
{ status: 301, location: '/fees/?tag=a&tag=b' },
|
||||||
|
],
|
||||||
|
/* /api/intake must NEVER be redirected — a 301 turns a POST into a GET and
|
||||||
|
the submission body is gone. This function is not associated with the
|
||||||
|
/api/* behaviour, so this case documents WHY the association matters: if it
|
||||||
|
ever were associated, this is the damage. */
|
||||||
|
['/api/intake', {}, { status: 301, location: '/api/intake/' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
const failures = [];
|
||||||
|
for (const [uri, qs, expected] of CASES) {
|
||||||
|
const out = handler(req(uri, qs));
|
||||||
|
let actual;
|
||||||
|
if (out.statusCode) {
|
||||||
|
actual = { status: out.statusCode, location: out.headers.location.value };
|
||||||
|
} else {
|
||||||
|
actual = { uri: out.uri };
|
||||||
|
}
|
||||||
|
if (JSON.stringify(actual) === JSON.stringify(expected)) pass += 1;
|
||||||
|
else
|
||||||
|
failures.push(
|
||||||
|
`${uri} -> ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pass + failures.length !== CASES.length) {
|
||||||
|
throw new Error(`case count ${pass + failures.length} != ${CASES.length}`);
|
||||||
|
}
|
||||||
|
console.log(`router: ${pass} of ${CASES.length} cases pass`);
|
||||||
|
for (const f of failures) console.error(' FAIL ' + f);
|
||||||
|
if (failures.length > 0) process.exit(1);
|
||||||
Generated
+11734
File diff suppressed because it is too large
Load Diff
+36
-14
@@ -2,31 +2,53 @@
|
|||||||
"name": "adr-smlcompany-ca",
|
"name": "adr-smlcompany-ca",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
"description": "The dispute resolution practice of Pouya Lajevardi \u2014 Toronto",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22" },
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24",
|
||||||
|
"npm": ">=9.6.5"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
|
"check:claims": "node scripts/check-claims.mjs",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lighthouse": "lhci autorun"
|
"deploy": "bash scripts/deploy-local.sh",
|
||||||
|
"lighthouse": "node scripts/lighthouse.mjs",
|
||||||
|
"og:proof": "node scripts/og-proof.mjs",
|
||||||
|
"check:intake": "node scripts/check-intake.mjs",
|
||||||
|
"bio:pdf": "node scripts/bio-pdf.mjs",
|
||||||
|
"icons": "node scripts/icons.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.0.0",
|
"@astrojs/mdx": "^7.0.8",
|
||||||
"@astrojs/mdx": "^4.0.0",
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
"@astrojs/sitemap": "^3.2.0",
|
"astro": "^7.2.9",
|
||||||
"sharp": "^0.33.0"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.0",
|
"@astrojs/check": "^0.9.10",
|
||||||
"typescript": "^5.7.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"prettier": "^3.4.0",
|
"@fontsource/geist": "^5.3.0",
|
||||||
"prettier-plugin-astro": "^0.14.0",
|
"@fontsource/instrument-serif": "^5.3.0",
|
||||||
"eslint": "^9.0.0",
|
"chrome-launcher": "^1.2.1",
|
||||||
"eslint-plugin-astro": "^1.3.0",
|
"eslint": "^10.9.1",
|
||||||
"@lhci/cli": "^0.14.0"
|
"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"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"eslint-plugin-jsx-a11y": {
|
||||||
|
"eslint": "$eslint"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
-1
@@ -2,8 +2,30 @@
|
|||||||
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
|
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
|
||||||
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
|
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
|
||||||
|
|
||||||
|
# EXACTLY ONE THING IS DISALLOWED, AND IT IS A SUBSTITUTE FOR A MECHANISM THIS
|
||||||
|
# DISTRIBUTION IS NOT ALLOWED TO HAVE.
|
||||||
|
# The bio PDF duplicates /bio/. The intended fix was `X-Robots-Tag: noindex` on
|
||||||
|
# *.pdf via a CloudFront response-headers policy, which the distribution's
|
||||||
|
# pricing plan forbids — AGENTS.md §7, and docs/09 Part 3 for the three failed
|
||||||
|
# attempts. Disallow is the remaining lever and it is NOT the same instrument:
|
||||||
|
# - it stops the PDF being FETCHED, so its contents are never indexed and the
|
||||||
|
# duplicate-content problem it was raised for is solved;
|
||||||
|
# - it does NOT de-index the URL. The PDF is linked from /bio/ and /about/, so
|
||||||
|
# a search engine can still list the bare URL with no snippet.
|
||||||
|
# That residual is accepted deliberately: a bare link to a bio PDF is not the
|
||||||
|
# harm the noindex was for. Revisit if the pricing plan ever changes.
|
||||||
|
#
|
||||||
|
# /legal/* is still NOT disallowed, and that reasoning is unchanged — it is the
|
||||||
|
# general rule this one path is the exception to. A crawler forbidden to FETCH a
|
||||||
|
# URL never reads the `noindex` on it, so the two cancel; the legal pages are
|
||||||
|
# linked from every footer, so they are discovered regardless, and the pair would
|
||||||
|
# have produced bare "no information available" listings with the directive that
|
||||||
|
# would have suppressed them sitting unread behind the wall.
|
||||||
|
# `noindex` is what de-indexes; `Disallow` is what prevents fetching. Use the one
|
||||||
|
# that matches the problem, and never both on the same path.
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
|
Disallow: /pouya-lajevardi-bio.pdf
|
||||||
Allow: /
|
Allow: /
|
||||||
Disallow: /legal/
|
|
||||||
|
|
||||||
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Collects the AWS resource identifiers this project needs (AGENTS.md Q10).
|
# Collects the AWS resource identifiers this project needs (AGENTS.md Q10).
|
||||||
# Read-only: every call is a list/describe. Nothing is created or changed.
|
# Read-only: no call creates or mutates anything.
|
||||||
#
|
#
|
||||||
# chmod +x scripts/aws-discover.sh
|
# chmod +x scripts/aws-discover.sh
|
||||||
# ./scripts/aws-discover.sh > aws-inventory.txt
|
# ./scripts/aws-discover.sh > aws-inventory.txt
|
||||||
@@ -14,7 +14,7 @@ set -uo pipefail
|
|||||||
hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; }
|
hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; }
|
||||||
try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; }
|
try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; }
|
||||||
|
|
||||||
command -v aws >/dev/null || { echo "AWS CLI not installed. See AWS-Hosting-Guide.md Part 0.5"; exit 1; }
|
command -v aws >/dev/null || { echo "AWS CLI not installed. See docs/reference/AWS-Hosting-Guide.md Part 0.5"; exit 1; }
|
||||||
|
|
||||||
hr "Identity and default region"
|
hr "Identity and default region"
|
||||||
try aws sts get-caller-identity --output table
|
try aws sts get-caller-identity --output table
|
||||||
|
|||||||
@@ -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,818 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* `npm run check:claims` — a mechanical gate on the SHIPPED OUTPUT.
|
||||||
|
*
|
||||||
|
* Pouya's ruling, 2026-08-29, and the reasoning is the point:
|
||||||
|
*
|
||||||
|
* "Your recurring failure this project is a specific shape: you write a rule
|
||||||
|
* into a header comment, then breach it in the file below, in the same
|
||||||
|
* change set. Q53's sweep, Q54's gate four times, the D13 'cannot' phrasing
|
||||||
|
* fifty lines under your own warning about it. That is not a discipline
|
||||||
|
* problem — prose in a comment does not govern the writing that follows it.
|
||||||
|
* The pattern that actually worked was mechanical: deleting the `class` prop
|
||||||
|
* so passing one is a build error. Nobody has breached that since."
|
||||||
|
*
|
||||||
|
* So this is not documentation. It is a build error.
|
||||||
|
*
|
||||||
|
* SCOPE — `dist/**\/*.html` AND NOTHING ELSE. Not `AGENTS.md`, not `docs/`, not
|
||||||
|
* `src/`. D19 bounds review to what ships, and §4's Forbidden table says in as
|
||||||
|
* many words that it "governs published pages… It is not a word filter over the
|
||||||
|
* repository's own documentation." A register that records a forbidden phrase in
|
||||||
|
* order to forbid it must not be failed by its own quotation of it.
|
||||||
|
*
|
||||||
|
* `<style>` BLOCKS AND NON-JSON-LD `<script>` BLOCKS ARE STRIPPED FIRST, and
|
||||||
|
* that is load-bearing rather than tidy. Measured against the step-4 build
|
||||||
|
* before this file existed: a bare case-insensitive sweep for `leading` returned
|
||||||
|
* **26 hits, 25 of them `var(--leading-body)`** inside Astro's inlined critical
|
||||||
|
* CSS. A check that cries wolf on every page on its first run is a check
|
||||||
|
* somebody deletes in week two. JSON-LD is deliberately NOT stripped — a claim
|
||||||
|
* in a `<script type="application/ld+json">` is still a claim, and docs/04 says
|
||||||
|
* so: "Marking an unheld credential as held in structured data is a
|
||||||
|
* misrepresentation that happens to be machine-readable."
|
||||||
|
*
|
||||||
|
* EVERY PATTERN CARRIES ITS `incident`, PRINTED ON FAILURE. Pouya's
|
||||||
|
* instruction: "so nobody deletes one for being noisy." A pattern whose cost is
|
||||||
|
* visible and whose reason is not gets deleted; this prints the reason at the
|
||||||
|
* moment the cost is felt.
|
||||||
|
*
|
||||||
|
* AND IT SELF-TESTS BEFORE IT SWEEPS. See `FIXTURES` below. A regex that has
|
||||||
|
* quietly stopped matching passes a clean sweep forever, which is the exact
|
||||||
|
* shape of AGENTS.md Q22 — a documented control that no longer existed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dirname, '..');
|
||||||
|
const DIST = resolve(ROOT, process.argv[2] ?? 'dist');
|
||||||
|
|
||||||
|
/* Sources whose mtime, if newer than the newest built page, means this sweep
|
||||||
|
would be reading yesterday's output. `docs/` is absent on purpose: it directs
|
||||||
|
copy but does not produce it, so a spec edit does not stale the build. */
|
||||||
|
const SOURCE_DIRS = ['src', 'public'];
|
||||||
|
const SOURCE_FILES = ['astro.config.mjs', 'package.json'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patterns.
|
||||||
|
*
|
||||||
|
* `re` is matched against the page's text with `<style>` and non-JSON-LD
|
||||||
|
* `<script>` removed. All are global and case-insensitive.
|
||||||
|
*
|
||||||
|
* WORD BOUNDARIES ARE NOT DECORATION ON ANY OF THESE. `\bleading\b` does not
|
||||||
|
* match "pleadings" and `\blicensed\b` does not match "licensing" — both are
|
||||||
|
* words this site publishes, and both were live false positives when the
|
||||||
|
* patterns were first drafted. Loosening a boundary here re-creates the noise
|
||||||
|
* this file was built to avoid.
|
||||||
|
*/
|
||||||
|
const PATTERNS = [
|
||||||
|
{
|
||||||
|
id: 'licensure-of-pouya',
|
||||||
|
rule: 'D13 / §4 Forbidden — the site asserts the JD and nothing further.',
|
||||||
|
incident:
|
||||||
|
'§4 opened with "Pouya is a licensed legal professional", then "a legal ' +
|
||||||
|
'professional", then an assertion that LSO marketing rules apply — three ' +
|
||||||
|
'progressively weaker forms of the same unverified claim, each surviving a ' +
|
||||||
|
'sweep meant to remove it, in the section written to stop exactly that. ' +
|
||||||
|
'§4 does not establish licence status either way.',
|
||||||
|
re: /\blawyer\b|\bcalled to the bar\b|\blicensed\b|\bpost-nominal\b/gi,
|
||||||
|
note: '"licensing" and "licence" are NOT matched — /practice/technology/ ships "IP and licensing".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acting-for-a-party',
|
||||||
|
rule: '§4 Forbidden — implication is the risk, not just the word.',
|
||||||
|
incident:
|
||||||
|
'§4 bars "acts for clients", "represents parties", "my clients", ' +
|
||||||
|
'"my litigation practice". The site sells neutrality; a partisan verb ' +
|
||||||
|
'undercuts the central claim before it reaches the licensure question. ' +
|
||||||
|
'Settlement counsel was struck from three specs for the same reason (Q42).',
|
||||||
|
re: /\bmy litigation practice\b|\bmy law practice\b|\bmy clients?\b|\bacts for\b|\bI act for\b|\brepresents?\b/gi,
|
||||||
|
note:
|
||||||
|
'"I do not act for a party" is the APPROVED sentence (/mediation/) and does ' +
|
||||||
|
'not match. THE SINGULAR WAS ADDED 2026-08-29: the pattern read ' +
|
||||||
|
'`my clients` and `/med-arb/` was shipping "The neutral who heard my ' +
|
||||||
|
'client in caucus" in its FAQ — the possessive belonged to the counsel ' +
|
||||||
|
'voicing the objection, not to Pouya, but the gate cannot tell and the ' +
|
||||||
|
'phrase is forbidden in his voice, so the copy changed to "our client" ' +
|
||||||
|
'and the pattern widened. Same shape as the capacity-phrasing hole: a ' +
|
||||||
|
'pattern narrow enough to miss the real form of the breach.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'capacity-phrasing',
|
||||||
|
rule: 'docs/03 — when a fact is [unestablished], write around the capacity question.',
|
||||||
|
incident:
|
||||||
|
'Attempt 1 "I do not give legal advice" was flagged as an ELECTION ' +
|
||||||
|
'(entitlement withheld by choice). Attempt 2 "I cannot give legal advice" ' +
|
||||||
|
'was flagged as a DENIAL of capacity. Both audits were right: they are ' +
|
||||||
|
'opposite answers to a question §4 holds neither side of. The shipped ' +
|
||||||
|
'sentence makes no capacity claim at all.',
|
||||||
|
re: /\b(cannot|can ?not|can't|do(es)? not|don't|will not|won't|unable to|not (qualified|permitted|able) to)\s+(give|provide|offer)\s+legal advice\b|\bI\s+(?:cannot|can ?not|can't|do not|don't|will not|won't)\b[^.<]{0,25}\badvis(?:e|ing)\b/gi,
|
||||||
|
note:
|
||||||
|
'THE SECOND ALTERNATIVE WAS ADDED 2026-08-29 BECAUSE THE FIRST MISSED A REAL ' +
|
||||||
|
'BREACH. `/practice/cross-cultural/` shipped "What I do not do is advise on the ' +
|
||||||
|
'law of another country" — the election form, narrowed by jurisdiction, which ' +
|
||||||
|
'makes it worse rather than better: naming only foreign law invites the reader ' +
|
||||||
|
'to supply the domestic half. `claims-auditor` caught it and noted, correctly, ' +
|
||||||
|
'that this pattern could not, because it required the literal object "legal ' +
|
||||||
|
'advice". The verb list is deliberately tight — give / provide / offer / advise ' +
|
||||||
|
'/ render — and the window is 25 characters, so the six approved first-person ' +
|
||||||
|
'negations this site ships ("I do not act for a party", "I do not run a case", ' +
|
||||||
|
'"I will not run a process", "I do not carry a number across the hall", "I will ' +
|
||||||
|
'not convert a mediation", "If I cannot decide a remaining issue") all stay ' +
|
||||||
|
'silent. Every one of them is in the mustNotMatch fixtures below.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'designation-name',
|
||||||
|
rule: "ADRIO's own form is Chartered Med-Arbitrator.",
|
||||||
|
incident:
|
||||||
|
'"Chartered Mediator-Arbitrator" is not the name of anything ADRIC or ' +
|
||||||
|
'ADRIO confers. docs/03 §About names the correct form and the source is ' +
|
||||||
|
'docs/reference/adrio-designations.md. A designation stated in a form its ' +
|
||||||
|
'own institution does not use reads as a designation not held.',
|
||||||
|
re: /Chartered Mediator[- ]Arbitrator/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rule-set-name',
|
||||||
|
rule: 'ADRIC publishes the ADRIC National Mediation Rules.',
|
||||||
|
incident:
|
||||||
|
'`docs/01` §/mediation/ said "Model Mediation Rules", which is not the ' +
|
||||||
|
'name of anything ADRIC publishes — "Model" belongs to the Model Dispute ' +
|
||||||
|
'Resolution Clause INSIDE the rules. Caught at build step 4 only because ' +
|
||||||
|
'the rules were fetched rather than named from recall (R14).',
|
||||||
|
re: /Model Mediation Rules/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'counts-and-tenure',
|
||||||
|
rule: '§4 Forbidden — no count of matters, hours mediated, or years in practice.',
|
||||||
|
incident:
|
||||||
|
'The site this replaces carried "Since 2009", "sixteen years", London and ' +
|
||||||
|
'New York offices and a company number, all artefacts of a purchased ' +
|
||||||
|
'template and all false. §4: the practice is new, and small true numbers ' +
|
||||||
|
'do not persuade a sophisticated GC — they invite scrutiny.',
|
||||||
|
re: /\b\d[\d,]*\s*\+?\s*(matters|mediations|arbitrations|appointments|awards)\b|\b\d[\d,]*\s+years\s+in\b|\bsince\s+(19|20)\d{2}\b|\b(ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty)[\s-]years\b/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'superlatives',
|
||||||
|
rule: '§4 Forbidden — unverifiable as written, and they read as insecure.',
|
||||||
|
incident:
|
||||||
|
'The reason §4 gives is editorial before it is regulatory: this audience ' +
|
||||||
|
'discounts everything after a superlative. AND THE FALSE POSITIVES ARE ' +
|
||||||
|
'REAL AND KNOWN — "pleadings" contains "leading" and every inlined ' +
|
||||||
|
'stylesheet contains var(--leading-body). Both are handled, by the word ' +
|
||||||
|
'boundary and by the <style> strip. Do not loosen the boundary and do not ' +
|
||||||
|
'delete this pattern because it once matched a token name.',
|
||||||
|
re: /\bleading\b|\bpremier\b|\btop[- ]rated\b|\bbest\b|\bworld[- ]class\b|\bunrivalled\b|\bunparalleled\b/gi,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Beyond Pouya's minimum list, 2026-08-29. Each names a real incident in
|
||||||
|
this repository and each has near-zero false-positive surface on this
|
||||||
|
site's vocabulary. Strike any of the three and the rest still stand. --- */
|
||||||
|
{
|
||||||
|
id: 'fabricated-founder',
|
||||||
|
rule: '§4 Forbidden — the fictitious founder from the template.',
|
||||||
|
incident:
|
||||||
|
'The site this replaces carried a founder who does not exist, a ' +
|
||||||
|
'testimonial attributed to a person who does not exist, invented matter ' +
|
||||||
|
'values and fabricated office locations. This register exists because of ' +
|
||||||
|
'that, and this is the one pattern whose match would be unambiguous.',
|
||||||
|
re: /\bS\.?\s?M\.?\s+Lawrence\b|\b07452218\b/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q-arb-as-a-stage',
|
||||||
|
rule: '§4 — Q.Arb is HELD. It must never read as a stage, a pathway, or pending.',
|
||||||
|
incident:
|
||||||
|
'THIS PATTERN WAS INVERTED ON 2026-08-29 AND ITS PREDECESSOR IS GONE. It ' +
|
||||||
|
'used to bar Q.Arb reading as HELD, because §4 recorded it as "commenced ' +
|
||||||
|
'August 2026"; Pouya ruled it held (obtained July 2026, date NOT ' +
|
||||||
|
'published), so every stage word now understates a real credential. The ' +
|
||||||
|
'site carried the struck forms on six surfaces — an h1 reading "Available ' +
|
||||||
|
'now, and open about the stage", a whole /arbitration/ section headed ' +
|
||||||
|
'"Where I am in the arc", /about/\'s arc section, the footer strip, and ' +
|
||||||
|
'both JSON-LD nodes. Publish "Q.Arb (ADRIC / ADRIO)" and nothing more.',
|
||||||
|
/* ~50 characters either side, per Pouya's specification. The window stops at
|
||||||
|
the next TAG boundary so it cannot leap a paragraph. It stopped at a
|
||||||
|
sentence boundary too (`[^.<]`) until the fixture below proved that too
|
||||||
|
tight: "Qualified Arbitrator. Newly commenced" splits the anchor from the
|
||||||
|
stage word across a full stop, 22 characters apart, and that is precisely
|
||||||
|
the copy this pattern exists for. The self-test caught the regression.
|
||||||
|
|
||||||
|
⚠️ THE ANCHOR MATCHES THE EXPANSION TOO, and it did not on the first
|
||||||
|
draft. Tested against the nine strings this change set removed: it caught
|
||||||
|
seven and MISSED "Qualified Arbitrator. Newly commenced — not held, and
|
||||||
|
not nearing completion." — /about/'s own arc body, which never wrote the
|
||||||
|
abbreviation. An abbreviation-only anchor cannot see copy that spells the
|
||||||
|
designation out, and prose is exactly where it gets spelled out.
|
||||||
|
|
||||||
|
KNOWN LIMIT, stated rather than papered over: this cannot catch a stage
|
||||||
|
expressed without naming the designation at all. The h1 it replaced —
|
||||||
|
"Available now, and open about the stage" — matches nothing here and no
|
||||||
|
regex over shipped HTML would catch it without firing on ordinary copy.
|
||||||
|
That one is `claims-auditor`'s. */
|
||||||
|
/* WHAT EACH ALTERNATIVE IS FOR, because none is obvious from the regex:
|
||||||
|
(1) stage word after the designation, (2) before it, (3) an acquisition
|
||||||
|
DATE near it — §4 records July 2026 and bars publishing it, and docs/03
|
||||||
|
cited this script as enforcing that before it did (the Q22 shape) —
|
||||||
|
(4) "once/when I hold it", which must take the designation as its object
|
||||||
|
or it fires on "when I hold a preliminary conference".
|
||||||
|
|
||||||
|
THE ANCHOR MATCHES THE EXPANSION because `docs/03` authorises "Qualified
|
||||||
|
Arbitrator" as publishable prose, and prose is where it gets spelled out.
|
||||||
|
|
||||||
|
KNOWN LIMIT, stated rather than papered over: this cannot catch a stage
|
||||||
|
expressed without naming the designation. The h1 it replaced — "Available
|
||||||
|
now, and open about the stage" — matches nothing here, and no regex over
|
||||||
|
shipped HTML would catch it without firing on ordinary copy. That one is
|
||||||
|
`claims-auditor`'s. */
|
||||||
|
re: /(?:Q\.?\s?Arb|Qualified Arbitrator)\b[^<\uE000]{0,50}?\b(commenc\w*|in progress|pathway|not yet|not held|nearing completion|pending|working toward|under ?way)\b|\b(commenc\w*|in progress|pathway|not yet|not held|nearing completion|pending|working toward|under ?way)\b[^<\uE000]{0,50}?\b(?:Q\.?\s?Arb|Qualified Arbitrator)\b|(?:Q\.?\s?Arb|Qualified Arbitrator)\b[^<\uE000]{0,50}?\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+20\d\d\b|\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+20\d\d\b[^<\uE000]{0,50}?\b(?:Q\.?\s?Arb|Qualified Arbitrator)\b|\b(?:once|when)\s+(?:I|he)\s+(?:holds?|obtains?|receives?|completes?)\s+(?:it\b|the\s+)?(?:Q\.?\s?Arb|designation)\b/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c-med-arb-struck',
|
||||||
|
rule: 'C.Med-Arb is out entirely — it appears on no page (Pouya, 2026-08-29).',
|
||||||
|
incident:
|
||||||
|
'It was the strategy brief\'s "explicit long-term professional narrative" ' +
|
||||||
|
'and it shipped on /, /about/, /arbitration/ and /med-arb/ as the endpoint ' +
|
||||||
|
'of a credentialing arc. Pouya dispensed with it: he holds Q.Med and ' +
|
||||||
|
'Q.Arb, which is what med-arb requires. THE DESIGNATION IS STILL REAL and ' +
|
||||||
|
'stays in AGENTS.md §11 as a definition — this bars naming it in shipped ' +
|
||||||
|
'output, not knowing what it is. Barred in every spelling both ' +
|
||||||
|
'institutions use, because the reinstatement risk is a well-meaning ' +
|
||||||
|
'implementer reading the brief, not a typo.',
|
||||||
|
/* ⚠️ THE LEADING \b AND THE MANDATORY DOT ARE BOTH LOAD-BEARING, and this
|
||||||
|
pattern shipped without them for one run. `C\.?\s?Med-Arb` matched
|
||||||
|
"ADRIC Med-Arb Rules" as "C Med-Arb" — the C of ADRIC, a space, then the
|
||||||
|
real rule-set name — and failed the build on approved copy on /med-arb/.
|
||||||
|
The abbreviation always carries the dot; "C Med-Arb" is not a form either
|
||||||
|
institution uses. The fixture below pins it. */
|
||||||
|
re: /\bC\.\s?Med-Arb(?:itrat(?:or|ion))?s?\b|\bChartered Med-Arb(?:itrat(?:or|ion))?s?\b/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'struck-universal-q39',
|
||||||
|
rule: 'Q39 — the class statement about arbitral gating was FALSE and was published once.',
|
||||||
|
incident:
|
||||||
|
'§4 carried "Anyone may be appointed an arbitrator in Ontario. Nothing in ' +
|
||||||
|
'law gates the role behind a designation." Pouya checked the ' +
|
||||||
|
'counter-example and confirmed it: "My assertion was FALSE as a ' +
|
||||||
|
'universal." Ontario family arbitrators must complete prescribed ' +
|
||||||
|
'training. The scoped replacement is COMMERCIAL arbitration and it is ' +
|
||||||
|
"Pouya's attributed position, not a verified fact. Swept four times; " +
|
||||||
|
'reached a public page once.',
|
||||||
|
re: /anyone (may|can) be appointed an arbitrator|nothing in law gates|no (licence|license|designation) is (required|needed) to (be appointed|arbitrate|act as an arbitrator)/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sole-administrator-q62',
|
||||||
|
rule:
|
||||||
|
'Q62 — the sole-administrative-access claim on /legal/privacy/ was FALSE ' +
|
||||||
|
'and is permanently barred from returning. The page does NOT make it now; ' +
|
||||||
|
'if this fired, something reintroduced the shape. ' +
|
||||||
|
"⚠️ IF THE SECOND ADMINISTRATOR'S ACCESS IS EVER ACTUALLY REMOVED, this " +
|
||||||
|
'pattern starts failing the build on TRUE copy, and the answer is neither ' +
|
||||||
|
'to delete it nor to work around it: re-run the verification in ' +
|
||||||
|
'`docs/reference/intake-table-access-verification.md`, rewrite the page to ' +
|
||||||
|
'the new measured truth, and narrow THIS pattern deliberately with a ' +
|
||||||
|
'Change Log entry. Q23 records the Gitea instance as jointly administered, ' +
|
||||||
|
'so that removal is live rather than hypothetical.',
|
||||||
|
incident:
|
||||||
|
'The page shipped "nobody else has access to the table. There is no team, ' +
|
||||||
|
'no assistant and no external administrator." while TWO principals could ' +
|
||||||
|
'read it. It was in dist/, `build`, `check` and this script all exited 0 ' +
|
||||||
|
'over it, and the only thing between it and a live privacy policy was a ' +
|
||||||
|
'TODO(pouya) in a JSX comment — which Astro strips. The gate was human ' +
|
||||||
|
'memory. Pouya ruled the pattern PERMANENT on 2026-09-01: it no longer ' +
|
||||||
|
'describes live copy, it bars the shape. Do not delete it, and do not ' +
|
||||||
|
'loosen it into a rule about a class. Full history: AGENTS.md entry (an).',
|
||||||
|
/* THREE CONSTRAINTS, AND THEY ARE WHY THIS IS SHAPED AS IT IS.
|
||||||
|
|
||||||
|
1. `\s+`, NOT LITERAL SPACES. `compressHTML` collapses whitespace between
|
||||||
|
tags and leaves it alone inside a text node, so the published bytes
|
||||||
|
read "nobody else has access to\n the table". A literal-space
|
||||||
|
version runs, prints `ok`, and exits 0 with the breach in `dist/`.
|
||||||
|
|
||||||
|
2. EACH ALTERNATIVE IS ONE STRING THAT REACHED `dist/`, NEVER A FAMILY.
|
||||||
|
The first draft of alternative 3 was `(?:or|and)\s+(?:outside|external)`
|
||||||
|
— four phrasings where one was published, so three had no probe and no
|
||||||
|
negative fixture, which is the freeze's stated condition.
|
||||||
|
|
||||||
|
3. ALTERNATIVE 3 IS A DELIBERATE OVER-BAR AND THIS IS ITS COST. The
|
||||||
|
truthful receipt-scoped sentence CONTAINS the published string, so any
|
||||||
|
pattern catching one catches the other — adding that truthful form as a
|
||||||
|
negative fixture failed the self-test, which is the instrument check
|
||||||
|
working. So: the clause "no assistant or outside administrator" cannot
|
||||||
|
be published here in ANY scoping, true or false, without failing this
|
||||||
|
build. That is the right trade for a phrase that has already put a
|
||||||
|
false statement on a privacy policy, and `rule:` says what to do. A
|
||||||
|
lookahead tuned to one guessed phrasing would be the speculative
|
||||||
|
pattern the freeze bars.
|
||||||
|
|
||||||
|
The window in alternative 5 is `[^\w<]{0,8}`, not `\W{0,8}`: `\W` matches
|
||||||
|
`<` and the block sentinel, so it would have been bounded by neither. */
|
||||||
|
re: /nobody\s+else\s+has\s+access\s+to\s+the\s+table|there\s+is\s+no\s+team,\s+no\s+assistant\s+and\s+no\s+external\s+administrator|no\s+assistant\s+or\s+outside\s+administrator|one\s+administrative\s+account,\s+which\s+is\s+mine|honest\s+answer\s+to\s+[^\w<]{0,8}who\s+can\s+see\s+this[^\w<]{0,8}\s*is:\s+me/gi,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE INSTRUMENT CHECK, and it runs before every sweep.
|
||||||
|
*
|
||||||
|
* `mustMatch` — a pattern that has stopped firing passes a clean sweep forever
|
||||||
|
* and looks identical to a clean site. `mustNotMatch` — the known-legitimate
|
||||||
|
* strings this site actually publishes; if one of them starts failing, the
|
||||||
|
* pattern got looser, not the copy worse.
|
||||||
|
*
|
||||||
|
* CLAUDE.md, five times over: "a measurement is a claim about your instrument
|
||||||
|
* until you check the instrument."
|
||||||
|
*/
|
||||||
|
const FIXTURES = {
|
||||||
|
mustMatch: {
|
||||||
|
'licensure-of-pouya': [
|
||||||
|
'He is a lawyer.',
|
||||||
|
'called to the bar',
|
||||||
|
'a licensed neutral',
|
||||||
|
],
|
||||||
|
'acting-for-a-party': [
|
||||||
|
'my litigation practice',
|
||||||
|
'acts for clients',
|
||||||
|
'I act for the claimant',
|
||||||
|
'represents parties',
|
||||||
|
'my clients',
|
||||||
|
/* The singular, which the pattern missed until 2026-08-29. */
|
||||||
|
'the neutral who heard my client in caucus',
|
||||||
|
],
|
||||||
|
'capacity-phrasing': [
|
||||||
|
'I cannot give legal advice',
|
||||||
|
'I do not give legal advice',
|
||||||
|
"I can't provide legal advice",
|
||||||
|
/* The real breach the first form of this pattern could not catch. */
|
||||||
|
'What I do not do is advise on the law of another country',
|
||||||
|
'I do not advise on foreign law',
|
||||||
|
],
|
||||||
|
'designation-name': ['Chartered Mediator-Arbitrator'],
|
||||||
|
'rule-set-name': ['the Model Mediation Rules'],
|
||||||
|
'counts-and-tenure': [
|
||||||
|
'40 matters',
|
||||||
|
'120 mediations',
|
||||||
|
'16 years in ADR',
|
||||||
|
'Since 2009',
|
||||||
|
'sixteen years',
|
||||||
|
],
|
||||||
|
superlatives: [
|
||||||
|
'a leading neutral',
|
||||||
|
'premier',
|
||||||
|
'top-rated',
|
||||||
|
'the best mediator',
|
||||||
|
],
|
||||||
|
'fabricated-founder': ['S. M. Lawrence', 'Co. № 07452218'],
|
||||||
|
'q-arb-as-a-stage': [
|
||||||
|
'The Q.Arb pathway commenced in August 2026',
|
||||||
|
'Q.Arb is in progress',
|
||||||
|
'Q.Arb — not yet held',
|
||||||
|
'once I hold Q.Arb',
|
||||||
|
'my Q.Arb pathway',
|
||||||
|
/* The expansion, which the first draft of this pattern missed. */
|
||||||
|
'Qualified Arbitrator. Newly commenced — not held, and not nearing completion.',
|
||||||
|
/* The acquisition date, which no stage word caught. */
|
||||||
|
'Q.Arb, obtained July 2026',
|
||||||
|
'Q.Arb (ADRIC / ADRIO), held since July 2026',
|
||||||
|
/* The `rule` line above named these and the regex never matched them —
|
||||||
|
a documented control not covering what it is cited for (Q22's shape). */
|
||||||
|
'Q.Arb is pending',
|
||||||
|
'Q.Arb — the designation I am working toward',
|
||||||
|
'the Q.Arb process is under way',
|
||||||
|
],
|
||||||
|
'c-med-arb-struck': [
|
||||||
|
'C.Med-Arb is the endpoint',
|
||||||
|
'Chartered Med-Arbitrator',
|
||||||
|
'the ADRIC Chartered Med-Arb designation',
|
||||||
|
/* The blend §11's own expansion invites, which the trailing \b in the
|
||||||
|
first draft made invisible: `C\.Med-Arb\b` cannot match when the next
|
||||||
|
character is a word char. */
|
||||||
|
'the C.Med-Arbitrator designation',
|
||||||
|
'Chartered Med-Arbitration',
|
||||||
|
/* Plurals: the trailing \b survived the first repair and blocked these. */
|
||||||
|
'Chartered Med-Arbitrators',
|
||||||
|
'C.Med-Arbitrators',
|
||||||
|
'C.Med-Arbitration',
|
||||||
|
],
|
||||||
|
'sole-administrator-q62': [
|
||||||
|
/* The FIVE published clauses from dist/legal/privacy/ as it stood at
|
||||||
|
`bd282aa`, before the Q62 correction. The first form of the pattern
|
||||||
|
caught only the first two: clauses 3 and 4 are the same falsehood in
|
||||||
|
different words in a different section, and clause 5 is the summary
|
||||||
|
that would have re-asserted the struck number. */
|
||||||
|
'nobody else has access to the table',
|
||||||
|
'There is no team, no assistant and no external administrator.',
|
||||||
|
'no analytics on the submission, and no assistant or outside administrator.',
|
||||||
|
'The table is reachable by the function that writes to it and by one administrative account, which is mine',
|
||||||
|
/* The third surface — the summary that would have re-asserted the struck
|
||||||
|
number four lines below the corrected paragraph. */
|
||||||
|
'So the honest answer to "who can see this" is: me, and Google as the company that runs my mail.',
|
||||||
|
],
|
||||||
|
'struck-universal-q39': [
|
||||||
|
'Anyone may be appointed an arbitrator in Ontario',
|
||||||
|
'nothing in law gates the role',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/* Mostly real published or spec-approved copy on this site. A few are
|
||||||
|
deliberate NEAR MISSES — truthful sentences about the same subject that
|
||||||
|
were never published — because a pattern also has to be proven silent on
|
||||||
|
the wording a correction is likely to reach for. Where a fixture is one of
|
||||||
|
those, the comment beside it says so. */
|
||||||
|
mustNotMatch: [
|
||||||
|
/* NEGATIVE FIXTURES FOR `sole-administrator-q62`. The first FIVE are LIVE
|
||||||
|
PAGE COPY, verbatim from the corrected `/legal/privacy/` — which is the
|
||||||
|
fixture that matters, because Q62's ruling required this pattern to be
|
||||||
|
proven silent on the true sentence as well as loud on the false one.
|
||||||
|
RE-SYNC THEM WHENEVER THAT COPY CHANGES. ⚠️ **A SENTENCE THAT LEAVES THE
|
||||||
|
PAGE LEAVES THIS LIST — it is not kept as a near miss.** Struck copy in a
|
||||||
|
list captioned "what this site legitimately publishes" is an invitation to
|
||||||
|
restore it. The rest below ARE near misses on the same subject: the
|
||||||
|
pattern is anchored on five strings that reached `dist/`, not on the ideas
|
||||||
|
in them, so a truthful sentence about administrative access must pass.
|
||||||
|
Rendered as text, without the `<strong>` wrappers — what is proven is that
|
||||||
|
the PATTERN is silent on the words. */
|
||||||
|
'The record in the table: me, and the small number of people who administer the account it sits in with me.',
|
||||||
|
'The system that receives what you send can only add a record — it cannot read back what is stored.',
|
||||||
|
"The notification goes to the practice's mailbox, which is read by me and by administrative staff and is hosted on Google Workspace — so Google holds a copy of whatever you send me.",
|
||||||
|
'The confirmation that went to you sits with whoever runs your email. That copy is in your hands rather than mine.',
|
||||||
|
'No one else is sent it. There is no CRM, no mailing list and no analytics on the submission.',
|
||||||
|
'The table is reachable by the function that writes to it.',
|
||||||
|
'Two accounts hold administrative access to the AWS account, and the function that writes to the table cannot read it.',
|
||||||
|
/* ⚠️ TWO FIXTURES WERE REMOVED FROM HERE ON 2026-09-02 AND THE REASON
|
||||||
|
MATTERS MORE THAN THE STRINGS: `'Nobody else has access to my mailbox.'`
|
||||||
|
and `'There is no team. Every inquiry is read by me.'` They were added as
|
||||||
|
harmless near-misses, and §7's `info@smlcompany.ca` row then established
|
||||||
|
that the mailbox is DELEGATED — Pouya and administrative staff — which
|
||||||
|
makes both FALSE. The second is also a paraphrase of a `mustMatch` breach
|
||||||
|
string. This list is documented as the copy the site legitimately
|
||||||
|
publishes, so a maintainer reaching for a tidier answer would have found
|
||||||
|
one here, which is how this page acquired its false sentence the first
|
||||||
|
time. Removing a false fixture keeps the list true; it is not a coverage
|
||||||
|
change and the freeze does not reach it. `adversarial-reviewer`, round 1. */
|
||||||
|
'I act as a neutral. I do not act for a party in a matter I take, and each party should have their own legal advice.',
|
||||||
|
'I run a process, I do not run a case for anybody in it.',
|
||||||
|
'I will not run a process whose shape nobody agreed to in advance.',
|
||||||
|
'I do not carry a number across the hall that I was not given to carry.',
|
||||||
|
'I will not convert a mediation into an arbitration on the day.',
|
||||||
|
'Commercial matters. I do not accept family arbitration.',
|
||||||
|
/* An OFFERING statement, not a capacity claim. The capacity pattern carried
|
||||||
|
`offer` for one pass and fired on this. */
|
||||||
|
'I do not offer family arbitration.',
|
||||||
|
/* Counsel's voice in /med-arb/'s FAQ, after the possessive was changed. */
|
||||||
|
'The neutral who heard our client in caucus then decides the case.',
|
||||||
|
/* Undertaking (c) — the nearest miss on the widened capacity pattern. */
|
||||||
|
'If I cannot decide a remaining issue without relying on something said to me in confidence, I say so and step out of the arbitral phase rather than decide on it.',
|
||||||
|
'Pleadings, disclosure, witnesses, experts, oral argument.',
|
||||||
|
/* Wrapped, because the wrapper IS what is being tested. The first draft of
|
||||||
|
this fixture was the bare declaration and the self-test failed on it —
|
||||||
|
correctly: an unwrapped token name is not something the strip can reach,
|
||||||
|
so the fixture was asserting the wrong thing. 25 of the 26 real hits were
|
||||||
|
inside <style>. */
|
||||||
|
'<style>.hero-lede{line-height:var(--leading-body);color:var(--text-secondary)}</style>',
|
||||||
|
'Software contracts, SLA and MSA breakdowns, data residency and processing, AI vendor diligence, IP and licensing.',
|
||||||
|
/* The approved replacements for the two negatives struck on 2026-08-29,
|
||||||
|
which were 'The Q.Arb pathway commenced in August 2026; C.Med-Arb is
|
||||||
|
the endpoint.' and a bare 'Chartered Med-Arbitrator'. Both are now
|
||||||
|
breaches, and both moved to mustMatch. */
|
||||||
|
'Q.Med (ADRIC / ADRIO) · Q.Arb (ADRIC / ADRIO)',
|
||||||
|
/* The expansion in approved use — §11 definitional, no stage word. */
|
||||||
|
'Q.Arb stands for Qualified Arbitrator, an ADRIC and ADRIO designation.',
|
||||||
|
/* `once` and `when` were BARE stage words for one run, and all four of these
|
||||||
|
compliant sentences failed the build. They now require a hold-verb. */
|
||||||
|
'I hold Q.Med and Q.Arb, and I say so when a party asks',
|
||||||
|
'Q.Arb (ADRIC / ADRIO), which is what parties look at when they appoint',
|
||||||
|
'Once appointed, I hold Q.Arb and act as sole arbitrator',
|
||||||
|
'Q.Arb, and when the parties agree the process runs on that basis',
|
||||||
|
/* `hold` is a common verb on an arbitration page with a different object;
|
||||||
|
the repair narrowed `once|when` rather than closing it, and this shipped
|
||||||
|
past it for one run. The hold-verb must now take the designation. */
|
||||||
|
'I hold Q.Arb, and when I hold a preliminary conference the parties attend',
|
||||||
|
'I hold the Q.Med and Q.Arb designations through the ADR Institute of Canada and the ADR Institute of Ontario.',
|
||||||
|
/* The false positive the first form of `c-med-arb-struck` produced. */
|
||||||
|
'The ADR Institute of Canada publishes ADRIC Med-Arb Rules, developed by a task force.',
|
||||||
|
'The ADRIC National Mediation Rules.',
|
||||||
|
'adopted a new edition effective 1 March 2025',
|
||||||
|
'Commercial arbitration in Ontario. I do not accept family arbitration.',
|
||||||
|
'Published as typical, not as a guarantee.',
|
||||||
|
'Liens, delay and change-order claims, scheduling, subcontract and deficiency disputes.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- machinery ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* `relative()` escapes the root with `../../..` when the optional path argument
|
||||||
|
points outside the repo (which only a test run does). Show the plain absolute
|
||||||
|
path in that case — an unreadable path in a failure report is one more thing
|
||||||
|
between a reader and the match. */
|
||||||
|
function rel(p) {
|
||||||
|
const r = relative(ROOT, p);
|
||||||
|
return r && !r.startsWith('..') ? r : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(full));
|
||||||
|
else if (entry.name.endsWith('.html')) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestMtime(paths) {
|
||||||
|
let newest = 0;
|
||||||
|
let which = null;
|
||||||
|
const visit = (p) => {
|
||||||
|
const s = statSync(p);
|
||||||
|
if (s.isDirectory()) {
|
||||||
|
for (const e of readdirSync(p)) visit(join(p, e));
|
||||||
|
} else if (s.mtimeMs > newest) {
|
||||||
|
newest = s.mtimeMs;
|
||||||
|
which = p;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const p of paths) visit(p);
|
||||||
|
return { newest, which };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip what is not published prose or published data.
|
||||||
|
*
|
||||||
|
* `<style>` goes because Astro inlines critical CSS into every page and the
|
||||||
|
* token names collide with the superlative pattern. Non-JSON-LD `<script>` goes
|
||||||
|
* because a third-party analytics snippet is somebody else's vocabulary — there
|
||||||
|
* is none today (D15's Plausible is one line and not yet added) and there will
|
||||||
|
* be. `ld+json` STAYS: it is claim-bearing.
|
||||||
|
*
|
||||||
|
* Attributes are NOT stripped, deliberately. `<title>`, `<meta name=
|
||||||
|
* "description">`, `og:*` and image `alt` text are published copy and are among
|
||||||
|
* the places a claim is least likely to be re-read.
|
||||||
|
*/
|
||||||
|
function publishedText(html) {
|
||||||
|
return (
|
||||||
|
html
|
||||||
|
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(
|
||||||
|
/<script\b(?![^>]*application\/ld\+json)[^>]*>[\s\S]*?<\/script>/gi,
|
||||||
|
' ',
|
||||||
|
)
|
||||||
|
/* Smart punctuation, so `can’t` is matched by a pattern written `can't`.
|
||||||
|
Astro emits whatever the source used and the source uses curly quotes. */
|
||||||
|
.replace(/[‘’]/g, "'")
|
||||||
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/&#(?:39|x27);/gi, "'")
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/ | /gi, ' ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SECOND VIEW, AND IT EXISTS BECAUSE `<strong>` DEFEATED THE GATE.
|
||||||
|
*
|
||||||
|
* `publishedText` leaves tags in place, and several patterns use `[^<]` as a
|
||||||
|
* window boundary so they cannot leap a paragraph. That boundary also stops
|
||||||
|
* them crossing INLINE markup: `<strong>Q.Arb</strong> — the pathway commenced
|
||||||
|
* in August 2026` matched nothing, and this site sets `<strong>` in prose on
|
||||||
|
* `/arbitration/` and `/`. Found by `adversarial-reviewer` 2026-08-30.
|
||||||
|
*
|
||||||
|
* So every page is scanned TWICE — once as published, once with inline tags
|
||||||
|
* collapsed to a single space. Block-level tags collapse to a full stop
|
||||||
|
* instead, which preserves the "do not leap a paragraph" property that made the
|
||||||
|
* `[^.<]` window worth having in the first place. Matches are deduplicated by
|
||||||
|
* the matched text, so a hit visible in both views is reported once.
|
||||||
|
*/
|
||||||
|
const BLOCK_TAGS =
|
||||||
|
/<\/?(?:p|div|section|article|header|footer|nav|main|aside|h[1-6]|li|ul|ol|dl|dt|dd|table|tr|td|th|blockquote|figure|figcaption|br|hr)\b[^>]*>/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ THE BLOCK BOUNDARY IS A SENTINEL (`\uE000`), NOT A FULL STOP, AND THAT IS
|
||||||
|
* WHAT LETS ONE REGEX SERVE BOTH VIEWS.
|
||||||
|
*
|
||||||
|
* First attempt substituted `' . '` and the windows were written `[^<]` — but
|
||||||
|
* collapsed text contains no `<` at all, so the window was bounded by nothing
|
||||||
|
* and leapt three block boundaries to join an `<h2>Q.Arb</h2>` to a paragraph
|
||||||
|
* reading "took effect on 1 March 2025" — approved copy that ships. Measured by
|
||||||
|
* `adversarial-reviewer`.
|
||||||
|
*
|
||||||
|
* Writing the windows `[^.<]` instead would fix that and reintroduce the defect
|
||||||
|
* the collapsed view exists for: a real sentence period inside one paragraph
|
||||||
|
* ("Qualified Arbitrator. Newly commenced") must still be crossable.
|
||||||
|
*
|
||||||
|
* A sentinel separates the two jobs. `[^<\uE000]` is bounded by `<` in the
|
||||||
|
* published view and by `\uE000` in the collapsed one; neither character occurs
|
||||||
|
* in the other view, and a full stop bounds neither. Every window in PATTERNS
|
||||||
|
* uses that class.
|
||||||
|
*/
|
||||||
|
const BLOCK_BOUNDARY = '\uE000'; // Private Use Area: never occurs in real content,
|
||||||
|
// and unlike \u0000 it is not a control character, which `no-control-regex` bars.
|
||||||
|
|
||||||
|
function collapsedText(html) {
|
||||||
|
return publishedText(html)
|
||||||
|
.replace(BLOCK_TAGS, BLOCK_BOUNDARY)
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/[ \t]+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextAt(text, index, length) {
|
||||||
|
const from = Math.max(0, index - 55);
|
||||||
|
const to = Math.min(text.length, index + length + 55);
|
||||||
|
const snip = text.slice(from, to).replace(/\s+/g, ' ');
|
||||||
|
return `${from > 0 ? '…' : ''}${snip}${to < text.length ? '…' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOf(text, index) {
|
||||||
|
let line = 1;
|
||||||
|
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) line++;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(text, re) {
|
||||||
|
const hits = [];
|
||||||
|
re.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
hits.push({ match: m[0], index: m.index });
|
||||||
|
if (m[0].length === 0) re.lastIndex++;
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfTest() {
|
||||||
|
const failures = [];
|
||||||
|
for (const p of PATTERNS) {
|
||||||
|
for (const positive of FIXTURES.mustMatch[p.id] ?? []) {
|
||||||
|
if (scan(positive, p.re).length === 0) {
|
||||||
|
failures.push(
|
||||||
|
`${p.id}: STOPPED MATCHING its own fixture — ${JSON.stringify(positive)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(p.id in FIXTURES.mustMatch)) {
|
||||||
|
failures.push(
|
||||||
|
`${p.id}: has no fixture. Every pattern proves it still fires.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const negative of FIXTURES.mustNotMatch) {
|
||||||
|
/* ⚠️ BOTH VIEWS. This read `publishedText` only, which left the entire
|
||||||
|
false-positive surface of `collapsedText` untested — and that is
|
||||||
|
exactly where the loosening happened: the collapsed view leapt three
|
||||||
|
block boundaries onto approved copy and the instrument check could not
|
||||||
|
see it. Found by `adversarial-reviewer`. */
|
||||||
|
const hits = [
|
||||||
|
...scan(publishedText(negative), p.re),
|
||||||
|
...scan(collapsedText(negative), p.re),
|
||||||
|
];
|
||||||
|
if (hits.length > 0) {
|
||||||
|
failures.push(
|
||||||
|
`${p.id}: matched APPROVED copy — ${JSON.stringify(hits[0].match)} in ${JSON.stringify(negative)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- run ----------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'check:claims — AGENTS.md §4 Forbidden, enforced on shipped output',
|
||||||
|
);
|
||||||
|
console.log(` target: ${rel(DIST)}/**/*.html\n`);
|
||||||
|
|
||||||
|
// 1. The instrument, before the measurement.
|
||||||
|
const selfTestFailures = selfTest();
|
||||||
|
if (selfTestFailures.length > 0) {
|
||||||
|
console.error(
|
||||||
|
'SELF-TEST FAILED — the patterns cannot be trusted, so nothing was swept.\n',
|
||||||
|
);
|
||||||
|
for (const f of selfTestFailures) console.error(` ${f}`);
|
||||||
|
console.error(
|
||||||
|
'\nA pattern that no longer fires passes a clean sweep forever.',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` self-test: ${PATTERNS.length} patterns fire on their fixtures and stay silent on ${FIXTURES.mustNotMatch.length} approved strings`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. The target has to exist and have content in it. An empty sweep is not a
|
||||||
|
// pass — CLAUDE.md: "a command that did not run is not evidence of absence."
|
||||||
|
let pages;
|
||||||
|
try {
|
||||||
|
pages = walk(DIST);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\nFAILED: cannot read ${DIST} — ${err.message}`);
|
||||||
|
console.error(
|
||||||
|
'Run `npm run build` first. This checks the built output, not the source.',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (pages.length === 0) {
|
||||||
|
console.error(`\nFAILED: no HTML under ${DIST}. Nothing was checked.`);
|
||||||
|
console.error(
|
||||||
|
'An empty sweep reads exactly like a clean one. Run `npm run build`.',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Staleness. A pass against yesterday's dist is the same false negative in
|
||||||
|
// slower motion.
|
||||||
|
const newestPage = newestMtime(pages);
|
||||||
|
const newestSource = newestMtime([
|
||||||
|
...SOURCE_DIRS.map((d) => join(ROOT, d)),
|
||||||
|
...SOURCE_FILES.map((f) => join(ROOT, f)),
|
||||||
|
]);
|
||||||
|
if (newestSource.newest > newestPage.newest) {
|
||||||
|
console.error(`\nFAILED: ${rel(DIST)} is older than the source.`);
|
||||||
|
console.error(` newest source: ${rel(newestSource.which)}`);
|
||||||
|
console.error(` newest page: ${rel(newestPage.which)}`);
|
||||||
|
console.error(
|
||||||
|
'\nThis would have checked a build that does not include your change.',
|
||||||
|
);
|
||||||
|
console.error('Run `npm run build`, then this again.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` pages: ${pages.length}\n`);
|
||||||
|
|
||||||
|
for (const p of PATTERNS) {
|
||||||
|
const hits = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
const raw = readFileSync(page, 'utf8');
|
||||||
|
/* ⚠️ DEDUPE BY OCCURRENCE COUNT, NEVER BY TEXT. The first version keyed a
|
||||||
|
Set on the matched string, so `He is a lawyer.` twice on one page was
|
||||||
|
reported ONCE and the count said 1 — the check truncating its own
|
||||||
|
output, which is the one thing CLAUDE.md says a check must never do.
|
||||||
|
Measured by `adversarial-reviewer` on a two-line fixture.
|
||||||
|
|
||||||
|
The published view is authoritative; the collapsed view only ever ADDS
|
||||||
|
occurrences the tags hid, so a collapsed hit counts only where it exceeds
|
||||||
|
what the published view already found for that same text. */
|
||||||
|
const counted = new Map();
|
||||||
|
const bump = (key) => {
|
||||||
|
const n = (counted.get(key) ?? 0) + 1;
|
||||||
|
counted.set(key, n);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const publishedCounts = new Map();
|
||||||
|
for (const [viewIndex, text] of [
|
||||||
|
publishedText(raw),
|
||||||
|
collapsedText(raw),
|
||||||
|
].entries()) {
|
||||||
|
for (const hit of scan(text, p.re)) {
|
||||||
|
const key = hit.match.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
|
if (viewIndex === 0) {
|
||||||
|
publishedCounts.set(key, (publishedCounts.get(key) ?? 0) + 1);
|
||||||
|
} else if (bump(key) <= (publishedCounts.get(key) ?? 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hits.push({
|
||||||
|
page: rel(page),
|
||||||
|
line: lineOf(text, hit.index),
|
||||||
|
match: hit.match,
|
||||||
|
context: contextAt(text, hit.index, hit.match.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hits.length === 0) {
|
||||||
|
console.log(` ok ${p.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
failed = true;
|
||||||
|
console.error(
|
||||||
|
`\n FAIL ${p.id} — ${hits.length} match${hits.length === 1 ? '' : 'es'}`,
|
||||||
|
);
|
||||||
|
console.error(` rule: ${p.rule}`);
|
||||||
|
console.error(` incident: ${p.incident}`);
|
||||||
|
if (p.note) console.error(` note: ${p.note}`);
|
||||||
|
console.error('');
|
||||||
|
for (const hit of hits) {
|
||||||
|
console.error(
|
||||||
|
` ${hit.page}:${hit.line} ${JSON.stringify(hit.match)}`,
|
||||||
|
);
|
||||||
|
console.error(` ${hit.context}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.error('\n' + '-'.repeat(72));
|
||||||
|
console.error(
|
||||||
|
'A match is not yet a finding — read the context printed above before acting.',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'This site has had a superlative sweep hit "pleadings" and a case-insensitive',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'sweep for LSO hit "I aLSO practise". Read what matched, then either fix the',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'copy or change this file DELIBERATELY, with an AGENTS.md Change Log entry.',
|
||||||
|
);
|
||||||
|
console.error('Do not delete a pattern to make a build pass.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'\nClean. Every pattern ran and every pattern is still firing on its fixture.',
|
||||||
|
);
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Cross-checks the intake form's two field tables. `npm run check:intake`.
|
||||||
|
*
|
||||||
|
* WHY THERE ARE TWO TABLES AT ALL, because the obvious reaction to this script
|
||||||
|
* is to delete one of them and share an import. `docs/05-backend-spec.md`:
|
||||||
|
* *"Client-side validation is a convenience. **The Lambda re-validates
|
||||||
|
* everything.**"* A server that validates against a list the client shipped it
|
||||||
|
* is not validating — it is asking the caller what the rules are. And the Lambda
|
||||||
|
* is a separately deployed zip that cannot import from `src/` anyway.
|
||||||
|
*
|
||||||
|
* So the duplication is architectural, and what makes it safe is this check
|
||||||
|
* rather than a shared module: the two tables must agree on every field NAME, on
|
||||||
|
* which fields are REQUIRED, on every length CAP, and on every closed OPTION
|
||||||
|
* SET. If they disagree, the form offers something the handler rejects, or the
|
||||||
|
* handler accepts something the form never shows — and the first is a lost
|
||||||
|
* inquiry that looks like a bug in the browser.
|
||||||
|
*
|
||||||
|
* This is the one place in the repo where a duplicated fact is deliberate, and
|
||||||
|
* `AGENTS.md`'s standing rule about duplicated facts is why it needs a mechanism
|
||||||
|
* on top of a comment.
|
||||||
|
*
|
||||||
|
* Both files are read directly — Node strips the types out of the `.ts` — so
|
||||||
|
* this script holds no third copy of the list.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
INTAKE_FIELDS,
|
||||||
|
HONEYPOT_FIELD,
|
||||||
|
DECOY_CHECKBOX_FIELD,
|
||||||
|
} from '../src/data/intake.ts';
|
||||||
|
import {
|
||||||
|
FIELDS as SERVER_FIELDS,
|
||||||
|
HONEYPOT,
|
||||||
|
DECOY_CHECKBOX,
|
||||||
|
} from '../backend/intake/fields.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOTH TABLES ARE IMPORTED, NOT PARSED. The first version of this script read
|
||||||
|
* `handler.mjs` as text, sliced out the `const FIELDS = [ … ]` literal, munged
|
||||||
|
* quotes and commas into JSON, and guarded the result with a regex meant to
|
||||||
|
* refuse anything executable.
|
||||||
|
*
|
||||||
|
* **That guard rejected the table on the word `process`, which is a FIELD NAME.**
|
||||||
|
* A guard that fires on the data it exists to protect is worse than no guard,
|
||||||
|
* and the munging underneath it would have broken on the first apostrophe or
|
||||||
|
* URL in a label. The fix was not a better regex: the server's table moved into
|
||||||
|
* `backend/intake/fields.mjs`, which has no module-scope side effects and can
|
||||||
|
* simply be imported. The independence that matters is that the SERVER's table
|
||||||
|
* lives with the server and the handler trusts nothing from `src/` — not that a
|
||||||
|
* check script refuses to load it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
const server = SERVER_FIELDS;
|
||||||
|
|
||||||
|
const clientNames = INTAKE_FIELDS.map((f) => f.name);
|
||||||
|
const serverNames = server.map((f) => f.name);
|
||||||
|
|
||||||
|
for (const name of clientNames) {
|
||||||
|
if (!serverNames.includes(name)) {
|
||||||
|
problems.push(
|
||||||
|
`"${name}" is on the form but the handler does not accept it — the ` +
|
||||||
|
'inquirer would fill it and it would be silently dropped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of serverNames) {
|
||||||
|
if (!clientNames.includes(name)) {
|
||||||
|
problems.push(
|
||||||
|
`"${name}" is validated by the handler but is not on the form.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (HONEYPOT !== HONEYPOT_FIELD) {
|
||||||
|
problems.push(
|
||||||
|
`honeypot name differs: form "${HONEYPOT_FIELD}", handler "${HONEYPOT}". ` +
|
||||||
|
'A bot fills the field the form renders; the handler checks the one it ' +
|
||||||
|
'knows about, so a mismatch disables the honeypot silently.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (serverNames.includes(HONEYPOT_FIELD)) {
|
||||||
|
problems.push(
|
||||||
|
`the honeypot "${HONEYPOT_FIELD}" is in the handler's FIELDS table; it must ` +
|
||||||
|
'be checked separately, or a bot filling it would just fail validation ' +
|
||||||
|
'instead of being sent to the success page.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THE SECOND HONEYPOT GETS THE SAME THREE CHECKS, and it needs a fourth.
|
||||||
|
Added 2026-09-04 with the decoy checkbox. Every failure mode below is silent
|
||||||
|
in production: a mismatched name disables the trap, a name inside `FIELDS`
|
||||||
|
turns it into ordinary validation, and two traps sharing one name is one
|
||||||
|
trap with a comment claiming there are two. */
|
||||||
|
if (DECOY_CHECKBOX !== DECOY_CHECKBOX_FIELD) {
|
||||||
|
problems.push(
|
||||||
|
`decoy checkbox name differs: form "${DECOY_CHECKBOX_FIELD}", handler ` +
|
||||||
|
`"${DECOY_CHECKBOX}". The form renders one name and the handler checks ` +
|
||||||
|
'another, so the trap is disabled and nothing fails.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (serverNames.includes(DECOY_CHECKBOX_FIELD)) {
|
||||||
|
problems.push(
|
||||||
|
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the handler's FIELDS ` +
|
||||||
|
'table; it must be checked separately, or ticking it would fail ' +
|
||||||
|
'validation instead of sending the bot to the success page.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clientNames.includes(DECOY_CHECKBOX_FIELD)) {
|
||||||
|
problems.push(
|
||||||
|
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the form's ` +
|
||||||
|
'INTAKE_FIELDS table; it would render as a real, visible field.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* `consent` is submitted by the form and read by the handler, and it is in
|
||||||
|
NEITHER field table — so the two checks above cannot see a collision with it.
|
||||||
|
A honeypot named `consent` would discard every valid submission behind the
|
||||||
|
success page, which is the worst failure this file can fail to catch. */
|
||||||
|
for (const [what, name] of [
|
||||||
|
['honeypot', HONEYPOT_FIELD],
|
||||||
|
['decoy checkbox', DECOY_CHECKBOX_FIELD],
|
||||||
|
]) {
|
||||||
|
if (name === 'consent') {
|
||||||
|
problems.push(
|
||||||
|
`the ${what} is named "consent", which the form submits and the handler ` +
|
||||||
|
'requires — every valid submission would be discarded behind the ' +
|
||||||
|
'success page.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (DECOY_CHECKBOX_FIELD === HONEYPOT_FIELD) {
|
||||||
|
problems.push(
|
||||||
|
'the two honeypots share the name ' +
|
||||||
|
`"${HONEYPOT_FIELD}" — that is one trap, not two, and the second ` +
|
||||||
|
'mechanism (a checkbox that must arrive absent) would not exist.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* And the first honeypot must not appear on the form's own table either — the
|
||||||
|
mirror of the check above it, which existed only for the handler's side. */
|
||||||
|
if (clientNames.includes(HONEYPOT_FIELD)) {
|
||||||
|
problems.push(
|
||||||
|
`the honeypot "${HONEYPOT_FIELD}" is in the form's INTAKE_FIELDS table; ` +
|
||||||
|
'it would render as a real, visible field.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const clientField of INTAKE_FIELDS) {
|
||||||
|
const serverField = server.find((f) => f.name === clientField.name);
|
||||||
|
if (!serverField) continue;
|
||||||
|
|
||||||
|
if (Boolean(clientField.required) !== Boolean(serverField.required)) {
|
||||||
|
problems.push(
|
||||||
|
`"${clientField.name}": form required=${Boolean(clientField.required)}, ` +
|
||||||
|
`handler required=${Boolean(serverField.required)}. A field the form ` +
|
||||||
|
'marks optional and the handler requires is a rejection the inquirer ' +
|
||||||
|
'cannot see the reason for.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LABELS TOO, since 2026-08-31. The handler now renders `f.label` into the
|
||||||
|
confirmation email the inquirer keeps, so a label that drifts from the
|
||||||
|
form's own wording means the receipt describes fields by names the form
|
||||||
|
never showed. One more comparison; the duplication stays mechanical. */
|
||||||
|
if (clientField.label !== serverField.label) {
|
||||||
|
problems.push(
|
||||||
|
`"${clientField.name}": labels differ.\n` +
|
||||||
|
` form: ${JSON.stringify(clientField.label)}\n` +
|
||||||
|
` handler: ${JSON.stringify(serverField.label ?? null)}\n` +
|
||||||
|
' The handler renders its label into the confirmation email.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((clientField.max ?? null) !== (serverField.max ?? null)) {
|
||||||
|
problems.push(
|
||||||
|
`"${clientField.name}": form max=${clientField.max ?? 'none'}, ` +
|
||||||
|
`handler max=${serverField.max ?? 'none'}. The form's maxlength stops ` +
|
||||||
|
'typing; a lower cap in the handler rejects a submission that looked fine.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOptions = clientField.options ? [...clientField.options] : null;
|
||||||
|
const serverOptions = serverField.options ? [...serverField.options] : null;
|
||||||
|
if (JSON.stringify(clientOptions) !== JSON.stringify(serverOptions)) {
|
||||||
|
problems.push(
|
||||||
|
`"${clientField.name}": option sets differ.\n` +
|
||||||
|
` form: ${JSON.stringify(clientOptions)}\n` +
|
||||||
|
` handler: ${JSON.stringify(serverOptions)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`check:intake — ${clientNames.length} form fields, ${serverNames.length} ` +
|
||||||
|
'handler fields, compared on name, label, requiredness, cap and option ' +
|
||||||
|
`set; 2 honeypots ("${HONEYPOT_FIELD}", "${DECOY_CHECKBOX_FIELD}") ` +
|
||||||
|
'compared on name and checked out of both tables.',
|
||||||
|
);
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`);
|
||||||
|
for (const p of problems) console.error(` - ${p}`);
|
||||||
|
console.error(
|
||||||
|
'\ndocs/05: the handler re-validates everything. The two tables are ' +
|
||||||
|
'independent on purpose; they still have to agree.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('OK — the form and the handler agree.');
|
||||||
Executable
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Local deploy — the fallback while Gitea Actions is unavailable.
|
||||||
|
#
|
||||||
|
# Gitea Actions needs `[actions] ENABLED = true` in app.ini and a registered
|
||||||
|
# act_runner. The instance is jointly administered, so both depend on a second
|
||||||
|
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
|
||||||
|
# ships.
|
||||||
|
#
|
||||||
|
# It matches .gitea/workflows/deploy.yml on everything that determines what gets
|
||||||
|
# published: the same guard coverage, `npm run check` before the build,
|
||||||
|
# `npm run check:claims` after it, the same three sync passes in the same order
|
||||||
|
# with the same cache headers, and the same invalidation. Any change to one must
|
||||||
|
# be made to the other.
|
||||||
|
#
|
||||||
|
# Two deliberate differences: it does not run `npm ci` (your node_modules is
|
||||||
|
# already installed, and CI starts empty), and it refuses to run as user/pouya,
|
||||||
|
# which CI cannot do because CI has no such credential.
|
||||||
|
#
|
||||||
|
# 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 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 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 "${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." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||||
|
|
||||||
|
echo "==> Identity check"
|
||||||
|
caller=$(aws sts get-caller-identity --query Arn --output text)
|
||||||
|
echo " $caller"
|
||||||
|
case "$caller" in
|
||||||
|
*:user/pouya)
|
||||||
|
echo >&2
|
||||||
|
echo "REFUSING: that is the broadly-permissioned personal user." >&2
|
||||||
|
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
|
||||||
|
echo "deploy user, adr-sml-deploy — PROVISIONED, AGENTS.md §7." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
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" \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# AFTER the build and BEFORE anything is uploaded. AGENTS.md §4 Forbidden,
|
||||||
|
# enforced mechanically on the output rather than by a reviewer reading it.
|
||||||
|
# Pouya's ruling 2026-08-29: "prose in a comment does not govern the writing
|
||||||
|
# that follows it." It also refuses to run against a stale or empty dist, so a
|
||||||
|
# pass here is a pass on the bytes about to be published.
|
||||||
|
echo "==> Claim check"
|
||||||
|
npm run check:claims
|
||||||
|
|
||||||
|
echo "==> Pass 1/3 — hashed assets and fonts (immutable)"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "_astro/*" --include "fonts/*" \
|
||||||
|
--cache-control "public, max-age=31536000, immutable" \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
echo "==> Pass 2/3 — images"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "*.avif" --include "*.webp" --include "*.jpg" \
|
||||||
|
--include "*.png" --include "*.svg" \
|
||||||
|
--cache-control "public, max-age=604800" \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
echo "==> Pass 3/3 — HTML and the rest (must-revalidate, --delete)"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "_astro/*" --exclude "fonts/*" \
|
||||||
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
|
--delete --no-progress
|
||||||
|
|
||||||
|
echo "==> Invalidate CloudFront"
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||||
|
--paths "/*" >/dev/null
|
||||||
|
|
||||||
|
# THE CHECK THAT REPLACES THE INTAKE_ENDPOINT GUARD, and it runs AFTER the
|
||||||
|
# deploy because it tests the deployed thing rather than a variable.
|
||||||
|
#
|
||||||
|
# The intake form posts to the same-origin path /api/intake, which only works if
|
||||||
|
# a CloudFront behaviour routes /api/* to the HTTP API origin AGENTS.md §7
|
||||||
|
# records. Nothing in the build can know whether that behaviour exists, and a
|
||||||
|
# deploy that succeeds while the form posts into a 404 is the failure the old
|
||||||
|
# guard was reaching for and could not see.
|
||||||
|
#
|
||||||
|
# ⚠️ IT ASSERTS A POSITIVE, AND THE FIRST VERSION ASSERTED THE ABSENCE OF ONE
|
||||||
|
# CODE. That version was `code=$(curl ... || echo 000)` and passed on anything
|
||||||
|
# that was not literally 404. Two defects, both measured by
|
||||||
|
# `adversarial-reviewer` round 2:
|
||||||
|
#
|
||||||
|
# - `curl -w '%{http_code}'` ALREADY prints 000 on a failed transfer, so
|
||||||
|
# `|| echo 000` double-appended and $code became `000000` — the 000 arm was
|
||||||
|
# unreachable and a connection failure reported success.
|
||||||
|
# - If the /api/* behaviour is MISSING, the POST falls through to the S3
|
||||||
|
# default behaviour and CloudFront answers 403 for a disallowed method —
|
||||||
|
# indistinguishable from the handler's Origin refusal, which is the one
|
||||||
|
# distinction the check exists to draw. It also passed on a real 501.
|
||||||
|
#
|
||||||
|
# So it now sends the correct Origin and asserts the answer it should get:
|
||||||
|
# the handler validates, finds an empty submission, and redirects 303 to
|
||||||
|
# /contact/could-not-send/. That happens BEFORE any DynamoDB write and before
|
||||||
|
# any email, which is what makes the probe safe against production.
|
||||||
|
echo "==> Intake route check"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
--max-time 15 \
|
||||||
|
-H "Origin: https://adr.smlcompany.ca" \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data 'deploy-route-probe=1' \
|
||||||
|
"https://adr.smlcompany.ca/api/intake")
|
||||||
|
rc=$?
|
||||||
|
location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||||
|
--max-time 15 \
|
||||||
|
-H "Origin: https://adr.smlcompany.ca" \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data 'deploy-route-probe=1' \
|
||||||
|
"https://adr.smlcompany.ca/api/intake" || true)
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo >&2
|
||||||
|
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
||||||
|
echo "The contact form posts there. The site is deployed and the form is" >&2
|
||||||
|
echo "unverified — see docs/06-deployment.md's cutover checklist." >&2
|
||||||
|
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||||
|
echo " POST /api/intake -> 303 -> $location (routed, validating, rejecting an empty probe)"
|
||||||
|
else
|
||||||
|
echo >&2
|
||||||
|
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
||||||
|
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
||||||
|
# 404 IS AMBIGUOUS BETWEEN THREE CAUSES and the distribution's custom error
|
||||||
|
# response hides the one string that would separate them: API Gateway's
|
||||||
|
# {"message":"Not Found"} is replaced by /404.html, because custom error
|
||||||
|
# responses are distribution-wide. So name the causes and the one command that
|
||||||
|
# tells them apart. Corrected 2026-09-01 by `adversarial-reviewer`; the earlier
|
||||||
|
# text named only the CloudFront behaviour.
|
||||||
|
echo "404 means one of three things, and \`aws apigatewayv2 get-routes" >&2
|
||||||
|
echo "--api-id <id> --query 'Items[].RouteKey'\` separates them in one call:" >&2
|
||||||
|
echo " - the CloudFront /api/* behaviour is missing (docs/09 Part 3);" >&2
|
||||||
|
echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2
|
||||||
|
echo " - the route exists and the distribution's 404 mapping is showing you" >&2
|
||||||
|
echo " /404.html instead of the API's own body." >&2
|
||||||
|
# THE ORIGIN REQUEST POLICY ON /api/* IS NO LONGER A CONSTANT. Since
|
||||||
|
# 2026-09-04 the behaviour may carry the custom `adr-sml-api-viewer-address`
|
||||||
|
# whitelist (docs/09 Part 3, change 8) instead of the managed policy, so this
|
||||||
|
# text no longer names one and tells the operator to read it. Naming the old
|
||||||
|
# one would send them to "restore" what was deliberately replaced.
|
||||||
|
echo "403 means CloudFront rejected the method, or the handler refused the" >&2
|
||||||
|
echo "Origin. Read which origin request policy /api/* carries — since" >&2
|
||||||
|
echo "2026-09-04 it may be the custom whitelist adr-sml-api-viewer-address" >&2
|
||||||
|
echo "rather than Managed-AllViewerExceptHostHeader — because a policy that" >&2
|
||||||
|
echo "drops or fails to forward Origin turns every real submission into a" >&2
|
||||||
|
echo "403. Rollback id: b689b0a8-53d0-40ab-baf2-68738e2966ac." >&2
|
||||||
|
echo "500 means the Lambda invoke permission for this route is missing" >&2
|
||||||
|
echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2
|
||||||
|
echo "Either way the form is not verified working. See docs/09-cutover-" >&2
|
||||||
|
echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Regenerates `public/favicon.ico` from the committed brand master.
|
||||||
|
*
|
||||||
|
* LOCAL ONLY, like `bio:pdf`. Not wired into `npm run build` or either deploy
|
||||||
|
* path — the icons are committed artefacts and this is what re-derives them.
|
||||||
|
*
|
||||||
|
* Writes the favicon ONLY. `apple-touch-icon.png` is deliberately not touched
|
||||||
|
* and must stay opaque — `docs/reference/brand-assets.md` §The icon set.
|
||||||
|
*/
|
||||||
|
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const MARK = `${root}src/assets/brand/sml-infinity-mark.png`;
|
||||||
|
const MASTER = `${root}src/assets/brand/sml-infinity-mark-master.png`;
|
||||||
|
const OUT = `${root}public/favicon.ico`;
|
||||||
|
|
||||||
|
/** Sizes carried in the container, ascending — the order BaseLayout declares. */
|
||||||
|
const SIZES = [16, 32, 48];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark spans 7/8 of the canvas and is centred on both axes. Not a taste
|
||||||
|
* decision at this point: it is the composition already shipping, measured off
|
||||||
|
* the previous icon at all three sizes (14/16, 28/32, 42/48) and off the touch
|
||||||
|
* icon (158/180). Regenerating for alpha must not also restyle the mark.
|
||||||
|
*/
|
||||||
|
const INK_FRACTION = 7 / 8;
|
||||||
|
|
||||||
|
/** `--cream` — the ground the previous icon was matted against. */
|
||||||
|
const CREAM = [250, 247, 242];
|
||||||
|
|
||||||
|
const die = (msg) => {
|
||||||
|
rmSync(`${OUT}.candidate`, { force: true });
|
||||||
|
console.error(`icons: ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R14 — the icon must be traceable to the artwork in this repository, not to a
|
||||||
|
* file on someone's disk. The render source is a tight crop of the master, so
|
||||||
|
* assert it still IS that crop before deriving anything from it.
|
||||||
|
*/
|
||||||
|
async function assertProvenance() {
|
||||||
|
const mark = await sharp(MARK).metadata();
|
||||||
|
const crop = { left: 159, top: 646, width: 2668, height: 1704 };
|
||||||
|
if (mark.width !== crop.width || mark.height !== crop.height) {
|
||||||
|
die(
|
||||||
|
`render source is ${mark.width}x${mark.height}, expected ${crop.width}x${crop.height}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
sharp(MASTER).extract(crop).raw().toBuffer(),
|
||||||
|
sharp(MARK).raw().toBuffer(),
|
||||||
|
]);
|
||||||
|
if (!a.equals(b))
|
||||||
|
die('render source is no longer the documented crop of the master');
|
||||||
|
console.log(
|
||||||
|
`provenance: ${crop.width}x${crop.height} at (${crop.left},${crop.top}) of the master — identical`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole point of the regeneration. A source without alpha would mean
|
||||||
|
* deriving a mask from the cream ground, which is a different and lossier job —
|
||||||
|
* so fail rather than silently ship a matted icon again.
|
||||||
|
*/
|
||||||
|
async function loadMark() {
|
||||||
|
const meta = await sharp(MARK).metadata();
|
||||||
|
if (!meta.hasAlpha)
|
||||||
|
die(
|
||||||
|
`${MARK} has no alpha channel — cannot export a transparent icon from it`,
|
||||||
|
);
|
||||||
|
const { data, info } = await sharp(MARK)
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
let transparent = 0;
|
||||||
|
for (let p = 3; p < data.length; p += 4) if (data[p] === 0) transparent++;
|
||||||
|
if (transparent === 0)
|
||||||
|
die(`${MARK} declares alpha but every pixel is opaque`);
|
||||||
|
console.log(
|
||||||
|
`source: ${info.width}x${info.height} alpha, ${transparent} fully transparent px`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize onto a TRANSPARENT canvas. sharp premultiplies around the resample, so
|
||||||
|
* the ribbon's anti-aliased edge blends toward its own colour rather than
|
||||||
|
* toward the RGB sitting under alpha 0 — that is the cream halo this change
|
||||||
|
* exists to remove, and it would come straight back with a matted background.
|
||||||
|
*/
|
||||||
|
async function frame(size) {
|
||||||
|
const w = Math.round(size * INK_FRACTION);
|
||||||
|
const png = await sharp(MARK)
|
||||||
|
.resize({
|
||||||
|
width: w,
|
||||||
|
kernel: 'lanczos3',
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: false,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
const { height: h } = await sharp(png).metadata();
|
||||||
|
if (h > size) die(`size ${size}: mark is ${w}x${h}, taller than the canvas`);
|
||||||
|
const left = Math.round((size - w) / 2);
|
||||||
|
const top = Math.round((size - h) / 2);
|
||||||
|
const out = await sharp({
|
||||||
|
create: {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite([{ input: png, left, top }])
|
||||||
|
.png({ compressionLevel: 9, effort: 10, palette: false })
|
||||||
|
.toBuffer();
|
||||||
|
console.log(
|
||||||
|
` ${size}x${size}: mark ${w}x${h} at (${left},${top}), ${out.length} B`,
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ICO container: 6-byte header, one 16-byte directory entry per frame, then the PNG payloads. */
|
||||||
|
function buildIco(frames) {
|
||||||
|
const header = Buffer.alloc(6);
|
||||||
|
header.writeUInt16LE(0, 0); // reserved
|
||||||
|
header.writeUInt16LE(1, 2); // type 1 = icon
|
||||||
|
header.writeUInt16LE(frames.length, 4);
|
||||||
|
|
||||||
|
const dir = Buffer.alloc(16 * frames.length);
|
||||||
|
let offset = header.length + dir.length;
|
||||||
|
frames.forEach(({ size, png }, i) => {
|
||||||
|
const e = i * 16;
|
||||||
|
dir[e] = size; // width — a byte; 0 would mean 256, which SIZES never is
|
||||||
|
dir[e + 1] = size; // height
|
||||||
|
dir[e + 2] = 0; // palette size — 0 for truecolour
|
||||||
|
dir[e + 3] = 0; // reserved
|
||||||
|
dir.writeUInt16LE(1, e + 4); // colour planes
|
||||||
|
dir.writeUInt16LE(32, e + 6); // bits per pixel
|
||||||
|
dir.writeUInt32LE(png.length, e + 8);
|
||||||
|
dir.writeUInt32LE(offset, e + 12);
|
||||||
|
offset += png.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.concat([header, dir, ...frames.map((f) => f.png)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the container FROM DISK and decode each frame, rather than inspecting
|
||||||
|
* the buffers we just built — a check that reads its own inputs proves nothing.
|
||||||
|
* (It is still `sharp` decoding `sharp`'s output, so it is not a second
|
||||||
|
* instrument. The independent reads are in `docs/reference/brand-assets.md`.)
|
||||||
|
*/
|
||||||
|
async function verify(path) {
|
||||||
|
const buf = readFileSync(path);
|
||||||
|
const count = buf.readUInt16LE(4);
|
||||||
|
if (count !== SIZES.length)
|
||||||
|
die(`container declares ${count} frames, expected ${SIZES.length}`);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const e = 6 + i * 16;
|
||||||
|
const size = buf[e];
|
||||||
|
const len = buf.readUInt32LE(e + 8);
|
||||||
|
const off = buf.readUInt32LE(e + 12);
|
||||||
|
if (off + len > buf.length)
|
||||||
|
die(`frame ${i}: range ${off}+${len} exceeds ${buf.length} B`);
|
||||||
|
const { data, info } = await sharp(buf.subarray(off, off + len))
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
if (info.width !== size || info.height !== size)
|
||||||
|
die(`frame ${i}: decoded ${info.width}x${info.height}, dir says ${size}`);
|
||||||
|
const corners = [
|
||||||
|
[0, 0],
|
||||||
|
[size - 1, 0],
|
||||||
|
[0, size - 1],
|
||||||
|
[size - 1, size - 1],
|
||||||
|
];
|
||||||
|
for (const [x, y] of corners) {
|
||||||
|
const a = data[(y * size + x) * 4 + 3];
|
||||||
|
if (a !== 0)
|
||||||
|
die(`frame ${size}: corner (${x},${y}) has alpha ${a}, expected 0`);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* THE CORNER AND TRANSPARENCY CHECKS CANNOT SEE A CREAM HALO. A frame whose
|
||||||
|
* edge was matted against cream and then had its background knocked out has
|
||||||
|
* clear corners, transparent pixels and opaque pixels, and passes every one
|
||||||
|
* of them. What distinguishes it is the colour the edge blends TOWARD.
|
||||||
|
*
|
||||||
|
* ⚠️ AND IT IS THE BOUNDARY, NOT THE PARTIAL-ALPHA PIXELS. A first version
|
||||||
|
* of this guard inspected only pixels at 0 < alpha < 255 and MISSED a
|
||||||
|
* purpose-built haloed fixture entirely, because a knockout sets alpha per
|
||||||
|
* pixel and leaves NO partial alpha at all — 0 such pixels in the fixture.
|
||||||
|
* A guard that cannot see the defect it is named for is worse than none.
|
||||||
|
*
|
||||||
|
* So: take every painted pixel that touches a fully transparent one, and
|
||||||
|
* measure how many sit near cream. Measured on this artwork — correct
|
||||||
|
* frames 1 / 2 / 5 of 61 / 146 / 258 boundary pixels (1.4-1.9%); the haloed
|
||||||
|
* fixture 33 of 115 (28.7%). The gate is 10%, roughly 5x clear of both.
|
||||||
|
*/
|
||||||
|
const NEAR_CREAM = 20;
|
||||||
|
const HALO_SHARE = 0.1;
|
||||||
|
const alphaAt = (x, y) =>
|
||||||
|
x < 0 || y < 0 || x >= size || y >= size
|
||||||
|
? 0
|
||||||
|
: data[(y * size + x) * 4 + 3];
|
||||||
|
let clear = 0;
|
||||||
|
let ink = 0;
|
||||||
|
let boundary = 0;
|
||||||
|
let boundaryNearCream = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
const a = data[i + 3];
|
||||||
|
if (a === 0) {
|
||||||
|
clear++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a === 255) ink++;
|
||||||
|
const onEdge =
|
||||||
|
alphaAt(x - 1, y) === 0 ||
|
||||||
|
alphaAt(x + 1, y) === 0 ||
|
||||||
|
alphaAt(x, y - 1) === 0 ||
|
||||||
|
alphaAt(x, y + 1) === 0;
|
||||||
|
if (!onEdge) continue;
|
||||||
|
boundary++;
|
||||||
|
const d = Math.max(
|
||||||
|
Math.abs(data[i] - CREAM[0]),
|
||||||
|
Math.abs(data[i + 1] - CREAM[1]),
|
||||||
|
Math.abs(data[i + 2] - CREAM[2]),
|
||||||
|
);
|
||||||
|
if (d <= NEAR_CREAM) boundaryNearCream++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const share = boundary === 0 ? 0 : boundaryNearCream / boundary;
|
||||||
|
if (clear === 0)
|
||||||
|
die(`frame ${size}: no transparent pixels — the ground is fully matted`);
|
||||||
|
if (ink === 0)
|
||||||
|
die(`frame ${size}: no opaque pixels — the mark did not render`);
|
||||||
|
if (boundary === 0)
|
||||||
|
die(`frame ${size}: no boundary pixels — cannot test the edge colour`);
|
||||||
|
if (share > HALO_SHARE)
|
||||||
|
die(
|
||||||
|
`frame ${size}: ${boundaryNearCream} of ${boundary} boundary pixels ` +
|
||||||
|
`(${(share * 100).toFixed(1)}%) sit within ${NEAR_CREAM} of cream — ` +
|
||||||
|
`the edge was matted against the ground before the ground was removed`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${size}x${size}: ${clear} transparent, ${ink} opaque, corners clear, ` +
|
||||||
|
`edge ${(share * 100).toFixed(1)}% near cream`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`verified ${path} (${buf.length} B)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertProvenance();
|
||||||
|
await loadMark();
|
||||||
|
console.log('frames:');
|
||||||
|
const frames = [];
|
||||||
|
for (const size of SIZES) frames.push({ size, png: await frame(size) });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verify a CANDIDATE file, then move it into place. Writing OUT first and
|
||||||
|
* verifying after would mean a failing check exits 1 having already replaced a
|
||||||
|
* good favicon with the one it just rejected — and nothing downstream re-checks,
|
||||||
|
* because this script is deliberately outside the build and both deploy paths.
|
||||||
|
*/
|
||||||
|
const candidate = `${OUT}.candidate`;
|
||||||
|
writeFileSync(candidate, buildIco(frames));
|
||||||
|
console.log('verify:');
|
||||||
|
try {
|
||||||
|
await verify(candidate);
|
||||||
|
} catch (err) {
|
||||||
|
rmSync(candidate, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
renameSync(candidate, OUT);
|
||||||
|
console.log(`wrote ${OUT}`);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Prints the intake Lambda's six environment variables as the JSON that
|
||||||
|
* `aws lambda update-function-configuration --environment` takes.
|
||||||
|
*
|
||||||
|
* ⚠️ THIS EXISTS SO THAT TWO PUBLISHED COMMITMENTS ARE NEVER RETYPED INTO A
|
||||||
|
* SHELL COMMAND. `RESPONSE_TIME` and `NO_RETAINER_NOTICE` are read from
|
||||||
|
* `src/data/site.ts` — the same constants `/contact/` renders — because a
|
||||||
|
* hand-typed copy of the notice inside the handler had already dropped a clause
|
||||||
|
* once (`docs/05`, and the handler's own comment on the constant). A deploy
|
||||||
|
* procedure that asks an operator to paste a sentence is the same defect one
|
||||||
|
* step further out, and the notice contains an EN DASH in "mediator–party",
|
||||||
|
* which is exactly the character a retype loses.
|
||||||
|
*
|
||||||
|
* Resource names come from `AGENTS.md` §7 and are passed in, not defaulted from
|
||||||
|
* a second copy here — except the two that are pure site facts.
|
||||||
|
*
|
||||||
|
* usage: node scripts/intake-env.mjs --table <name> --notify <addr> --from <addr>
|
||||||
|
* node scripts/intake-env.mjs ... --shell # export lines instead
|
||||||
|
*/
|
||||||
|
import { CONTACT, NO_RETAINER_NOTICE, SITE } from '../src/data/site.ts';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flag = (name) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i === -1 ? undefined : args[i + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const table = flag('table');
|
||||||
|
const notify = flag('notify');
|
||||||
|
const from = flag('from');
|
||||||
|
const missing = [
|
||||||
|
['--table', table],
|
||||||
|
['--notify', notify],
|
||||||
|
['--from', from],
|
||||||
|
]
|
||||||
|
.filter(([, v]) => !v)
|
||||||
|
.map(([k]) => k);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`missing: ${missing.join(' ')}`);
|
||||||
|
console.error(
|
||||||
|
'usage: node scripts/intake-env.mjs --table <dynamodb-table> ' +
|
||||||
|
'--notify <address> --from <ses-verified-address> [--shell]',
|
||||||
|
);
|
||||||
|
console.error('Resource names are in AGENTS.md §7.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The site origin is not a deploy-time choice: the handler compares the request
|
||||||
|
Origin against it and redirects to pages ON it, so it must be the canonical
|
||||||
|
origin `astro.config.mjs` builds against. */
|
||||||
|
const origin = SITE.url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const vars = {
|
||||||
|
INTAKE_TABLE: table,
|
||||||
|
SITE_ORIGIN: origin,
|
||||||
|
NOTIFY_TO: notify,
|
||||||
|
MAIL_FROM: from,
|
||||||
|
RESPONSE_TIME: CONTACT.responseTime,
|
||||||
|
NO_RETAINER_NOTICE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Guards, not decoration. Each one is a failure this project has already had or
|
||||||
|
has written down as the next one. */
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
if (typeof v !== 'string' || v.trim() === '') {
|
||||||
|
throw new Error(
|
||||||
|
`${k} resolved empty — the handler throws at cold start on that`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^https:\/\//.test(origin)) {
|
||||||
|
throw new Error(`SITE_ORIGIN must be an https origin, got ${origin}`);
|
||||||
|
}
|
||||||
|
/* The clause a hand-copy dropped. `docs/01` §/contact/ requires it, so its
|
||||||
|
absence is a published-disclosure defect rather than a typo. */
|
||||||
|
if (!NO_RETAINER_NOTICE.includes('create a conflict check')) {
|
||||||
|
throw new Error(
|
||||||
|
'NO_RETAINER_NOTICE is missing its fourth clause about not itself creating ' +
|
||||||
|
'a conflict check — docs/01 §/contact/ requires it. Do not deploy this.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!/–/.test(NO_RETAINER_NOTICE)) {
|
||||||
|
throw new Error(
|
||||||
|
'NO_RETAINER_NOTICE no longer contains the en dash in "mediator–party". ' +
|
||||||
|
'Either the constant changed deliberately, or something re-typed it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!/\btwo business days\b/.test(CONTACT.responseTime)) {
|
||||||
|
throw new Error(
|
||||||
|
`RESPONSE_TIME is "${CONTACT.responseTime}" — AGENTS.md §4/Q27 is a ` +
|
||||||
|
'two-business-day commitment. If the commitment changed, /contact/, the ' +
|
||||||
|
'bio and this all move together.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--shell')) {
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
console.log(`export ${k}=${JSON.stringify(v)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify({ Variables: vars }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* The performance gate. Budget: docs/04-seo-spec.md §Performance —
|
||||||
|
* Lighthouse >= 95 on all four categories, on mobile, for every page.
|
||||||
|
*
|
||||||
|
* WHY THIS IS `lighthouse` AND NOT `@lhci/cli`, WHICH IS WHAT R11 SAID TO PUT
|
||||||
|
* BACK. Measured 2026-08-31 from two probe lockfiles, not recalled:
|
||||||
|
*
|
||||||
|
* @lhci/cli@0.15.1 10 vulnerabilities (7 high) pins lighthouse 12.6.1
|
||||||
|
* high: tmp@0.1.0 <- a DIRECT dependency of @lhci/cli itself
|
||||||
|
* high: extract-zip@2.0.1 <- via @puppeteer/browsers
|
||||||
|
* lighthouse@13.4.1 0 vulnerabilities 109 packages
|
||||||
|
* tmp ABSENT, extract-zip ABSENT
|
||||||
|
*
|
||||||
|
* So the carrier was never Lighthouse. AGENTS.md §7 recorded the advisories as
|
||||||
|
* arriving "via lighthouse -> puppeteer-core -> extract-zip", and on that
|
||||||
|
* attribution the tool looked unusable for as long as the advisories stood.
|
||||||
|
* Standalone `lighthouse` measures the same budget with nothing outstanding.
|
||||||
|
* What is given up is real and is recorded in §7: `lhci autorun`'s assertion
|
||||||
|
* config, its server, and its CI upload.
|
||||||
|
*
|
||||||
|
* THIS IS A LOCAL GATE, NOT A CI CHECK, and the reason is Chrome. Standalone
|
||||||
|
* Lighthouse drives an installed browser; the Gitea runner has none (§7 — the
|
||||||
|
* runner is not registered at all yet, Q23). So this runs from a keyboard and
|
||||||
|
* as a blocking item on docs/06's cutover checklist. It is not wired into
|
||||||
|
* `npm run build` or either deploy path, and saying so is the point: a check
|
||||||
|
* described as running where it cannot is the defect Q22 turned out to be.
|
||||||
|
*
|
||||||
|
* PAGES ARE ENUMERATED FROM `dist/`, NEVER LISTED HERE. A hand-written list
|
||||||
|
* silently stops covering the site the first time a page is added — which is
|
||||||
|
* this project's most expensive recurring shape. Every `index.html` under
|
||||||
|
* `dist/` is a page, so the set cannot go stale.
|
||||||
|
*
|
||||||
|
* Usage: npm run build && npm run lighthouse
|
||||||
|
* npm run lighthouse -- /fees/ /insights/ # a subset, by pathname
|
||||||
|
*/
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||||
|
import { join, extname, relative, sep } from 'node:path';
|
||||||
|
import lighthouse from 'lighthouse';
|
||||||
|
import * as chromeLauncher from 'chrome-launcher';
|
||||||
|
|
||||||
|
const DIST = new URL('../dist/', import.meta.url).pathname;
|
||||||
|
const THRESHOLD = 95;
|
||||||
|
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'];
|
||||||
|
|
||||||
|
/** docs/04's own budgets, reported alongside the scores rather than asserted
|
||||||
|
* separately — LCP is the one the spec states in seconds. */
|
||||||
|
const LCP_BUDGET_MS = 2000;
|
||||||
|
const CLS_BUDGET = 0.05;
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `trailingSlash: 'always'` + `build.format: 'directory'` (astro.config.mjs),
|
||||||
|
* so `/mediation/` is `dist/mediation/index.html` and an extensionless path
|
||||||
|
* without the slash is a 404 here exactly as it is on CloudFront. Serving it
|
||||||
|
* anyway would measure a URL the site does not have.
|
||||||
|
*/
|
||||||
|
function resolveFile(pathname) {
|
||||||
|
if (pathname.endsWith('/')) return join(DIST, pathname, 'index.html');
|
||||||
|
if (extname(pathname)) return join(DIST, pathname);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectPages(dir = DIST) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...(await collectPages(full)));
|
||||||
|
else if (entry.name === 'index.html') {
|
||||||
|
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||||
|
out.push(rel ? `/${rel}/` : '/');
|
||||||
|
}
|
||||||
|
/* ⚠️ `index.html` ALONE MISSED THE 404 PAGE, so the budget was measured on
|
||||||
|
22 pages of 23 while the header above claims it enumerates the site.
|
||||||
|
`404.astro` is emitted as `dist/404.html`, outside `build.format:
|
||||||
|
'directory'`. The path pushed here is a URL this script SERVES, so it is
|
||||||
|
`/404.html` — the form CloudFront's custom error response fetches — and
|
||||||
|
`resolveFile()` resolves it on the `extname` branch. `og-proof.mjs` needs
|
||||||
|
the `OG_CARDS` key `/404/` for the same file; the two differ on purpose. */
|
||||||
|
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||||
|
out.push(`/${entry.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveDist() {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||||
|
const file = resolveFile(pathname);
|
||||||
|
if (!file) {
|
||||||
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||||
|
res.end('404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stream = createReadStream(file);
|
||||||
|
stream.on('error', () => {
|
||||||
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||||
|
res.end('404');
|
||||||
|
});
|
||||||
|
stream.once('open', () => {
|
||||||
|
// NO `cache-control` HEADER, AND THAT IS DELIBERATE — measured
|
||||||
|
// 2026-08-31. `cache-control: no-store` was set here to force a cold
|
||||||
|
// cache, which it did not need to do (Lighthouse resets storage between
|
||||||
|
// runs by default) and which cost the `bf-cache` audit outright:
|
||||||
|
// "Pages whose main resource has cache-control:no-store cannot enter
|
||||||
|
// back/forward cache." The audit failed on every page, in a report whose
|
||||||
|
// whole job is to find defects on the site. Verified by toggling the one
|
||||||
|
// header: bf-cache 0 with it, 1 without, twice each.
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
|
||||||
|
});
|
||||||
|
stream.pipe(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () =>
|
||||||
|
resolve({ server, port: server.address().port }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (s, n) => String(s).padEnd(n);
|
||||||
|
const scoreOf = (lhr, id) => Math.round((lhr.categories[id]?.score ?? 0) * 100);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ AN INTENTIONALLY `noindex` PAGE CANNOT SCORE 95 ON LIGHTHOUSE'S SEO
|
||||||
|
* CATEGORY, AND THE BUDGET AS WRITTEN DID NOT KNOW THAT.
|
||||||
|
*
|
||||||
|
* Measured 2026-08-31, first full run over 22 pages: five pages scored SEO
|
||||||
|
* **69**, and on every one the ONLY failing audit was `is-crawlable` — *"Page is
|
||||||
|
* blocked from indexing"* — firing on `<meta name="robots" content="noindex,
|
||||||
|
* follow">`. That meta tag is what `docs/04` REQUIRES on `/legal/*`, and it is
|
||||||
|
* deliberate on `/bio/`, `/contact/received/` and `/contact/could-not-send/`.
|
||||||
|
* So the category is measuring the page doing exactly what it was built to do.
|
||||||
|
*
|
||||||
|
* The wrong fix is to drop the SEO threshold, or to except these pages, or to
|
||||||
|
* stop measuring them: each of those hides every OTHER SEO defect on the pages
|
||||||
|
* where a defect is hardest to notice. What is asserted instead is stricter than
|
||||||
|
* a number:
|
||||||
|
*
|
||||||
|
* indexable page -> SEO category >= 95, as before
|
||||||
|
* noindex page -> EVERY SEO audit must pass EXCEPT `is-crawlable`
|
||||||
|
*
|
||||||
|
* A missing canonical, a missing title, an unreadable font size or a bad link on
|
||||||
|
* a noindex page still fails the gate. Only the one audit that is measuring the
|
||||||
|
* intent is set aside, and the page is marked in the table so the number is
|
||||||
|
* never read as unqualified.
|
||||||
|
*
|
||||||
|
* `noindex` is read from the BUILT HTML rather than from a list of paths here —
|
||||||
|
* a list would stop covering the site the first time a page is added.
|
||||||
|
*/
|
||||||
|
const EXPECTED_NOINDEX_FAILURE = 'is-crawlable';
|
||||||
|
|
||||||
|
async function isNoindex(page) {
|
||||||
|
const file = resolveFile(page);
|
||||||
|
const html = await readFile(file, 'utf8');
|
||||||
|
return /<meta[^>]+name="robots"[^>]+content="[^"]*noindex/i.test(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failingAudits(lhr, category) {
|
||||||
|
return (lhr.categories[category]?.auditRefs ?? [])
|
||||||
|
.map((ref) => lhr.audits[ref.id])
|
||||||
|
.filter((audit) => audit && audit.score !== null && audit.score < 1)
|
||||||
|
.map((audit) => audit.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await stat(join(DIST, 'index.html'));
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
'dist/index.html is missing. Run `npm run build` first — this gate ' +
|
||||||
|
'measures the bytes that would ship, not the dev server.',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = process.argv.slice(2).filter((a) => a.startsWith('/'));
|
||||||
|
const all = await collectPages();
|
||||||
|
const pages = requested.length ? requested : all;
|
||||||
|
const unknown = requested.filter((p) => !all.includes(p));
|
||||||
|
if (unknown.length) {
|
||||||
|
console.error(`Not built: ${unknown.join(', ')}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server, port } = await serveDist();
|
||||||
|
const baseFlags = ['--headless', '--no-sandbox', '--disable-gpu'];
|
||||||
|
const chrome = await chromeLauncher.launch({ chromeFlags: baseFlags });
|
||||||
|
/**
|
||||||
|
* ⚠️ A SECOND BROWSER, AND THE ACCESSIBILITY CATEGORY IS MEASURED IN IT.
|
||||||
|
*
|
||||||
|
* `--force-prefers-reduced-motion`. This is a deliberate deviation from a
|
||||||
|
* single default run and it must be stated wherever the number is, which is
|
||||||
|
* why the table below labels the column. Measured 2026-08-31, twice per
|
||||||
|
* condition, on `/process/`:
|
||||||
|
*
|
||||||
|
* motion on a11y = 96 color-contrast FAILED, 24 nodes
|
||||||
|
* motion off a11y = 100 color-contrast passed, 0 nodes
|
||||||
|
*
|
||||||
|
* The 24 nodes were the scroll-driven reveal (`animation-timeline: view()`,
|
||||||
|
* global.css) caught mid-flight: axe reported foregrounds like `#d0cbc4` on
|
||||||
|
* `#f8f4ed`, and NEITHER is in this site's palette — they are the real colours
|
||||||
|
* blended toward the background by an in-progress `opacity` keyframe. So the
|
||||||
|
* audit was measuring animation state, not contrast.
|
||||||
|
*
|
||||||
|
* WHY THIS IS THE HONEST RUN RATHER THAN THE CONVENIENT ONE. A category that
|
||||||
|
* reports 24 known-false nodes on ten of fourteen pages cannot surface the
|
||||||
|
* twenty-fifth, real one — it is a control that has stopped controlling, which
|
||||||
|
* is the shape `AGENTS.md` Q22 and the Lighthouse removal both took. The
|
||||||
|
* reduced-motion rendering is not a synthetic one: it is the branch
|
||||||
|
* `global.css` ships for `prefers-reduced-motion: reduce`, a real user setting,
|
||||||
|
* and it is the branch in which every element sits at its FINAL colour, which
|
||||||
|
* is what a contrast audit is asking about. Contrast ratios for the palette
|
||||||
|
* itself are computed and recorded in `docs/02-design-system.md`.
|
||||||
|
*
|
||||||
|
* Performance is NOT measured here — reduced motion would suppress work the
|
||||||
|
* site really does on a default profile.
|
||||||
|
*/
|
||||||
|
const chromeA11y = await chromeLauncher.launch({
|
||||||
|
chromeFlags: [...baseFlags, '--force-prefers-reduced-motion'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const PERF_CATEGORIES = CATEGORIES.filter((id) => id !== 'accessibility');
|
||||||
|
const rows = [];
|
||||||
|
const breaches = [];
|
||||||
|
try {
|
||||||
|
for (const page of pages) {
|
||||||
|
const url = `http://127.0.0.1:${port}${page}`;
|
||||||
|
// Default config otherwise: Lighthouse's mobile preset — mobile form
|
||||||
|
// factor, mobile screen emulation, simulated Slow 4G. That is the
|
||||||
|
// budget's own wording in docs/04, so none of it is overridden.
|
||||||
|
const run = async (chromeInstance, onlyCategories) => {
|
||||||
|
const result = await lighthouse(url, {
|
||||||
|
logLevel: 'error',
|
||||||
|
output: 'json',
|
||||||
|
port: chromeInstance.port,
|
||||||
|
onlyCategories,
|
||||||
|
});
|
||||||
|
if (!result?.lhr) {
|
||||||
|
throw new Error(`Lighthouse returned nothing for ${page}`);
|
||||||
|
}
|
||||||
|
if (result.lhr.runtimeError?.code) {
|
||||||
|
throw new Error(`${page}: ${result.lhr.runtimeError.message}`);
|
||||||
|
}
|
||||||
|
return result.lhr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lhr = await run(chrome, PERF_CATEGORIES);
|
||||||
|
const lhrA11y = await run(chromeA11y, ['accessibility']);
|
||||||
|
|
||||||
|
const scores = Object.fromEntries([
|
||||||
|
...PERF_CATEGORIES.map((id) => [id, scoreOf(lhr, id)]),
|
||||||
|
['accessibility', scoreOf(lhrA11y, 'accessibility')],
|
||||||
|
]);
|
||||||
|
const lcp = lhr.audits['largest-contentful-paint']?.numericValue ?? NaN;
|
||||||
|
const cls = lhr.audits['cumulative-layout-shift']?.numericValue ?? NaN;
|
||||||
|
const noindex = await isNoindex(page);
|
||||||
|
rows.push({ page, scores, lcp, cls, noindex });
|
||||||
|
|
||||||
|
for (const id of CATEGORIES) {
|
||||||
|
// The SEO category on a noindex page is asserted audit by audit
|
||||||
|
// instead — see the comment on EXPECTED_NOINDEX_FAILURE.
|
||||||
|
if (id === 'seo' && noindex) continue;
|
||||||
|
if (scores[id] < THRESHOLD) {
|
||||||
|
breaches.push(`${page} ${id} = ${scores[id]} (< ${THRESHOLD})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (noindex) {
|
||||||
|
const unexpected = failingAudits(lhr, 'seo').filter(
|
||||||
|
(id) => id !== EXPECTED_NOINDEX_FAILURE,
|
||||||
|
);
|
||||||
|
if (unexpected.length) {
|
||||||
|
breaches.push(
|
||||||
|
`${page} seo — noindex page, so only \`${EXPECTED_NOINDEX_FAILURE}\` ` +
|
||||||
|
`may fail; these also failed: ${unexpected.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// `kill()` is synchronous in chrome-launcher 1.x — `await` on it draws
|
||||||
|
// ts(80007) from `astro check`, which this repo keeps at zero.
|
||||||
|
chrome.kill();
|
||||||
|
chromeA11y.kill();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = Math.max(28, ...rows.map((r) => r.page.length + 2));
|
||||||
|
console.log(`\n${pad('page', w)} perf a11y* bestp seo LCP CLS`);
|
||||||
|
console.log('-'.repeat(w + 44));
|
||||||
|
for (const r of rows) {
|
||||||
|
const cells = CATEGORIES.map((id) =>
|
||||||
|
pad(id === 'seo' && r.noindex ? `${r.scores[id]}n` : r.scores[id], 6),
|
||||||
|
).join(' ');
|
||||||
|
const lcpCell = pad(`${(r.lcp / 1000).toFixed(2)}s`, 8);
|
||||||
|
console.log(`${pad(r.page, w)} ${cells} ${lcpCell} ${r.cls.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The worst-of row excludes noindex pages from the SEO column, because
|
||||||
|
// including them would report 69 as the site's worst SEO score forever and
|
||||||
|
// train a reader to ignore the column — which is how a real regression there
|
||||||
|
// would go unnoticed.
|
||||||
|
const worst = (id) => {
|
||||||
|
const relevant = id === 'seo' ? rows.filter((r) => !r.noindex) : rows;
|
||||||
|
return relevant.length
|
||||||
|
? Math.min(...relevant.map((r) => r.scores[id]))
|
||||||
|
: 100;
|
||||||
|
};
|
||||||
|
console.log('-'.repeat(w + 44));
|
||||||
|
console.log(
|
||||||
|
`${pad(`worst of ${rows.length}`, w)} ` +
|
||||||
|
CATEGORIES.map((id) => pad(worst(id), 6)).join(' ') +
|
||||||
|
` ${pad(`${(Math.max(...rows.map((r) => r.lcp)) / 1000).toFixed(2)}s`, 8)} ` +
|
||||||
|
Math.max(...rows.map((r) => r.cls)).toFixed(3),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`\nbudgets: all four categories >= ${THRESHOLD} (mobile) · ` +
|
||||||
|
`LCP < ${LCP_BUDGET_MS / 1000}s · CLS < ${CLS_BUDGET} — docs/04-seo-spec.md`,
|
||||||
|
);
|
||||||
|
const noindexCount = rows.filter((r) => r.noindex).length;
|
||||||
|
if (noindexCount) {
|
||||||
|
console.log(
|
||||||
|
`n = deliberately noindex (${noindexCount} page(s)). Lighthouse's SEO\n` +
|
||||||
|
' category cannot exceed ~69 on such a page: `is-crawlable` fails on the\n' +
|
||||||
|
' `noindex` the page is supposed to carry. Those pages are asserted audit\n' +
|
||||||
|
' by audit instead — every SEO audit must pass except that one — and are\n' +
|
||||||
|
' excluded from the SEO worst-of above.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'* a11y is measured with prefers-reduced-motion forced. The scroll-driven\n' +
|
||||||
|
" reveal otherwise puts axe's colour-contrast audit on mid-animation\n" +
|
||||||
|
' opacity rather than on the palette — 24 false nodes, measured. See the\n' +
|
||||||
|
' comment on chromeA11y in this script.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reported, not asserted. docs/04 states LCP and CLS as budgets; Lighthouse's
|
||||||
|
// simulated throttling on a loopback server is not the Slow 4G field
|
||||||
|
// measurement they describe, so a hard failure here would be a claim about
|
||||||
|
// the instrument. The category scores ARE the gate.
|
||||||
|
const lcpOver = rows.filter((r) => r.lcp >= LCP_BUDGET_MS);
|
||||||
|
const clsOver = rows.filter((r) => r.cls >= CLS_BUDGET);
|
||||||
|
if (lcpOver.length) {
|
||||||
|
console.log(
|
||||||
|
`note: LCP at or over budget on ${lcpOver.length} page(s): ` +
|
||||||
|
lcpOver.map((r) => r.page).join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clsOver.length) {
|
||||||
|
console.log(
|
||||||
|
`note: CLS at or over budget on ${clsOver.length} page(s): ` +
|
||||||
|
clsOver.map((r) => r.page).join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breaches.length) {
|
||||||
|
console.error(`\nBUDGET BREACH — ${breaches.length}:`);
|
||||||
|
for (const b of breaches) console.error(` - ${b}`);
|
||||||
|
console.error('\nCLAUDE.md: treat a budget breach as a failing build.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`\nOK — ${rows.length} page(s), no category below ${THRESHOLD}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Proves the Open Graph cards, two ways. `npm run og:proof`, after a build.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS AT ALL. `AGENTS.md` R15: *"Nobody on this project will ever
|
||||||
|
* see the defect. A link preview is rendered by LinkedIn, Slack and Teams for a
|
||||||
|
* reader who is not us."* Generating the cards does not fix that — it moves the
|
||||||
|
* invisible thing from "wrong image" to "wrong image, generated". So the two
|
||||||
|
* failures that would stay invisible are checked mechanically:
|
||||||
|
*
|
||||||
|
* 1. **Every page's `og:image` resolves to a file that exists in `dist/`.** A
|
||||||
|
* 404 preview image renders as a blank card, and nothing else in this repo
|
||||||
|
* would notice. Checked by reading the built HTML, not the source.
|
||||||
|
*
|
||||||
|
* 2. **Every card's headline and eyebrow are its page's own `<h1>` and first
|
||||||
|
* `.eyebrow`, character for character.** This is the compliance half. Text
|
||||||
|
* baked into a JPEG is text `npm run check:claims` cannot grep, and under D20
|
||||||
|
* that script is the only per-step claims control there is — so a card must
|
||||||
|
* never carry a claim its page does not already make in auditable HTML. The
|
||||||
|
* check enforces that structurally rather than trusting an author to
|
||||||
|
* remember it, and it fails in both directions: editing the page without the
|
||||||
|
* registry, or the registry without the page.
|
||||||
|
*
|
||||||
|
* It also catches the quiet one: a straight apostrophe in the registry
|
||||||
|
* against the typographic apostrophe the page renders. Found exactly that on
|
||||||
|
* the first run, on `/practice/insurance/`.
|
||||||
|
*
|
||||||
|
* It reads `src/data/og-cards.ts` DIRECTLY — Node strips the types — so there is
|
||||||
|
* no second list of cards to keep in step with the first.
|
||||||
|
*
|
||||||
|
* Optional: `npm run og:proof -- --sheet` writes a contact sheet of every card
|
||||||
|
* to `dist/og-proof.jpg` so the set can be looked at in one go. Not part of the
|
||||||
|
* check; a human still has to look.
|
||||||
|
*/
|
||||||
|
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, relative, sep } from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import {
|
||||||
|
OG_CARDS,
|
||||||
|
PORTRAIT_PAGES,
|
||||||
|
articleCard,
|
||||||
|
ogCardPath,
|
||||||
|
} from '../src/data/og-cards.ts';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const DIST = join(ROOT, 'dist');
|
||||||
|
const SITE = 'https://adr.smlcompany.ca';
|
||||||
|
|
||||||
|
const strip = (html) =>
|
||||||
|
html
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
async function pages(dir = DIST) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...(await pages(full)));
|
||||||
|
else if (entry.name === 'index.html') {
|
||||||
|
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||||
|
out.push({ path: rel ? `/${rel}/` : '/', file: full });
|
||||||
|
}
|
||||||
|
/* ⚠️ `index.html` ALONE MISSED A WHOLE PAGE. `build.format: 'directory'`
|
||||||
|
puts every route at `<dir>/index.html` — except the ones Astro emits
|
||||||
|
outside the convention, and `404.astro` becomes `dist/404.html`. So this
|
||||||
|
script enumerated 22 pages of 23, and the symptom was backwards: it
|
||||||
|
reported the 404 page's card as ORPHANED ("generated, but no built page
|
||||||
|
references it") rather than reporting the page as unchecked. `path` here
|
||||||
|
is an `OG_CARDS` key, which is `Astro.url.pathname` — `/404/`, not
|
||||||
|
`/404.html`. `scripts/lighthouse.mjs` had the same blind spot and needs
|
||||||
|
the URL form instead; see the note there. */
|
||||||
|
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||||
|
out.push({ path: `/${entry.name.replace(/\.html$/, '')}/`, file: full });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
const fail = (msg) => problems.push(msg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(join(DIST, 'index.html'));
|
||||||
|
} catch {
|
||||||
|
console.error('dist/ is missing or empty. Run `npm run build` first.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = await pages();
|
||||||
|
const seenCards = new Set();
|
||||||
|
let checkedHeadlines = 0;
|
||||||
|
let checkedArticles = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An article's expected card, from the SAME `articleCard()` the endpoint calls,
|
||||||
|
* given the title in that article's own frontmatter. Read from the `.mdx` rather
|
||||||
|
* than from the built page, so the comparison has two independent sides: what
|
||||||
|
* the article says its title is, and what the route rendered as the `<h1>`.
|
||||||
|
*/
|
||||||
|
async function expectedArticleCard(path) {
|
||||||
|
const slug = path.replace(/^\/insights\/|\/$/g, '');
|
||||||
|
for (const ext of ['mdx', 'md']) {
|
||||||
|
try {
|
||||||
|
const src = await readFile(
|
||||||
|
join(ROOT, 'src', 'content', 'insights', `${slug}.${ext}`),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const m = /^title:\s*(.*)$/m.exec(src);
|
||||||
|
if (!m) break;
|
||||||
|
let title = m[1].trim();
|
||||||
|
// YAML scalar: strip one layer of quoting and unescape a doubled single
|
||||||
|
// quote, which is how YAML writes a literal apostrophe inside '…'.
|
||||||
|
if (
|
||||||
|
(title.startsWith("'") && title.endsWith("'")) ||
|
||||||
|
(title.startsWith('"') && title.endsWith('"'))
|
||||||
|
) {
|
||||||
|
title = title.slice(1, -1);
|
||||||
|
}
|
||||||
|
title = title.replace(/''/g, "'");
|
||||||
|
return articleCard(title);
|
||||||
|
} catch {
|
||||||
|
/* try the next extension */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { path, file } of built) {
|
||||||
|
const html = await readFile(file, 'utf8');
|
||||||
|
|
||||||
|
// ---- 1. og:image exists -------------------------------------------------
|
||||||
|
const og = /<meta property="og:image" content="([^"]+)"/.exec(html);
|
||||||
|
if (!og) {
|
||||||
|
fail(`${path}: no og:image meta tag at all`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const url = og[1];
|
||||||
|
if (!url.startsWith(SITE + '/')) {
|
||||||
|
fail(`${path}: og:image is not an absolute URL on ${SITE} — ${url}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const assetPath = url.slice(SITE.length);
|
||||||
|
try {
|
||||||
|
await stat(join(DIST, assetPath));
|
||||||
|
} catch {
|
||||||
|
fail(`${path}: og:image points at ${assetPath}, which is not in dist/`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenCards.add(assetPath);
|
||||||
|
|
||||||
|
// ---- 2. card copy is the page's own copy --------------------------------
|
||||||
|
const isPortrait = PORTRAIT_PAGES.includes(path);
|
||||||
|
const card = OG_CARDS[path];
|
||||||
|
|
||||||
|
if (isPortrait) {
|
||||||
|
if (card) fail(`${path}: in PORTRAIT_PAGES and in OG_CARDS — pick one`);
|
||||||
|
if (assetPath.startsWith('/og/')) {
|
||||||
|
fail(`${path}: is a portrait page but its og:image is a generated card`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = ogCardPath(path);
|
||||||
|
if (assetPath !== expected) {
|
||||||
|
fail(`${path}: og:image is ${assetPath}, expected ${expected}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ AN ARTICLE IS CHECKED THE SAME WAY AS A REGISTRY PAGE, AND UNTIL
|
||||||
|
* 2026-08-31 IT WAS NOT CHECKED AT ALL.
|
||||||
|
*
|
||||||
|
* The first version of this script matched an article's card FILENAME and then
|
||||||
|
* `continue`d — skipping the headline and eyebrow comparisons entirely. So the
|
||||||
|
* one surface `check:claims` cannot reach was also the one surface this script
|
||||||
|
* did not compare, which is the opposite of what its own header claims and what
|
||||||
|
* `docs/04` says it enforces.
|
||||||
|
*
|
||||||
|
* `adversarial-reviewer` proved it rather than arguing it: with
|
||||||
|
* `headline: 'DELIBERATELY WRONG CARD TEXT — probe'` set in the endpoint and one
|
||||||
|
* article published, the card rendered that sentence in 68px Instrument Serif
|
||||||
|
* and this script printed `OK — every og:image resolves, and no card asserts
|
||||||
|
* anything its page does not`, exit 0. **`checkedHeadlines` stayed pinned at the
|
||||||
|
* registry size** no matter how many articles published — a coverage number
|
||||||
|
* that reads like completeness and falls further behind as the site grows,
|
||||||
|
* which is exactly the uniform-pass shape `CLAUDE.md` warns is the dangerous
|
||||||
|
* half.
|
||||||
|
*
|
||||||
|
* An article has no registry entry by design — its card comes from the
|
||||||
|
* collection — so the expectation comes from the page instead: the endpoint
|
||||||
|
* sets an article card's headline to `entry.data.title`, which is also the
|
||||||
|
* page's `<h1>`. Comparing the card's source of truth against the rendered
|
||||||
|
* `<h1>` is therefore the same check, and the eyebrow is the literal the
|
||||||
|
* endpoint sets.
|
||||||
|
*/
|
||||||
|
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
|
||||||
|
if (!card && !isArticle) {
|
||||||
|
fail(`${path}: built, not a portrait page, and has no OG_CARDS entry`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let expected_card = card;
|
||||||
|
if (!expected_card) {
|
||||||
|
expected_card = await expectedArticleCard(path);
|
||||||
|
if (!expected_card) {
|
||||||
|
fail(
|
||||||
|
`${path}: could not read a \`title:\` from this article's own .mdx, so ` +
|
||||||
|
'its card cannot be compared against anything. That is a failure, not ' +
|
||||||
|
'a skip — an unchecked card is the one surface check:claims cannot see.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expectedEyebrow = expected_card.eyebrow;
|
||||||
|
|
||||||
|
const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/.exec(html);
|
||||||
|
if (!h1) {
|
||||||
|
fail(`${path}: no <h1> to compare the card headline against`);
|
||||||
|
} else {
|
||||||
|
const text = strip(h1[1]);
|
||||||
|
const expectedHeadline = expected_card.headline;
|
||||||
|
if (text !== expectedHeadline) {
|
||||||
|
fail(
|
||||||
|
`${path}: card headline is not the page's <h1>.\n` +
|
||||||
|
` <h1>: ${JSON.stringify(text)}\n` +
|
||||||
|
` card: ${JSON.stringify(expectedHeadline)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
checkedHeadlines += 1;
|
||||||
|
if (isArticle) checkedArticles += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eyebrow = /<p class="eyebrow"[^>]*>([\s\S]*?)<\/p>/.exec(html);
|
||||||
|
if (!eyebrow) {
|
||||||
|
fail(`${path}: no .eyebrow to compare the card eyebrow against`);
|
||||||
|
} else {
|
||||||
|
const text = strip(eyebrow[1]);
|
||||||
|
if (text !== expectedEyebrow) {
|
||||||
|
fail(
|
||||||
|
`${path}: card eyebrow is not the page's first .eyebrow.\n` +
|
||||||
|
` page: ${JSON.stringify(text)}\n` +
|
||||||
|
` card: ${JSON.stringify(expectedEyebrow)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ AND THE COVERAGE IS ASSERTED, NOT REPORTED. Printing "20 headlines matched"
|
||||||
|
* beside a growing site is how the gap above stayed invisible: the number went up
|
||||||
|
* and never went up ENOUGH, and nothing said so. Every built page except the
|
||||||
|
* portrait pages carries a generated card, so the count must equal that or a page
|
||||||
|
* was silently skipped.
|
||||||
|
*/
|
||||||
|
const shouldCheck = built.filter(
|
||||||
|
({ path }) => !PORTRAIT_PAGES.includes(path),
|
||||||
|
).length;
|
||||||
|
if (checkedHeadlines !== shouldCheck) {
|
||||||
|
fail(
|
||||||
|
`only ${checkedHeadlines} of ${shouldCheck} non-portrait pages had their ` +
|
||||||
|
'card headline compared against their <h1>. A page was skipped, which is ' +
|
||||||
|
'the failure this assertion exists to make loud.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. no card generated for a page that does not exist -------------------
|
||||||
|
// A stray card is not a shipped defect, but it is the signature of a page that
|
||||||
|
// was renamed or removed and a registry entry that was not — which the next
|
||||||
|
// person reads as "the card exists, so the page must".
|
||||||
|
let strays = [];
|
||||||
|
try {
|
||||||
|
const files = await readdir(join(DIST, 'og'));
|
||||||
|
strays = files
|
||||||
|
.filter((f) => f.endsWith('.jpg'))
|
||||||
|
.map((f) => `/og/${f}`)
|
||||||
|
.filter((p) => !seenCards.has(p));
|
||||||
|
} catch {
|
||||||
|
fail('dist/og/ does not exist — no cards were generated');
|
||||||
|
}
|
||||||
|
for (const s of strays) {
|
||||||
|
fail(`${s}: generated, but no built page references it`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- optional contact sheet ----------------------------------------------
|
||||||
|
if (process.argv.includes('--sheet')) {
|
||||||
|
const files = (await readdir(join(DIST, 'og')))
|
||||||
|
.filter((f) => f.endsWith('.jpg'))
|
||||||
|
.sort();
|
||||||
|
const COLS = 3;
|
||||||
|
const W = 400;
|
||||||
|
const H = 210;
|
||||||
|
const rows = Math.ceil(files.length / COLS);
|
||||||
|
const tiles = await Promise.all(
|
||||||
|
files.map(async (f, i) => ({
|
||||||
|
input: await sharp(join(DIST, 'og', f))
|
||||||
|
.resize(W, H)
|
||||||
|
.toBuffer(),
|
||||||
|
left: (i % COLS) * W,
|
||||||
|
top: Math.floor(i / COLS) * H,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const sheet = await sharp({
|
||||||
|
create: {
|
||||||
|
width: COLS * W,
|
||||||
|
height: rows * H,
|
||||||
|
channels: 3,
|
||||||
|
background: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(tiles)
|
||||||
|
.jpeg({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
await writeFile(join(DIST, 'og-proof.jpg'), sheet);
|
||||||
|
console.log(
|
||||||
|
`contact sheet: dist/og-proof.jpg — ${files.length} cards, ${COLS}x${rows}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`og:proof — ${built.length} built pages, ${seenCards.size} distinct og:image ` +
|
||||||
|
`targets, ${checkedHeadlines} card headlines matched their page <h1> ` +
|
||||||
|
`(${checkedArticles} of them articles).`,
|
||||||
|
);
|
||||||
|
if (problems.length) {
|
||||||
|
console.error(`\nOG CARD PROBLEMS — ${problems.length}:`);
|
||||||
|
for (const p of problems) console.error(` - ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'OK — every og:image resolves, and no card asserts anything its page does not.',
|
||||||
|
);
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 251 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The visible breadcrumb trail. `docs/04` requires `BreadcrumbList` markup on
|
||||||
|
* "all nested pages" AND that it **match the visible breadcrumbs** — so the
|
||||||
|
* markup and this component are built from one array at the call site, and a
|
||||||
|
* page cannot emit one without showing the other.
|
||||||
|
*
|
||||||
|
* WHICH PAGES GET ONE, AND WHY IT IS NOT EVERY PAGE. `/about/`, `/mediation/`,
|
||||||
|
* `/arbitration/`, `/med-arb/` and `/practice/` are all ONE HOP from the root
|
||||||
|
* and show no breadcrumb, so emitting the markup on them would assert a
|
||||||
|
* navigation structure the page does not have. The trail begins at the
|
||||||
|
* two-level pages: `/practice/<area>/`, and `/insights/<slug>/` at step 7.
|
||||||
|
*
|
||||||
|
* THE CURRENT PAGE IS NOT A LINK. A link to the page you are on is a target
|
||||||
|
* that does nothing, and `aria-current="page"` is the property that carries the
|
||||||
|
* meaning. `schema.org` still wants it as the last `ListItem`, which is why the
|
||||||
|
* caller passes the full trail and this component decides what to render.
|
||||||
|
*
|
||||||
|
* THE SEPARATOR IS `aria-hidden` AND LIVES IN CSS-adjacent markup rather than
|
||||||
|
* in the link text: a screen reader announcing "slash" between every crumb is
|
||||||
|
* noise, and `<nav aria-label="Breadcrumb">` already names the structure.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** Root-first, INCLUDING the current page as the last entry. */
|
||||||
|
trail: ReadonlyArray<{ name: string; href: string }>;
|
||||||
|
}
|
||||||
|
const { trail } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="crumbs" aria-label="Breadcrumb">
|
||||||
|
<ol role="list">
|
||||||
|
{
|
||||||
|
trail.map((crumb, i) =>
|
||||||
|
i === trail.length - 1 ? (
|
||||||
|
<li aria-current="page">{crumb.name}</li>
|
||||||
|
) : (
|
||||||
|
<li>
|
||||||
|
<a href={crumb.href}>{crumb.name}</a>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* `role="list"` on the <ol> and no local `list-style: none` — global.css does
|
||||||
|
both for `ol[role='list']`, and WebKit drops list semantics when the marker
|
||||||
|
goes, which is why the role is there. Same pairing as `.stage` on
|
||||||
|
`/arbitration/`. */
|
||||||
|
.crumbs ol {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.crumbs li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
/* 44px is the touch-target floor in docs/02. These are small uppercase mono
|
||||||
|
links in a row, which is exactly the shape that lands under it — the same
|
||||||
|
defect measured on `/med-arb/`'s onward links at 390px (21px tall against a
|
||||||
|
44px floor). `inline-flex` + `min-block-size` is the fix the rest of the
|
||||||
|
site uses. */
|
||||||
|
.crumbs a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 44px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.crumbs a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
.crumbs [aria-current='page'] {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
|
||||||
|
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
|
||||||
|
* a link that navigates must be an <a>, whatever it looks like.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
href?: string;
|
||||||
|
variant?: 'primary' | 'ghost' | 'gold';
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
href,
|
||||||
|
variant = 'primary',
|
||||||
|
type = 'button',
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
const classes = ['btn', `btn-${variant}`, className];
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
href ? (
|
||||||
|
<a href={href} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button type={type} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
/* 44 × 44 is the touch-target floor in docs/02. */
|
||||||
|
min-block-size: 44px;
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
padding-inline: var(--space-5);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-hover) var(--ease),
|
||||||
|
border-color var(--dur-hover) var(--ease),
|
||||||
|
color var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
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(--btn-ghost-border, var(--border));
|
||||||
|
color: var(--btn-ghost-fg, var(--text));
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
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 {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The conversion band — maroon ground, one heading, one CTA. `docs/01` puts it
|
||||||
|
* at the foot of `/` (item 8) and it belongs at the foot of every page that
|
||||||
|
* wants an appointment enquiry.
|
||||||
|
*
|
||||||
|
* EXTRACTED 2026-08-28 ON `adversarial-reviewer`'S FINDING, and the finding was
|
||||||
|
* not "this is duplicated" but "this is duplicated AND HAS ALREADY DRIFTED."
|
||||||
|
* `/` and `/about/` carried identical markup and ~20 identical lines of CSS,
|
||||||
|
* except `.contact-body` — `52ch` on `/`, `46ch` plus a `line-height` on
|
||||||
|
* `/about/`. Two call sites, one already divergent, seventeen pages to come.
|
||||||
|
*
|
||||||
|
* NO PROPS AND NO SLOTS AT ALL, AND THAT IS A CORRECTION MADE ON REVIEW.
|
||||||
|
* This shipped with `eyebrow?`, `cta?` and a named `heading` slot, all
|
||||||
|
* defaulted, and **not one of the two call sites overrode any of them** — the
|
||||||
|
* exact pattern this repo has already deleted twice with the reasons written
|
||||||
|
* into the source: `Eyebrow.astro` (*"`tag?: 'p' | 'span'` had zero call sites,
|
||||||
|
* so its `<span>` branch was unreachable code"*) and `SectionHeading` (*"two
|
||||||
|
* mechanisms for one job… One way in."*). The header even argued against
|
||||||
|
* `title`/`body` props and then added `eyebrow`/`cta`. Strings are inlined; add
|
||||||
|
* a prop when a second call site actually needs one.
|
||||||
|
*
|
||||||
|
* The empty `Props` guard stays, though, and it is not decoration: without it
|
||||||
|
* an Astro component's props widen to `any` and `<ContactBand class="x" />`
|
||||||
|
* compiles clean while matching nothing — the parent-scope defect `CLAUDE.md`
|
||||||
|
* records four times, and the one `Pill` was caught by. Verified by probe.
|
||||||
|
*
|
||||||
|
* THE `<h2>` IS FIXED AT LEVEL 2 rather than taken as a prop. Every page that
|
||||||
|
* uses this band has an `<h1>` of its own and top-level sections at `<h2>`, so a
|
||||||
|
* configurable level here is a way to skip a heading level by accident. If a
|
||||||
|
* page ever needs otherwise, that is a spec question, not a prop.
|
||||||
|
*
|
||||||
|
* `CONTACT.responseTime` is rendered from the constant, never typed: §4 records
|
||||||
|
* it as **a public commitment** that *"must read identically on `/contact/`, in
|
||||||
|
* the inquirer confirmation email, and in any bio."*
|
||||||
|
*/
|
||||||
|
import Button from './Button.astro';
|
||||||
|
import Eyebrow from './Eyebrow.astro';
|
||||||
|
import { CONTACT } from '../data/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: unknown;
|
||||||
|
}
|
||||||
|
const _props: Props = Astro.props;
|
||||||
|
void _props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
/* NO BOOKING LINK, and that is not an omission: booking is parked
|
||||||
|
(AGENTS.md R6) and `CONTACT.bookingUrl` is null, so `/contact/` ships the
|
||||||
|
intake form with a reserved slot for an embed. Stated rather than silently
|
||||||
|
dropped, on every page that renders this band. */
|
||||||
|
}
|
||||||
|
<section class="section section-accent contact-band">
|
||||||
|
<div class="wrap contact-inner">
|
||||||
|
<div class="contact-copy">
|
||||||
|
<Eyebrow dot>Next step</Eyebrow>
|
||||||
|
<h2 class="display contact-h">Start with a call.</h2>
|
||||||
|
<p class="contact-body">
|
||||||
|
Tell me the shape of the matter and who is involved, and I will tell you
|
||||||
|
whether I am the right neutral for it. {CONTACT.responseTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="contact-action">
|
||||||
|
<Button href="/contact/" variant="gold"
|
||||||
|
>Request a consultation →</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
.contact-h {
|
||||||
|
margin-block: var(--space-4) var(--space-4);
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
.contact-body {
|
||||||
|
/* 52ch, which is `/`'s value. The two call sites had drifted to 52ch and
|
||||||
|
46ch; 52 is the one that shipped first and was reviewed. */
|
||||||
|
max-inline-size: 52ch;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.contact-action {
|
||||||
|
/* `0 1 auto` + `min-inline-size: 0`, NOT `flex: none`. `none` is `0 0 auto`,
|
||||||
|
which refuses to shrink below max-content and pushed the band into
|
||||||
|
overflow at 320px. This lets the button wrap instead. Measured on `/`. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Three or four credential slots. NEVER matter counts — AGENTS.md §4."
|
||||||
|
*
|
||||||
|
* THE SUBSTITUTION PRINCIPLE IS THE POINT OF THIS COMPONENT (§4). Wherever a
|
||||||
|
* design wants a "how much / how many", it takes a longer-arc credential
|
||||||
|
* instead: something already substantial and true at launch that does not grow
|
||||||
|
* by closing files. `Q.Med` / `JD + ML` / `EN · FA`, with `Q.Arb` fourth.
|
||||||
|
*
|
||||||
|
* So this component is not a stat row wearing a different name, and it must
|
||||||
|
* never be handed one. §4 Forbidden bars every count of matters closed, hours
|
||||||
|
* mediated or years in practice, plus settlement rates and dollar figures. The
|
||||||
|
* slots come from src/data/site.ts, which mirrors §4; nothing is typed here.
|
||||||
|
*
|
||||||
|
* <dl> RATHER THAN A DIV GRID. Each pair is a term and its description, which
|
||||||
|
* is what a description list is, and it fixes the reading order: a screen
|
||||||
|
* reader gets "Q.Arb — ADRIC / ADRIO" as one associated pair rather than four
|
||||||
|
* values then four labels. Wrapping each dt/dd pair in a <div> inside <dl> is
|
||||||
|
* valid HTML and is what makes the grid tractable.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
slots: ReadonlyArray<{ value: string; label: string }>;
|
||||||
|
}
|
||||||
|
// No `class` prop: it was declared, never passed, and a parent cannot reach this
|
||||||
|
// root anyway (SectionHeading carries the measurement; CLAUDE.md the rule).
|
||||||
|
const { slots } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<dl class="credentials">
|
||||||
|
{
|
||||||
|
slots.map((slot) => (
|
||||||
|
<div class="credential">
|
||||||
|
<dt class="credential-value">{slot.value}</dt>
|
||||||
|
<dd class="credential-label">{slot.label}</dd>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.credentials {
|
||||||
|
display: grid;
|
||||||
|
/* EXPLICIT COLUMNS, BECAUSE `auto-fit` NEVER DELIVERED THE ROW IT CLAIMED.
|
||||||
|
This read `repeat(auto-fit, minmax(11rem, 1fr))` under a comment saying
|
||||||
|
"two up on a phone, four up where there is room". `adversarial-reviewer`
|
||||||
|
measured it: at 390px the resolved template was a SINGLE 342px track and
|
||||||
|
all four items stacked, running the band ~430px tall. The longest label in
|
||||||
|
the row is and remains `Legal training and engineering practice` (Q37, 39
|
||||||
|
chars — see below), which is what drives the track; the fourth slot's
|
||||||
|
label went from `Commenced August 2026` (21) to `ADRIC / ADRIO
|
||||||
|
designation` (25), so it got slightly LONGER and is still well inside
|
||||||
|
that 39. *(A previous version of this comment said "shorter". It was not
|
||||||
|
measured; `claims-auditor` counted the characters.)* The arithmetic is not
|
||||||
|
subtle: two
|
||||||
|
tracks at an 11rem (176px) floor plus a 24px gap need 376px and the
|
||||||
|
container is 342px, so `auto-fit` correctly dropped to one. A
|
||||||
|
measured-sounding comment that was false is this project's own named
|
||||||
|
failure mode.
|
||||||
|
|
||||||
|
`minmax(0, 1fr)` means the TRACK cannot overflow at any width or any root
|
||||||
|
font size, which retires the `min(11rem, 100%)` guard this line briefly
|
||||||
|
carried — that guard was fixing the overflow symptom of a floor that
|
||||||
|
should not have been there. ⚠️ **THE TRACK IS NOT THE CONTENT:** a label's
|
||||||
|
own words can overflow the track, and at a 200% default font size they
|
||||||
|
did. That is why `.credential-label` below carries `overflow-wrap`. */
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-6) var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
padding-block: var(--space-7);
|
||||||
|
border-block: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.credential {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.credential-value {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* Four across only where four actually fit, measured rather than inferred.
|
||||||
|
Two 11rem-equivalent tracks plus three 24px gaps need 776px of container;
|
||||||
|
above 48rem the gutter is 48px each side, so that is a 872px viewport. 56rem
|
||||||
|
(896px) is the clean token above it. Re-measure if --space-5 or the label
|
||||||
|
type changes. */
|
||||||
|
@media (min-width: 56rem) {
|
||||||
|
.credentials {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-label {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-meta);
|
||||||
|
/* "Legal training and engineering practice" is 39 characters and is
|
||||||
|
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
|
||||||
|
and must not be prevented from doing so. Do not add `white-space: nowrap`
|
||||||
|
here, and do not shorten the label to make the row tidier: the asymmetry
|
||||||
|
is the honest part. ⚠️ `overflow-wrap: anywhere` is LOAD-BEARING here, not
|
||||||
|
cosmetic — `anywhere`, not `break-word`: see `docs/02` §Reflow, instrument
|
||||||
|
finding 1. */
|
||||||
|
text-wrap: pretty;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* A `<dl>` of term/description pairs on an auto-fitting grid.
|
||||||
|
*
|
||||||
|
* EXTRACTED AT STEP 4 ON `adversarial-reviewer`'S FINDING, and the finding was
|
||||||
|
* that `/mediation/`'s `.formats` and `/arbitration/`'s `.cols` were the same
|
||||||
|
* component under two names — identical markup, near-identical CSS, and
|
||||||
|
* `.cols` was already serving two different content types on one page.
|
||||||
|
* `/practice/*` at step 5 wants it a fourth time. Same argument that extracted
|
||||||
|
* `ContactBand`: two call sites, one already divergent, pages to come.
|
||||||
|
*
|
||||||
|
* `<dl>` RATHER THAN A DIV GRID, for `CredentialRow`'s reason: each pair is a
|
||||||
|
* term and its description, so a screen reader gets them as an associated pair
|
||||||
|
* rather than as a visual arrangement. Wrapping each `<dt>`/`<dd>` in a `<div>`
|
||||||
|
* inside the `<dl>` is valid HTML and is what makes the grid tractable.
|
||||||
|
*
|
||||||
|
* A `<dt>` IS NOT A HEADING and must not become one. These sit under the
|
||||||
|
* section's `<h2>`; promoting them to `<h3>` would be a heading level that adds
|
||||||
|
* nothing to the outline, and `docs/02` forbids skipped levels either way.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
items: ReadonlyArray<{ name: string; body: string }>;
|
||||||
|
/** The grid's per-column floor, passed to `.grid-autofit` as `--grid-min`.
|
||||||
|
* The `min(N, 100%)` guard lives there, in one place. */
|
||||||
|
minColumn?: string;
|
||||||
|
}
|
||||||
|
const { items, minColumn = '17rem' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<dl class="grid-autofit defs" style={`--grid-min: ${minColumn}`}>
|
||||||
|
{
|
||||||
|
items.map((item) => (
|
||||||
|
<div class="def">
|
||||||
|
<dt class="def-name">{item.name}</dt>
|
||||||
|
<dd class="def-body">{item.body}</dd>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Columns and the `min()` guard come from `.grid-autofit` (global.css); this
|
||||||
|
sets only the gap. It re-implemented them for one pass — a second copy of
|
||||||
|
the guard, inside the extraction made to remove copies of the guard.
|
||||||
|
`--grid-min` is passed inline by the caller because a parent cannot style
|
||||||
|
this component's root, and a custom property is the one mechanism that
|
||||||
|
crosses that boundary. */
|
||||||
|
.defs {
|
||||||
|
gap: var(--space-7);
|
||||||
|
}
|
||||||
|
/* ⚠️ `--def-name-fg`, NOT `--text-meta` DIRECTLY. `--text-meta` is `--muted`,
|
||||||
|
and `tokens.css` states the constraint on that token in terms: "metadata —
|
||||||
|
ON CREAM ONLY (3.07:1 on ink)". `/practice/` is the first page to put this
|
||||||
|
component on an inverse ground, and it shipped these labels at **3.07:1 at
|
||||||
|
12px** against a 4.5:1 AA floor — measured three ways by
|
||||||
|
`adversarial-reviewer` (token arithmetic, `getComputedStyle` against the
|
||||||
|
served build, and a screenshot), all agreeing.
|
||||||
|
|
||||||
|
A custom property is the fix rather than a `:global()` rule because it is
|
||||||
|
the one mechanism that crosses Astro's component-scope boundary — the same
|
||||||
|
route `Pill` already uses, and `global.css` sets this alongside `--pill-fg`
|
||||||
|
on `.section-inverse, .section-accent`. The fallback keeps cream correct. */
|
||||||
|
.def-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--def-name-fg, var(--text-meta));
|
||||||
|
}
|
||||||
|
.def-body {
|
||||||
|
margin-block-start: var(--space-2);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Mono label with optional maroon dot."
|
||||||
|
*
|
||||||
|
* NO SCOPED STYLES ON PURPOSE. `.eyebrow` and `.eyebrow .dot` are already
|
||||||
|
* global (global.css) because `.section-inverse .eyebrow` has to recolour them
|
||||||
|
* from an ancestor, and a scoped rule here would be a second source for the
|
||||||
|
* same thing. This component exists to stop `<span class="eyebrow">` being
|
||||||
|
* hand-typed on nineteen pages, not to own the look.
|
||||||
|
*
|
||||||
|
* AN EYEBROW IS A LABEL, NEVER A HEADING. It renders <p> or <span>, never
|
||||||
|
* <h*>: docs/02's accessibility floor forbids skipped heading levels, and an
|
||||||
|
* eyebrow above an <h2> is exactly where an <h3>-before-<h2> creeps in.
|
||||||
|
*
|
||||||
|
* ⚠️ NEVER NAME AN ASTRO PROP `as`. The prop deleted below was briefly called
|
||||||
|
* `as`, and that name SILENTLY TURNED OFF PROP TYPE-CHECKING for every caller
|
||||||
|
* of this component. Kept here because the next person to want a dynamic tag
|
||||||
|
* will reach for `as` first.
|
||||||
|
*
|
||||||
|
* `astro check` reported it only as a hint — `ts(6196) 'Props' is declared but
|
||||||
|
* never used` — which reads exactly like lint noise and is the reason it is
|
||||||
|
* worth writing down. It is not noise: it is the compiler saying the `Props`
|
||||||
|
* interface is not attached to anything.
|
||||||
|
*
|
||||||
|
* Measured rather than assumed, 2026-08-27. With the prop named `as`,
|
||||||
|
* `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**. The same
|
||||||
|
* probe against CredentialRow, SectionHeading, PracticeCard and ProcessStep
|
||||||
|
* produced `ts(2322)` on all four, so the loss was specific to this file.
|
||||||
|
* Renaming the single identifier `as` to `tag` — one variable changed, nothing
|
||||||
|
* else — took the file from 1 hint / 0 errors to 0 hints, and the same probe
|
||||||
|
* now fails correctly: `Type '"h9"' is not assignable to type '"p" | "span" |
|
||||||
|
* undefined'`. `const { as = 'p' } = Astro.props` is read as a type assertion
|
||||||
|
* somewhere in the generated TSX and detaches the binding.
|
||||||
|
*
|
||||||
|
* A DYNAMIC `<Tag>` FROM A VARIABLE was the first hypothesis for the lost
|
||||||
|
* binding and it was WRONG — restructuring the template changed nothing, the
|
||||||
|
* rename fixed it. Recorded so the wrong cause is not re-derived.
|
||||||
|
*
|
||||||
|
* DO NOT "FIX" A ts(6196) HINT WITH `Astro.props as Props`. It silences the
|
||||||
|
* warning inside the component and leaves every call site unchecked, which is
|
||||||
|
* strictly worse than the warning. If this hint appears on another component,
|
||||||
|
* probe a bogus prop before believing the props are checked.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** The maroon dot. Decorative — it is a CSS box, so it is invisible to AT. */
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE PROP, AND THE OTHER TWO ARE DELETED. `tag?: 'p' | 'span'` had zero call
|
||||||
|
* sites, so its `<span>` branch was unreachable code and the file carried two
|
||||||
|
* near-identical templates for it. `class?: string` had zero call sites too, and
|
||||||
|
* a parent cannot reach this root anyway (see SectionHeading, and CLAUDE.md).
|
||||||
|
*
|
||||||
|
* The rename that produced the finding below is kept; only the prop is gone.
|
||||||
|
*/
|
||||||
|
const { dot = false } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="eyebrow">
|
||||||
|
{dot && <span class="dot" aria-hidden="true" />}
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The SML infinity mark.
|
||||||
|
*
|
||||||
|
* ⚠️ DELIBERATE, TEMPORARY EXCEPTION TO docs/02's "inline SVG, never a PNG".
|
||||||
|
* Tracked as AGENTS.md Q38, with a standing reminder (R13) so it cannot become
|
||||||
|
* permanent by neglect. Read both before changing this file.
|
||||||
|
*
|
||||||
|
* WHY A RASTER — and the reason is PAYLOAD, not fidelity. The mark is not a
|
||||||
|
* stroked curve; it is a shaded ribbon of variable width that twists in three
|
||||||
|
* dimensions, maroon flowing into champagne, passing over itself at the
|
||||||
|
* crossing. That is gradient-mesh artwork, not the flat vector paths docs/02
|
||||||
|
* assumes.
|
||||||
|
*
|
||||||
|
* We do hold an SVG — src/assets/brand/sml-logo-source.svg — and **it renders
|
||||||
|
* faithfully**: rasterised at 8333px it reproduces the master exactly, at the
|
||||||
|
* same 1.566:1 [verified 2026-08-26]. An earlier version of this comment implied
|
||||||
|
* it was inadequate artwork. It is not; that was unfair and is corrected here.
|
||||||
|
* What rules it out is weight and composition: 257,278 bytes against 3,063 for
|
||||||
|
* the AVIF a Retina browser takes — 84× — and SEVEN embedded base64 PNGs plus
|
||||||
|
* a 1,225-stop gradient mesh, so inlining it would breach CLAUDE.md's rule
|
||||||
|
* against base64-inlining images. The exception ends when a vector master lands
|
||||||
|
* that is both faithful AND light.
|
||||||
|
*
|
||||||
|
* WHAT THIS REPLACES, and why it had to go. Until 2026-08-26 this component
|
||||||
|
* drew a hand-traced cubic path lifted from the old site's loading thumbnail.
|
||||||
|
* Pouya compared it against the master and it was wrong in three ways; two are
|
||||||
|
* reproducible from the path itself:
|
||||||
|
*
|
||||||
|
* 1. TANGENT, NOT CROSSING. All four branches met the origin at exactly 90°,
|
||||||
|
* so the loops were mutually tangent on a vertical line rather than
|
||||||
|
* crossing. At stroke-width 28 that renders as two circles kissing — the
|
||||||
|
* one thing an infinity mark must not be. Verified by computing the
|
||||||
|
* tangent vector of every segment at the origin.
|
||||||
|
* 2. WRONG PROPORTION. The real mark's ink bounding box is 2668 × 1704 =
|
||||||
|
* 1.5657:1. The traced path measured 1.667:1 ink / 1.597:1 stroked.
|
||||||
|
* 3. FLAT. Two uniform strokes standing in for a shaded ribbon.
|
||||||
|
*
|
||||||
|
* It is deleted rather than kept as a fallback, on Pouya's instruction: a wrong
|
||||||
|
* mark that renders is worse than a missing one, because it stops looking wrong.
|
||||||
|
*
|
||||||
|
* SOURCE OF TRUTH. src/assets/brand/sml-infinity-mark.png is the master tight-
|
||||||
|
* cropped to its ink bounding box, so the file's aspect ratio IS the mark's and
|
||||||
|
* layout can be tuned to it directly. Provenance: docs/reference/brand-assets.md.
|
||||||
|
*/
|
||||||
|
import { Picture } from 'astro:assets';
|
||||||
|
import mark from '../assets/brand/sml-infinity-mark.png';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rendered height. Width follows 1.5657:1. */
|
||||||
|
size?: string;
|
||||||
|
/**
|
||||||
|
* Intrinsic width of the 1x variant, in px. RAISE IT FOR A LARGE CALL SITE.
|
||||||
|
*
|
||||||
|
* The default 64 is sized for the header (50.1px wide) and footer (56.4px) —
|
||||||
|
* with densities [1,2,3] that gives 64/128/192 and both are sharp to DPR 3.
|
||||||
|
* The home page's approach section renders the mark at 225.5px, five times
|
||||||
|
* larger, and inherited the same 64: measured 1.17x upscale at DPR 1, 2.35x
|
||||||
|
* at DPR 2, 3.52x at DPR 3. A prop, because the component cannot infer this
|
||||||
|
* from `size` — `size` may be a `clamp()`.
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
/**
|
||||||
|
* `eager` for the two above-the-fold marks; `lazy` for anything below it.
|
||||||
|
* The component hardcoded `eager`, which is right for a masthead and wrong
|
||||||
|
* for a 700px-wide decorative anchor two screens down.
|
||||||
|
*/
|
||||||
|
loading?: 'eager' | 'lazy';
|
||||||
|
/**
|
||||||
|
* Accessible name. Omit for decorative use — the default, and the case at all
|
||||||
|
* three current call sites: the header and footer marks sit beside the name
|
||||||
|
* they stand for, and the home page's mark sits beside a paragraph that says
|
||||||
|
* what it is. Kept because the moment the mark appears without adjacent text
|
||||||
|
* it needs one, and a component that cannot take an accessible name invites
|
||||||
|
* `alt=""` on an informative image.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NO `class` PROP, AND ITS REMOVAL IS THE POINT. It existed, had no call site,
|
||||||
|
* and `class:list` put it on the <img> — so a parent writing
|
||||||
|
* `<InfinityMark class="foo" />` would get a rule compiled against the PARENT's
|
||||||
|
* cid that never matches the child's root. That is the exact defect CLAUDE.md
|
||||||
|
* records twice on this project. The prop was an invitation to reproduce it.
|
||||||
|
* To position a mark, style a wrapper the parent owns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { size = '1.75rem', width, loading = 'eager', label } = Astro.props;
|
||||||
|
|
||||||
|
// THE DEFAULT IS SIZED TO THE LARGEST *DEFAULT* CALL SITE — the footer at
|
||||||
|
// 2.25rem, i.e. 56.4 CSS px wide. With densities [1,2,3] that is 64/128/192 and
|
||||||
|
// the footer is sharp to DPR 3.
|
||||||
|
//
|
||||||
|
// It has now been wrong in both directions, which is why it is a prop:
|
||||||
|
// - Too big, 2026-08-26: 320px, justified by a 4rem sample on the step-1
|
||||||
|
// proof sheet (a page step 2 has since deleted), with densities stacked on
|
||||||
|
// top — so the ladder double-counted its own headroom and every Retina
|
||||||
|
// device pulled a 640px file into a 56px slot.
|
||||||
|
// - Too small, 2026-08-27: the home page added a 225.5px call site and
|
||||||
|
// inherited 64, upscaling 3.52x at DPR 3. Raise `width` at the call site.
|
||||||
|
//
|
||||||
|
// PASSING A WIDTH AT ALL IS STILL THE POINT. Without it Astro emits the
|
||||||
|
// untouched 2668px master as the <img src> fallback: 1,146,406 bytes, which any
|
||||||
|
// client without AVIF or WebP support would actually download, sitting in dist
|
||||||
|
// looking like an optimisation had happened.
|
||||||
|
const INTRINSIC_WIDTH = width ?? 64;
|
||||||
|
|
||||||
|
// A NOTE ON MEASURING THIS, because the obvious probe lies. `img.naturalWidth`
|
||||||
|
// on an image chosen from a `srcset` with an `x` descriptor is DENSITY-
|
||||||
|
// CORRECTED: the 192px file selected at 3x reports 64. Reading it at DPR 1, 2
|
||||||
|
// and 3 therefore returns 64 every time, which looks exactly like "the density
|
||||||
|
// ladder is not being generated at all" — a far more alarming defect than the
|
||||||
|
// real one. It is generated: 64x41, 128x82, 192x123 on disk, in all three
|
||||||
|
// formats. Check the files, not naturalWidth. [verified 2026-08-27]
|
||||||
|
---
|
||||||
|
|
||||||
|
<Picture
|
||||||
|
src={mark}
|
||||||
|
width={INTRINSIC_WIDTH}
|
||||||
|
densities={[1, 2, 3]}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="png"
|
||||||
|
alt={label ?? ''}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
class="mark"
|
||||||
|
pictureAttributes={{ style: `block-size:${size}` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* THE FLEX ITEM IS THE <picture>, NOT THE <img>.
|
||||||
|
`class:list` lands on the <img>, which Astro's <Picture> wraps in a
|
||||||
|
<picture> — and that wrapper is what `.brand`'s flex layout actually sizes.
|
||||||
|
So `flex: none` on .mark reached the wrong box entirely and the mark was
|
||||||
|
free to be squeezed: measured 28.52 x 32 at 1024px with seven nav items,
|
||||||
|
against a correct 50.09 x 32 — an aspect of 0.891 where it should be 1.5657.
|
||||||
|
|
||||||
|
This is the SAME defect CLAUDE.md already records for <Button> in
|
||||||
|
SiteHeader — a parent cannot style a child component's root — reintroduced
|
||||||
|
inside the fix for it, one round later. The bare `picture` selector below is
|
||||||
|
scoped by Astro's cid, which the emitted markup does carry, and the height
|
||||||
|
now goes on the wrapper via `pictureAttributes` rather than on the image.
|
||||||
|
|
||||||
|
Worse than the bug: the page-level overflow check passed throughout, because
|
||||||
|
the brand block absorbed the deficit by crushing the logo. "0 overflow at
|
||||||
|
every width" was true and misleading — it measured the document, not the
|
||||||
|
elements inside it. The harness now asserts the rendered aspect ratio. */
|
||||||
|
picture {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
aspect-ratio: 667 / 426;
|
||||||
|
inline-size: auto;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
/* 2668 / 1704 reduced — the master's exact ink bounding box, so the box the
|
||||||
|
layout reserves is the shape that fills it. */
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
/* Belt and braces: if anything ever squeezes the box again, the artwork
|
||||||
|
letterboxes instead of distorting. */
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Small bordered label for designations and sector chips."
|
||||||
|
*
|
||||||
|
* A PARENT CANNOT STYLE THIS ELEMENT. Astro does not pass a parent's scope
|
||||||
|
* attribute to a child component's root, so `<Pill class="chip" />` compiles the
|
||||||
|
* parent's `.chip` rule against the parent's cid and it never matches — the
|
||||||
|
* defect CLAUDE.md records for <Button> in SiteHeader, which then recurred with
|
||||||
|
* <Picture> in InfinityMark. CLAUDE.md names Pill as the next place it will
|
||||||
|
* happen.
|
||||||
|
*
|
||||||
|
* THE HOOK IS A CUSTOM PROPERTY, and that is the one mechanism that legitimately
|
||||||
|
* crosses the boundary: custom properties inherit. An ancestor sets
|
||||||
|
* `--pill-border` / `--pill-fg` on ITSELF and this component reads it. No
|
||||||
|
* :global(), no wrapper div, and no rule that silently does nothing.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* NO PROPS — AND THE EMPTY `Props` INTERFACE IS LOAD-BEARING, NOT DECORATION.
|
||||||
|
*
|
||||||
|
* A `class?: string` was declared here once, was never passed, and a parent
|
||||||
|
* cannot reach this root regardless — see SectionHeading for the measurement and
|
||||||
|
* CLAUDE.md for the rule. It was deleted, and the deletion was written up as
|
||||||
|
* "passing one is now a build error". **It was not.** With frontmatter
|
||||||
|
* containing only comments, an Astro component's props widen to `any`, so
|
||||||
|
* `<Pill class="chip">` compiled with **zero** errors, matched nothing, and let
|
||||||
|
* the flex or grid child absorb the difference — silently.
|
||||||
|
*
|
||||||
|
* Measured by probe page, `<Eyebrow class>`, `<Pill class>`,
|
||||||
|
* `<SectionHeading class>`, `<Button bogus>`: `astro check` reported **3 errors
|
||||||
|
* — Eyebrow, SectionHeading, Button. Nothing for Pill.** Adding the three lines
|
||||||
|
* below takes the same probe to **4 errors, 0 hints** (and no `ts(6196)`,
|
||||||
|
* because the interface is referenced by the destructure below).
|
||||||
|
*
|
||||||
|
* CLAUDE.md names `Pill` as the next place the parent-scope defect will happen.
|
||||||
|
* The guard that was documented as protecting it was absent on exactly it.
|
||||||
|
*
|
||||||
|
* The custom-property hooks in the style block are how an ancestor influences
|
||||||
|
* this component: custom properties inherit, which is the one mechanism that
|
||||||
|
* legitimately crosses the boundary.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* `children` DECLARED, NOTHING ELSE. Getting to this line took two wrong turns
|
||||||
|
* and both are worth recording, because each looked correct:
|
||||||
|
*
|
||||||
|
* - `interface Props {}` — rejected by eslint
|
||||||
|
* (`@typescript-eslint/no-empty-object-type`), and it would have been the
|
||||||
|
* wrong tool anyway: `{}` in TypeScript means "any non-nullish value", not
|
||||||
|
* "no properties".
|
||||||
|
* - `Record<string, never>` — passes eslint and does reject `class`, but it
|
||||||
|
* also rejects `children`, so it broke the two REAL call sites
|
||||||
|
* (`PracticeCard.astro:34` and `/about/`'s arc) while the probe page went
|
||||||
|
* green on the thing it was testing. A fix that satisfies its own test and
|
||||||
|
* breaks production is exactly what `/build` Phase 4 warns about.
|
||||||
|
*
|
||||||
|
* Slot content arrives as `children`, so `children` is the one permitted
|
||||||
|
* property and every other prop is an error. Verified by probe: all six
|
||||||
|
* components now reject `class`, and `<Pill>text</Pill>` compiles. Deleting
|
||||||
|
* this re-disables checking at every call site.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
children?: unknown;
|
||||||
|
}
|
||||||
|
const _props: Props = Astro.props;
|
||||||
|
void _props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class="pill"><slot /></span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: var(--space-1);
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
border: 1px solid var(--pill-border, var(--border));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pill-fg, var(--text-meta));
|
||||||
|
/* NEVER `nowrap`. `Pill` takes arbitrary children, and a four-word pill at a
|
||||||
|
200% default font size (root 32px) overflowed the document by 111px at
|
||||||
|
320px — WCAG 1.4.10 Reflow. Measured 2026-08-28; no pill on the site is
|
||||||
|
that long today, which is exactly why this is easy to delete and must not
|
||||||
|
be. `normal` costs nothing at default size.
|
||||||
|
⚠️ `normal` ALONE IS NOT ENOUGH — a one-word pill cannot wrap at a space
|
||||||
|
that is not there. `anywhere`, not `break-word`, and this is the BACKSTOP
|
||||||
|
rather than the cause: `docs/02` §Reflow, instrument finding 1 and the
|
||||||
|
`Pill` row. */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Sector chip, heading, one paragraph, arrow link."
|
||||||
|
*
|
||||||
|
* ONE LINK PER CARD, AND THE WHOLE CARD IS ITS HIT AREA. The link wraps only
|
||||||
|
* the heading text, so its accessible name is "Construction & Infrastructure"
|
||||||
|
* rather than the card's entire contents; a `::after` pseudo-element stretched
|
||||||
|
* over the positioned card carries the click. Six of these on the home page
|
||||||
|
* would otherwise be six links each announcing three sentences.
|
||||||
|
*
|
||||||
|
* The arrow is `aria-hidden` and outside the link text for the same reason.
|
||||||
|
*
|
||||||
|
* THE GRID MUST NOT TRY TO STYLE THIS ROOT. A parent's `.card { block-size:
|
||||||
|
* 100% }` compiles against the parent's cid and never matches (CLAUDE.md;
|
||||||
|
* it has now cost twice). The card sizes ITSELF to its grid cell below, so a
|
||||||
|
* parent only ever needs `display: grid` and `gap` on its own element.
|
||||||
|
*/
|
||||||
|
import Pill from './Pill.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: string;
|
||||||
|
chip?: string;
|
||||||
|
title: string;
|
||||||
|
/** Explicit: docs/02 forbids skipped heading levels. */
|
||||||
|
level: 2 | 3;
|
||||||
|
}
|
||||||
|
// No `class` prop — declared, never passed, unreachable from a parent. The card
|
||||||
|
// sizes itself to its grid cell instead; see the note above.
|
||||||
|
const { href, chip, title, level } = Astro.props;
|
||||||
|
const H = `h${level}` as 'h2' | 'h3';
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{chip && <Pill>{chip}</Pill>}
|
||||||
|
<H class="card-title">
|
||||||
|
<a class="card-link" href={href}>{title}</a>
|
||||||
|
</H>
|
||||||
|
<p class="card-body"><slot /></p>
|
||||||
|
<span class="card-arrow" aria-hidden="true">→</span>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
/* Sizes itself to its cell — see the note on why the grid cannot do this. */
|
||||||
|
block-size: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
/* CLAMPED, not a flat --space-6, for the reason `.feature` on `/` carries:
|
||||||
|
the space scale is rem-based, so `2rem` is 64 px a side at a 200% default
|
||||||
|
font size — 128 px of padding inside a ~224 px content box, which left
|
||||||
|
~96 px for the whole card column and was the real cause of a `Pill`
|
||||||
|
overflowing. The 10vw term holds it at 32 px on every viewport from 320 px
|
||||||
|
up and lets it collapse only when the rem is doubled. */
|
||||||
|
padding: clamp(var(--space-4), 10vw, var(--space-6));
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-block-start: 2px solid var(--rule);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-hover) var(--ease),
|
||||||
|
box-shadow var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.card-link {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.card-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.card:hover .card-link {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* The focus ring belongs on the CARD, not on the heading text: the hit area
|
||||||
|
is the card, so a ring around three words would point at the wrong box.
|
||||||
|
`:focus-visible` on the descendant, ring on the ancestor. */
|
||||||
|
.card:has(.card-link:focus-visible) {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: var(--focus-offset);
|
||||||
|
}
|
||||||
|
.card-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
margin: 0;
|
||||||
|
/* Pushes the arrow to the bottom edge so a row of cards aligns on it
|
||||||
|
whatever the body length. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.card-arrow {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: transform var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover .card-arrow {
|
||||||
|
transform: translateX(var(--space-2));
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card:hover .card-arrow {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Numbered step, timing, body."
|
||||||
|
*
|
||||||
|
* THE NUMBER IS NOT CONTENT. It is `aria-hidden` and supplied by the caller
|
||||||
|
* rather than by a CSS counter, because the compressed strip on `/` and the
|
||||||
|
* full page at `/process/` must agree on it. The reading order for assistive
|
||||||
|
* technology is heading then timing then body; the numeral adds nothing to it
|
||||||
|
* and would be read as a bare digit before every step.
|
||||||
|
*
|
||||||
|
* <ol> IS THE CALLER'S JOB. These are ordered steps, so the parent wraps them
|
||||||
|
* in an <ol> and this renders the <li>. That keeps "step 3 of 5" available from
|
||||||
|
* the list semantics instead of from the decorative numeral.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
n: number;
|
||||||
|
title: string;
|
||||||
|
/** "Day 0", "Days 1–7". docs/03: "Five steps with real timing." */
|
||||||
|
timing: string;
|
||||||
|
}
|
||||||
|
// No `class` prop — declared, never passed, unreachable from a parent.
|
||||||
|
const { n, title, timing } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li class="step">
|
||||||
|
<span class="step-n" aria-hidden="true">{String(n).padStart(2, '0')}</span>
|
||||||
|
<h3 class="step-title">{title}</h3>
|
||||||
|
<p class="step-timing">{timing}</p>
|
||||||
|
<p class="step-body"><slot /></p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-block-start: var(--space-4);
|
||||||
|
border-block-start: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.step-n {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
/* --gold-d, not --gold: on cream, gold measures 2.10:1 and gold-d 3.11:1.
|
||||||
|
docs/02 permits gold-d for LARGE DECORATIVE text only, 24px+ — this is
|
||||||
|
12px, so neither qualifies and the numeral is maroon. Kept as a comment
|
||||||
|
because "make the step numbers gold" is the obvious next edit. */
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
.step-timing {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-meta);
|
||||||
|
}
|
||||||
|
.step-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The single metadata component. Spec: docs/04-seo-spec.md.
|
||||||
|
*
|
||||||
|
* "Every page passes through one SEO component. A page without it is not
|
||||||
|
* finished." — so the length rules in that spec are ENFORCED here rather than
|
||||||
|
* described. An out-of-range title or description throws at build time and
|
||||||
|
* names the offending string and its length, the same way src/content.config.ts
|
||||||
|
* does for article frontmatter. A build that fails on unfinished metadata is a
|
||||||
|
* correct build.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
title: string;
|
||||||
|
/** 140–160 characters, unique, written for a human. */
|
||||||
|
description: string;
|
||||||
|
/** Overrides the canonical path. Defaults to this page's own URL. */
|
||||||
|
canonical?: string;
|
||||||
|
ogType?: 'website' | 'article' | 'profile';
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
noindex?: boolean;
|
||||||
|
/**
|
||||||
|
* Page-appropriate structured data — Person, ProfessionalService, Service,
|
||||||
|
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
|
||||||
|
* would be a claim this component is in no position to make.
|
||||||
|
*/
|
||||||
|
jsonLd?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
canonical,
|
||||||
|
ogType = 'website',
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
noindex = false,
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const TITLE_MIN = 50;
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
const DESC_MIN = 140;
|
||||||
|
const DESC_MAX = 160;
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
|
||||||
|
problems.push(
|
||||||
|
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}.\n ${JSON.stringify(title)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (description.length < DESC_MIN || description.length > DESC_MAX) {
|
||||||
|
problems.push(
|
||||||
|
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}–${DESC_MAX}.\n ${JSON.stringify(description)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
|
||||||
|
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
|
||||||
|
// absolute URL below would silently become a relative one.
|
||||||
|
if (!Astro.site) {
|
||||||
|
throw new Error(
|
||||||
|
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, 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
|
||||||
|
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
|
||||||
|
const jsonLdText =
|
||||||
|
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalUrl.href} />
|
||||||
|
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
|
||||||
|
|
||||||
|
<meta property="og:type" content={ogType} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl.href} />
|
||||||
|
<meta property="og:site_name" content={SITE.name} />
|
||||||
|
<meta property="og:locale" content={SITE.locale} />
|
||||||
|
<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={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={resolvedImageAlt} />
|
||||||
|
|
||||||
|
{
|
||||||
|
jsonLdText && (
|
||||||
|
<script type="application/ld+json" is:inline set:html={jsonLdText} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Eyebrow + display heading + optional lede, one measure."
|
||||||
|
*
|
||||||
|
* THE LEVEL IS A REQUIRED DECISION, not a default. docs/02's accessibility
|
||||||
|
* floor: "One <h1> per page; heading levels never skipped." A component that
|
||||||
|
* defaulted to <h2> would silently produce an <h2> inside an <h3> section the
|
||||||
|
* first time one is nested, and nothing would fail. `level` is explicit and
|
||||||
|
* `astro check` enforces the union.
|
||||||
|
*/
|
||||||
|
import Eyebrow from './Eyebrow.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eyebrow?: string;
|
||||||
|
/** 1 only on the page's single H1. */
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
lede?: string;
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NO `class` PROP, AND NO `title` PROP. Both are deletions with reasons.
|
||||||
|
*
|
||||||
|
* `class` — a parent CANNOT reach this component's root. It was passed as
|
||||||
|
* `class="section-head"` from `/` and the page's rule compiled to
|
||||||
|
* `.section-head[data-astro-cid-<page>]` while the rendered div carried
|
||||||
|
* `data-astro-cid-<SectionHeading>`. **Measured: `margin-block-end: 0px` and a
|
||||||
|
* 0px gap on all three call sites** — 48px of intended separation missing, with
|
||||||
|
* `.display`'s 0.98 line-height putting the glyphs over the top edge of the
|
||||||
|
* cards below. `astro check` and `eslint` both passed. Fourth instance of the
|
||||||
|
* defect `CLAUDE.md` records; a `class` prop here is an invitation to a fifth.
|
||||||
|
* To space this block, wrap it in an element the page owns.
|
||||||
|
*
|
||||||
|
* `title` — two mechanisms for one job. The `heading` slot is the general one
|
||||||
|
* (it takes an italic phrase, which `docs/02` allows once per headline); a
|
||||||
|
* plain-text prop is the same thing minus a capability. One way in.
|
||||||
|
*
|
||||||
|
* `dot` defaults TRUE here and FALSE on `<Eyebrow>`, deliberately: a section
|
||||||
|
* heading's eyebrow is the pattern the dot was designed for, and a bare
|
||||||
|
* `<Eyebrow>` is used in places where it would be noise.
|
||||||
|
*/
|
||||||
|
const { eyebrow, level, lede, dot = true } = Astro.props;
|
||||||
|
const H = `h${level}` as 'h1' | 'h2' | 'h3';
|
||||||
|
const size = level === 1 ? 'size-display' : 'size-section';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="heading-block">
|
||||||
|
{eyebrow && <Eyebrow dot={dot}>{eyebrow}</Eyebrow>}
|
||||||
|
<H class:list={['display', size]}><slot name="heading" /></H>
|
||||||
|
{lede && <p class="lede">{lede}</p>}
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.heading-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.size-display {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
}
|
||||||
|
.size-section {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
/* `color` INHERITS across the component boundary, which is how an inverse
|
||||||
|
section reaches this — but `--text-secondary` resolves to a cream-only
|
||||||
|
value, so it has to be overridden rather than inherited. Custom properties
|
||||||
|
DO inherit, so an ancestor setting --lede-color would work too; a global
|
||||||
|
ancestor selector is fewer moving parts for one rule. */
|
||||||
|
:global(.section-inverse) .lede {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user