Compare commits

..
11 Commits
Author SHA1 Message Date
Pouya LajevardiandClaude Opus 5 17e316dc1d feat: park the two policy changes the pricing plan forbids; robots.txt stands in
Build and deploy / build-and-deploy (push) Failing after 4s
The third --apply of 2026-09-04 reached update-distribution and was rejected
atomically: "Distributions with the Free pricing plan can't have the following
features: Custom origin request policy, Custom response headers policy."
Pouya's ruling: both are PARKED as unavailable — a platform constraint, not a
defect.

The pre-flight added in the previous commit could not have caught this, and
that is the point: every limit in PAYLOAD_LIMITS is a property of the payload,
while this is a property of the account, reported only by the call the
pre-flight exists to avoid. Both sections now stop before creating anything.

The plan is not in the CloudFront API — checked across 167 operations, no
operation, shape, member or documentation string mentions one, and
PriceClass_All is the edge-location price class, not the plan. So the gate is a
constant, PLAN_ALLOWS_CUSTOM_POLICIES, and the two sections report as PARKED
under their own heading rather than as skips: the previous commit made a skip
exit 3, and a constraint true on every run would have made 3 permanent. Proven
with a shim that refuses every mutating verb: --apply now makes zero of them.

Substitute (a): Disallow: /pouya-lajevardi-bio.pdf in robots.txt, placed before
Allow:/ so first-match crawlers honour it too. It is not an equivalent and the
file says so — it stops the PDF being fetched, solving the duplicate-of-/bio/
problem, but does not de-index a URL linked from /bio/ and /about/. Verified:
syntax, a match simulation under both crawler semantics, and that the sitemap
does not list the PDF.

Substitute (b): the WAF web ACL CreatedByCloudFront-f8fbf256 is already
attached — 925 WCU, three AWS managed rule groups, no rate-based statement.
That corrects §9 Q65, which framed WAF as a cost decision about adding one and
named the now-unappliable header forwarding as its groundwork. The real
question is one rule on an ACL already paid for, and a rate-based rule matches
the viewer address directly, so the capability is superseded rather than lost.

Reviewed in two rounds by me rather than a separate agent, per instruction.

Nothing was applied to the distribution and nothing was deployed; robots.txt
needs one site deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 14:52:13 -04:00
Pouya LajevardiandClaude Opus 5 07a8ff6989 fix: assert which aws binary runs, because this script rewrites the whole config
Build and deploy / build-and-deploy (push) Failing after 4s
Round 2 returned an addendum after the previous commit, having re-checked its
own instrument. Two findings, one blocking on the next --apply.

This machine carries two AWS CLIs on PATH: /opt/homebrew/bin/aws 2.34.53 and
/usr/local/bin/aws 2.11.15 (April 2023). configure.mjs called bare `aws`, so
PATH decided. That matters because update-distribution is a full replace and
botocore parses a config against its own model, dropping members it does not
know — an old CLI reads a lossy config and writes the loss back, and --if-match
cannot catch it because the ETag is genuinely current.

Measured, which the review could not do: the older model is missing nine
members, and E1OK7G98KNKUTA carries two of them — GrpcConfig on the default
behaviour and on /api/*, both {Enabled: false}. So a round trip through the old
CLI would write back the same effective value and change nothing observable
today. That is precisely why it needed a guard rather than a look: nothing
reports it when that stops being true.

The script now resolves the binary, prints it and its version as the first line
of output, and exits 2 below a floor before any AWS call. Verified both ways.

Second finding: entry (av) claimed "no client-side validation of any kind"
stood between the 182-character Comment and the API, from reading one
validate.py without naming which CLI it came from — and the other install is
frozen, so it is unchecked rather than confirmed. That is CLAUDE.md's named
shape. Both records now name the instrument and version and lead with the claim
that needs no qualification: the Comment reached the API and came back
InvalidArgument, so nothing stopped it on the CLI that ran.

The second install's model independently confirms FunctionARN max 108, its
pattern, and the 128 on both Comment members.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 14:32:11 -04:00
Pouya LajevardiandClaude Opus 5 a07193d561 fix: pre-flight the CloudFront payload limits the dry run is the only guard for
Build and deploy / build-and-deploy (push) Failing after 4s
The second `--apply` of 2026-09-04 created adr-sml-pdf-noindex and then failed
at create-origin-request-policy: InvalidArgument, "The parameter Comment is too
big" — 182 characters against a 128 cap. update-distribution never ran, so the
distribution is unchanged, but the account was left holding an orphaned policy.

Nothing local could have caught it, and that is now measured rather than
assumed: botocore/validate.py checks neither `max` nor `pattern` (range_check
reads only `min`; the word `pattern` does not appear in the file), and the 128
is not modelled as a constraint at all — `Comment` is a bare `string` and the
cap lives in the shape's documentation prose. So the dry run really is the only
pre-flight, and it now enforces PAYLOAD_LIMITS: 13 entries across both policy
payloads and the function ARN, each with the source it came from.

The entries that matter guard CLONED values rather than literals this file
authors — a literal is reviewed when it is written, while a value copied out of
the default behaviour's policy changes with no diff here. The API declares
TooLongCSPInResponseHeadersPolicy for exactly that case and docs/05 already
specifies a CSP that would land there. RemoveHeadersConfig is a recorded gap:
its cap is real but unpublished, and inventing a number would be worse.

Both comments are now 76 and 74 characters. `--function-arn` is validated
before any AWS call, and an unrecognised `--flag` is a usage error — the `=`
form was invisible to the parser and to the presence check, for a clean exit 0
with no router attached. A skipped section now exits 3, because docs/09 uses
exit 0 as its own success stamp and a partial run read as a complete one.

Confirmed by measurement, as asked: the next run REUSES the orphan by name,
matches every reconciled field, and stages it — create line gone, 4 changes
down to 3, no duplicate and no collision.

Reviewed twice. Round 2 found that the §7 record broke the table it lives in,
and that two comments asserted behaviour the code did not have. 17 findings
across both rounds, all fixed.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 14:24:33 -04:00
Pouya LajevardiandClaude Opus 5 bbe535d158 fix: omit AWS's empty-object placeholders from the cloned PDF policy
Build and deploy / build-and-deploy (push) Failing after 4s
`configure.mjs --apply` failed on its first write, 2026-09-04, and nothing
reached the distribution. `get-response-headers-policy` returns
`"ContentSecurityPolicy": {}` for a member the source does not define, and
sending that back fails `create-response-headers-policy` on ParamValidation
before the call leaves the machine — a config AWS hands back is not
necessarily a config AWS will accept.

Of the 16 structures reachable from `ResponseHeadersPolicyConfig` in the CLI's
service model, 15 declare a required field, so `{}` is illegal there and can
only be the placeholder; the one exception is `SecurityHeadersConfig` itself,
which section 4 already skips on when empty. The strip is therefore recursive.
The dry run now asserts the generated config carries no empty object, and does
so as a section-4 SKIP rather than a throw — section 4 must never block
sections 1-3 from re-applying `router.js`.

The two functions move to `policy-shapes.mjs` with a 23-case test (7 of 7
mutations killed), because `configure.mjs` reads argv and calls AWS at import
time and the runbook was otherwise claiming a proof nobody could re-run.

Also: the handler was redeployed 2026-09-04 via docs/09 §5.5. Re-read against
production — the two bundled SDK clients moved 3.1125.0 -> 3.1126.0 with no
file in this repository changing, which is what §7's own row predicted. §12
gains R22, because that row named itself as the reminder covering them while
no such reminder existed. docs/05, docs/06 and docs/09 §5.5 each held their
own stale copy of the deployed commit; all three now cite §7.

Reviewed twice by adversarial-reviewer: 7 findings, then 8, of which five were
defects in the first round's repairs. All 15 fixed.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 12:59:04 -04:00
Pouya LajevardiandClaude Opus 5 3c3ba5dc6e feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the
spam observation and four mitigations), in one change set.

D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already
published, so /fees/'s "Every figure is on this page" is true as written rather
than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the
rule INTERIM against R5, and R5 now carries it back, because a derived price
moves silently when a rate moves.

D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his
wording verbatim on /legal/privacy/ and /contact/. The clause that raised the
finding promised to DISCLOSE a conflicts check's outcome, which the attestation
does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed.

Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and
signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label
that tells a human not to tick it) and scoring that LABELS and never rejects:
nothing is dropped, nothing new is stored, and only the operator notification
changes. Q65 opens the WAF cost call.

The timing floor could not be built: there is no timing check and never has
been. docs/05 carries it struck, and every mechanism that would give a real
per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66.

configure.mjs gains section 5 — a custom origin request policy forwarding
CloudFront-Viewer-Address on /api/*. Written, dry-run against the live
distribution, NOT applied. It reads the handler's own header reads and refuses
to run if the whitelist omits one.

And reading the live account to do it found four AGENTS.md §7 rows saying the
intake backend was undeployed, two days after it went live — corrected against
get-function-configuration, get-routes, get-stage, get-policy and the deployed
zip, which was downloaded and read.

Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has
run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent
refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own
repairs. Stopped at two per D19.

Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0
· check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations
killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category
below 95 · configure.mjs dry run 0, nothing written.

Nothing deployed and nothing applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 10:06:37 -04:00
Pouya LajevardiandClaude Opus 5 02739adac9 fix: refute (ar)'s intake finding; fix the D20 gloss class; add X-Robots-Tag on *.pdf
Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's rulings of 2026-09-03, in five parts.

1. THE INTAKE FORM IS NOT BROKEN. (ar) was wrong. docs/09 §7.1 verbatim —
POST /api/intake with an Origin header — returns 303 to /contact/could-not-send/
with access-control-allow-origin echoed; the same probe without Origin returns
403. A bare POST 403s BY DESIGN and §7.1 says so three lines below the probe it
prescribes: "403 means the Origin header did not arrive". The earlier finding
read a status code without reading the document that defines it. Second time in
two days. CLAUDE.md's instrument list goes eight to nine. D20 findings 12 and 19
fall with it; §7.2 (that both emails arrive) is still owed.

The correction is APPENDED as entry (as); (ar) stands unedited.

2. The privacy retention comment was stale, not a defect — superseded by his
decision to publish and confirm after launch, reading from 2026-09-04. Reworded;
the TODO(pouya) came off with the gate it enforced. The mechanism finding
survives: it was a JSX comment, stripped by Astro, so no build or deploy path
could see it. A publication gate that lives only in a stripped comment is not a
gate. §9 Q60 corrected.

3. The gloss class is fixed — 15 of the 20 D20 findings, 14 distinct edits across
9 files, under the rule "the gloss may say no more than the extract says; no new
claims, no new sources". Swept three unpublished insights drafts too, and
corrected the wrong CAA attribution at its source in docs/reference/, which is
where a fixed page re-seeds. /bio/ changed, so the committed PDF is regenerated
(89,549 B, 1 page asserted). Three findings outstanding: 10 needs a ruling, 11 is
ruled and owed via Q60, 13 needs him to have said it. R1 is not one of the twenty.

4. X-Robots-Tag cannot be done with S3 object metadata — --metadata writes user
metadata, returned as x-amz-meta-x-robots-tag, which no crawler reads. Built as
the CloudFront response-headers policy docs/06 has specified all along:
configure.mjs section 4. It needs a --apply run, not a deploy. The policy is
cloned from whatever is attached at run time and reconciled on every run, because
a response-headers policy replaces rather than merges.

5. Headshot deferred as an open non-defect. The master and the srcset ladder are
both fine; Astro passes no quality, so AVIF encodes at sharp's default 50 and is
served first.

Two review rounds, 29 findings, all resolved, none declined; stopped at two per
D19. NINE of round 2's fourteen were defects in round 1's own repairs — including
a fix that harmonised both /fees/ rows onto wording that was itself unregistered,
publishing an unsourced fee term twice where it had been once.

Gates, exit status read for each: check 0 (0 errors, 0 warnings, 0 hints),
build 0 (23 pages), check:claims 0, check:intake 0, og:proof 0, lint 0, minifier
grep exit 1, router.test.mjs 30/30. Lighthouse NOT run. Nothing deployed and
nothing applied to the distribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-03 17:23:20 -04:00
Pouya LajevardiandClaude Opus 5 b9523817e2 docs: record the cutover; the D20 claims pass returns FAIL with 20 findings
Build and deploy / build-and-deploy (push) Failing after 5s
The site went live 2026-09-02 at 67847d9. Launch verified independently
rather than transcribed: 26 routes with the iteration count asserted (the
first sweep used `for r in $ROUTES` and iterated ONCE — the zsh trap), all
22 pages plus robots/sitemap/PDF 200, 404 styled at 14,321 B, cache-control
correct on both classes, PDF 89,496 B. All 22 live pages are byte-identical
to a dist/ rebuilt at 67847d9, which is what makes the audit below binding.

The D20 claims pass then ran against those bytes and returned FAIL: 13
auditors over 23 pages, 41 raw findings, 31 distinct, each adversarially
verified by an independent claims-auditor — 20 confirmed, 11 refuted, plus
13 from two completeness critics. The credential spine traced clean for the
third pass running; no finding concerns Pouya, his credentials, designations,
memberships or the boutique. The failures are glosses over-reaching their
committed extract, and disclosures on /legal/privacy/ and /contact/received/
describing a backend that is not deployed.

Three defects are live: the intake form POSTs to /api/intake and gets 403
with an empty body, so a submitter sees a blank page; /legal/privacy/
published while Q60 is open, carrying a TODO(pouya) that said in terms it
must not — a JSX comment, stripped by Astro, so no build or deploy path
could see it; and no X-Robots-Tag on the crawlable bio PDF.

Nothing is fixed here. claims-auditor reports; the implementer fixes.

- AGENTS.md: Change Log (ar); §7 gains a THE SITE IS LIVE row; the
  credential row's "NEVER USED" struck; §12 R17 clock reset to 2026-12-02,
  stamped as reported and not re-read from IAM.
- docs/06: cutover recorded, callout now at three blockers, the
  claims-auditor item records run 3 and stays unticked.

Gates on the edited tree, exit status read for each: check 0 (0 errors,
0 warnings, 0 hints), build 0, check:claims 0, check:intake 0, lint 0,
og:proof 0, minifier grep exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-03 16:14:07 -04:00
Pouya LajevardiandClaude Opus 5 67847d94fa fix: regenerate favicon.ico with a transparent ground; tick Pouya's read-through
public/favicon.ico shipped with no transparency: all three frames declared a
32-bit alpha channel and then carried alpha=255 on every one of their 256/1024/
2304 pixels, ground opaque cream rgba(250,247,242,255). Pouya's read-through
finding, confirmed by parsing the ICO container directly.

The render source carries true alpha (2,272,386 transparent px, 20,795 partial),
so this is an export, not a mask derived from the cream ground — R13's harder
branch did not fire and R13 is unchanged on its own terms.

New scripts/icons.mjs + npm run icons re-derives the icon from the committed
master: asserts the source is still the documented crop (R14), verifies a
candidate file and renames on success so a rejected build cannot replace a good
favicon, and runs a boundary-colour halo test. Composition is unchanged —
ink bbox and pixel count identical at all three sizes.

apple-touch-icon.png is byte-identical and stays opaque cream deliberately; the
reason lives in docs/reference/brand-assets.md §The icon set, with the bar and a
pointer in BaseLayout.astro, docs/06 and R13.

docs/06: the read-through is ticked, and the cutover callout drops to ONE
blocker — Q60's waiting period.

Two adversarial review rounds, 15 findings, all resolved, none declined; stopped
at two per D19. claims-auditor correctly deferred to cutover per D20. Gates on
the committed bytes, exit status read: check 0, build 0 (23 pages),
check:claims 0, check:intake 0, og:proof 0, lint 0, lighthouse 0 (worst of 23
99/100/100/100). Nothing deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 16:14:35 -04:00
Pouya LajevardiandClaude Opus 5 4735989f0b feat: cut /legal/privacy/ §Who can see it to four plain statements; name SML Company Ltd on the consent; close Q64 moot
Two rulings from Pouya, 2026-09-02.

(1) The section stays generic — "it over-explains technical mechanics that
belong in the evidence file, not in front of an inquirer." Deleted: the
measurement paragraph, the root-credential sentence, the SSO/federated-login
enumeration, the resource-policy clause, the "company that runs a database"
aside, the deploy-credential sentence and the three-copies summary. All of it
stays true and stays measured in AGENTS.md §7 and the evidence file, which now
maps each shipped sentence to what it rests on.

(2) The consent string names the corporation: "I consent to SML Company Ltd
storing and using the information in this form…". docs/05 §Consent text moves
with it, proven byte-identical. Two new §4 rows carry the attestations the copy
rests on.

(3) The §Who can see it approval closes via the page read-through, which is now
blocker 2 in docs/06's callout rather than a checklist line.

Q64 closes MOOT — the paragraph it was about was deleted, so it gates nothing.
The underlying gap is unchanged: §7 records root as held by Pouya, not held only
by Pouya, and nothing about root custody may be published without asking again.

Two sentences were added back under review: the shared-account disclosure, to
§Where it is stored (a storage disclosure, never named in the ruling — without
it no page said the intake sits in a shared account), and one naming SML Company
Ltd in the policy, because a consent naming a company the linked policy never
mentions is an accountability gap.

adversarial-reviewer, two rounds, 14 findings, all resolved, none declined;
nine of round 2's ten were defects in round 1's own repairs. claims-auditor
correctly deferred to cutover per D20.

Gates, exit status read: check 0 · build 0 (23 pages) · check:claims 0
(12 patterns, 33 approved strings) · check:intake 0 · og:proof 0 · lint 0 ·
lighthouse 0, worst of 23 99/100/100/100. Tripwire proven both ways — exit 0 on
the revised page, exit 1 with 5 matches on the bd282aa bytes. Regex untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 12:03:47 -04:00
Pouya LajevardiandClaude Opus 5 99889a3491 feat: rule Q63 in three limbs; take the human headcount off /legal/privacy/; close R10; ratify /med-arb/
Pouya's rulings, 2026-09-02.

Q63(a) — wording approved with two trims: the editorial closing sentence is
struck, and the mailbox clause is rewritten per (b).

Q63(b) — info@smlcompany.ca is a delegated mailbox read by Pouya and by
administrative staff. The page said "anyone who can reach that mailbox"; it now
says who. The answer reached four sentences, not the one the ruling named, in
three sections: "my mailbox" had been written as a personal one throughout.

Q63(c) — the account root credential is held by Pouya, has no programmatic key
and carries MFA. The page now states the first two.

And the answer to (c) took the human headcount off the page. His attestation:
"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 was
exhaustive and the inference off it was not: two IAM identities is a LOWER BOUND
on people and was published as an exact count. The page now attributes read
access to "the account's administrators — me, and the small number of people who
administer it with me", and the false inference is corrected at its source in
docs/reference/intake-table-access-verification.md as well as on the page.

R10 — CLOSED on a fresh one-line confirmation, not on the 2026-08-28 stamp.
ADRIC, ADRIO, the three OBA sections and the CTF all current. Re-stamped on all
four stamp-bearing sites; the row stays live, because its trigger is an event.

/med-arb/ — ratified as shipped, no credential line restored. His note for the
record: med-arb is a service he provides, not a designation.

The tripwire is unchanged and proven both ways: exit 0 on the revised page,
exit 1 with 5 matches on the pre-correction bytes rebuilt from bd282aa.

A sentence added to back the correction had no command behind it. "The table
carries no policy of its own granting access to anyone" was published with
nothing in the repo establishing it — a DynamoDB resource policy is invisible to
describe-table, and every simulation on file asks what a principal may do.
Measured rather than deleted: get-resource-policy returns PolicyNotFoundException,
and describe-organization returns AWSOrganizationsNotInUseException, which is
what makes the Identity Center zero conclusive rather than merely local. Both
recorded as commands 7 and 8; R21 gains claim (v) and two falsifiers.

Q64 OPENED, with a TODO(pouya) and an unticked cutover item: "I hold it" is true
whether or not a second person holds it, and reads as sole custody one paragraph
below "the small number of people who administer it with me". The request to
strike the possessive now is declined with a reason — he dictated the clause —
and the page cannot ship while the TODO stands.

Two review rounds, 22 findings, 21 resolved, 1 declined. Nine of round 2's twelve
were defects in round 1's own repairs. Stopped at two per D19.

Gates, exit status read: check 0 · build 0 (23 pages) · check:claims 0 ·
og:proof 0 · check:intake 0 · lint 0 · router.test 0 (30/30) · lighthouse 0,
worst of 23 99/100/100/100, /legal/privacy/ 100/100/100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 11:00:15 -04:00
Pouya LajevardiandClaude Opus 5 6aaf089b05 feat: rule Q62 by stating the truth; strike the /med-arb/ gloss; re-stamp R18
Pouya's four rulings of 2026-09-01, applied 2026-09-02.

Q62 — RULED "state the truth", not "remove the access". /legal/privacy/
now says two people can read the intake table, names their role, and adds
the two stronger facts the false sentence had crowded out: the handler
role holds PutItem only, and adr-sml-deploy is implicitDeny on all seven
read and write actions. Wording is subject to Pouya's read-through —
Q63(a), with a TODO(pouya) beside the copy.

The ruling named one sentence; a vocabulary sweep found the falsehood in
three places, and the audit then found two more. Five paragraphs now
answer "who can see it" and change together.

The tripwire stays permanently, per ruling, and grew from two
alternatives to five. Every alternative is one string that reached dist/.
Proven both ways against the pre-correction page rebuilt from bd282aa:
exit 1 with 5 matches at dist/legal/privacy/index.html:54,67,67,68,72;
exit 0 on the corrected page, self-test 12 patterns / 36 approved
strings.

/med-arb/ — the gloss is struck with no replacement, per ruling. The
strike left "the section above" pointing at the ADRIC rule set and "the
agreement" with no antecedent; both fixed. The bare designations line
sitting under ADRIC's quoted competence requirement is also struck, which
goes beyond the ruling and is flagged for Pouya.

R18 — re-stamped, two-tier: (a)(c)(d) re-verified against a source,
(b)(e)(f)(g) held on a cadence judgement. All seven hold, no shipped
sentence changed. R18's trigger had NO cutover checklist item and had
stamped five extracts of seven; both fixed. Candidate limb (h) flagged.

R10 — fired and unsatisfied; left open on instruction.

The evidence behind the new privacy sentence was weaker than the
sentence. Re-measured: 33 of 33 roles simulated (23 of 26 carried inline
policies nobody had read; the two CDK lookup roles can read the table),
four trust policies, the CloudFormation escalation path for all five
users, 0 federated providers, root recorded. Every read path terminates
at the same two people.

Two review rounds, 36 findings. 35 fixed, 1 declined. Five of round 2's
were defects in round 1's own fixes; stopped at two per D19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 07:59:50 -04:00
52 changed files with 8474 additions and 397 deletions
+6 -1
View File
@@ -215,7 +215,12 @@ jobs:
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 "check Managed-AllViewerExceptHostHeader is on the behaviour."
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
+2704 -20
View File
File diff suppressed because one or more lines are too long
+43 -1
View File
@@ -140,6 +140,7 @@ npm run check:intake # the form's field table vs the Lambda's — they are two
npm run og:proof # every og:image resolves; every card headline IS its page's <h1>
npm run lighthouse # the performance budget. LOCAL ONLY — needs Chrome, not in CI
npm run bio:pdf # re-renders the committed one-page PDF from /bio/. LOCAL ONLY
npm run icons # re-derives public/favicon.ico from the brand master. REGENERATOR
npm run lint # eslint + prettier check
npm run format # prettier — rewrite files in place
npm run deploy # build + deploy from this machine (see docs/06)
@@ -153,6 +154,17 @@ 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
@@ -363,7 +375,7 @@ an operator to delete the three records that authenticate outbound mail —
under the heading "Never delete".
**A measurement is a claim about your instrument until you check the
instrument.** This has now cost six times, and the shape is identical every
instrument.** This has now cost nine times, and the shape is identical every
time: a number that looks like a finding, from a probe nobody validated.
- `timeout 60 ls "$DRIVE"`**the command never ran.** `timeout` is not
@@ -391,6 +403,36 @@ time: a number that looks like a finding, from a probe nobody validated.
from the list. **A `grep -o` window count is not an enumeration** — to count
occurrences of a string, iterate every match position, or `grep -o` the bare
string with no context window.
- **An ink bounding box of `48x48` filling the whole 48 px favicon** — which
reads as *the regeneration blew the mark up to the full square*. The probe
identified ink as "differs from cream", which is **correct on a matted icon and
meaningless on a transparent one**, because `alpha = 0` pixels still carry RGB
`(0,0,0)`. **Nothing about the probe changed — the class of input did**, and a
probe cannot tell you that. Composite over a known ground first, so both things
compared are the kind of thing the instrument was built for.
- **A contrast ratio of `14.02:1` for the mark's darkest ink** — computed from a
raw channel value **without asking what alpha it is painted at**. Exactly one
pixel carried that colour and its alpha was 251; a `>= 250` filter let it
through and the ratio was then taken as if it were opaque. **It is a number
about a colour that is never painted.** Composite against the actual ground
before measuring contrast, and take "the ink colour" only from fully opaque
pixels.
- **`POST /api/intake` returning 403 — read as "the route does not exist", on a
LIVE site.** ⚠️ **A bare POST to `/api/intake` returns 403 BY DESIGN.** The
handler rejects a request with no `Origin` header, and **`docs/09` §7.1 says so
in as many words** — *"403 means the `Origin` header did not arrive"* — three
lines below the probe it prescribes. **The only valid route probe is `docs/09`
§7.1 verbatim, `Origin` header included; a 403 without that header is not
evidence about the route.** Run correctly it returns **303** to
`/contact/could-not-send/`, which is the handler answering as designed. This
fired **twice on this project in two days** — Pouya's own probe tripped it
2026-09-02, and it then reached a Change Log entry, a `docs/06` blocker and a
report to him as *"the intake form is live and broken"*. **The status code was
read without reading the document that defines what that status code means on
that route**, and the document was in the repo the whole time. Generalised:
**before interpreting a response, check whether the endpoint documents its own
failure modes** — an API that rejects by design looks exactly like an API that
is missing.
So before acting on a number: say what it is a number *of*; confirm the command
actually ran and read its exit status; and check it against a second method that
+22
View File
@@ -115,3 +115,25 @@ export const FIELDS = [
* 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';
+147 -29
View File
@@ -3,13 +3,16 @@
* table and SES state: AGENTS.md §7 — this file reads them from the environment
* and does not restate them.
*
* ⚠️ THIS IS NOT DEPLOYED. Written at build step 8; nothing on this project
* deploys before cutover (D11). AGENTS.md §7 records that a hand-built
* `adr-intake-handler` already exists in the console, created before this repo,
* and this file REPLACES it rather than describing it. docs/06's cutover
* checklist carries the deployment steps and the CloudFront `/api/*` behaviour
* the form depends on. Until both are done the form on /contact/ posts into
* nothing, which is why that page also publishes the email address.
* ⚠️ 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 ─────────────────────────────
*
@@ -30,6 +33,13 @@
*
* ── 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:
@@ -42,10 +52,11 @@
*
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
* records of what a control that exists on paper and not in fact costs here. So
* it is omitted, said out loud, and the load is carried by the honeypot, the
* Origin check, the aggregate API Gateway route throttle and the validation
* below. (Aggregate, not per-IP — see above; the earlier wording here said
* "rate limit" and let the reader supply the stronger meaning.)
* 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 ──────────────────────────────
*
@@ -65,10 +76,20 @@
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { randomUUID } from 'node:crypto';
/* The field table and the honeypot name live in their own module so that
/* 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 { FIELDS, HONEYPOT } from './fields.mjs';
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
@@ -219,14 +240,18 @@ function parseBody(event) {
* 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 — but reaching it needs a CUSTOM origin
* request policy on the /api/* behaviour (the managed
* AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API
* Gateway, which is why AllViewerExceptHostHeader was chosen). That is an
* infrastructure change, and `docs/09` Part 7.2 measures what this field
* actually contains at cutover rather than reasoning about the proxy chain —
* with a decision table for each outcome. Do not "fix" this from the header
* again without that measurement.
* 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';
@@ -293,7 +318,51 @@ export async function handler(event) {
* human cannot reach this field — it is `display: none`, `tabindex="-1"` and
* `aria-hidden` — so a non-empty value is not a mistake anyone made.
*/
if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') {
/* ⚠️ 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);
}
@@ -403,6 +472,50 @@ export async function handler(event) {
.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
@@ -419,14 +532,19 @@ export async function handler(event) {
ReplyToAddresses: [clean.email],
Content: {
Simple: {
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` },
/* 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: {
Text: {
// The bare id, because it is the partition key: this line is
// what gets pasted into the console to find the record, so it
// must be the key and not a rendering of it.
Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`,
},
// 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 },
},
},
},
+196
View File
@@ -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;
+336
View File
@@ -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`,
);
+48 -17
View File
@@ -386,11 +386,27 @@ the C.Med-Arb endpoint" until 2026-08-29; C.Med-Arb is off the site.)*
Q.Arb, which is what med-arb requires. The page says he conducts med-arb and
stops."* The section is **rewritten, not edited** — it earned its credibility
from an incomplete credential and now says what med-arb demands of one
neutral and that he holds both designations.
neutral. ⚠️ **AND IT NO LONGER RESTATES THE DESIGNATIONS EITHER —
2026-09-02, `claims-auditor` D20 pass, finding F.** §Why this practice held a
bare `DESIGNATIONS_HELD_LINE` sentence sitting directly beneath §Rules'
quotation of ADRIC requiring *"a high level of practitioner competence"*, so
the designations read as meeting a bar ADRIC answers with the Chartered
Med-Arb — struck from this site entirely. The section now makes **no
credential claim at all**; `/about/` publishes them and the JSON-LD carries
them. Do not reinstate one here.
5. **The ADRIC Med-Arb Rules**, which this spec did not mention and which exist:
a published rule set, presented to ADRIC's membership at its 2019 annual conference, drafted for domestic
commercial disputes and designed to work with its Mediation and Arbitration
Rules. Sourced: `docs/reference/adric-rules.md` Finding 2. Quote ADRIC's own
~~a published rule set, presented to ADRIC's membership~~ — **CORRECTED
2026-09-02, `claims-auditor` D20 pass, finding 6.** The source says a
**discussion draft** was presented, not the rules; `adric-rules.md` Finding 2
records no adoption and no effective date, in deliberate contrast to the
Arbitration Rules ("effective March 1, 2025") and the Mediation Rules ("the
existing rules remain in effect"). The page was corrected on 2026-09-01 and
**this line still instructed the struck form for a day** — the Q.Arb lesson
exactly: page fixed, spec still telling the next implementer to write the
false version. Write it as **presented to the membership as a discussion
draft at ADRIC's 2019 annual conference** — drafted for domestic commercial
disputes and designed to work with its Mediation and Arbitration Rules.
Sourced: `docs/reference/adric-rules.md` Finding 2. Quote ADRIC's own
framing rather than paraphrasing it, and **keep its superlatives inside the
quotation marks** — Finding 5 lists the two not to lift.
@@ -481,9 +497,17 @@ position, not a claim of existing volume.**
> occurrences of "allocation" of any kind across the connection process. It also
> reached `src/data/site.ts` and shipped in the six-card grid on three pages.
>
> **Use the terms these bodies use:** *connection assessment and approval (CAA)*
> is the umbrella; the IESO performs a *System Impact Assessment (SIA)* and the
> transmitter a *Customer Impact Assessment (CIA)*. **Ontario has no
> **Use the terms these bodies use:** the IESO's own words are *"the IESO's and
> transmitter's connection assessment and approval (CAA) process"*, within which
> the IESO performs a *System Impact Assessment (SIA)* and the transmitter a
> *Customer Impact Assessment (CIA)*. ⚠️ **This read "CAA is the umbrella" until
> 2026-09-03** — which is the extract's own COMMENTARY, not the IESO's, and
> `CLAUDE.md` is explicit that commentary around a quotation is this
> repository's voice. The pages took the attribution from here and gave the
> process to the IESO alone. **And it is the CONNECTION PROCESS that runs to
> *up to* six stages, not the CAA** — the source scopes the count by connection
> type, and CAA is stage 2 of that process rather than a name for it. Naming the
> wrong subject here is how the conflation reaches a page. **Ontario has no
> interconnection queue** — the IESO says so in terms and works from "committed
> projects" instead, so "our place in the queue" describes nothing. The
> genuinely adjacent term, the OEB's *Capacity Allocation Model* in the
@@ -514,9 +538,10 @@ position, not a claim of existing volume.**
> - **LAT Rule 2.4:** *"'Case Conference' has the same meaning as 'Pre-Hearing
> Conference' as defined in the SPPA."* **"Pre-hearing" is the Tribunal's own
> label**, and what it labels is a case conference.
> - **Rule 14.3:** a **Member** presides and is then disqualified from the
> hearing panel; **Rule 14.6:** parties must attend. The neutral is the
> Tribunal's. A privately retained one is not appointed to it and cannot be.
> - **Rule 14.3:** a **Member** presides and does not then sit on the hearing
> panel except with the consent of the parties; **Rule 14.6:** parties must
> attend. The neutral is the Tribunal's. A privately retained one is not
> appointed to it and cannot be.
> - The LAT Rules contain **zero** occurrences of `mediat` or `arbitrat` —
> 0 in 66,593 characters. The concept is not in them.
> - The LAT-AABS page itself, though, says: *"Before you apply to the LAT-AABS,
@@ -602,9 +627,11 @@ commitments, the first matter that slips makes the page false."*
**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
in `docs/07-fees.md`; still do not invent numbers.**
Hourly rate; half-day and full-day mediation; preparation time policy;
cancellation terms; administrative fee; HST treatment; who pays and how costs
are shared between parties; payment terms. A real page with real numbers, or a
Hourly rate; half-day and full-day mediation; **med-arb, billed by phase and
carrying no figure of its own** (added 2026-09-03, `docs/07` §Med-arb, INTERIM
against R5 — the page shipped it and this outline did not name it); preparation
time policy; cancellation terms; administrative fee; HST treatment; who pays and
how costs are shared between parties; payment terms. A real page with real numbers, or a
`TODO(pouya)` — nothing in between.
### `/for-parties/`
@@ -690,10 +717,14 @@ Dependency-ordered, so nothing is blocked mid-stream:
**The section is not live and cannot be**: D9 and `src/content.config.ts`
between them mean an article publishes only when Pouya sets both flags, and
`SiteHeader` keeps Insights out of the nav until two are live
8.`/contact/`**the page is built; the pipe behind it is not.** The handler
is written (`backend/intake/`) and undeployed, and the CloudFront `/api/*`
behaviour it posts to does not exist yet. Both are cutover items, and `docs/05`
§Build step 8 records three deliberate deviations from that spec
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)
+14 -1
View File
@@ -153,7 +153,20 @@ here.** This spec used to reproduce the file inline and the reproduction had
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
§7 rule exists to stop.
**It disallows nothing, and that is deliberate.** This spec previously
**It disallows exactly one path, and everything about that exception is in the
file.** ⚠️ **THIS READ "It disallows nothing, and that is deliberate" UNTIL
2026-09-04.** `Disallow: /pouya-lajevardi-bio.pdf` was added that day as the
stand-in for `X-Robots-Tag: noindex` on `*.pdf`, which needs a CloudFront
response-headers policy the distribution's pricing plan forbids (`AGENTS.md` §7).
⚠️ **IT IS A SUBSTITUTE, NOT AN EQUIVALENT, AND THE RULE BELOW IS WHY.** It stops
the PDF being *fetched* — so its contents are never indexed and the
duplicate-of-`/bio/` problem is solved — but it does **not** de-index the URL,
and the PDF is linked from `/bio/` and `/about/`, so a bare listing remains
possible. That residual is accepted deliberately. **The rule below is unchanged
and this is its exception, not its repeal.**
The general rule: this spec previously
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
cancel each other: a crawler forbidden to *fetch* a URL never reads the
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
+150 -21
View File
@@ -22,14 +22,22 @@ The shape is right. This is a hardening and rework pass, not a replacement.
**What is in the repository:** `/contact/` with the intake form, two
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built
`adr-intake-handler` §7 records.
`backend/intake/fields.mjs` + `backend/intake/spam-score.mjs` the handler that
**replaced** the hand-built `adr-intake-handler` §7 records.
**What is NOT done, and the form does not work until it is.** Nothing on this
project deploys before cutover (D11), so: the handler is not deployed, and the
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
as the form for exactly this reason.
🟢 **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
@@ -146,7 +154,7 @@ might reasonably treat as privileged. The intake call is for that.
### Consent text
> I consent to Pouya Lajevardi storing and using the information in this form to
> I consent to SML Company Ltd storing and using the information in this form to
> respond to my inquiry and to run a conflicts check. I understand that
> submitting this form does not create a retainer, does not appoint a neutral,
> and does not itself establish a mediatorparty relationship.
@@ -158,6 +166,24 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
- Required fields present; email well-formed; lengths within bounds
- Reject any field over its cap rather than truncating silently
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
- **Second honeypot** — a hidden CHECKBOX that must arrive **absent**, added
2026-09-04. A different trap, not a second copy: the first catches a bot that
fills every text input, this one catches a bot that sets every control it
enumerates. ⚠️ **IT IS PROBABLY INERT AGAINST THE TRAFFIC THAT PROMPTED IT —
see §Observed abuse, which retracts in full the argument this bullet made for
one round** (*"which anything reaching validation must do, because the consent
box is required and unchecked by default"*). The retraction was written sixty
lines below this bullet and did not reach it. **Unchecked sends nothing, so
absence is the pass —
and so is an empty value**, because the handler tests for a non-empty one
rather than for presence: no dropped-field path and no blind form serialiser
can turn it into a lost inquiry. It carries its **own** wrapper class (not the
first honeypot's), the `hidden` attribute as well as the CSS rule, and a label
that tells a human not to tick it — see `src/pages/contact.astro`, where each
of the three is a correction rather than a precaution
- **Spam SCORING that labels and never rejects**, added 2026-09-04. See
§Observed abuse. It changes the operator notification's subject line and
nothing else
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
while this line stayed an unqualified imperative** — the handler's header,
@@ -178,11 +204,86 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
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,
and the two controls above stop the traffic that matters
- No CAPTCHA. It is a third-party script on a page collecting legal information.
⚠️ **THIS BULLET USED TO END "and the two controls above stop the traffic that
matters", WHICH THE FIRST REAL SPAM FALSIFIED** — see §Observed abuse. The
reason to keep CAPTCHA out is unchanged and stands on its own; the claim that
what ships is sufficient was an untested prediction and has been removed rather
than reworded
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
- Strip HTML from every field before storage and before it enters an email body
## Observed abuse
**First real-world spam: 2026-09-04.** Two automated submissions, **10:51Z** and
**12:16Z**, `submissionId` prefixes `50cda580…` and `e3e21122…`. Recorded here
rather than in the Change Log alone because this section's controls were
specified against an imagined attacker and this is the first measured one.
**Both passed the honeypot**, and neither was stopped by anything else that
ships: the aggregate route throttle is 1 request/second with a burst of 5
(`AGENTS.md` §7), and two submissions ninety minutes apart are nowhere near it.
**The signature, as Pouya recorded it:**
| | |
|---|---|
| Names | random |
| Email | random Gmail addresses — **one using the dot trick** |
| Phone | Russian format |
| Organisation | big-brand names |
| Dispute summary | scraped text |
⚠️ **THE HONEYPOT WAS NOT DEFEATED BY CLEVERNESS — IT WAS NOT ENGAGED.** A bot
that submits only the fields it recognises never touches a decoy text input.
🛑 **AND THAT CUTS BOTH WAYS. THE SECOND HONEYPOT IS PROBABLY INERT AGAINST THIS
PAIR, AND THIS SECTION CLAIMED THE OPPOSITE FOR ONE ROUND.** It said the checkbox
*"is aimed at a behaviour the traffic must have"*, reasoning that the consent box
is required so anything that validated must have been ticking checkboxes.
**Sending `consent=on` shows only that it knows one field name.** A bot selective
enough to skip a hidden text input is selective enough to skip a hidden checkbox,
and the same evidence that explains the first honeypot's silence predicts the
second's. It is **defence in depth against a different and common class** — the
bot that enumerates controls and sets all of them — which is worth adding and is
not a counter to what was observed. Nothing in this repository has yet caught a
bot with it.
**What was added, and the ordering rule Pouya set:** *"Nothing is dropped; a
false positive costs him one glance."*
1. A second honeypot — above.
2. **Scoring that labels.** `backend/intake/spam-score.mjs`, unit-tested at
`spam-score.test.mjs`. Signals and weights: summary under a floor **(1)**,
phone present and not North American **(1)**, a link in the summary **(2)**,
a Gmail address with dot-trick density **(2)**; **threshold 2**. Above it the
record is still stored, both emails are still sent, and only the operator
notification changes — subject prefixed `[Possible spam] `, plus one line
naming the signals. **The confirmation to the inquirer is untouched.**
3. **Nothing is stored.** The score and signals do not enter the DynamoDB item,
because §Storage's attribute list is published on `/legal/privacy/` and adding
one would make that page wrong.
⚠️ **THE TIMING FLOOR WAS RULED AND COULD NOT BE BUILT — §9 Q66.** Pouya's ruling
of 2026-09-04 asked to *"raise the timing floor"*. **There is no floor to raise:**
the timestamp check is struck above and has never existed, for a reason unchanged
by the spam arriving — `/contact/` is a CDN-cached static file, so no per-visitor
"served at" value exists to subtract from. Nothing inside *"handler + form only,
zero-JS preserved"* can produce one, and the three mechanisms that could each
break one of his constraints:
| mechanism | what it costs |
|---|---|
| Client-side script timing the fill | **Breaks zero JavaScript** (§7, and it is *none*, not *minimal*) |
| A CloudFront Function on viewer-response setting a signed short-lived cookie, read by the handler | Outside *"handler + form only"*, and it puts a **cookie** on a site whose privacy policy turns on there being none — a `/legal/privacy/` change and a consent question this repository must not answer for itself |
| A dynamic origin for `/contact/` | Reverses D1's `output: 'static'` |
**A fourth is worse than doing nothing:** shipping a build-time timestamp and
calling it a timing check. `now served` would be hours or days for every
caller, so it would pass for a bot exactly as it passes for a human — the control
that exists on paper and not in fact, which is what deviation 1 and `AGENTS.md`
Q22 are both records of.
## Storage
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
@@ -211,13 +312,21 @@ against this row.
| `submissionId` | `<uuid>`**the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
| fields | as above |
| `sourceIp`, `userAgent` | abuse investigation only |
| `sourceIp`, `userAgent` | ~~abuse investigation only~~ — ⚠️ **AMENDED 2026-09-02: that purpose holds for `userAgent` and `submittedAt`, and NOT for `sourceIp`.** Behind the `/api/*` behaviour `requestContext.http.sourceIp` is a CloudFront edge, so it identifies the network rather than the sender and cannot serve an abuse investigation. `/legal/privacy/` now states the two purposes separately — timestamp and user-agent for abuse, the address as something that simply arrives with the request. **A spec and a page disagreeing about WHY data is held is the disclosure PIPEDA actually turns on**, and this row said one thing while the page said another for a day. `docs/09` Part 7.2 measures the field; if it holds the reader's own address, this row and that paragraph both change. `adversarial-reviewer`, round 2 |
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
on.** Table access limited to the Lambda role and one named administrative
principal.
on.** ⚠️ **THE THIRD LINE HERE WAS *"table access limited to the Lambda role and
one named administrative principal"*, AND IT WAS THE Q62 FALSEHOOD — struck
2026-09-02.** It is false on both halves: `adr-intake-lambda-role` holds
`PutItem` **only** and cannot read the table at all, and access is not one
principal. **`AGENTS.md` §7's `Intake table — who can read it` row is the answer
and this spec does not restate it** — a duplicated fact is one that goes wrong in
the copy nobody re-reads, which is what happened here: the Q62 sweep ran over
`src/` and never reached a spec, and `check:claims` carries this exact sentence
as a string that reached `dist/`. It survived the sweep this file's own
definition-of-done claims to have completed (`adversarial-reviewer`, round 2).
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
@@ -440,26 +549,46 @@ Plausible or Fathom, cookieless, no consent banner.
## Definition of done
- [x] **Server-side validation independent of the client**`backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
- [ ] **Throttle configured** — an **aggregate** API Gateway 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 first honeypot is live** — the hidden text input that must arrive
empty. Deployed since cutover. ⚠️ **The timing check is NOT implemented**
see deviation 1 above and §Observed abuse; it is unimplementable on a
CDN-cached static page and would be a control that does nothing.
**Re-ruled and re-blocked 2026-09-04, §9 Q66**
- [ ] 🛑 **THE SECOND HONEYPOT AND THE SPAM SCORING ARE WRITTEN AND NOT
DEPLOYED** — 2026-09-04. Both live in `backend/intake/`, and **a site
deploy does not carry `backend/`**: `scripts/deploy-local.sh` is an S3 sync
and an invalidation, nothing more. They need `docs/09` Part 5 (and Part 5.5,
which is the path in production). ⚠️ **THIS LINE READ `[x]` … "live" FOR ONE
ROUND, ON AN UNCOMMITTED WORKING TREE**, while §7's own row recorded the
running function as last modified 2026-09-02 with source digests matching
`HEAD` — the spec asserting a control that its neighbour proved absent.
`node backend/intake/spam-score.test.mjs` returns **39 of 39** and all four
signals are exercised `[verified 2026-09-04]`; that is a statement about
the repository, not about production. ⚠️ **The only paths that discard a
submission are the two honeypots**, and both answer with the success page
rather than an error. Validation failures redirect to
`/contact/could-not-send/`, which is a told failure, not a silent one
- [x]**Throttle configured — `POST /api/intake` at rate 1.0 req/s, burst 5, detailed metrics on** `[verified 2026-09-04 — get-stage]`. ⚠️ **IT IS A `RouteSettings` ENTRY, NOT THE STAGE DEFAULT**, and a query projecting `DefaultRouteSettings` alone returns only `DetailedMetricsEnabled` and reads as *no throttle configured* — which is how §7 came to say so. Read `RouteSettings` before concluding it is absent. An **aggregate** route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
- [x] **PITR enabled**`ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
- [ ] **Table access matches what `/legal/privacy/` says about it.** ⚠️ **IT DOES NOT, AS AT 2026-09-01.** The page says *"nobody else has access to the table… no external administrator"*; the account's `admins` group carries `AdministratorAccess` and has **two** members, and `simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`/`Query`/`Scan` for both. Evidence and commands: `docs/reference/intake-table-access-verification.md`. §9 **Q62**, and it blocks that page going public
- [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
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
> **The three remaining items below are commands, and 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
> ✅ **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).
- [ ] **CloudFront `/api/*` behaviour created**, 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
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
- [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
+686 -27
View File
@@ -395,19 +395,288 @@ Then invalidate `/*`.
> reversing them puts 22 of 23 pages behind a 403 for as long as a CloudFront
> deployment takes.
> 🛑 **THREE THINGS BLOCK THIS ENTIRE LIST AS AT 2026-09-01, AND TWO OF THEM WERE
> FOUND BY READING THE RUNNING SYSTEM RATHER THAN THE SPECS.**
> **CUTOVER EXECUTED 2026-09-02 — THE SITE IS LIVE AT `https://adr.smlcompany.ca`.**
> Deployed by Pouya from this machine via `scripts/deploy-local.sh` with the
> `adr-sml-deploy` credential. **Verified independently the same day rather than
> transcribed**, 26 routes with the iteration count asserted (a `for r in $VAR`
> loop ran ONCE first — the zsh trap `CLAUDE.md` records, caught by the count):
> all 22 pages, `robots.txt`, `sitemap-index.xml` and the bio PDF return **200**;
> an unknown path returns **404** and serves the styled Astro page, 14,321 B, not
> S3's XML. HTML carries `max-age=0, must-revalidate`, `_astro` assets
> `max-age=31536000, immutable`, and the PDF is **89,496 B**, matching `public/`
> exactly. **All 22 live pages are byte-identical to a local `dist/` rebuilt at
> `67847d9`** — SHA-256 compared page by page, 22 same / 0 differ / 0 errors. The
> five `noindex` surfaces and the 17-URL sitemap are correct.
>
> 1. **`/legal/privacy/` states something false about who can read the intake
> table** — §9 **Q62**, evidence in
> `docs/reference/intake-table-access-verification.md`. A privacy policy is
> the wrong page to be wrong on, and this one is wrong about third parties'
> dispute information.
> 2. **Q60 is still open** — TTL is `ENABLED` and no record has been watched to
> disappear, and the same page asserts the mechanism. `docs/09` Part 10 is the
> test and its answer arrives days after it starts, so **start it early**.
> 3. **`claims-auditor`'s D20 pass returned six copy findings on 2026-09-01;
> five are corrected and one is a ruling** — the `/med-arb/` gloss, below.
> 🛑 **THIS LIST WAS NOT CLEAN WHEN THE SITE PUBLISHED, AND THAT IS THE RECORD,
> NOT A REPROACH. TWO BLOCKING ITEMS WERE GENUINELY OPEN AT CUTOVER; BOTH ARE
> NOW NARROWED RATHER THAN CLOSED.** D11 is a single shot and the checklist
> exists because of it; a launch that crosses its own gates should be legible as
> one afterwards rather than smoothed over.
>
> ⚠️ **THE COUNT SAID THREE FOR ONE DAY AND THREE WAS WRONG — corrected
> 2026-09-03.** The third, *"the intake form is live and broken"*, was **a false
> alarm from a malformed probe** and is refuted in item 2 below. It is corrected
> here rather than deleted because a blocker that was never real, asserted on the
> most-read part of this page, is the same failure as a real one that goes
> unrecorded — and because **this is the first time the count moved for a reason
> the earlier notes did not anticipate: not closed, not deleted, not moot, but
> WRONG.** That is a fourth way off this list, and it looks identical to the
> other three in a tally.
>
> **The state as at 2026-09-03:** **Q60** is owed rather than pending — Pouya
> ruled the page publishes and the deletion is confirmed after launch, reading
> from **2026-09-04**. **The D20 pass** returned 20 confirmed findings, of which
> **15 are fixed, 2 refuted and 3 need a ruling from him** rather than an edit.
>
> ✅ **THE READ-THROUGH IS COMPLETE — Pouya, 2026-09-02, and it returned ONE
> FINDING WHICH WAS NOT COPY.** `public/favicon.ico` shipped with no
> transparency; fixed and verified the same day (see **Favicon set complete**
> below). His read carried the approvals with it, in terms: the
> `/legal/privacy/` §Who can see it wording, the **SML Company Ltd** consent
> line, and `/med-arb/` **as shipped**. That discharges blocker 2 and every
> wording sign-off that had been routed into it.
>
> ⚠️ **THE COUNT WENT UP, AND THAT IS THE FIRST TIME IT HAS — READ THE REASON,
> NOT THE NUMBER.** It read ONE for part of 2026-09-02 because an approval had
> gone **missing**; it then read ONE because the pass that approval was routed
> into had been **done**; it now reads **THREE**, because the site published and
> the D20 pass then ran against the shipped bytes and returned **FAIL**. Every
> earlier move in this note was a tally shrinking for a reason a tally could not
> show. This one grew, and the lesson is the same in the other direction: **the
> list did not get worse, the measurement finally happened.** Two of the three
> were true before cutover and unmeasured; one was invisible by construction.
>
> ⚠️ *(The count has moved repeatedly in one day and the DIRECTION is the only
> part worth reading — the number of moves is deliberately not stated, because a
> tally of how often a tally changed is the same trap one level up. It said ONE for part of 2026-09-02 and that was a
> **defect** — the wording approval Pouya reserved had been recorded only inside
> records marked closed, the `TODO(pouya)` deleted, Q62 struck, this callout
> ticked, so nothing would have stopped unapproved copy publishing
> (`adversarial-reviewer`, D20 pass round 1). Q63 was then **answered** in three
> limbs by ruling, which is a gate closed by an answer rather than by deletion —
> and answering it **opened Q64**, one paragraph lower on the same page. Q64 then
> left the list a **third** way: **the paragraph it was about was deleted**, so
> the question is moot rather than answered. **Closed, deleted, and moot look
> identical in a count and nowhere else, which is why the count is never the
> record.** The second slot is no longer a question at all — it is the human
> pass, promoted here from the checklist below because that is where the last
> reserved approval went missing.)*
>
> 1. 🛑 **Q60 — the retention MECHANISM has still not been observed, AND
> `/legal/privacy/` PUBLISHED ANYWAY.** TTL is `ENABLED` and no record has been
> watched to disappear, and the page asserts the **mechanism**, not merely the
> period. `docs/09` Part 10 is the test; earliest useful reading **48 hours**
> after the record is written, failure not called before **7 days** — Pouya
> started the window 2026-09-02, so **check from 2026-09-04**.
> ⚠️ **THE PAGE CARRIED ITS OWN INSTRUCTION NOT TO DO THIS — RULED STALE BY
> POUYA 2026-09-03 AND REWORDED.** `src/pages/legal/privacy.astro` held a
> `TODO(pouya)` ending *"This page must not go public until a deletion has
> actually been seen."* **His decision supersedes it: publish, then confirm the
> deletion after launch.** The comment now states that decision and its date,
> and the `TODO(pouya)` marker is gone, which also clears the checklist item
> *"No `TODO(pouya)` remains in any shipped page"* below.
> **What stays true is the mechanism finding, and it is worth keeping:** that
> instruction was a **JSX comment**, so Astro strips it and it never reached
> `dist/` — which is why `check:claims`, the build and both deploy paths were
> green over it. **A publication gate that lives only in a stripped comment is
> not a gate**, whatever the gate happens to say. Q60 itself is unchanged and
> the confirmation is now *owed* rather than *pending*.
> 2. ✅ **REFUTED BY MEASUREMENT 2026-09-03 — THE INTAKE FORM IS NOT BROKEN, AND
> THIS ENTRY IS THE CORRECTION.** Pouya's probe, reproduced here in both
> directions: `docs/09` §7.1 verbatim — `POST /api/intake` with
> `Origin: https://adr.smlcompany.ca` and
> `Content-Type: application/x-www-form-urlencoded` — returns **HTTP/2 303**,
> `location: https://adr.smlcompany.ca/contact/could-not-send/`, with
> `access-control-allow-origin` echoed and an `apigw-requestid` present. **The
> handler answered as designed**: it validated, found an empty submission and
> redirected to the failure page before any write and any email. The **same
> probe with the `Origin` header removed returns 403**, which is the control.
> ⚠️ **A BARE POST TO `/api/intake` RETURNS 403 BY DESIGN, AND §7.1 SAYS SO
> THREE LINES BELOW THE PROBE** — *"403 means the `Origin` header did not
> arrive"*. The earlier finding read a status code without reading the document
> that defines what that code means on that route. **This false alarm has now
> fired twice in two days** — Pouya's own probe tripped it 2026-09-02 — and it
> is recorded in `CLAUDE.md`'s instrument list, which stands at nine.
> **The only valid route probe is `docs/09` §7.1 verbatim, `Origin` included.**
>
> ⚠️ **AND THE TWO "BACKEND NOT DEPLOYED" CLAIMS FINDINGS FALL WITH IT.**
> `/legal/privacy/` §Where it is stored (*"Two emails are sent when you submit
> the form"*) and `/contact/received/` (*"A confirmation goes to the email
> address you gave"*) were both premised on the route not existing. It exists.
> **What is NOT settled by this probe is that both emails actually arrive** —
> §7.1 stops before any write and any email by design, and that is `docs/09`
> §7.2, the real-submission test Pouya has in progress. The disclosures are
> unblocked; the end-to-end confirmation is still owed.
> 3. ⚠️ **THE D20 CLAIMS PASS RETURNED FAIL WITH 20 CONFIRMED FINDINGS; 17 ARE
> NOW FIXED, 2 REFUTED, 1 OWED — updated 2026-09-04, and the three numbers
> partition the twenty.** **Findings 10 and 13 were both RULED by Pouya on
> 2026-09-03 and are closed** (see below); **the one remaining is 11**, which
> is ruled and waiting on Q60's observation window rather than on a copy
> change. ⚠️ **THIS ITEM STAYS UNTICKED, AND NOT BECAUSE A CLAIM IS WRONG.**
> What is outstanding is a *confirmation that a record was seen to vanish*,
> not a sentence anyone disputes — tick it when Q60 closes. The previous
> tally follows. Fixed under Pouya's rule *"the gloss may say
> no more than the extract says; no new claims, no new sources"*: findings
> 19, 1418 and 20 — the whole gloss class, plus `/bio/`'s role verb.
> ⚠️ **15 FINDINGS, 14 DISTINCT EDITS: findings 4 and 15 quote the same
> sentence** on `/practice/energy/`, so one edit closed both. **REFUTED:**
> findings 12 and 19, the two backend disclosures, with item 2 above.
> **OUTSTANDING — findings 10, 11 and 13, and each is outstanding for a
> different reason:**
> **(10) ✅ RULED AND CLOSED 2026-09-03 — PRICED, NOT NARROWED.** Med-arb is
> billed **by phase**: the mediation phase at the published mediation rates,
> the arbitration phase (if it is reached) at the published arbitration
> rates; additional-party and cancellation terms apply to each phase as they
> apply to that process on its own; **there is no separate med-arb fee.**
> Pouya took the more expensive of the two fixes — the promise is unchanged
> and is now true, rather than being trimmed to fit. `FEES.medArb` is the
> single source, `/fees/` §4 renders it, `docs/07` §Med-arb carries the rule
> **marked INTERIM, set 2026-09-03, reviewed at §12 R5**. ⚠️ **It carries NO
> figure of its own and must not be given one** — a fourth price for a
> process priced twice would disagree with one of them. ⚠️ **AND BECAUSE IT
> IS DERIVED, MOVING A RATE AT R5 MOVES IT SILENTLY**, with no diff on the
> med-arb rule; R5 carries that. Verified by reading the built page.
> *(The original wording of this item follows.)* `/fees/`'s *"Every figure is
> on this page"* against §4's **Med-Arb** offering, which `docs/07-fees.md`
> priced nowhere. Either a med-arb fee term or a scoped promise; it cannot be
> closed by narrowing.
> **(11) IS RULED, AND THE CONFIRMATION IS OWED.** The retention *mechanism*
> sentence on `/legal/privacy/` is unchanged and still ships, deliberately —
> that is blocker 1 above and §9 Q60, reading from 2026-09-04. It is listed so
> the twenty account for themselves, not because it is unresolved.
> **(13) ✅ RULED AND CLOSED 2026-09-03 — HE SAID IT, AND THE PAGE SAID MORE
> THAN HE SAID.** Pouya attested that he runs a conflicts check on every
> inquiry before engaging. §4 gains **conduct undertaking (g)**, `[attested
> 2026-09-03]`, and `CONDUCT_UNDERTAKINGS` now holds **seven** strings, not
> six. ⚠️ **THE ATTESTATION DOES NOT COVER THE SENTENCE THAT RAISED THE
> FINDING.** Finding 13 quoted a promise to **disclose the outcome** —
> *"I will tell you what its outcome was"* — which is a different commitment
> from running the check, and his instruction was that the page *"may say no
> more than that attestation"*. So the clause is **struck**; the page now
> reads *"it does not undo a conflicts check that has already been run"*, and
> the undertaking itself ships through `<Undertaking>` in §Information about
> other people, replacing a hand-typed near-equivalent. ⚠️ **IT DOES NOT
> REVERSE Q57**, which refused an undertaking about what happens when a check
> turns something up; that one is still refused. *(The original wording of
> this item follows.)* **NEEDS HIM TO HAVE SAID IT.** `/legal/privacy/`'s *"if a conflicts
> check has already been run I will tell you what its outcome was"* is an
> **undertaking**, and §4's gate for that class is one line: Pouya must have
> made it **in terms**. It is not in `CONDUCT_UNDERTAKINGS`.
> ⚠️ **§12 R1 IS NOT ONE OF THE TWENTY.** An earlier form of this item named it
> as the third outstanding finding and dropped 11 to make room — a tally that
> did not partition its own set. R1 is a standing reminder on licensure that a
> completeness critic reached independently from the copy; it is live, and it
> is counted nowhere. The original entry follows.
>
> 🛑 **THE D20 CLAIMS PASS HAS NOW RUN AGAINST THE SHIPPED BYTES AND RETURNED
> FAIL — 20 CONFIRMED FINDINGS ON LIVE PAGES.** Run 2026-09-02 at `67847d9`,
> after cutover, over all 23 built pages: 13 auditors (8 page groups + 5
> cross-cutting lenses) → 41 raw findings → 31 distinct → each adversarially
> verified by an independent `claims-auditor` instructed to refute it. **20
> CONFIRMED, 11 REFUTED**, plus 13 further findings from two completeness
> critics. See the **`claims-auditor`** item below for the breakdown, and
> `AGENTS.md`'s Change Log entry of 2026-09-02 (ar) for the full list.
> **Nothing here is a claim about Pouya, his credentials or his designations —
> every one of those traced, for the third pass running.** The failures are
> over-reaches in glosses on sourced legal material, and disclosures on
> `/legal/privacy/` and `/contact/received/` that describe a backend which is
> not deployed.
> 4. ✅ **DONE 2026-09-02 — Pouya read every page against `AGENTS.md` §4.** The
> human pass, the other half of D20 and not delegable. It was also where the
> §Who can see it approval was routed, and his ruling that the read-through
> *is* the approval means that sign-off is now discharged rather than
> pending. **Sole finding: the favicon's opaque ground.** No copy finding on
> any of the 23 pages.
>
> ✅ **CLOSED 2026-09-02 — Q64, MOOT.** It asked whether anyone else holds the
> AWS root password or its MFA device, because the page published *"has no
> programmatic key, and I hold it"* one paragraph below *"the small number of
> people who administer it with me"*, where a reader takes it as **sole**
> custody. **Pouya's second ruling that day deleted the sentence** — the section
> is now four plain statements and says nothing about root — so the question no
> longer gates anything. ⚠️ **The underlying fact is unchanged and unestablished:
> §7 records root as *held by Pouya*, which is not *held only by Pouya*, and
> nothing measured can settle it. Nothing may be published about root custody
> without asking again.**
>
> ✅ **CLOSED 2026-09-02 — Q63, all three limbs, by ruling.** **(a)** The §Who
> can see it wording is **approved with two trims** — the editorial closing
> sentence struck, and the mailbox clause rewritten per (b). **(b)**
> `info@smlcompany.ca` is a **delegated mailbox read by Pouya and by
> administrative staff**, and the page now says so instead of *"anyone who can
> reach that mailbox"*. **(c)** The account **root credential is held by Pouya**;
> it has no programmatic key and MFA is on, and the page now states the first two
> of those. ⚠️ **AND THE ANSWER CHANGED THE HEADLINE SENTENCE:** Pouya's
> attestation is that *"two people is an exaggeration… a handful is accurate"*,
> because **the simulation counts identities and the page was reading them as
> humans**. No numeric human headcount ships; the page attributes read access to
> *"the account's administrators — me, and the small number of people who
> administer it with me"*. §12 **R21** is re-scoped to match.
> ⚠️ *(Superseded the same day in its details, not in its rulings: the ruling
> below cut the section to four plain statements, so the sentence quoted above is
> no longer the shipped one and root is not mentioned at all. Each limb of Q63
> still stands — no headcount, the mailbox named, root attested in §7.)*
>
> ✅ **CLOSED 2026-09-02 — THE SECTION IS GENERIC, by a second ruling the same
> day.** *"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**
> — who can read it, that the receiving system can only add a record, where the
> notification goes and who reads it, and that the confirmation sits with the
> reader's own provider. **Deleted from §Who can see it:** 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 verified
> material was lost** — all of it stays in `AGENTS.md` §7 and
> `docs/reference/intake-table-access-verification.md`, and the section comment in
> `src/pages/legal/privacy.astro` bars restoring it to the page. **The risk moved
> in the right direction:** every deleted sentence was a claim about a system
> outside this repository that nothing reports on, which is what §12 **R21**
> exists for — R21 is re-scoped from five live claims to two.
>
> ✅ **CLOSED 2026-09-02 — THE CONSENT STRING NAMES THE CORPORATION.** *"I
> consent to **SML Company Ltd** storing and using the information in this
> form…"*, per ruling, replacing the natural person. It is the one sentence a
> submitter actually agrees to and it is the PIPEDA basis, and the policy it
> links to describes a mailbox read by administrative staff — a corporation is
> the party that matches, and `/legal/privacy/` now names it in terms under §Why
> it is collected. **The NAME ONLY:**
> §4 verifies the federal incorporation, records it as *not published*, and
> cautions that it must never be read beside the licence-status row. `docs/05`
> §Consent text carries the string verbatim and moved with it.
>
> ✅ **CLOSED 2026-09-02 — Q62.** `/legal/privacy/` no longer states anything
> false about who can read the intake table. Pouya's ruling was **state the
> truth**, not remove the second administrator's access: the page attributes read
> access to the account's administrators and names their role. *(It said "two
> people can read it" until the Q63 ruling later the same day replaced the count,
> and the ruling after that cut the section to four plain statements — of the two
> stronger facts this entry originally credited it with, the writing function's
> add-only access still ships and the deploy credential's lack of access does
> not. See the two blocks above.)* The
> `sole-administrator-q62` tripwire in `check-claims.mjs` **stays permanently**
> by the same ruling, extended from two alternatives to **five**: the clause the
> first form could not see two sections up the same page, the summary that would
> have re-asserted the struck number four lines below the corrected paragraph,
> and the sentence that carried the false count. Proven both ways against the
> pre-correction page rebuilt from `bd282aa` — **exit 1 with 5 matches**, exit 0
> on the corrected page, and **re-proven both ways after the Q63 rewrite**, same
> 5 matches at the same lines.
>
> ✅ **CLOSED 2026-09-02 — the `/med-arb/` gloss.** Struck, with no replacement
> and no competence claim, per ruling; **ratified as shipped** on 2026-09-02 with
> no credential line restored. **Pouya's note, recorded because it is the reason
> and not a detail: med-arb is a service he provides, not a designation.** That is
> what makes the struck gloss unrecoverable rather than merely unsourced — there
> is no designation to cite for it. The ADRIC-sourced material carries the
> section.
**Cutover prep — deferred maintenance, done BEFORE the checklist below**
@@ -464,10 +733,84 @@ the decision is re-readable rather than re-litigated.
instruments its extract checked — and its own note then disclaimed that
conclusion as the Commissioner's words; `/med-arb/` said the ADRIC Med-Arb
Rules were presented to the membership where the source says a **discussion
draft** was). Three remain and each has its own item below or above:
the `/med-arb/` gloss (a ruling), Q60, and Q62.
draft** was). ✅ **Two of the three remaining were ruled and closed
2026-09-02** — Q62 (the page now states the measured truth) and the
`/med-arb/` gloss (struck, no replacement).
**Two things about that result are worth carrying forward.** The pass found
⚠️ **AND THE PASS WAS RE-RUN OVER THOSE TWO FIXES ON 2026-09-02, WHICH IS
WHAT MAKES THE ITEM CLOSEABLE — IT RETURNED FAIL WITH EIGHT MORE.** This is
the same pass completing, not a per-step audit reinstated: a FAIL whose
fixes are never re-audited leaves *"findings resolved"* asserted rather than
checked. **Five of the eight were in the fixes themselves** — the
replacement copy reintroduced an absolute third-party negative of the
shape struck from this page on 2026-08-31, claimed an enumeration the
evidence file did not support, asserted *"the one other place a copy
exists"* when the inquirer's confirmation carries the whole submission,
left `LAST_UPDATED` at 31 August on the change set that rewrote the page's
central disclosure, and left *"your IP address"* standing when `docs/09`
Part 7.2 had said in terms to fold it into the Q62 edit. Two were on
`/med-arb/`: the struck gloss left *"the section above"* pointing at the
ADRIC rule set and *"the agreement"* with no antecedent, and the bare
designations line was left sitting directly under ADRIC's competence
quotation. One was declined with a reason (§Rules' heading — see the
Change Log). **All corrected or declined; the pass is clean on findings
and the item is open only on the human halves above.**
🛑 **AND IT HAS NOW RUN A THIRD TIME — 2026-09-02, AFTER CUTOVER, AGAINST
THE BYTES THAT ACTUALLY SHIP — AND RETURNED FAIL WITH 20 CONFIRMED
FINDINGS. THIS ITEM STAYS UNTICKED.** The two runs above predate the
Q62/Q63 privacy rewrite, the `/med-arb/` strike and the consent change, so
neither had seen the shipped copy. This one did: all 22 live pages were
confirmed **byte-identical** to a `dist/` rebuilt at `67847d9` before the
audit began, and the auditors read a parse5 extraction of the visitor text
and JSON-LD of all 23 pages, validated against known shipped strings first.
**13 auditors — 8 page groups and 5 cross-cutting lenses (adjacency,
gloss-vs-source, structured data, licensure, §4 Forbidden) — returned 41
raw findings, deduped to 31, each then handed to an independent
`claims-auditor` instructed to REFUTE it. 20 confirmed, 11 refuted.** Two
completeness critics added 13 more. Full list: `AGENTS.md` Change Log
2026-09-02 (ar).
**The shape of the 20, because it is the same shape as run 2 and that is
the finding about the process rather than the pages.** Not one is a claim
about Pouya, his credentials, his designations, his memberships or the
boutique — **the credential spine has now traced clean three passes
running**, and D16, D13 and the Forbidden table were swept with every hit
printed and read. What failed is two classes: **glosses that assert more
than their committed extract establishes** (`/practice/energy/` calling the
IESO process six stages where the source says *"up to six"*;
`/practice/construction/` stating the 30-day adjudication deadline without
its *"Subject to subsection (2)"* extension; `/practice/technology/`
asserting Ontario *"has one AI instrument"*), and **disclosures describing a
backend that is not deployed** (`/legal/privacy/` on retention and on the
two emails, `/contact/received/` on the confirmation). The second class is
not a wording problem: it is the privacy policy of a live site describing a
mechanism that cannot run, which is the defect class `AGENTS.md` Q22 named.
⚠️ **THE SECOND CLASS WAS REFUTED — 2026-09-03, AND AGAIN BY DIRECT
MEASUREMENT 2026-09-04.** The backend **is** deployed; the 403 that founded
those two findings was a bare POST with no `Origin` header, which the
handler rejects by design. `docs/09` §7.1 run correctly returns **303**, and
on 2026-09-04 the function's own configuration and its deployed artefact
were read: `handler.handler`, six environment variables, and source files
byte-identical to the commit **§7 records**. ⚠️ **THIS SAID "both source
files … `02739ad`" AND THE REDEPLOY LATER THAT DAY MADE BOTH HALVES
FALSE** — three files now, and a later commit. A count and a commit are
§7's to hold; this line cites it. The paragraph above is preserved as what the pass
found; **only findings 10, 11 and 13 outlived it, and 10 and 13 are now
ruled** — see item 3 of the callout near the top of this file, which is the
current tally and this is not.
⚠️ **AND THE PASS RAN AFTER THE SITE PUBLISHED, WHICH IS THE ONE THING D20
RESTED ON AND NO LONGER HAS.** D20's reasoning is explicit that deferring
the claims pass is safe because *"nothing has shipped and there is no public
site, so every claims finding to date has been about a page no visitor can
reach."* That premise expired at cutover. It is recorded here rather than
argued: whether D20 needs amending is Pouya's call, and the honest version
of the trade is that the deferral bought nine build steps of speed and the
bill came due on a live page.
**Two things about the earlier result are worth carrying forward.** The pass found
no defect in any claim about Pouya, his credentials or his designations —
every one traced. What it found was **five over-reaches in glosses on
sourced legal material**, which is the failure mode a per-step claims run on
@@ -478,11 +821,116 @@ the decision is re-readable rather than re-litigated.
have flagged correct copy and demanded the struck form. It read §4 instead.
That is the fifth stale claim found in that file and it is not the agent's
to fix
- [ ] **Pouya has read every page against `AGENTS.md` §4.** The human pass. It is
the other half of D20 and it is not delegable — his reading is what the
per-step audit was traded for.
- [ ] **Memberships RE-CONFIRMED AGAIN, on the day of cutover**`AGENTS.md`
§12 **R10**, which is now an **event trigger and cutover is one of its two
- [x] ✅ **THE §Who can see it WORDING APPROVAL IS DISCHARGED — Pouya's ruling,
2026-09-02: *"the read-through is the approval."*** Q62 settled what the
section must **say** and he reserved the **wording**; he then ruled twice on
it the same day — Q63(a) approving with two trims, and the second ruling
cutting the section to four plain statements — and directed in terms that
nothing be held open waiting on a separate sign-off. ⚠️ **THIS TICK IS NOT
"THE TEXT HAS BEEN READ".** It records that the reserved approval has
**moved**, to the read-through blocker in the callout above and the item
below. Two ticked boxes stood here for one round — one for the ruling, one
for the text — because a single tick over changed copy is how an approval
requirement went missing the first time (`adversarial-reviewer`, round 2).
They collapse into this one only because the ruling collapsed them, and
the gate did not disappear: **it is blocker 2 in the callout above**, which
is the most-read place on this page rather than the least.
- [x]**CLOSED 2026-09-02 — Q64 IS MOOT: THE PARAGRAPH WAS DELETED.** It asked
whether anyone else holds the AWS root password or its MFA device, because
`/legal/privacy/` published *"has no programmatic key, and I hold it"* one
paragraph below *"the small number of people who administer it with me"*
where a reader takes it as **sole** custody, which nothing establishes.
Pouya's second ruling that day struck the sentence along with the rest of
the mechanics, so no page says anything about root and the question gates
nothing. The `TODO(pouya)` is gone from
`src/pages/legal/privacy.astro` with the paragraph that carried it.
⚠️ **THE FACT IS STILL UNESTABLISHED AND THAT DID NOT CHANGE.** §7 records
root as *held by Pouya*, which is not *held only by Pouya*; root is not an
IAM principal and cannot be simulated. **Nothing about root custody may be
published without asking him again** — the section comment in the page
source carries that bar, because "we deleted it" and "we checked it" are
the same green tick from three weeks away.
- [x] ✅ **Q63(b) ANSWERED 2026-09-02 — `info@smlcompany.ca` is a DELEGATED
MAILBOX: Pouya and administrative staff read it.** The page said *"anyone
who can reach that mailbox"*, which was true either way and answered the
mail half of the question on a lower standard than the table half. It now
states who reads it. The fact is in `AGENTS.md` §7 and **§12 R21's trigger
covers it** — like the AWS enumeration, nothing reports when a delegation
changes. ⚠️ **The answer reached FOUR sentences, not the one the question
named** — §Where it is stored twice, §How long it is kept once, §Who can
see it once — because *"my mailbox"* had been written as a personal one
throughout. That is the fifth partial sweep on this page's who-can-see-it
set; the section comment in `src/pages/legal/privacy.astro` enumerates
them **by opening phrase rather than by count** — deliberately, because the
count has been wrong twice, and this line carried "eight" for a round after
the comment itself had been corrected to nine (`adversarial-reviewer`,
round 2). Read the list, not a number
- [x]**DONE 2026-09-02 — Pouya has read every page against `AGENTS.md` §4.**
The human pass, the other half of D20 and not delegable — his reading is
what the per-step audit was traded for. **It returned one finding across 23
pages and that finding was not copy:** the favicon shipped with an opaque
cream ground. **The `/legal/privacy/` §Who can see it wording, the
SML Company Ltd consent line and `/med-arb/` as shipped are approved by
this read**, per his ruling that the read-through *is* the approval.
⚠️ **This does NOT discharge `claims-auditor`'s cutover pass**, which is a
separate item on this list: D20 traded the per-step machine audit for the
human pass **plus** one machine pass over the finished site, and one of
those two has now happened.
~~⚠️ **START WITH `/legal/privacy/` §Who can see it. IT IS BLOCKER 2 IN THE
CALLOUT ABOVE, AND THIS READ *IS* THE APPROVAL**~~ — **struck 2026-09-02:
the pass is DONE, and an unstruck imperative on a ticked item told an
operator to begin a read this page also records as finished, pointing at a
blocker that no longer exists** (`adversarial-reviewer`, round 2). What it
said remains true of what happened: Pouya ruled on
2026-09-02 that nothing waits on a separate wording sign-off, and he read
§Who can see it first. Every
sentence in it changed three times that day — Q62's ruling, Q63's, then the
ruling that cut it to **four plain statements** — and it is the only
section on the site whose subject lives entirely outside this repository.
**It is now four sentences and should take a minute**; that is the point of
the cut. The verified material behind them is in
`docs/reference/intake-table-access-verification.md` and `AGENTS.md` §7 if
he wants to check any of it, and **the page deliberately no longer cites
it**.
**Then read the two `/contact/` sentences against it, which is a judgement
rather than a defect** — `/contact/received/` says *"email me directly at
`info@smlcompany.ca` — that reaches me whether or not the receipt did"* and
`/contact/` says *"Send the form below, or email me directly"*. **Neither is
false and neither asserts exclusivity**; the mail does reach him. But
`/legal/privacy/` now discloses that administrative staff read that mailbox,
and a party who has just been told to send dispute detail *"directly"* to
the neutral may take more from the word than is true. A sweep of all 23
built pages found these two as the only other surfaces touching the point.
Raised as **consider**, not blocking, by `adversarial-reviewer` round 1.
✅ **THE THIRD SURFACE IS DECIDED — the CONSENT string now names the
corporation.** *"I consent to **SML Company Ltd** storing and using the
information in this form…"*, Pouya's ruling 2026-09-02, replacing the
natural person. It is the one sentence a submitter actually agrees to and
it is the PIPEDA basis, and the policy it links to describes a mailbox read
by administrative staff — a corporation is the party that matches, and
`/legal/privacy/` now names it in terms under §Why it is collected. **The sweep that had missed it was anchored on
mailbox vocabulary** (*"email me directly"*, *"reaches me"*), which is R8's
sharpest edge: the right command, the wrong anchor.
⚠️ **THE PAGE AROUND IT STILL SAYS "me" AND "I", AND THAT IS DELIBERATE, NOT
AN OVERSIGHT — read the two together and say if it reads wrong.** The
ruling changed the consent sentence and nothing else; `/legal/privacy/` is
written in the first person throughout (*"whatever you send me"*, *"in your
hands rather than mine"*), and `/contact/` is too. Nothing is false either
way — he is the accountable individual, the corporation holds the systems —
but the checkbox and the prose beside it now name different parties, and
**that is a judgement about voice which is his and not a reviewer's.**
- [x] ✅ **MEMBERSHIPS RE-CONFIRMED 2026-09-02 — Pouya: ADRIC, ADRIO, the three
OBA sections and the CTF are all current.** §4 and `src/data/site.ts` are
re-stamped `[verified 2026-09-02 — Pouya]`. ⚠️ **THERE ARE TWO ARRAYS AND
RE-STAMPING DOES NOT CHECK THAT THEY AGREE.** `CREDENTIALS.memberships`
feeds `/about/`'s visible list and `/bio/`; **`MEMBERSHIP_ORGS` feeds
`/process/` §Confidentiality and the `memberOf` triples**, and `site.ts`
records that the two differ on three of four lines. `_MembershipParity`
compares their lengths only, so a substitution passes `npm run check` in
silence. An earlier form of this line said `schema.ts` emitted from the
same constant (`adversarial-reviewer`, round 1).
**RE-ARM THIS FOR THE NEXT REPUBLISH — the row does not close.** `AGENTS.md`
§12 **R10**, which is an **event trigger and cutover is one of its two
events.** Q44 closed 2026-08-28 and the group is published on `/about/`
(ADRIC, ADRIO, the three OBA sections, the CTF, `[verified 2026-08-28 —
Pouya]`), so this item is no longer "publish them" — it is **"ask him
@@ -502,6 +950,38 @@ the decision is re-readable rather than re-litigated.
**§4 records yearly renewal for the OBA sections and the CTF only** — it
says nothing about ADRIC's or ADRIO's period, and an earlier version of
this line asserted "all renew yearly", which §4 does not support.
⚠️ **ASKED AND ANSWERED ON 2026-09-02 — that is why this is ticked, and
the distinction is the whole of R10.** It was ticked against a fresh
one-line confirmation from Pouya, not against the 2026-08-28 stamp: *a
stamp is not a renewal receipt.* The question to ask next time is
unchanged — *"are ADRIC, ADRIO, the three OBA sections and the CTF all
still current?"* — and the answer is followed by re-stamping **all four
stamp-bearing sites**: §4, `src/data/site.ts`, `src/data/schema.ts` and
**`src/pages/about.astro`**, which is the page that renders the group and
is therefore the file an editor most plausibly reads to check currency.
⚠️ **It was missing from this list and carried a stale date in the present
tense** (`adversarial-reviewer`, round 2).
⚠️ **NO RUNNING TALLY OF WHAT WAITS ON POUYA IS KEPT HERE ANY MORE.** This
line said "the only cutover item", was corrected to "one of three", and was
then rewritten as "the one item still waiting… is his own read-through" **in
the same change set that opened Q64** — wrong three times, in the same
direction each time: a count written while the change set was still adding
items. **The checkbox column is the tally.** Unticked items above are what
waits on him.
- [x] ✅ **THE SEVEN VOLATILE `docs/reference/` EXTRACTS RE-CHECKED — `AGENTS.md`
§12 R18, whose trigger is the same "before any cutover" event R10 uses.** ⚠️ **THIS ITEM DID NOT EXIST UNTIL 2026-09-02 AND THAT
WAS THE DEFECT**: R18 names a cutover as its trigger and the cutover
checklist carried no item to fire it, which is Q22's shape — a documented
control living somewhere it cannot run. R10 was on this list; R18 was not.
**Re-checked 2026-09-01 by Pouya. All seven hold and no shipped sentence
changed.** Three were re-verified against a source and four are held
unchanged on a cadence judgement rather than a fresh retrieval — the
distinction is stamped per limb in `AGENTS.md` §12 R18 and in each
extract, because "re-checked" and "not looked at, judged slow" are not the
same stamp and collapsing them is how OCNI lapsed.
**Re-fire this on the next republish that turns on one of them**, and note
that the fastest mover — Bill C-36 — needs no page edit while it sits at
second reading and needs one the day it does not.
- [ ] **The OBA sections stay listed; the LSO stays out** — a check that nobody
has tidied the two into one list, not an open question. `AGENTS.md` **Q51
answered 2026-08-28**: the Law Society is the **regulator**, so membership
@@ -588,7 +1068,29 @@ the decision is re-readable rather than re-litigated.
- [ ] Security headers present (`securityheaders.com` A or better)
- [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]`
- [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(SES production access itself is granted — Q19 closed.)*
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
- [ ] 🛑 **THE END-TO-END SUBMISSION TEST IS STILL OWED — `docs/09` §7.2.**
The route answers (§7.1 returns **303**), which is a different fact:
**§7.1 stops before any DynamoDB write and before any SES send, by
design.** What is unproven is that a real submission stores a record and
that **both** emails arrive — the notification and the inquirer's
confirmation, D18's whole point. ⚠️ **THIS ITEM DID NOT EXIST FOR ONE
ROUND.** Ticking "the intake form works" below removed the only unticked
line covering §7.2, so the one genuinely outstanding intake verification
lived inside an item marked done. Pouya has this in progress; §7.2 also
says to read `sourceIp` against `checkip` and to delete the test record
- [x] ✅ **THE INTAKE FORM WORKS — all three happened at cutover, 2026-09-02**,
and every one was re-verified against the live account on 2026-09-04:
`handler.handler` with six variables, one route `POST /api/intake`, and the
`/api/*` behaviour on the distribution. `docs/09` §7.1 returns **303**.
⚠️ **THIS ITEM READ "THE INTAKE FORM DOES NOT WORK YET" UNTIL 2026-09-04**,
unticked, near the top of the list an operator follows — the same staleness
as §7's two intake rows and from the same cause: the list was written under
D11 and never re-read after Part 5 ran. **What is still owed is §7.2**, the
real-submission test that proves both emails arrive; §7.1 stops before any
write and any send by design. **The original text follows, because the two
things it records are what made this hard and they are still true of the
code.**
**THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
IT DOES — build step 8 shipped the page and not the pipe.**
⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND
WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY
@@ -709,8 +1211,92 @@ the decision is re-readable rather than re-litigated.
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
identical content differ in digest and every re-render is a binary diff.
Re-commit it when something actually changed, and say what in the message
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**, via a CloudFront response-headers
policy. **This is the PDF half of a decision already taken for the page.**
- [ ] 🛑 **THE SPAM MITIGATIONS ARE HALF-SHIPPED BY A DEPLOY, AND THE HALF THAT
MATTERS IS NOT — 2026-09-04.** `scripts/deploy-local.sh` does an S3 sync
and a CloudFront invalidation and **nothing else**: it contains no Lambda
step `[verified 2026-09-04 — read]`. So `npm run deploy` ships the second
honeypot, because that is markup in `dist/contact/index.html`, and ships
**neither the check that reads it nor the spam scoring**, because both are
in `backend/intake/`. **The handler needs `docs/09` Part 5** — 5.1, 5.2,
5.3, then **5.4, and 5.5 if 5.4 fires**, which it did at cutover.
⚠️ **`spam-score.mjs` IS A THIRD FILE IN THE ZIP, AND SINCE 2026-09-04 BOTH
5.1 AND 5.5 DERIVE THE LIST FROM THE DIRECTORY RATHER THAN NAMING IT** —
they were hand-typed in both, with nothing checking they agreed, until the
review found it. A zip missing a module fails at cold start with
`Runtime.ImportModuleError` and every submission then 500s. Run
`node backend/intake/spam-score.test.mjs` (**39 of 39**) before packaging.
**There is no ordering hazard either way**: a form ahead of the handler
renders a field nothing checks, and a handler ahead of the form checks a
field nothing renders. Both are inert, so the only cost of doing one and
not the other is that the mitigation is not yet in force
- [x] **`CloudFront-Viewer-Address` forwarded on `/api/*` — CLOSED 2026-09-04
AS NOT AVAILABLE ON THIS PRICING PLAN, AND SUPERSEDED.** Pouya's ruling
after the third `--apply`: `update-distribution` rejected the change
atomically — *"Distributions with the Free pricing plan can't have the
following features: Custom origin request policy, Custom response headers
policy"* — so this is a **platform constraint, not a defect**. It is
**revisited only if the plan changes**; `configure.mjs` now parks section 5
instead of attempting it, and `AGENTS.md` §7 records the plan.
🛑 **SUPERSEDED, NOT MERELY PARKED: a WAF web ACL is already attached to
this distribution (`CreatedByCloudFront-f8fbf256`, §7), and that is where
any future per-IP rate rule belongs** — a forwarded viewer address was only
ever the means to an end this already provides. The original item is kept
below because its reasoning about the whitelist is what makes section 5
safe to un-park. ⚠️ **WRITTEN
2026-09-04, NOT APPLIED, AND NOW UNAPPLIABLE. Same `configure.mjs --apply`
run as the item below; not a deploy.** `infra/cloudfront/configure.mjs` §5 creates a custom
origin request policy `adr-sml-api-viewer-address` and points the `/api/*`
behaviour at it. Pouya's ruling of 2026-09-04, after the first real spam:
forward it **so per-IP measures become possible later — measured, not yet
acted on**. 🛑 **THIS IS THE ONLY CHANGE IN `configure.mjs` THAT REPLACES
RATHER THAN ADDS, AND IT REPLACES THE POLICY ON THE PATH THE INTAKE FORM
POSTS TO.** AWS has no behaviour meaning *"all viewer headers except Host,
plus a CloudFront header"* — `allExcept` can only subtract, and
`allViewerAndWhitelistCloudFront` forwards `Host` and 403s at API Gateway
(derived from the API's own enum, 2026-09-04). A **whitelist** is forced,
so the five listed headers are load-bearing: the handler's four `headerOf`
reads plus the new one. **A missing header does not error — every
submission would validate short and land on `/contact/could-not-send/`,
which reads as the inquirer's own browser misbehaving.** So `docs/09`
Part 3's `303` probe and its one-field rollback are **mandatory** after
this, not advisory. ⚠️ **AND THE HANDLER STILL STORES THE EDGE ADDRESS.**
Forwarding is infrastructure; **storing** the viewer address is a
`/legal/privacy/` change governed by `docs/09` §7.2's decision table, and
it is deliberately not made here
- [x] **`X-Robots-Tag: noindex` on `*.pdf` — CLOSED 2026-09-04 AS NOT AVAILABLE
ON THIS PRICING PLAN. A SUBSTITUTE SHIPPED IN ITS PLACE.** Same rejection
as the item above: a custom response headers policy is not available on the
Free plan, so this is a **platform constraint, not a defect**, revisited
only if the plan changes.
**The substitute is `Disallow: /pouya-lajevardi-bio.pdf` in
`public/robots.txt`** — it needs a **site deploy**, not a `configure.mjs`
run. ⚠️ **IT IS NOT AN EQUIVALENT AND `public/robots.txt` SAYS SO IN THE
FILE.** `Disallow` stops the PDF being **fetched**, which solves the
duplicate-of-`/bio/` problem this item was raised for; 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.
`docs/04` §Crawlability carries the general rule this is the exception to.
The original item follows, because its reasoning is what makes section 4
safe to un-park. ⚠️ **WRITTEN 2026-09-03, NOT
APPLIED, AND NOW UNAPPLIABLE. It needed a `configure.mjs --apply` run, not
a deploy** — the same run as the item above; one `--apply` did both.
`infra/cloudfront/configure.mjs` §4 creates a response-headers policy
`adr-sml-pdf-noindex` and a `*.pdf` cache behaviour carrying it. ⚠️ **S3
OBJECT METADATA CANNOT DO THIS, which is the natural first reach and was
the instruction this was implemented against.** `aws s3 sync --metadata`
writes USER metadata, which S3 returns as `x-amz-meta-x-robots-tag` — a
header no crawler reads. Only a literal `X-Robots-Tag` counts and S3's REST
endpoint will not emit one, so the mechanism is the response-headers policy
this line has specified from the start. ⚠️ **THE POLICY CLONES THE
SECURITY HEADERS AT RUN TIME RATHER THAN RETYPING THEM** — a
response-headers policy REPLACES rather than merges, and all five
(`strict-transport-security`, `x-content-type-options`, `x-frame-options`,
`x-xss-protection`, `referrer-policy`) were measured arriving on the live
PDF 2026-09-03, so a hand-written policy would have silently dropped them.
Verify after applying with `docs/09` Part 3's header block, which counts
each of the six separately — an alternation `grep` exits 0 on any one match
and would call a partial clone a pass. **This is the PDF half of a decision
already taken for the page.**
`/bio/` is `noindex` and excluded from the sitemap because it is a condensed
duplicate of `/about/` and `/fees/`, and *"two URLs competing on the same
content is the one thing `docs/04` is most concerned with."* The committed
@@ -720,7 +1306,55 @@ the decision is re-readable rather than re-litigated.
instead. A `Disallow` will not do it: a blocked URL can still be listed.
Found by `adversarial-reviewer`, 2026-08-31
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately
- [ ] Favicon set complete
- [x] ⚠️ **THE HEADSHOT SHIPS SOFT, AND IT IS A DEFERRED DECISION RATHER THAN A
DEFECT — Pouya, 2026-09-03. NO CHANGE.** ✅ **Ticked because the decision is
taken, not because anything was done** — an item recording a decision *not*
to act can never be ticked on completion, and leaving it open would stop
this checklist ever reading clean. He raised it on the live site;
measured 2026-09-03 and the cause is not the master and not the delivery.
**The master is fine** (1600×1600, 4:4:4, real detail at full size — a
1/2-scale round trip is visibly softer than it is) and **the srcset ladder
is correct** (9 device profiles in Chrome: ratios **1.001.21, no
upscaling anywhere**, `sizes` 476 px matching the measured rendered width
exactly). **The cause is that Astro passes no `quality`, so sharp's
per-format defaults apply — AVIF 50, WebP 80, JPEG 80 — and
`formats={['avif','webp']}` puts AVIF first, so every modern browser gets
the quality-50 encode.** At 960 px it retains **55%** of the reference's
high-frequency energy; WebP retains 85% and JPEG 95%, and neither is
served. Sweep at 960 px: q60 → 76% at 33 KB, q65 → 80% at 39 KB, **q70 →
90% at 51 KB**, q80 → 94% at 77 KB, against today's **21 KB**.
⚠️ **IT IS DEFERRED BECAUSE IT IS A REAL TRADE, NOT BECAUSE IT IS SMALL:**
the portrait is the LCP element from 768 px up, and **§7's Lighthouse row
records `/` at LCP 2.03 s** `[verified 2026-08-31 — lcp-breakdown-insight]`,
so +30 KB needs a fresh `npm run lighthouse` before it ships.
⚠️ **THAT IS NOT A MEASUREMENT AGAINST `docs/04`'s BUDGET AND MUST NOT BE
READ AS ONE.** `docs/04`'s < 2.0 s is a **Slow 4G field** figure; 2.03 s is
a local run under loopback throttling, which is why `npm run lighthouse`
*reports* LCP and does not assert it (§7). The two are close enough to look
comparable and are not the same measurement — so this is a reason to
re-measure before adding bytes, not a recorded budget breach. **Three call sites would be affected
and none sets `quality`** — `src/pages/index.astro`, `src/pages/about.astro`
and `src/components/InfinityMark.astro`; the mark is line art and would
want a different number from the portrait, so this is not one edit.
- [x] ✅ **Favicon set complete, and REGENERATED 2026-09-02 — it had shipped with
no transparency at all.** Pouya's read-through finding. All three frames
(16/32/48) declared a 32-bit alpha channel and then carried `alpha = 255`
on every pixel, the ground opaque cream — so the tab icon showed as a cream
rectangle on any dark tab strip. `public/favicon.ico` is now transparent,
regenerated by `npm run icons` from the committed master and verified
programmatically and by eye, on dark grounds and light.
⚠️ **`public/apple-touch-icon.png` STAYS OPAQUE CREAM AND MUST NOT BE
"FIXED" TO MATCH.** The reason is a platform behaviour — iOS composites a
transparent touch icon onto black — **stated by Pouya on 2026-09-02 and not
re-tested on a handset**; the item directly below is where it would be. The
touch icon is byte-identical across this change.
⚠️ **AND THE CHANGE IS NOT FREE ON DARK.** The maroon half of the ribbon
effectively drops out against a dark tab strip; the champagne half carries
the mark. **The figures are deliberately NOT repeated here** — they live in
`docs/reference/brand-assets.md` §The icon set, with the method, and a copy
on this page had already gone stale within a day by quoting the 32 px row
as if it were the general case (`adversarial-reviewer`, round 2). Read them
there.
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
- [ ] Tested at 320 px and at 200% zoom
- [x] ✅ **THE 200%-TEXT NAV OVERFLOW IS FIXED, 2026-09-01 — THIS ITEM IS
@@ -814,8 +1448,23 @@ the decision is re-readable rather than re-litigated.
Not a conformance failure of the same shape as Q61: a partial obscuring is
not SC 2.4.11, which is about a component **entirely** hidden. `docs/02`
§Reflow carries the 777-cell grid
- [ ] ⚠️ **THE `/med-arb/` GLOSS NEEDS A RULING — `claims-auditor`, D20 pass,
finding 1.** §Why this practice says *"Med-arb is not a third service
- [x] **THE `/med-arb/` GLOSS — RULED AND STRUCK, 2026-09-02.** Pouya:
*"strike the gloss sentence. The ADRIC-sourced material carries the page.
No replacement claim, no competence claim."* Applied: the definitional
gloss is struck. ⚠️ **AND SO IS THE DESIGNATIONS CLAUSE THAT THE FIRST
VERSION OF THIS ITEM SAID WAS "NOW THE LEDE" — corrected 2026-09-02,
`adversarial-reviewer` round 2, which found this line describing a page
state the same change set had deleted.** Round 1 of the closing claims
pass found that a bare `DESIGNATIONS_HELD_LINE` sitting directly beneath
§Rules' quotation of ADRIC requiring *"a high level of practitioner
competence"* read as meeting that bar. **§Why this practice now carries an
acceptance sentence and no credential claim at all**; `/about/` publishes
the designations and the JSON-LD carries them. The constraint is recorded
in the section's own comment in `src/pages/med-arb.astro`. **The original
finding is kept below unstruck**, because it is the reasoning behind a
paragraph that is deliberately thin, and a later reader who cannot see why
will fill it.
`claims-auditor`, D20 pass, finding 1: §Why this practice said *"Med-arb is not a third service
bolted onto mediation and arbitration; it is the two of them run under one
agreement"*, and one section above the page publishes ADRIC's own words:
med-arb is *"not merely the merging of separate mediation and arbitration
@@ -846,7 +1495,17 @@ the decision is re-readable rather than re-litigated.
that spec line is struck and an aggregate route throttle ships instead
(`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what
would do per-IP. Decide it on price, not on the spec's old wording — and do
not let anything describe the throttle that ships as per-IP
not let anything describe the throttle that ships as per-IP.
🛑 **THE PRICE QUESTION IS SMALLER THAN THIS ITEM ASSUMES, MEASURED
2026-09-04.** A web ACL is **already attached and already running** on the
distribution — `CreatedByCloudFront-f8fbf256`, 925 WCU, three AWS managed
rule groups, and **no rate-based statement** (`AGENTS.md` §7 and §9 Q65).
So this is not "buy WAF"; it is "add one rule to an ACL already being paid
for". ⚠️ **AND THE ROUTE THIS ITEM ASSUMED IS GONE:** the
`CloudFront-Viewer-Address` forwarding was parked as unavailable on the
pricing plan — but a rate-based rule matches on the viewer address itself
and never needed that header, so the capability is **superseded, not
blocked**
- [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER
MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document
+49
View File
@@ -249,6 +249,46 @@ paragraph this one used to point at.
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
offer tribunal-secretary work on the site.
### Med-arb — billed by phase
⚠️ **INTERIM. Set by Pouya 2026-09-03; reviewed at the twelve-month fee review,
`AGENTS.md` §12 R5.** It is stamped interim because it is the only rule on this
page set after the card was published rather than with it, and because it prices
an offering by reference to two other rows — if either moves at R5, this moves
with them and nobody will be reminded by a figure changing.
**The rule, and it carries no figure of its own:**
- Med-arb is billed **by phase**. The mediation phase is charged at the
**mediation** rates above. If the matter proceeds to arbitration, that phase is
charged at the **arbitration** rates above.
- **There is no separate med-arb fee.**
- The additional-party and cancellation terms apply to each phase **as they
apply to that process on its own**.
**Why this rule exists at all, because a fee page does not usually need one.**
`/fees/` opens *"Every figure is on this page"*, and `AGENTS.md` §4 Offerings
carries a **Med-Arb** row that this document priced nowhere. The promise was
therefore wider than the card — the D20 cutover claims pass, finding 10. Pouya
closed it by **pricing the offering rather than narrowing the promise**, which is
the more expensive of the two fixes and the one that leaves the page saying the
stronger thing.
⚠️ **DO NOT GIVE MED-ARB A RATE ROW.** A med-arb figure would be a fourth price
for a process that is already priced twice, and the first thing it would do is
disagree with one of them. The rule is expressed as a pointer to the two cards
above **on purpose**; that is what keeps the count of published figures the same
as the count of published rates.
⚠️ **"AS THEY APPLY TO THAT PROCESS ON ITS OWN" IS NOT "TO BOTH PHASES".** The
additional-party fee is a **mediation** row; the arbitration card has no
equivalent. The wording above invents nothing. *"The additional-party term
applies throughout"* would invent an additional-party charge in the arbitral
phase, which no ruling has set.
`FEES.medArb` in `src/data/site.ts` holds the three sentences and `/fees/`
renders them, so the rule is not retyped into the template.
### Other services — hourly
Early neutral evaluation, dispute-system design, and pre-dispute technical
@@ -313,6 +353,15 @@ for a reader with no counsel to catch it.)*
## Recorded dissent — for the 12-month review (R5)
⚠️ **SECOND ITEM FOR R5, ADDED 2026-09-04 — MED-ARB, AND IT IS NOT A DISSENT.**
It is here because **R5 names this section as where its items live**, and the
med-arb rule was stamped INTERIM against R5 in §Med-arb above and written into no
list the review actually reads. **The rule is derived** — each phase at the rates
for that process, no figure of its own — so **moving any mediation or arbitration
number at R5 moves the med-arb price with it, silently, with no diff on the
med-arb rule.** Nothing else on this page has that property. Check it against
whatever the review does to the two cards above.
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
recorded here so the 12-month review has something to test against, not to
re-open a settled decision.
+627 -28
View File
@@ -309,7 +309,41 @@ status, not the absence of an error.
---
## Part 3 — Apply the three distribution changes
## Part 3 — Apply the distribution changes (three of five; two are parked)
🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION AND THE SCRIPT NO
LONGER TRIES.** Pouya's ruling of 2026-09-04, after the third `--apply` reached
`update-distribution` and was rejected atomically:
```
An error occurred (InvalidArgument) when calling the UpdateDistribution operation:
Distributions with the Free pricing plan can't have the following features:
Custom origin request policy, Custom response headers policy
```
**A platform constraint, not a defect.** Both are closed in `docs/06` and
revisited only if the plan changes. `configure.mjs` gates them on
`PLAN_ALLOWS_CUSTOM_POLICIES` and reports them as **PARKED** — printed under
their own heading, **not counted as skips, and not affecting the exit status**,
because a constraint true on every run is not a signal.
⚠️ **THE PLAN IS NOT IN THE CLOUDFRONT API, WHICH IS WHY THIS IS A CONSTANT AND
NOT A PROBE.** Checked 2026-09-04 across **167 operations**: no operation, shape,
member or documentation string mentions a pricing plan. **`PriceClass_All`, which
this distribution carries, is the EDGE-LOCATION price class — a different and
much older concept. Do not read it as the plan.** The only signal AWS gives is
the rejection above, which is the thing the pre-flight exists to avoid.
**What replaces them.** The `X-Robots-Tag` is replaced by
`Disallow: /pouya-lajevardi-bio.pdf` in `public/robots.txt` — **a site deploy,
not a `configure.mjs` run** — which stops the PDF being *fetched* but does not
de-index the URL; the file itself carries that distinction. The viewer-address
forwarding is **superseded**: a WAF web ACL is already attached to this
distribution (`AGENTS.md` §7), and that is where a per-IP rule belongs.
**Everything below is the record of how sections 4 and 5 were built and why they
failed three times. Keep it: it is what makes them safe to un-park.** Changes 13
are unaffected and still apply.
One script, `infra/cloudfront/configure.mjs`, because the alternative is
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
@@ -321,26 +355,441 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
--function-arn "$ROUTER_ARN"
```
**Expect** — this is the dry run, and the output on a distribution in the state
Part 0.3 records is exactly:
⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES.
IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against
the **live** distribution the dry run is **59 lines**, and all 59 account for:
2 resolved-policy lines, **10** `·` lines, **3** `+` lines, a **40-line JSON
dump** of the `*.pdf` behaviour it would add, 2 blank lines, the
`N change(s) to distribution …` header and the `DRY RUN — nothing was sent.`
footer `[measured 2026-09-04, exit 0, nothing written]`.
🛑 **THE FIRST LINE OF OUTPUT NAMES THE `aws` BINARY AND ITS VERSION. READ IT.**
This script **rewrites the whole distribution config**, and botocore drops
members its own model does not know — so an old CLI reads a lossy config and
writes the loss back, on a distribution serving 23 pages and the intake form.
`--if-match` cannot catch that: the ETag is genuinely current.
⚠️ **THIS MACHINE HAS CARRIED TWO CLIs**, `2.34.53` and `2.11.15` (April 2023),
both on `PATH` `[measured 2026-09-04]`. The older model does not know
`GrpcConfig`, and `E1OK7G98KNKUTA` carries it on **two** behaviours — both
`{Enabled: false}`, so the round trip is lossless *in effect* today, and nothing
would report it when that stops being true. It is also missing `ConnectionMode`,
`VpcOriginConfig`, `CacheTagConfig` and five more. The script now **refuses below
a floor** rather than leaving it to `which`:
```
resolved aws = /opt/homebrew/bin/aws (2.34.53)
```
If that line names `/usr/local/bin/aws` or a version below the floor, the run
exits **2 before any AWS call**. Run `which -a aws` and fix `PATH` — do not
lower the floor to get through.
🛑 **READ THE EXIT STATUS, AND IT HAS THREE VALUES.** `0` — everything this
script manages was applied or is already present. `2` — a usage error, before any
AWS call. **`3` — sections that could run did, and something was SKIPPED: read
the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists
because a skip used to exit `0`, and this document uses `exit 0` as its own
success stamp throughout — so a partial run read as a complete one.
⚠️ **PARKED IS NOT SKIPPED, AND ONLY ONE OF THEM MOVES THE EXIT STATUS.** The two
pricing-plan items print under a `· … PARKED` heading and leave the status at
`0`: they are true on every run, and a signal that is always on is not a signal.
A **skip** is the unexpected kind — a source policy that vanished, a generated
payload that breaches a CloudFront limit, a handler-reads probe that matched
nothing — and the skip line always says which.
⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE
TOTAL.** Each item Parts 13 have already applied prints `·` when it is found
and `+` when it is staged, so in the Part 0.3 state four lines cross from one
column to the other and the totals move with them. **The `+` lines are the
check.**
**Expect** — ⚠️ **THE BLOCK BELOW IS THE PRE-PARKING SHAPE AND IS KEPT AS THE
RECORD OF WHAT SECTIONS 4 AND 5 WOULD HAVE ADDED.** On a distribution in the
Part 0.3 state **today** the last four `+` lines do not appear: those are
sections 4 and 5, and both park. Expect **four** `+` lines, the `· … PARKED`
block, and exit 0. On the distribution as it now stands, changes 13 are already
applied, so expect **no** `+` lines at all and `NOTHING TO CHANGE`. The change
lines, as they were:
```
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
4 change(s) to distribution E1OK7G98KNKUTA (ETag …):
8 change(s) to distribution E1OK7G98KNKUTA (ETag …):
+ DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router
+ CustomErrorResponses += 404 -> /404.html with status 404
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
+ create response-headers policy adr-sml-pdf-noindex (SecurityHeadersConfig cloned from … + X-Robots-Tag: noindex)
+ CacheBehaviors += *.pdf -> <s3-origin>, default cache policy, adr-sml-pdf-noindex (policy id created in the same --apply pass)
+ create origin request policy adr-sml-api-viewer-address (whitelist: CloudFront-Viewer-Address, Content-Type, Origin, Referer, User-Agent; cookies all; query strings all)
+ /api/* OriginRequestPolicyId b689b0a8-… -> adr-sml-api-viewer-address
DRY RUN — nothing was sent. Re-run with --apply to write it.
```
Fewer than four changes means part of this is already done — read which lines are
prefixed `·` (already present) and carry on. More than four, or a different set,
⚠️ **SECTION 4 CANNOT SHOW THE POLICY ID IN A DRY RUN, AND SAYS SO — IT IS
STILL ONE `--apply`.** The `*.pdf` behaviour has to carry the response-headers
policy's id, and on a first run that policy does not exist yet, so the dry run
prints the behaviour it *would* add with `(policy id created in the same --apply
pass)` where the id goes. **A single `--apply` creates the policy and adds the
behaviour in one call — do not run it twice.** The dry run reports both changes
either way; one that listed only the policy would hide the half that touches a
distribution serving 23 pages.
Fewer than eight changes means part of this is already done — read which lines
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
for *already present* **and** for *would CREATE / would SET / would ADD*, so the
prefix alone does not say whether a line is done or still pending. **On the live distribution as at
2026-09-04, after sections 4 and 5 were parked, the dry run returns **no `+`
lines at all** — changes 13 are already applied and 4 and 5 are parked, so it
prints `NOTHING TO CHANGE` and **exit 0**, with a two-line PARKED block above
it** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
means the distribution is not in the state 0.3 recorded: stop and re-read it.
🛑 **INCIDENT — TWO `--apply` ATTEMPTS FAILED ON 2026-09-04, FOR TWO DIFFERENT
REASONS, AND THE SECOND ONE LEFT A POLICY BEHIND.** The distribution is
unchanged after both. Read both before the next attempt.
**ATTEMPT 1 — nothing reached the distribution and nothing was created.**
Section 4's clone was sent to `create-response-headers-policy` verbatim and the
AWS CLI rejected it **client-side**, before the call left the machine:
```
An error occurred (ParamValidation): Parameter validation failed:
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override"
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy"
```
**The cause, and it generalises past this script: a config AWS hands back is not
necessarily a config AWS will accept.** `get-response-headers-policy` on
`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` — an empty
object standing for a member the policy does not define — and
`ResponseHeadersPolicySecurityHeadersConfig` has **no required members** while
**every one of its six members requires at least `Override`**. So an empty member
is always "undefined here" and is **never** a legal input.
⚠️ **AND THE SAME IS TRUE ONE LEVEL UP, WHICH THE FIRST FIX MISSED.** Every
sibling member of `ResponseHeadersPolicyConfig` also declares required fields —
`CorsConfig` five of them, `RemoveHeadersConfig` and `CustomHeadersConfig` a
`Quantity`, `ServerTimingHeadersConfig` an `Enabled` — while the container
itself requires only `Name`. So `{}` is a placeholder at **both** levels, and a
fix covering only the inner one turns the outer placeholder into a hard abort
instead of an omission. All of that is read out of the CLI's own service model,
not inferred from the symptom.
**State after the failure** ``[verified 2026-09-04 — `get-distribution-config`, `list-response-headers-policies --type custom`, `list-origin-request-policies --type custom`]``**:** `/api/*` still on
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, **no** `*.pdf` behaviour, **zero** custom
response-headers policies, **zero** custom origin request policies.
⚠️ **THE "ONE REVIEWABLE TRANSACTION" PROPERTY IS ABOUT THE DISTRIBUTION, NOT
THE ACCOUNT — AND THIS RECORD ASSERTED THE WIDER VERSION FOR ONE ROUND.**
`update-distribution` is the script's last call, so a throw above it does leave
the **distribution** untouched. But sections 4 and 5 each make their own write
first — `create-response-headers-policy` and `create-origin-request-policy` —
and the script's own comment on section 5's drift throw documents a reachable
path where section 4 has **already created `adr-sml-pdf-noindex`** when section
5 aborts. **So after any failed `--apply`, check for an orphaned policy as well
as for a changed distribution**, with both of these:
```bash
aws cloudfront list-response-headers-policies --type custom --output json \
--query 'ResponseHeadersPolicyList.Quantity'
aws cloudfront list-origin-request-policies --type custom --output json \
--query 'OriginRequestPolicyList.Quantity'
```
An orphan is harmless and self-healing — the next run finds it by name, matches
it and attaches it — **so do not delete it by hand.**
⚠️ **THE EXPECTED VALUES DIFFER BY WHICH FAILURE YOU ARE RECOVERING FROM.** After
**attempt 1** both returned `0` and nothing needed doing. After **attempt 2**
they return **`1` and `0`** — the response-headers policy is the orphan recorded
below, and `1` is the correct reading, not a second problem
`[verified 2026-09-04]`.
**Two changes came out of it.** The clone now **omits** any empty member at
either of those two levels — a `ResponseHeadersPolicyConfig` member, or a
`SecurityHeadersConfig` member — and the dry run **asserts** that the generated
config carries no empty object at any *other* level, naming the dotted path if
it does. The asymmetry is deliberate: those two levels are where AWS is known to
synthesise a placeholder, and anywhere else is unaccounted for and stops the
run rather than being discarded quietly. The assertion runs before every branch, so **the dry run now catches this
class** rather than an `--apply` discovering it — and if it ever does fire it
**skips section 4** rather than throwing, so `router.js` can still be
re-applied.
**The proof is a command rather than a session**, which is the point of
`infra/cloudfront/policy-shapes.mjs` existing as its own module — `configure.mjs`
reads argv and calls AWS at import time, so the two functions could not
otherwise be reached:
```bash
node infra/cloudfront/policy-shapes.test.mjs
```
**Expect** `policy-shapes: 53 of 53 cases pass`, exit 0. Its first case is this
incident verbatim — the live `SecurityHeadersConfig`, empty
`ContentSecurityPolicy` and all. The same suite covers attempt 2's limit checks,
so this is the only command in this Part that proves both.
---
**ATTEMPT 2 — section 4 SUCCEEDED, section 5 FAILED, and the run left an orphan.**
With the clone fixed, `create-response-headers-policy` created
**`adr-sml-pdf-noindex` = `51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
then failed:
```
An error occurred (InvalidArgument) when calling the CreateOriginRequestPolicy
operation: The parameter Comment is too big
```
Its `Comment` was **182 characters** against a **128** cap. `update-distribution`
never ran, so the distribution is untouched — **but the account now holds a
response-headers policy that no behaviour references.**
🛑 **DO NOT DELETE THAT POLICY BY HAND.** This is the orphan case Part 3 and §7
predicted before it happened, and the recovery is **measured, not asserted**
`[measured 2026-09-04, dry run, exit 0, nothing written]`: the next run finds it
**by name**, matches it on every reconciled field, and stages it for attachment —
```
· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour
+ CacheBehaviors += *.pdf -> …, adr-sml-pdf-noindex (51c4e79b-d9c6-4c6f-907c-dbb0e73dd374)
```
— with the **create line gone** and the change count down from 4 to 3. **No
duplicate and no name collision.** (A collision would not be silent either: a
duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a
different error from the `InvalidArgument` above. The script never reaches it,
because it looks the policy up by name first.)
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` kept its original
118-character text while the script carries a shorter one. Deliberate: adding
`Comment` to the drift check would have thrown on that policy and blocked the
run that attached it.
---
**ATTEMPT 3 — both policies were created, and `update-distribution` rejected the
whole change atomically.** With the `Comment` shortened, section 5's create
succeeded too, so the run reached the last call and was refused there:
```
An error occurred (InvalidArgument) when calling the UpdateDistribution operation:
Distributions with the Free pricing plan can't have the following features:
Custom origin request policy, Custom response headers policy
```
**The distribution was unchanged — but two orphaned policies were left**,
`51c4e79b-…` and `e88b32be-…`, both since **deleted by Pouya on 2026-09-04**. The
account is clean: **zero** custom response-headers policies, **zero** custom
origin request policies `[verified 2026-09-04]`.
⚠️ **THE "DO NOT DELETE THE ORPHAN BY HAND" GUIDANCE ABOVE WAS RIGHT FOR A
RECOVERABLE RUN AND IS NOW MOOT.** It rested on a later run adopting the policy
by name — which it did, measured — but a run that can never apply cannot adopt
anything. Deleting them was correct once the sections were parked.
⚠️ **EACH ATTEMPT GOT ONE STEP FURTHER AND THE LAST FAILED AT THE LAST CALL.**
That is precisely the case the pre-flight was built to prevent, and it could not:
the constraint is not in the payload, it is on the account. **Both sections now
stop before creating anything at all** — see the top of this Part.
**WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** 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 128 cap is not modelled
as a constraint at all: on both policy configs `Comment` is a bare `string`, and
the number lives in the shape's **`documentation` prose**.
⚠️ **THAT NAMES ONE VALIDATOR, DELIBERATELY.** This machine also carries
`aws-cli/2.11.15`, which is PyInstaller-frozen and whose `validate.py` cannot be
read — unchecked, not confirmed. The claim that holds without qualification is
the narrower and more useful one: **the 182-character `Comment` reached the API
and came back `InvalidArgument`, so nothing stopped it on the CLI that ran.**
That is why the pre-flight below had to be built rather than relied upon.
**BOTH POLICY COMMENTS ARE NOW UNDER 80 CHARACTERS**, and the dry run enforces
the limits it knows about — `infra/cloudfront/policy-shapes.mjs`,
`PAYLOAD_LIMITS`, one entry per limit with the source it came from. **Every
entry with a cited AWS source is enforced by the service and by nothing local**
— see below. Two entries are stamped `[assumed]` and are not: no AWS source
states a policy **name** length, and those two constrain nothing this script
sends (our names are 19 and 26 characters). A breach **skips its section** rather than throwing, and — the part that
matters — **skips the whole section**, so a policy that is not created is never
staged for attachment. The suite named earlier in this Part
(`node infra/cloudfront/policy-shapes.test.mjs`) covers both the 182-character
`Comment` that failed here and the 118-character one that did not.
---
**One of the `·` lines carries a number worth reading**, and it is not a
warning:
```
· cloning 5 defined security header(s); omitting 1 the source does not define (ContentSecurityPolicy)
```
**Five is the number to read.** It is the count of security headers the PDF
policy will carry, and the verification block at the end of this Part counts the
same five arriving on the live PDF. A drop in this number is a partial clone
announcing itself one step earlier than that `curl` would.
⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.**
Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations
alone` and skips change 1 — which is right on a re-run and wrong on a first one,
and the two look identical in a count.
🛑 **SECTION 5 IS THE ONLY ONE THAT REPLACES SOMETHING, AND WHAT IT REPLACES IS
ON THE INTAKE FORM'S PATH.** Sections 14 add. Section 5 swaps the origin request
policy on `/api/*` from `Managed-AllViewerExceptHostHeader` to a **whitelist** of
five headers, because AWS has no behaviour meaning "all viewer headers except
Host, plus a CloudFront header" — `allExcept` can only subtract, and
`allViewerAndWhitelistCloudFront` drags `Host` along and 403s at API Gateway.
Whitelisting is therefore forced, and the cost is that **a header missing from
that list is a header the handler never sees.** The list is the handler's four
`headerOf` reads plus `CloudFront-Viewer-Address`. The check prints the names,
so they can be compared to the whitelist rather than counted:
```bash
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \
| sed "s/.*'\(.*\)'/\1/" | sort
```
**Expect** exactly `content-type`, `origin`, `referer`, `user-agent`.
⚠️ **`grep -n "headerOf(event"` WAS PRESCRIBED HERE AND RETURNS FIVE** — it
matches `function headerOf(event, name)`, the definition itself — so an operator
comparing it against a documented "four" concludes the handler grew a read.
🛑 **THE THREE PROBES BELOW ARE MOOT WHILE SECTION 5 IS PARKED** — nothing
replaces the origin request policy on `/api/*`, so there is nothing for them to
catch. **They become mandatory again the moment `PLAN_ALLOWS_CUSTOM_POLICIES` is
flipped**, which is why they stay here rather than being deleted. Part 7.1's
probe is unaffected and still applies.
**The failure mode is not an error.** Every submission would validate short and
redirect to `/contact/could-not-send/` — a real inquirer would read it as their
own browser misbehaving, and nothing would appear in a log as a fault. So the
block below is **not optional after an `--apply` that includes change 8**, and there are
**three** of them. The first is Part 7.1's probe with its output read differently
— **not "unchanged", which this said for one round**: §7.1 pipes into `head -12`
and reads the status by eye, while these read curl's own exit status and count
the `location` separately.
**Run all three, in this order, and each answers a different question:**
| # | probe | what only it can tell you |
|---|---|---|
| 1 | `Origin` + body | `Origin` is still forwarded — a **403** means it is not |
| 2 | `Referer`, no `Origin` | the Firefox fallback still works — nothing else tests it |
| 3 | honeypot value | the **body parsed** — probes 1 and 2 return the same 303 whether it did or not |
**PROBE 1 — is `Origin` still forwarded?**
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Origin: https://adr.smlcompany.ca' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'probe=1' -o /tmp/api.h
echo "curl_exit=$?" # curl's OWN status, on its own line
head -1 /tmp/api.h
grep -ic '^location: .*could-not-send' /tmp/api.h
```
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1`. A **403** here means the
`Origin` header is no longer reaching the handler — i.e. the whitelist dropped
it — and the form is broken for everyone.
**PROBE 2 — the `Referer` fallback, which nothing else tests.** The handler
accepts `Referer` when `Origin` is absent (Firefox omits `Origin` on some
same-origin form navigations), so a whitelist that forwarded `Origin` and dropped
`Referer` passes probe 1 and fails for exactly those users:
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Referer: https://adr.smlcompany.ca/contact/' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'company_website=probe' -o /tmp/api3.h
echo "curl_exit=$?"
head -1 /tmp/api3.h
grep -ic '^location: .*contact/received' /tmp/api3.h
```
**Expect** `curl_exit=0`, `303` and `1` `[verified against production 2026-09-04
— it returns 303 today, on the managed policy]`. A **403** means `Referer` is not
being forwarded.
🛑 **PROBE 3, AND NEITHER OF THE FIRST TWO CAN REPLACE IT: THEY CANNOT FAIL IN THE
INTERESTING DIRECTION.** `303 →
could-not-send` is what the handler returns **both** when it parsed the body and
found an empty submission **and** when `parseBody` threw because
`Content-Type` never arrived. Two opposite outcomes, one status, one location —
so a dropped `Content-Type` reads as a pass. This probe separates them, and
**writes nothing and sends nothing**:
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Origin: https://adr.smlcompany.ca' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'company_website=probe' -o /tmp/api2.h
echo "curl_exit=$?"
head -1 /tmp/api2.h
grep -ic '^location: .*contact/received' /tmp/api2.h
```
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1` — location
`/contact/received/`, **not** `could-not-send`. That is the honeypot branch: it
is reached **only if the body parsed**, and it returns before validation, before
any DynamoDB write and before any SES send, so it leaves no record and sends no
email. `could-not-send` here means the body did not parse — `Content-Type` is
missing from the whitelist. **Roll back.**
⚠️ **IT DEPENDS ON THE HONEYPOT'S NAME** (`company_website`, `fields.mjs`). If
that is ever renamed, this probe degrades to the `could-not-send` branch — which
reads as a failure and starts an investigation, not as a pass. That direction is
the safe one; keep it that way if you change the probe.
**ROLLBACK, and it is one field.** Do not debug a broken intake form in place:
```bash
# ⚠️ THIS RETURNS THE ID THE BEHAVIOUR HAS NOW — which, if change 8 applied, is
# the whitelist you are rolling back FROM, not the value to restore. The value to
# restore is the managed id on the line below. Run this to confirm which state
# you are in, then PUT the managed id back with
# update-distribution --if-match. ⚠️ NOT by re-running configure.mjs: section 5
# converges FORWARD and cannot tell a deliberate revert from a first run — the
# two are byte-identical in the config — so --apply would re-attach the
# whitelist and put the form back in the state you are rolling back from.
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query 'DistributionConfig.CacheBehaviors.Items[?PathPattern==`/api/*`].OriginRequestPolicyId'
# Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
```
Set that behaviour's `OriginRequestPolicyId` back to
`b689b0a8-53d0-40ab-baf2-68738e2966ac` and `update-distribution` with the current
ETag. `configure.mjs` prints the same id on the line it changes, prefixed ``, at
the moment it changes it.
⚠️ **THE HANDLER STILL STORES THE EDGE ADDRESS AFTER THIS.** Forwarding the
header does not change what is recorded, and it must not be made to as a
follow-up edit: what the record holds is published field by field on
`/legal/privacy/`, so storing `CloudFront-Viewer-Address` is a **disclosure**
change governed by §7.2's decision table, not a code tidy. Pouya's ruling of
2026-09-04 is *measured, not yet acted on*.
⚠️ **AND `adr-sml-pdf-noindex` IS RECONCILED ON EVERY RUN, NOT ONLY CREATED.** A
response-headers policy **replaces** rather than merges, so the PDF policy has to
carry everything the default behaviour's policy carries. If they have diverged —
someone adds the `Content-Security-Policy` or `Permissions-Policy` that
`docs/05` specifies to one and not the other — the script **throws and names the
diff** rather than passing. That is deliberate: the failure it guards is the PDF
being served different headers from the pages, which is silent.
```bash
node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" \
--function-arn "$ROUTER_ARN" --apply
@@ -352,11 +801,64 @@ echo "deployed: $?"
```bash
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query 'DistributionConfig.{Fn:DefaultCacheBehavior.FunctionAssociations.Items[].EventType,Err:CustomErrorResponses.Items[].{Code:ErrorCode,Page:ResponsePagePath,Status:ResponseCode},Beh:CacheBehaviors.Items[].{P:PathPattern,O:TargetOriginId,Methods:AllowedMethods.Items},Origins:Origins.Items[].Id}'
--query 'DistributionConfig.{Fn:DefaultCacheBehavior.FunctionAssociations.Items[].EventType,Err:CustomErrorResponses.Items[].{Code:ErrorCode,Page:ResponsePagePath,Status:ResponseCode},Beh:CacheBehaviors.Items[].{P:PathPattern,O:TargetOriginId,RHP:ResponseHeadersPolicyId,Fn2:FunctionAssociations.Items[].EventType,Methods:AllowedMethods.Items},Origins:Origins.Items[].Id}'
```
**Expect:** `Fn: ["viewer-request"]`; one error response `404 → /404.html → 404`;
one behaviour `/api/*``intake-api` with POST in its method list; two origins.
**two** behaviours — `/api/*` → `intake-api`, POST in its method list, **no
`RHP` and `Fn2: null`** (the association is withheld there deliberately: a 301
would turn the form's POST into a GET and drop the body), and `*.pdf` → the S3
origin **with an `RHP` id and `Fn2: ["viewer-request"]`**; two origins.
⚠️ **THAT QUERY DOES NOT PROJECT `OriginRequestPolicyId`, SO IT CANNOT SEE
CHANGE 8.** Read it separately rather than concluding anything from its absence:
```bash
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query 'DistributionConfig.CacheBehaviors.Items[].{P:PathPattern,ORP:OriginRequestPolicyId}'
```
**Expect** `/api/*` carrying the **`adr-sml-api-viewer-address`** id — *not*
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced
and is what a rollback restores.
🛑 **THE HEADER CHECK BELOW CANNOT PASS WHILE SECTION 4 IS PARKED, AND THAT IS
NOT A REGRESSION.** No response-headers policy is attached to `*.pdf`, so
`x-robots-tag` will read `0` — the substitute is `Disallow:` in
`public/robots.txt`, verified by fetching `/robots.txt`, not by fetching the PDF.
⚠️ **THE OTHER FIVE STILL MATTER AND SHOULD STILL READ `1`**: they come from the
**default behaviour's** policy, which is untouched, so a `0` among them is a real
regression and nothing to do with the parking. Run it that way — five `1`s and a
`0` — or skip it until the sections are un-parked.
**Then verify the header actually arrives, because the config landing is not the
same fact:**
```bash
curl -D /tmp/pdf.h -o /dev/null "$SITE/pouya-lajevardi-bio.pdf"
echo "curl_exit=$?" # curl's OWN status, on its own line
for h in x-robots-tag strict-transport-security x-content-type-options \
x-frame-options x-xss-protection referrer-policy; do
printf '%-28s %s\n' "$h" "$(grep -ic "^$h:" /tmp/pdf.h)"
done
```
**Expect** `curl_exit=0` and **`1` against every one of the six** — the five
security headers *and* `x-robots-tag`.
⚠️ **THE SHAPE OF THIS BLOCK IS THE POINT, and its first version got all three
wrong.** It piped `curl -sI` into one `grep -E` with six alternatives and read
`$?`. That reports **grep's** status, not curl's, so a DNS failure, a TLS failure
and a 5xx all read as `exit=1` — indistinguishable from "the headers are
missing", with `-s` deleting the message that would have told them apart. And an
alternation exits **0 if ANY ONE** matches, so `exit=0` would not have meant the
five arrived, which is the only regression the block exists to catch. Counting
each header separately is what makes a partial clone visible. (`CLAUDE.md`: never
suppress stderr, never read a pipeline's status as its first command's, and a
uniform pass is the result that ends a check rather than starting one.)
⚠️ **If any of the five security headers reads `0`, the policy did not clone them
and the PDF has LOST headers it had before this change.**
---
@@ -389,16 +891,40 @@ after 8.4, when both halves are true at once.
### 5.1 Package
🛑 **THREE FILES SINCE 2026-09-04, AND THE ZIP FOLLOWS NO IMPORT.**
`handler.mjs` imports both `./fields.mjs` and `./spam-score.mjs`; a zip missing
either fails at cold start with `Runtime.ImportModuleError` and every submission
then 500s. **The list is now derived from the directory** — `ls *.mjs` minus the
tests — in this step and in 5.5, so a new module is packaged without editing
anything. It was typed out in both until 2026-09-04, and this banner still said
so, fifteen lines above the paragraph that says otherwise.
```bash
rm -f /tmp/intake.zip
(cd backend/intake && zip -q -X /tmp/intake.zip handler.mjs fields.mjs)
(cd backend/intake \
&& echo "packaging: $(ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')" \
&& zip -q -X /tmp/intake.zip $(ls *.mjs | grep -v '\.test\.'))
unzip -l /tmp/intake.zip
```
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root —
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at
cold start.
⚠️ **THE LIST IS SUBSTITUTED DIRECTLY, NOT HELD IN A VARIABLE, AND THAT IS NOT
STYLE.** A first version read `MODULES=$(ls …)` then `zip … $MODULES`. **In zsh
that packages ONE file whose name is all three joined by newlines** — zsh does
not word-split parameter expansions, only command substitutions — so it fails on
the shell this project is actually operated from while working in bash.
`CLAUDE.md` names this trap; it was reintroduced here and caught by running the
block in both shells rather than by reading it.
**Expect:** exactly three entries — `handler.mjs`, `fields.mjs`,
`spam-score.mjs` — **42,604 bytes uncompressed and 18,462 zipped**
`[measured 2026-09-04]`. All three at the zip root: the imports are `./`-relative,
so a nested directory breaks them at cold start. *(This read "two entries, ≈ 25.7
KB / ≈ 10.8 KB" `[measured 2026-09-01]`, before the scorer existed.)*
⚠️ **`spam-score.test.mjs` IS NOT IN THE ZIP AND MUST NOT BE.** Run it at a
keyboard — `node backend/intake/spam-score.test.mjs`, **39 of 39** — before
packaging. It is the only check on the scorer, whose failure mode is labelling
real inquiries rather than throwing.
### 5.2 Configuration first, code second
@@ -447,11 +973,22 @@ aws lambda get-function-configuration --function-name "$FN" \
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
```
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`.
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000",
which was 5.1's uncompressed figure applied to a different quantity — an
operator seeing `10819` against an expectation of 23,000 would reasonably
conclude the wrong artefact went up.
**Expect** `Update: Successful`, and a `CodeSize` that says **which path you
took** — it is the ZIP, not the source:
| path | expected `CodeSize` |
|---|---|
| 5.1's plain three-file zip | **≈ 18,462** |
| 5.5's bundled variant | **low single-digit MB** — it was **3,307,021** on 2026-09-02 `[measured 2026-09-04 — get-function-configuration]` |
🛑 **5.5 IS THE PATH THAT WAS ACTUALLY TAKEN AT CUTOVER.** The live function
carries the bundled zip, so **a redeploy that runs 5.1 and stops would replace it
with an unbundled one and reintroduce the `Runtime.ImportModuleError` 5.5 exists
to fix.** Run 5.4 after 5.3, every time, and follow it to 5.5 if it fires.
*(This line said "≈ 10,800", and before that "around 23,000" — 5.1's uncompressed
figure applied to a different quantity. Both were written against the unbundled
path, which is not the one in production.)*
### 5.4 Prove it loads, without writing anything
@@ -492,16 +1029,38 @@ Versions are resolved from the registry at run time rather than pinned in this
file: `CLAUDE.md`'s rule is that a version is checked against the registry and
never recalled, and a literal here would be stale the week after it was written.
⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled
zip.** ⚠️ **THE TWO RESOLVED VERSIONS AND THE COMMIT THE DEPLOYED SOURCES MATCH
ARE IN `AGENTS.md` §7 AND ARE DELIBERATELY NOT REPEATED HERE.** They were
repeated here until 2026-09-04, and the paragraph directly above is the argument
against it — **the literals went stale in two days rather than a week**: the
2026-09-04 redeploy moved both packages one patch and added a third source file,
and this copy still named the old versions and a superseded commit while reading
as a measurement. Read §7's two Lambda rows; this step's duty is to **update**
them, not to mirror them.
✅ **THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.**
They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and
with nothing checking that the two agreed — so a module added to one and not the
other would ship from whichever path the operator happened to take. Both now read
the directory.
```bash
rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle
cp backend/intake/handler.mjs backend/intake/fields.mjs /tmp/intake-bundle/
echo "bundling: $(cd backend/intake && ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')"
(cd backend/intake && cp $(ls *.mjs | grep -v '\.test\.') /tmp/intake-bundle/)
# ⚠️ ASSERT THE COPY LANDED. A glob that matches nothing makes `cp` fail, `zip`
# succeed on an empty set, and `update-function-code` upload a bundle with no
# handler — a silent failure that only shows up as 5xx on the live form.
test -f /tmp/intake-bundle/handler.mjs || { echo "FATAL: handler.mjs not copied"; exit 1; }
echo "copied: $(ls /tmp/intake-bundle/*.mjs | wc -l | tr -d ' ') module(s)"
( cd /tmp/intake-bundle \
&& npm init -y > /dev/null \
&& npm install --omit=dev --no-audit --no-fund \
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
rm -f /tmp/intake.zip
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip handler.mjs fields.mjs node_modules package.json )
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip $(ls *.mjs) node_modules package.json )
unzip -l /tmp/intake.zip | tail -1
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
aws lambda wait function-updated --function-name "$FN"
@@ -634,9 +1193,20 @@ have named the cause — API Gateway's `{"message":"Not Found"}` — is replaced
you see it. **Check the route first; it is one command:**
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
**403** means the `Origin` header did not arrive — check that the behaviour uses
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns
every real submission into a 403. **500** means Part 6.1 was skipped.
**403** means the `Origin` header did not arrive, and **as of 2026-09-04 there
are two policies it could be** — read which one the behaviour carries before
repairing:
- **`adr-sml-api-viewer-address`** (Part 3, change 8) — a **whitelist**. If
`Origin` is missing from its Headers list, or the list drifted, every real
submission 403s. Roll back by PUTting the managed id below with
`update-distribution --if-match` — **not** by re-running `configure.mjs`,
which converges forward and would re-attach the whitelist.
- **`Managed-AllViewerExceptHostHeader`** (`b689b0a8-53d0-40ab-baf2-68738e2966ac`)
— what it replaced, and what a rollback restores.
Either way, a policy that drops `Origin` turns every real submission into a 403.
**500** means Part 6.1 was skipped.
### 7.2 A real submission, from the real form
@@ -672,7 +1242,7 @@ AWS address, not yours. Three outcomes and each has a different consequence:
| what `sourceIp` holds | what it means |
|---|---|
| an **AWS** address (not the `checkip` value) | As designed. The field records the CDN, so it **cannot serve abuse investigation**, and `/legal/privacy/`'s *"your IP address"* is inaccurate — fold it into the Q62 edit on the same page rather than leaving two wrong sentences there |
| **your** address, matching `checkip` | Better than expected, and worth knowing before anyone relies on it. Do not conclude it is trustworthy: verify it is not simply echoing a header by resubmitting with `-H 'X-Forwarded-For: 8.8.8.8'` and confirming `8.8.8.8` is **not** what lands |
| **your** address, matching `checkip` | Better than expected, and worth knowing before anyone relies on it. Do not conclude it is trustworthy: verify it is not simply echoing a header by resubmitting with `-H 'X-Forwarded-For: 8.8.8.8'` and confirming `8.8.8.8` is **not** what lands. ⚠️ **AND CORRECT `/legal/privacy/` §What is collected**, whose network-address paragraph says the address is *"normally the network's own rather than your connection's"* — wrong in this branch, and it understates what is held about the reader. This row carried **no instruction at all** until 2026-09-02, so two of these three outcomes had nothing reconciling the page with the measurement (`adversarial-reviewer`, round 2) |
| `8.8.8.8` after that resubmission | **Stop.** The field is client-controlled and a record can be made to name an uninvolved third party. Revert to storing nothing rather than storing that |
⚠️ **AN EARLIER REVISION OF THE HANDLER READ `x-forwarded-for` HERE, AND THAT WAS
@@ -682,8 +1252,19 @@ the client sent. The fix, if a usable value is wanted, is a **custom** origin
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
CloudFront generates and overwrites — not the managed
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
at API Gateway. That is an infrastructure change and it is deliberately not in
this runbook: measure first.
at API Gateway. 🛑 **THAT CHANGE IS PARKED AND WILL NOT BE APPLIED — the pricing plan forbids a
custom origin request policy (Part 3, and `AGENTS.md` §7).** So the handler
stores the edge address and will keep doing so. **The capability it was for is
superseded, not lost**: a rate-based rule on the web ACL already attached to this
distribution matches the viewer address directly and needs no forwarded header
(§9 Q65). ⚠️ **Read the rest of this paragraph as the reasoning that makes the
change safe to un-park, not as a pending action.** This paragraph said it was
*"deliberately not in this runbook: measure first"*, which was true until the
ruling and false afterwards. **Forwarding the header does not change what is
stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the
decision table above is still the procedure for changing that, because what the
record holds is published field by field on `/legal/privacy/`. Measure first
still governs the STORING, not the forwarding.
**Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24
months out — read it, do not assume it:
@@ -860,9 +1441,27 @@ Each of these is independent. None of them needs the others undone first.
Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes.
**9.2 Parts 23** — re-run `configure.mjs` is *not* a rollback; it is idempotent
forward-only. To undo, `get-distribution-config`, remove the
forward-only, **and that now matters most for change 8**: section 5 re-attaches
the `/api/*` whitelist on the next `--apply`, because a deliberately reverted
behaviour and a never-configured one are byte-identical in the config and no
detector can separate them. To undo, `get-distribution-config`, remove the
`FunctionAssociations` entry / the `404` custom error response / the `/api/*`
behaviour and the `intake-api` origin, and `update-distribution --if-match`. Then
behaviour and the `intake-api` origin, and `update-distribution --if-match`.
**Sections 4 and 5 were added after this paragraph and undo the same way:**
put `/api/*`'s `OriginRequestPolicyId` back to
`b689b0a8-53d0-40ab-baf2-68738e2966ac` (the managed policy) and/or remove the
`*.pdf` behaviour, with `update-distribution --if-match`. The two custom policies
`adr-sml-api-viewer-address` and `adr-sml-pdf-noindex` can then be deleted with
`delete-origin-request-policy` / `delete-response-headers-policy`, each of which
**fails while still attached** — the same ordering feature as the function below.
⚠️ **Deleting the policies does not prevent re-attachment either** — the next
`--apply` simply creates them again by name and attaches them. Nothing in this
script can be made to remember a deliberate revert, because a reverted behaviour
and a never-configured one are byte-identical in the config. **The rollback holds
only until someone runs `configure.mjs --apply` again**; that is a property of a
forward-converging script, and the fix if it ever matters is a flag, not a
deletion. Then
`aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which
fails while the function is still associated — that ordering is a feature.
+12
View File
@@ -13,6 +13,18 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
> date and effective date below is as at the retrieval date and nothing more.
> Re-check before cutover, and before any republish that turns on one of them.
> ### R18 re-check — cutover pass, 2026-09-01
>
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
> the sources; this stamp does not restate them. **The quoted bytes below
> are still the original retrieval and were not re-fetched** — what was
> re-checked is whether the *facts* they support have moved.
>
> - **(g) ADRIC's Code of Ethics, quoted verbatim on `/process/` — NOT
> RE-RETRIEVED.** Held unchanged on a cadence judgement `[assumed
> 2026-09-01 — Pouya]`. It is the slowest-moving of the seven; the live link
> on `/process/` is what makes a stale quotation visible to a reader.
**Topic as researched:** The Canadian ADR institutions this practice names, and the exact form of their names — ADRIC / ADRIO rule sets, designations, codes; ADR Chambers; early neutral evaluation and dispute-system design
---
+13
View File
@@ -10,6 +10,19 @@ construction. This is the same fetch-before-writing that caught
**It caught one immediately. See Finding 1.**
> ### R18 re-check — cutover pass, 2026-09-01
>
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
> the sources; this stamp does not restate them. **The quoted bytes below
> are still the original retrieval and were not re-fetched** — what was
> re-checked is whether the *facts* they support have moved.
>
> - **(f) the ADRIC National Mediation Rules, under review by ADRIC's own
> committee — NOT RE-RETRIEVED.** Held unchanged on a cadence judgement
> rather than a fresh fetch `[assumed 2026-09-01 — Pouya]`. The digests in
> the Provenance table below are the 2026-08-28 bytes and were not
> recomputed, so this stamp says nothing about whether the page changed.
## Provenance
| | |
+148 -3
View File
@@ -5,9 +5,22 @@ is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the
infinity mark was reconstructed wrongly and **two adversarial review passes could
not catch it**, since the real artwork was not in the repo to compare against.
**Every measurement below is `[verified 2026-08-26]`** — computed with `sharp`
against the files in this repository, and re-derivable by anyone from the
commands given. Nothing here is quoted from an external source.
**The measurements here are computed with `sharp` against the files in this
repository**, and are re-derivable by anyone from the commands given.
**Unless a section says otherwise, every figure below is `[verified
2026-08-26]`. Where a section carries its own stamp, that stamp wins** — the
render ladders are `[measured 2026-08-27]` and §The icon set is `[verified
2026-09-02]`. ⚠️ *This line has been wrong in both directions in one week: first
as a blanket 2026-08-26 that was already stale for the ladders, then as
"each section carries its own stamp" when five sections carry none. It is a floor
plus overrides because that is the only shape that covers every section without
mis-dating one* (`adversarial-reviewer`, rounds 1 and 2).
⚠️ **Two things in §The icon set are NOT read off this repository and are
labelled where they appear:** the tab-strip colours used in the contrast figures,
and iOS's handling of a transparent touch icon. Neither is derivable from
anything committed, so neither is stamped as if it were.
## Files
@@ -103,6 +116,138 @@ Passing an explicit `width` is load-bearing: without it Astro emits the
untouched 2668 px master as the `<img src>` fallback — **1,146,406 bytes**
which any client without AVIF or WebP support would actually download.
## The icon set
`[verified 2026-09-02 — every figure below read off the icon files in this
repository, EXCEPT the two external constants flagged inline]`
| File | What it is | Ground |
|---|---|---|
| `public/favicon.ico` | 16, 32 and 48 px frames, each a PNG-encoded 32-bit RGBA image inside the ICO container | **Transparent** |
| `public/apple-touch-icon.png` | 180 × 180 | **Opaque cream `#faf7f2`, and that is deliberate — see below** |
**The two grounds differ on purpose, and this is the line that stops someone
"fixing" it.** ⚠️ *The reason is an external platform behaviour, not a repository
fact, and it is recorded here as what it is:* **iOS does not honour transparency
in a touch icon — it composites it onto black**, so a transparent touch icon
ships a black tile on the home screen. Stated by Pouya in his ruling of
**2026-09-02** and not independently re-tested here; **it has not been checked on
a handset in this repository, and the place that does check it is the unticked
`docs/06` item "Tested on iOS Safari…".** The tab favicon has the opposite
requirement: a tab strip is dark for many readers, and an opaque ground shows
there as a visible rectangle around the mark. So the favicon is transparent, the
touch icon is matted, and **neither should be changed to match the other.**
### Composition
The mark spans **7/8 of the canvas width**, centred on both axes — 14/16, 28/32,
42/48, and 158/180 on the touch icon. That was measured off the icons as they
already shipped, so a regeneration reproduces the composition rather than
restyling the mark.
### Regenerating
```
npm run icons
```
`scripts/icons.mjs` resizes `src/assets/brand/sml-infinity-mark.png` onto a
transparent square canvas and assembles the ICO container itself, because
`sharp` does not write `.ico`. **It writes the favicon only** and never touches
the touch icon. It refuses to run if the render source has no alpha channel, and
it asserts that the source is still byte-identical to the documented crop of
`sml-infinity-mark-master.png` — R14, so the icon stays traceable to committed
artwork. It verifies a **candidate** file and renames it into place only on
success, so a rejected build cannot replace a good favicon.
**It also carries the halo test**, because the check that matters was living
only in prose here while the generator could not run it
(`adversarial-reviewer`, round 2). For each frame it takes every painted pixel
touching a fully transparent one and measures how many sit within 20 of cream:
| | boundary px | near cream | share |
|---|---|---|---|
| correct, 16 / 32 / 48 px | 61 / 146 / 258 | 1 / 2 / 5 | **1.6 / 1.4 / 1.9 %** |
| haloed fixture, 16 px | 56 | 11 | **19.6 %** |
The gate is **10 %**, roughly 5× clear of both. ⚠️ **The first version of this
guard inspected only partial-alpha pixels and MISSED the fixture completely** —
a knockout sets alpha per pixel and leaves **no partial alpha at all**, so there
was nothing for it to look at. The fixture is built by matting the mark on cream
and then knocking the ground out by alpha, which is the defect exactly; it fires
at 19.6 % and leaves `public/favicon.ico` untouched. **A guard that cannot see
the defect it is named for is worse than none**, and this one passed the real
icon while blind, which is the shape that ends a check instead of starting one.
### Why the favicon was regenerated, 2026-09-02
It had **no transparency at all**: all three frames declared a 32-bit alpha
channel and then carried `alpha = 255` on every one of their 256 / 1,024 / 2,304
pixels, with the ground opaque cream `rgba(250,247,242,255)`. Found by Pouya on a
read-through, confirmed by parsing the container directly.
Three measurements stand behind the replacement.
**1. The composition did not change.** Composited back onto cream, the new icon
reproduces the old matted one to within **1 of 255 on every channel of every
pixel** at all three sizes (mean delta 0.010.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.041.15:1**, and counting pixels that reach 3:1 against each
ground gives **23 / 87 / 189** on dark against **40 / 168 / 378** on light, at
16 / 32 / 48 px. **So about half as many pixels reach 3:1 on dark as on light.**
⚠️ *Those pixels are not absent — they are painted and fall below 3:1, and the
mark's full outline is still there, dim. An earlier draft said "roughly half the
mark's visible pixels are absent", which is neither what was counted (pixels at
`alpha > 0` are 109 / 388 / 805) nor what a render shows* — `adversarial-reviewer`,
round 2. The previous opaque-cream icon was still more legible on dark.
**That is the price of the change and it was accepted, not overlooked.** What was
bought is the removal of a cream rectangle from every dark tab strip and from
white. Raised by `adversarial-reviewer`, 2026-09-02, against a draft that
recorded the change as costless.
**The obvious alternative is an icon pair keyed on `prefers-color-scheme`, and it
was NOT ruled out on measurement.** ⚠️ *An earlier draft dismissed it on a
mechanism that is wrong: it said the query keys off the page's colour scheme. It
does not — it reports the user's system or browser preference, and this site
declares `color-scheme` nowhere (`git grep -n color-scheme -- src`, exit 1), so
it would resolve to the same preference that makes the tab strip dark.* The real
objections are **untested here** and are recorded as such: `media` on
`<link rel="icon">` is unevenly supported for raster icons, a browser theme can
be set independently of the system preference, and it doubles the artefact
`npm run icons` has to keep in sync. **If this cost is ever revisited, that is
the option to test** — do not re-dismiss it on the reason struck above.
## The colours are the artwork's, not the palette's
`tokens.css` is not involved. The ribbon carries its own gradient and it is
@@ -21,6 +21,19 @@ not published.
> at second reading on the retrieval date and could be law, or dead, by the time
> anyone reads this. **Re-check before cutover.**
> ### R18 re-check — cutover pass, 2026-09-01
>
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
> the sources; this stamp does not restate them. **The quoted bytes below
> are still the original retrieval and were not re-fetched** — what was
> re-checked is whether the *facts* they support have moved.
>
> - **(a) Bill C-36 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 — Pouya,
> <https://www.parl.ca/legisinfo/en/bill/45-1/c-36>]`. Still at second
> reading in the House of Commons; latest completed stage is first reading,
> 2026-06-15; no advance since. `/practice/technology/`'s sentence stands as
> written.
**Topic as researched:** Canadian technology / data / AI dispute context — privacy legislation status as at 2026-08-29, Ontario public-sector and health privacy statutes, data residency law, and Canadian arbitral-institution rules for technology/AI disputes
---
@@ -10,16 +10,104 @@ 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.** Every figure below was read from AWS on **2026-09-01** with the
commands listed at the end, run read-only as `arn:aws:iam::327082975128:user/pouya`.
**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.
---
## The claim being checked
## ✅ RULED AND APPLIED — 2026-09-02
`src/pages/legal/privacy.astro`, §Who can see it:
**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.
@@ -75,9 +163,13 @@ access, and it belongs to a second administrator of a shared account.
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
in the S3 bucket listing §10 describes.
## What has to happen before `/legal/privacy/` goes public
## What had to happen before `/legal/privacy/` went public — ✅ RESOLVED BY OPTION 2
Tracked as `AGENTS.md` §9 **Q62**. It is one of two things and both are Pouya's:
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
@@ -139,3 +231,305 @@ 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.
@@ -13,6 +13,31 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
> date and effective date below is as at the retrieval date and nothing more.
> Re-check before cutover, and before any republish that turns on one of them.
> ### R18 re-check — cutover pass, 2026-09-01
>
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
> Five of seven extracts were stamped and two were not, so a reader could
> not tell whether this one was considered and found non-volatile or simply
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
> control-fires-over-part-of-its-scope defect as R18 having no checklist
> item, one notch smaller. **Nothing here was re-retrieved.**
>
> - **No R18 limb, but ONE CANDIDATE LIMB, and it is flagged rather than
> silently adopted.** `/practice/construction/` publishes: *"Ontario Power
> Generation … applied in March 2026 for a licence to operate it."* That is a
> **pending application**, so it moves the way limb (a) moves. It is not
> false today — the application was made, and a completed past act stays
> true — which is why this is a note and not a blocker. But a reader takes it
> as current status, and unlike limb (a) the sentence is **not time-anchored**
> ("when this page was written"). **For Pouya at the next re-check: adopt it
> as R18 limb (h), or time-anchor the sentence and drop it.**
> - The rest is stable: Part II.1 in force since 2019-10-01, and ODACC's own
> statement that it is the Authorized Nominating Authority. The ODACC 2025
> Annual Report is cited for institutional facts rather than for figures, so
> it does not carry limb (d)'s exposure `[verified 2026-09-02 — swept
> `dist/practice/construction/index.html`]`.
**Topic as researched:** Ontario construction dispute resolution — the statutory machinery (Construction Act, R.S.O. 1990, c. C.30); plus verification of the OPG Darlington New Nuclear Project and Bruce Power's Bruce C Project
---
+16 -2
View File
@@ -13,6 +13,20 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
> date and effective date below is as at the retrieval date and nothing more.
> Re-check before cutover, and before any republish that turns on one of them.
> ### R18 re-check — cutover pass, 2026-09-01
>
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
> the sources; this stamp does not restate them. **The quoted bytes below
> are still the original retrieval and were not re-fetched** — what was
> re-checked is whether the *facts* they support have moved.
>
> - **(c) ERO 026-0853 — RE-VERIFIED UNMOVED** `[re-checked 2026-09-01 —
> Pouya]`. The comment period to 2026-09-12 is still open.
> - **(b) the regulation under `Electricity Act` s. 28.1 — NOT RE-RETRIEVED.**
> Held unchanged on a cadence judgement rather than a fresh retrieval
> `[assumed 2026-09-01 — Pouya: unchanged by its nature at this cadence]`.
> That is a weaker stamp than (c) and is written weaker on purpose.
**Topic as researched:** Ontario electricity/energy regulatory processes that generate disputes: OEB leave to construct (OEB Act, 1998 ss. 90/92/95/96), the IESO and market participation, the IESO connection assessment process (SIA/CIA), large-load and data-centre grid connection (Electricity Act, 1998 s. 28.1), and Ontario Bill 40
---
@@ -412,11 +426,11 @@ supports is a defect in this file, not a fact.
*Source:* <https://www.ontario.ca/laws/statute/98e15>
- MARKET PARTICIPATION — operationally, per the IESO: "To participate in the IESO-controlled grid, IESO-administered markets or programs, you must register your organization with the IESO to authorize it as a market or program participant." Registration runs through Online IESO, requires an OEB licence, prudential support for real-time market participation, and a market registration application fee of $1,130; it ends with the IESO issuing a "registration approval notification (RAN)".
*Source:* <https://www.ieso.ca/en/Sector-Participants/Connection-Process/Authorize-Market-and-Program-Participation>
- CONNECTION PROCESS — the IESO runs a six-stage connection process: (1) Prepare application; (2) Obtain conditional approval to connect; (3) Design and build; (4) Authorize market and program participation; (5) Register equipment; (6) Commission equipment and validate performance. "New or modified connections to a transmitter's system are generally subject to all six stages, while new or modified connections to a distributor's system may only be subject to the first three."
- CONNECTION PROCESS — the IESO's published connection process runs to **up to six** stages (⚠️ this read *"the IESO runs a six-stage connection process"* until 2026-09-03 — it gave the process to the IESO alone and stated the count unscoped, which are the two things the note below corrects; the quotation it rests on is the Overview's *"involves up to six stages"*): (1) Prepare application; (2) Obtain conditional approval to connect; (3) Design and build; (4) Authorize market and program participation; (5) Register equipment; (6) Commission equipment and validate performance. "New or modified connections to a transmitter's system are generally subject to all six stages, while new or modified connections to a distributor's system may only be subject to the first three."
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
- "System Impact Assessment" IS the IESO's real term, confirmed on multiple IESO pages. The IESO: "New connections or modifications to facilities connected to a transmitter's system are subject to the IESO's system impact assessment (SIA) and the transmitter's customer impact assessment (CIA)." The IESO conducts the SIA; the transmitter conducts the CIA.
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Overview>
- The umbrella name for the process is the "connection assessment and approval (CAA)" process. On application the IESO "will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID". The SIA agreement is prepared "in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules". The IESO then "will assess the impact of your proposed new or modified connection on the reliability of the integrated power system" and issues a draft, then final, SIA report accompanied by either a "Notification of conditional approval (NoCA)" or a "Notification of disapproval with reasons (NoDR)".
- ⚠️ **CORRECTED 2026-09-03 — THIS LINE IS COMMENTARY AND IT MISATTRIBUTED THE PROCESS.** It read *"The umbrella name for the process is the 'connection assessment and approval (CAA)' process"*, and the pages took that from here: `/practice/energy/` published *"The IESO operates a six-stage connection process and calls it connection assessment and approval"* and `docs/01` directed *"CAA is the umbrella"*. **The IESO's own words, quoted above at the Stage 2 heading, are "the IESO's **and transmitter's** connection assessment and approval (CAA) process"**, and the Overview says the process *"involves **up to** six stages"*, scoped by connection type. Both were corrected on the pages the same day. This is `CLAUDE.md`'s point exactly — the quotations here are evidence, the prose around them is this repository's voice, and it is where a corrected page re-seeds if the commentary is left standing. The original line follows. The umbrella name for the process is the "connection assessment and approval (CAA)" process. On application the IESO "will determine if the application qualifies for a system impact assessment (SIA) or an expedited system impact assessment (ESIA) and will assign a unique CAA ID". The SIA agreement is prepared "in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules". The IESO then "will assess the impact of your proposed new or modified connection on the reliability of the integrated power system" and issues a draft, then final, SIA report accompanied by either a "Notification of conditional approval (NoCA)" or a "Notification of disapproval with reasons (NoDR)".
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
- The transmitter "generally initiates the customer impact assessment (CIA) after the draft SIA report from the IESO", and a CIA agreement between the connection applicant and the transmitter is required as part of the transmitter's CIA process.
*Source:* <https://www.ieso.ca/Sector-Participants/Connection-Process/Obtain-Approval>
+17
View File
@@ -13,6 +13,22 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
> date and effective date below is as at the retrieval date and nothing more.
> Re-check before cutover, and before any republish that turns on one of them.
> ### R18 re-check — cutover pass, 2026-09-01
>
> The trigger fired. `AGENTS.md` §12 R18 holds the per-limb findings and
> the sources; this stamp does not restate them. **The quoted bytes below
> are still the original retrieval and were not re-fetched** — what was
> re-checked is whether the *facts* they support have moved.
>
> - **(d) the Tribunals Ontario annual report — RE-VERIFIED UNMOVED**
> `[re-checked 2026-09-01 — Pouya,
> <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`.
> **No 2025-26 report is published; FY2024-25 remains current**, so the
> figures `/practice/insurance/` publishes are still the latest. This closes
> the open item at the foot of this file, which asked exactly that question.
> - **(e) the SABS, amended with effect 2026-07-01 — NOT RE-RETRIEVED.** Held
> unchanged on a cadence judgement `[assumed 2026-09-01 — Pouya]`.
**Topic as researched:** Ontario accident benefits (SABS) disputes — regulation, Minor Injury Guideline, the LAT-AABS dispute route, tribunal caseload volume, mediation references in tribunal materials, and FSRA's role
---
@@ -341,6 +357,7 @@ honest, and on this project it is the half that has twice been skipped.
- **Whether a more recent annual report than 2024-25 exists (i.e. a 2025-26 report covering the year ending March 31, 2026).**
- *Searched:* WebSearch for Tribunals Ontario annual report LAT AABS caseload; retrieved the 2024-25 report, which is dated June 30, 2025 and is the most recent surfaced.
- *Outcome:* NOT CONFIRMED either way. The 2024-25 report (fiscal year ending March 31, 2025) is the latest located as of 2026-08-29, but no search was run specifically to rule out a 2025-26 edition. Given today's date, one may well have been published. Re-check before publishing any 'most recent' or 'latest available' framing around these figures.
- *Outcome, R18 re-check:* **CONFIRMED — no 2025-26 edition is published, and FY2024-25 remains current** `[re-checked 2026-09-01 — Pouya, <https://tribunalsontario.ca/en/about/governance-and-accountability/>]`. This closes the item as originally posed. The caution in the line above is retained rather than struck, because it is about a FRAMING and not about this fact: nothing on `/practice/insurance/` calls these figures the most recent or the latest available, and nothing should start to — a 2025-26 edition will appear eventually and a bare year label goes stale gracefully where a superlative does not.
- **What the SABS amendments effective July 1, 2026 actually changed.**
- *Searched:* Noted the consolidation banner on the e-Laws SABS page ('From July 1, 2026') and the tribunal's warning that 'Changes to the Statutory Accident Benefits Schedule effective July 1, 2026 may impact your insurance benefits'. Did not fetch O. Reg. 383/24 or the amending instruments.
- *Outcome:* NOT ESTABLISHED. Search-result snippets suggested that certain benefits became optional, but no primary amending regulation was fetched, so nothing about the substance of the July 2026 changes is asserted here. The s. 3(1) definitions and the s. 18(1) $3,500 limit quoted above ARE from the post-July-2026 consolidation and are current as retrieved.
@@ -13,6 +13,22 @@ cite it. Do not paraphrase a fact into a page that is not stated here.
> date and effective date below is as at the retrieval date and nothing more.
> Re-check before cutover, and before any republish that turns on one of them.
> ### R18 re-check — cutover pass, 2026-09-01
>
> ⚠️ **THIS FILE CARRIES THE STANDING RE-CHECK INSTRUCTION ABOVE AND WAS
> NOT ONE OF R18's SEVEN LIMBS, AND IT WENT UNSTAMPED IN THE FIRST PASS.**
> Five of seven extracts were stamped and two were not, so a reader could
> not tell whether this one was considered and found non-volatile or simply
> missed. Found by `adversarial-reviewer`, 2026-09-02 — the same
> control-fires-over-part-of-its-scope defect as R18 having no checklist
> item, one notch smaller. **Nothing here was re-retrieved.**
>
> - **No volatile limb at this cutover.** The facts this file supports on
> `/practice/shareholder/` are OBCA sections and case law, which do not move
> at this cadence, and a sweep of the built page for dates, statuses and
> "as of" framings returned nothing time-anchored `[verified 2026-09-02 —
> swept `dist/practice/shareholder/index.html`]`.
**Topic as researched:** Ontario/Canada shareholder, partnership and closely-held business disputes — statutory remedies (oppression, dissent/appraisal, winding up), the Partnerships Act, arbitration references in the corporations statutes; plus an independent re-check of Ontario family arbitration training requirements.
---
+13
View File
@@ -100,4 +100,17 @@ export default [
'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
+328
View File
@@ -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;
}
+525
View File
@@ -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`);
+8 -1
View File
@@ -1,5 +1,12 @@
/**
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
* 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
+2 -1
View File
@@ -20,7 +20,8 @@
"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"
"bio:pdf": "node scripts/bio-pdf.mjs",
"icons": "node scripts/icons.mjs"
},
"dependencies": {
"@astrojs/mdx": "^7.0.8",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.
+22 -10
View File
@@ -2,18 +2,30 @@
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
# NOTHING IS DISALLOWED, DELIBERATELY.
# /legal/* is kept out of the index by
# `<meta name="robots" content="noindex,follow">`, which is the directive that
# actually de-indexes. Disallowing them as well would defeat it: a crawler that
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are
# linked from the footer of every page, so Google discovers them regardless and
# would have listed the bare URLs as "no information available" — the opposite
# of the intent — with the noindex sitting unread behind the wall.
# Add a Disallow only for something that must not be FETCHED. Use noindex for
# something that must not be LISTED. They are different problems.
# EXACTLY ONE THING IS DISALLOWED, AND IT IS A SUBSTITUTE FOR A MECHANISM THIS
# DISTRIBUTION IS NOT ALLOWED TO HAVE.
# The bio PDF duplicates /bio/. The intended fix was `X-Robots-Tag: noindex` on
# *.pdf via a CloudFront response-headers policy, which the distribution's
# pricing plan forbids — AGENTS.md §7, and docs/09 Part 3 for the three failed
# attempts. Disallow is the remaining lever and it is NOT the same instrument:
# - it stops the PDF being FETCHED, so its contents are never indexed and the
# duplicate-content problem it was raised for is solved;
# - it does NOT de-index the URL. The PDF is linked from /bio/ and /about/, so
# a search engine can still list the bare URL with no snippet.
# That residual is accepted deliberately: a bare link to a bio PDF is not the
# harm the noindex was for. Revisit if the pricing plan ever changes.
#
# /legal/* is still NOT disallowed, and that reasoning is unchanged — it is the
# general rule this one path is the exception to. A crawler forbidden to FETCH a
# URL never reads the `noindex` on it, so the two cancel; the legal pages are
# linked from every footer, so they are discovered regardless, and the pair would
# have produced bare "no information available" listings with the directive that
# would have suppressed them sitting unread behind the wall.
# `noindex` is what de-indexes; `Disallow` is what prevents fetching. Use the one
# that matches the problem, and never both on the same path.
User-agent: *
Disallow: /pouya-lajevardi-bio.pdf
Allow: /
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
+87 -30
View File
@@ -259,28 +259,52 @@ const PATTERNS = [
},
{
id: 'sole-administrator-q62',
rule: 'Q62 — /legal/privacy/ claims sole administrative access to the intake table, and that is FALSE.',
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." The AWS account has an ' +
'`admins` IAM group carrying AdministratorAccess with TWO members, and ' +
'`simulate-principal-policy` returns allowed for dynamodb:GetItem, Query ' +
'and Scan on the table for both ' +
'[verified 2026-09-01 — docs/reference/intake-table-access-verification.md]. ' +
'THE REACHED-DIST CONDITION IS WHY THIS PATTERN EXISTS AT ALL: the ' +
'sentence was in dist/legal/privacy/index.html, `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, ' +
'so `grep -rn "TODO(pouya)" dist/` returned nothing. The gate was human ' +
'memory. Found by `adversarial-reviewer`, 2026-09-01. ' +
'DELETE THIS PATTERN when Q62 is ruled: either the access is removed and ' +
'the sentence becomes true, or the sentence is rewritten. It is a tripwire ' +
'on one specific published falsehood, not a rule about a class.',
/* `\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/`. */
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/gi,
'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,
},
];
@@ -367,27 +391,60 @@ const FIXTURES = {
'C.Med-Arbitration',
],
'sole-administrator-q62': [
/* The two published clauses, verbatim from dist/legal/privacy/. */
/* The FIVE published clauses from dist/legal/privacy/ as it stood at
`bd282aa`, before the Q62 correction. The first form of the pattern
caught only the first two: clauses 3 and 4 are the same falsehood in
different words in a different section, and clause 5 is the summary
that would have re-asserted the struck number. */
'nobody else has access to the table',
'There is no team, no assistant and no external administrator.',
'no analytics on the submission, and no assistant or outside administrator.',
'The table is reachable by the function that writes to it and by one administrative account, which is mine',
/* The third surface the summary that would have re-asserted the struck
number four lines below the corrected paragraph. */
'So the honest answer to "who can see this" is: me, and Google as the company that runs my mail.',
],
'struck-universal-q39': [
'Anyone may be appointed an arbitrator in Ontario',
'nothing in law gates the role',
],
},
/* Every one of these is real published or spec-approved copy on this site. */
/* Mostly real published or spec-approved copy on this site. A few are
deliberate NEAR MISSES truthful sentences about the same subject that
were never published because a pattern also has to be proven silent on
the wording a correction is likely to reach for. Where a fixture is one of
those, the comment beside it says so. */
mustNotMatch: [
/* NEGATIVE FIXTURES FOR `sole-administrator-q62` the approved copy nearest
to the two clauses it catches. The pattern is deliberately anchored on the
two published sentences rather than on the ideas in them, because the
replacement wording is not yet decided and a looser pattern would fire on
whatever Q62's ruling produces. These four are what "nearest" means: the
same page's true sentences about the same subject. */
/* 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.',
'Nobody else has access to my mailbox.',
'There is no team. Every inquiry is read by me.',
/* 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.',
+66 -2
View File
@@ -23,10 +23,15 @@
* Both files are read directly Node strips the types out of the `.ts` so
* this script holds no third copy of the list.
*/
import { INTAKE_FIELDS, HONEYPOT_FIELD } from '../src/data/intake.ts';
import {
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';
/**
@@ -81,6 +86,63 @@ if (serverNames.includes(HONEYPOT_FIELD)) {
);
}
/* 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;
@@ -128,7 +190,9 @@ for (const clientField of INTAKE_FIELDS) {
console.log(
`check:intake — ${clientNames.length} form fields, ${serverNames.length} ` +
'handler fields, compared on name, label, requiredness, cap and option set.',
'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}:`);
+11 -3
View File
@@ -181,10 +181,18 @@ else
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 — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2
echo "because a policy that drops Origin turns every real submission into a" >&2
echo "403. 500 means the Lambda invoke permission for this route is missing" >&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
+278
View File
@@ -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}`);
@@ -56,7 +56,7 @@ So one date sorts a pipeline into two regimes, and the requirements the later on
The mechanics of getting connected sit outside Bill 40, and they are what a supply agreement or a construction programme is quietly dated against.
The IESO's own description of the connection process sets out up to six stages, beginning with preparing the application and ending after the equipment is registered and tested. A transmitter's connections are generally subject to all six; a distributor's may be subject only to the first three. The umbrella name is connection assessment and approval. The IESO decides whether an application qualifies for a system impact assessment or an expedited one, and the transmitter generally runs its own customer impact assessment after the IESO's draft report, under a separate agreement. The final report goes out with either a notification of conditional approval or a notification of disapproval with reasons.
The IESO's own description of the connection process sets out up to six stages, beginning with preparing the application and ending after the equipment is registered and tested. A transmitter's connections are generally subject to all six; a distributor's may be subject only to the first three. The IESO decides whether an application qualifies for a system impact assessment or an expedited one, and the transmitter generally runs its own customer impact assessment after the IESO's draft report, under a separate agreement. The final report goes out with either a notification of conditional approval or a notification of disapproval with reasons.
There is no queue. The IESO states in terms that it is not using an interconnection queue, and works instead from the concept of committed projects defined in its Market Manual 1.4. An argument built on a project's place in line is an argument about nothing.
@@ -19,11 +19,11 @@ There is a connection: an assessment run by the Independent Electricity System O
Each has a different decision-maker, a different vocabulary, and a different idea of what a deadline is. They converge on the date the facility can energise. That convergence is the shape of the dispute, and a dispute clause drafted for one of the three contracts alone will not hold it.
## What the connection assessment and approval process is
## How a connection is assessed and approved
The terminology is precise and the wrong word travels badly, so it is worth taking from the IESO's own description of the connection process. The umbrella is connection assessment and approval, or CAA. Within it the IESO performs a System Impact Assessment (SIA), or an expedited SIA where the application qualifies, and assigns a unique CAA ID. The transmitter performs a Customer Impact Assessment (CIA), which the IESO says the transmitter generally initiates after the draft SIA report. The SIA agreement is prepared in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules. The IESO issues a draft SIA report to the applicant and the transmitter for comment, then a final report, and with it either a Notification of Conditional Approval or a Notification of Disapproval with Reasons.
The terminology is precise and the wrong word travels badly, so it is worth taking from the IESO's own description of the connection process. Obtaining conditional approval runs through the IESO's and transmitter's connection assessment and approval (CAA) process. Within it the IESO performs a System Impact Assessment (SIA), or an expedited SIA where the application qualifies, and assigns a unique CAA ID. The transmitter performs a Customer Impact Assessment (CIA), which the IESO says the transmitter generally initiates after the draft SIA report. The SIA agreement is prepared in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules. The IESO issues a draft SIA report to the applicant and the transmitter for comment, then a final report, and with it either a Notification of Conditional Approval or a Notification of Disapproval with Reasons.
The published process runs to as many as six stages. Connections to a transmitter's system are generally subject to all six; connections to a distributor's system may be subject only to the first three. On the IESO's own figures, obtaining conditional approval "typically takes one year", registering equipment "takes at least three months", and the whole process can run "anywhere from a few months for small modifications to existing facilities, to more than three years for major modifications or to connect new facilities".
The IESO's published connection process runs to as many as six stages. Connections to a transmitter's system are generally subject to all six; connections to a distributor's system may be subject only to the first three. On the IESO's own figures, obtaining conditional approval "typically takes one year", registering equipment "takes at least three months", and the whole process can run "anywhere from a few months for small modifications to existing facilities, to more than three years for major modifications or to connect new facilities".
Two features matter to anyone drafting a dispute clause. The SIA assesses the proposed connection's impact on the reliability of the integrated power system; what comes out of it is a report and a notification, not a ruling between parties. And there is no ordered line to be moved up. The IESO says so in terms in its connection-process FAQ: it works from "committed projects", a concept defined in section 3.3 of Market Manual 1.4, Connection Assessment and Approval, each assessment following section 5.8 of the same manual. The four IESO connection-process pages read for this piece describe only the six-stage process; no large-load or data-centre variant appears. This is the process I write about under [energy, grid and regulatory disputes](/practice/energy/).
@@ -24,9 +24,9 @@ or modifications to facilities connected to a transmitter's system are subject
to the IESO's system impact assessment (SIA) and the transmitter's customer
impact assessment (CIA)." Two documents, two authors. The IESO conducts the SIA.
The transmitter conducts the CIA. Treating the pair as one exhibit loses the
distinction most of these disputes turn on. The umbrella name is the connection
assessment and approval process, CAA in the IESO's usage, and each application
is given a unique CAA ID.
distinction most of these disputes turn on. Both sit in the IESO's and
transmitter's connection assessment and approval process, CAA in the IESO's
usage, and each application is given a unique CAA ID.
## What the assessment is actually of
+1 -1
View File
@@ -31,7 +31,7 @@ The second is what the neutral will actually do. That is a different question, a
## What I undertake
{/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says
so in terms: "The six strings live in `CONDUCT_UNDERTAKINGS` in
so in terms: "The strings live in `CONDUCT_UNDERTAKINGS` in
`src/data/site.ts` and the pages render them, so the diff that would soften
one is visible on one constant rather than distributed through three
templates." They were hand-typed here in the first draft, which put a fourth
+74 -9
View File
@@ -10,8 +10,15 @@
*
* What stops the two drifting is a check rather than a shared import:
* **`npm run check:intake`** asserts that the two tables agree on every field
* name, on which are required, and on every length cap and fails the build
* script if they do not. Independent validation, mechanically cross-checked. If
* name, on which are required, on every length cap, and since 2026-09-04 on
* both honeypot names.
*
* **IT IS A KEYBOARD GATE, NOT A DEPLOY GATE, AND THIS COMMENT SAID IT "fails
* the build script".** It does not: `npm run build` is `astro build`, and
* `scripts/deploy-local.sh` runs `check`, `build` and `check:claims` and not this
* one. Run it yourself. A control described as running where it does not is
* `AGENTS.md` Q22, and this change set makes this check the only thing keeping
* the second honeypot's two names in step. Independent validation, mechanically cross-checked. If
* you add a field here, add it there, and the check will tell you if you didn't.
*
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
@@ -172,9 +179,17 @@ export const INTAKE_FIELDS: readonly IntakeField[] = [
* is the one the inquirer ticks; both ship on `/contact/`, which is deliberate:
* `docs/01` requires the page to carry the notice, and `docs/05` requires the
* checkbox to carry it too.
*
* **IT NAMES SML COMPANY LTD Pouya's ruling, 2026-09-02 AND THREE
* CONSTRAINTS RIDE ON THAT.** **Name only, no terminal period**, and never
* beside the licence-status row (`AGENTS.md` §4). **`docs/05` §Consent text is a
* byte-identical second copy with no `check:` script over it**, so it moves with
* this string. And **`/legal/privacy/` must keep naming the same party** it
* does, under §Why it is collected; a consent naming a company the linked policy
* never mentions is an accountability gap, not a matter of voice.
*/
export const CONSENT_TEXT =
'I consent to Pouya Lajevardi storing and using the information in this form ' +
'I consent to SML Company Ltd storing and using the information in this form ' +
'to respond to my inquiry and to run a conflicts check. I understand that ' +
'submitting this form does not create a retainer, does not appoint a neutral, ' +
'and does not itself establish a mediatorparty relationship.';
@@ -192,6 +207,50 @@ export const CONSENT_TEXT =
*/
export const HONEYPOT_FIELD = 'company_website';
/**
* THE SECOND HONEYPOT, AND IT IS A DIFFERENT TRAP RATHER THAN A SECOND COPY OF
* THE FIRST. Pouya's ruling, 2026-09-04, after two automated submissions walked
* through `HONEYPOT_FIELD` (`docs/05` §Observed abuse).
*
* **THE MECHANISM IS INVERTED, WHICH IS THE POINT.** `HONEYPOT_FIELD` is a
* text input that must arrive EMPTY it catches a bot that fills every input it
* finds. The pair of 2026-09-04 did not fill it, so a second field of the same
* kind would catch them exactly as well as the first did: not at all.
*
* This is a CHECKBOX, and what it catches is a bot that sets every control it
* enumerates rather than one that fills every text field.
*
* **WHAT IT IS AIMED AT, AND WHAT THE EVIDENCE ACTUALLY SUPPORTS READ THIS
* BEFORE RELYING ON IT.** An earlier version of this comment said the decoy
* targets *"a behaviour anything reaching validation must have"*, because the
* consent box is required and unchecked by default, so a submission that
* validated must have sent `consent=on`. **That argument does not survive its own
* premise.** The 2026-09-04 pair did NOT fill the text honeypot, so they are
* selective about hidden fields and a bot selective enough to skip a hidden
* text input is selective enough to skip a hidden checkbox. Sending `consent=on`
* shows only that it knows one field name, not that it ticks everything it finds.
*
* **So this trap is very likely INERT against the traffic it was built from**,
* and it is defence in depth against a different and common class: the bot that
* enumerates controls and sets all of them. That is worth having and it is not
* what the observation proved. `docs/05` §Observed abuse states the same limit;
* the two must not drift, because the tempting sentence is the confident one.
*
* **ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE.** A browser sends nothing
* at all for an unchecked box, so every way this field can fail to arrive a
* stripping extension, a proxy, a future template that drops it reads as a
* HUMAN; and a serialiser that emits `updates_optin=` without reading the
* checked state reads as one too, because the handler tests for a NON-EMPTY
* value rather than for presence. The failure mode of a trap is a lost legal
* inquiry that looks like a successful one, and this trap fires only on
* something that deliberately ticked a box no person can see.
*
* The name is a plausible marketing opt-in, which is what a bot expects to find
* and a real form here does not have. Hidden the same way as the first the
* hiding is standard, the mechanism is not.
*/
export const DECOY_CHECKBOX_FIELD = 'updates_optin';
/**
* WHERE THE FORM POSTS AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
* HOSTNAME. This is a design decision with four consequences, taken at step 8
@@ -201,6 +260,13 @@ export const HONEYPOT_FIELD = 'company_website';
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
* to that origin:
*
* **AND IT IS ALL LIVE SINCE 2026-09-02** see the closing paragraph of this
* block. The same stale sentence was corrected in `handler.mjs`, `docs/01` and
* `docs/05` before it was corrected here; this note was added, in the same pass,
* ABOVE a paragraph that still said the opposite twenty lines below it. **A note
* asserting a correction is not the correction**, and the two sat contradicting
* each other until `adversarial-reviewer` round 2.
*
* 1. **`Content-Security-Policy: form-action 'self'`** `docs/05` specifies
* `form-action 'self' <api-endpoint>`; with a same-origin post the second
* term is unnecessary, so the policy is strictly tighter.
@@ -221,11 +287,10 @@ export const HONEYPOT_FIELD = 'company_website';
* a POST 404s. Under the alternative, clicking Submit on a laptop would
* write a real DynamoDB record and send two real emails.
*
* **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.**
* Neither has been done nothing on this project deploys before cutover (D11),
* and both are checklist items in `docs/06`. Until then the page is complete and
* the pipe behind it is not, which is why `/contact/` also publishes the email
* address rather than treating the form as the only way in.
* **THE COST, WHICH WAS REAL AND IS NOW PAID: THE FORM DID NOT WORK UNTIL
* THAT CLOUDFRONT BEHAVIOUR EXISTED AND THE HANDLER WAS DEPLOYED.** Both ran at
* cutover on 2026-09-02 `AGENTS.md` §7 holds the state and this comment does
* not restate it. `/contact/` still publishes the email address beside the form,
* which is now a courtesy rather than a fallback.
*/
export const INTAKE_ACTION = '/api/intake';
+10 -10
View File
@@ -151,7 +151,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
paragraphs: [
{
lead: 'Interim adjudication.',
text: 'Part II.1 of the Construction Act — "Construction Dispute Interim Adjudication" — has been in force since 1 October 2019. An adjudicator must determine the referred matter no later than 30 days after receiving the referring party\'s documents, and a determined amount is payable within 15 days of the determination being communicated. Judicial review is available only with leave of the Divisional Court.',
text: 'Part II.1 of the Construction Act — "Construction Dispute Interim Adjudication" — has been in force since 1 October 2019. An adjudicator must determine the referred matter no later than 30 days after receiving the referring party\'s documents, unless that date is extended in the way the Act allows. A determined amount is payable within 15 days of the determination being communicated. Judicial review is available only with leave of the Divisional Court.',
},
{
lead: 'A designated authority runs it.',
@@ -177,7 +177,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
text: 'Ontario Power Generation holds a licence to construct a BWRX-300 small modular reactor at Darlington, granted by the Canadian Nuclear Safety Commission in April 2025, and applied in March 2026 for a licence to operate it. Bruce Power has a federal impact assessment under way for the Bruce C project, aimed at creating an option for up to 4,800 megawatts at the existing site, with reactor technology not yet selected.',
},
{
text: 'Programmes on that scale run for years, through dozens of trade contracts, and they produce exactly the disputes above. This practice is built to facilitate procurement and subcontract disputes on that pipeline. I am naming it as the shape of the market, not as a list of files — nothing here is a claim to be on any of these projects.',
text: 'Programmes on that scale run for years, through dozens of trade contracts. This practice is built to facilitate procurement and subcontract disputes on that pipeline. I am naming it as the shape of the market, not as a list of files — nothing here is a claim to be on any of these projects.',
},
],
},
@@ -255,8 +255,8 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
text: 'The Personal Information Protection and Electronic Documents Act remains the federal private-sector privacy statute. Bill C-27, which would have enacted the Consumer Privacy Protection Act and the Artificial Intelligence and Data Act, died without royal assent when the session ended, and was not reinstated. A newer bill — C-36, for a Protecting Privacy and Consumer Data Act — was introduced in June 2026 and was at second reading when this page was written. Canada has no federal AI statute.',
},
{
lead: 'Ontario has one AI instrument, and it is mostly not switched on.',
text: 'The Enhancing Digital Security and Trust Act, 2024 conditions each of its artificial-intelligence obligations on regulations prescribing who they apply to and when. Two regulations have been made under it — one on cyber security, one on digital technology affecting people under 18 — and neither is the AI one.',
lead: "Ontario's AI-relevant statute has its artificial-intelligence obligations switched off.",
text: 'The Enhancing Digital Security and Trust Act, 2024 conditions the artificial-intelligence obligations in its section 5 on regulations prescribing which public sector entities they apply to and in what circumstances. Two regulations have been made under it — one on cyber security, one on digital technology affecting people under 18 — and neither is the AI one.',
},
{
/* THE LEAD WAS "And no federal or Ontario statute requires data to
@@ -337,7 +337,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
disputeTypes: [
{
name: 'Connection assessment',
body: "Disputes arising out of the IESO connection assessment and approval process — the system impact assessment, the transmitter's customer impact assessment, and the conditions attached to either.",
body: "Disputes arising out of the IESO's and transmitter's connection assessment and approval process — the system impact assessment, the transmitter's customer impact assessment, and the conditions attached to either.",
},
{
name: 'Leave to construct',
@@ -386,7 +386,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
},
{
lead: 'Connection runs through the IESO, and it is not a queue.',
text: 'The IESO operates a six-stage connection process and calls it connection assessment and approval. An application is assessed by system impact assessment, and the transmitter generally runs a customer impact assessment after the draft. The IESO states plainly that it does not use an interconnection queue — it works from a defined set of committed projects instead, so "our place in the queue" describes nothing.',
text: 'The IESO operates a connection process of up to six stages. An application is assessed by system impact assessment, and the transmitter generally runs a customer impact assessment after the draft. The IESO states plainly that it does not use an interconnection queue — it works from a defined set of committed projects instead, so "our place in the queue" describes nothing.',
},
{
lead: 'And large loads now have their own gate.',
@@ -407,7 +407,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
the extract's *quotations* rather than against its adversarial
check. R18(b) tracks this fact as volatile; that is a different
problem from never having been established. */
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025 and creates a connection-approval requirement for a "specified load facility", a category defined to include data centres meeting criteria that may be set by regulation. The enabling section is in force; the Ministry\'s August 2026 consultation still described the connection-approval regulation as under consideration, and described it as something the province was considering drafting. That consultation, on an assessment framework for new data centres, ran a comment period to 12 September 2026.',
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025. It bars a transmitter or distributor from connecting a "specified load facility" unless it is satisfied that the connection requirements the regulations specify have been complied with. That category is defined to include data centres meeting criteria that may be set by regulation. The enabling section is in force; the Ministry\'s August 2026 consultation still described the connection-approval regulation as under consideration, and described it as something the province was considering drafting. That consultation, on an assessment framework for new data centres, ran a comment period to 12 September 2026.',
},
],
note: "Described so the process is legible, not applied to anyone's file — and the terms above are the ones these bodies actually use. Sourced in docs/reference/ontario-energy-regulatory.md.",
@@ -460,8 +460,8 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
body: 'Whether an impairment falls inside the minor injury definition, and the monetary limit that follows if it does.',
},
{
name: 'Treatment and assessment plans',
body: 'Denied or partially approved plans, competing assessments, and disputes about the reasonableness and necessity of proposed treatment.',
name: 'Medical and rehabilitation benefits',
body: 'Which treatment, services or goods are payable, and the conditions a guideline may attach to them.',
},
{
name: 'Catastrophic impairment',
@@ -608,7 +608,7 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
},
{
lead: 'And the end of the road.',
text: 'Both statutes also provide for the company to be wound up, or liquidated and dissolved, including on the ground that it is just and equitable, and the Ontario Partnerships Act lets a partner apply to the court to dissolve a partnership on grounds that include conduct making it not reasonably practicable to carry on business together.',
text: 'Both statutes also provide for the company to be wound up, or liquidated and dissolved, including on the ground that it is just and equitable, and the Ontario Partnerships Act lets a partner apply to the court to dissolve a partnership on grounds that include conduct by a partner other than the one suing, in matters relating to the partnership business, that makes it not reasonably practicable for the other partners to carry on the business in partnership with that partner.',
},
{
lead: 'One provision points the other way.',
+5 -1
View File
@@ -124,7 +124,11 @@ export function personNode(
R10 fires on an event, not a date: re-confirm before any cutover or major
republish, and re-stamp §4. That covers this field as well as the visible
list. */
list. **Last fired 2026-09-02** all four re-confirmed by Pouya on the day
of cutover. **The stamp lives on `CREDENTIALS.memberships` in site.ts
and THIS FIELD READS `MEMBERSHIP_ORGS`** a different array, as the note
seven lines above says. Content parity between them is manual, so
re-stamping is not the same act as re-checking that the two agree. */
...(opts.memberships
? {
memberOf: MEMBERSHIP_ORGS.map((name) => ({
+69 -7
View File
@@ -75,9 +75,10 @@ export const CREDENTIALS = {
],
languages: ['English', 'Farsi'],
/**
* [verified 2026-08-28 Pouya, AGENTS.md Q44] re-stamped when Q44 closed;
* the original confirmation was 2026-08-26 (Q28 plus the CTF addition of the same
* date] and FOR NOW.
* [verified 2026-09-02 Pouya, R10's cutover fire] re-confirmed on the day
* of cutover: ADRIC, ADRIO, the three OBA sections and the CTF all current.
* Earlier stamps: 2026-08-28 (Q44), 2026-08-26 (Q28 plus the CTF addition of
* the same date). AND FOR NOW the stamp is a snapshot, not a warranty.
*
* WHAT §4 ACTUALLY SAYS ABOUT RENEWAL, because a widened version of it reached
* a public page. §4, quoted exactly: *"the OBA sections and the CTF renew
@@ -95,8 +96,11 @@ export const CREDENTIALS = {
*
* **PUBLISHED FROM 2026-08-28 Q44 CLOSED.** Pouya re-confirmed all four as
* current, which discharges R10's prohibition, and `/about/` now renders a
* Memberships group from this array. Re-stamped `[verified 2026-08-28 —
* Pouya]`; the earlier stamp was 2026-08-26.
* Memberships group from this array.
*
* **RE-CONFIRMED 2026-09-02 R10's cutover event, and that is the stamp
* above.** R10 fires on an event and cutover is one of its two; a stamp is not
* a renewal receipt, so this was asked again rather than read again.
*
* **RENDER THE LIST; NEVER RENDER A CLAIM ABOUT ITS CURRENCY.** No
* "renewed annually", no "current as of", no "listed as current", no stamp
@@ -299,7 +303,12 @@ export const NEUTRAL_ROLE_LINE =
'party should have their own legal advice.';
/**
* THE SIX CONDUCT UNDERTAKINGS Q54, ANSWERED BY POUYA 2026-08-29.
* THE CONDUCT UNDERTAKINGS Q54, ANSWERED BY POUYA 2026-08-29, plus (g).
*
* **(a)(f) ARE Q54's SIX. (g) IS NOT** it was attested 2026-09-03 to
* close D20 finding 13 and carries its own stamp on the object below. The
* heading no longer states a count: this comment said "THE SIX" while the
* object held seven for exactly as long as it took to notice.
*
* A THIRD CLASS OF CLAIM, and the class is his: not a credential (a fact about
* him, §4 Verified) and not an offering (a process the practice conducts, §4
@@ -361,7 +370,26 @@ export const CONDUCT_UNDERTAKINGS = {
arbitrationAwardDate:
'The date the award is due is fixed in the first procedural order rather ' +
'than left open.',
} as const; // [verified 2026-08-29 — Pouya, Q54]
/**
* (g) `/legal/privacy/` the conflicts check. **ATTESTED 2026-09-03 by
* Pouya, closing D20 finding 13.** It is NOT one of the Q54 six: its own
* date, its own ruling, and it is stamped separately below.
*
* The page was already stating a conflicts undertaking in prose, and §4's
* gate for this class is one line he must have made it IN TERMS. He now
* has, so the sentence is rendered from here rather than typed there.
*
* **IT IS HIS WORDING, NOT A RENDERING OF IT, AND THAT IS THE WHOLE GATE.**
* The attestation is *"runs a conflicts check on every inquiry before
* engaging"*. This string shipped for one round as *"before I accept an
* appointment"* the site's own vocabulary, defensible, and **a paraphrase of
* a commitment the page publishes as his**. §4's gate for this class is that
* he made it IN TERMS, and a substitution recorded in a code comment is not
* that. Do not smooth it back. If "engaging" turns out to be the wrong verb,
* the fix is a second attestation, never an edit here.
*/
conflictsCheck: 'I run a conflicts check on every inquiry before engaging.',
} as const; // (a)(f) [verified 2026-08-29 — Pouya, Q54]; (g) [attested 2026-09-03 — Pouya]
/**
* THE HELD-DESIGNATIONS SENTENCE, RENDERED AND NEVER RETYPED.
@@ -548,6 +576,40 @@ export const FEES = {
* it, and do not price it.
*/
hourly: 500, // [verified 2026-08-26]
/**
* MED-ARB IS BILLED BY PHASE, AND THAT IS WHY THERE IS NO NUMBER IN HERE.
*
* Pouya's ruling, 2026-09-03, closing D20 finding 10. `/fees/` opens *"Every
* figure is on this page"* while §4 Offerings carries a **Med-Arb** row that
* `docs/07-fees.md` priced nowhere so the promise was wider than the card.
* The ruling closes it by pricing the offering out of the two rate sets that
* are already published rather than by narrowing the promise: each phase is
* charged at the rates for that process, so no third set of figures exists
* and the sentence becomes true as written.
*
* **THERE IS NO `amount` HERE ON PURPOSE. Do not add one.** A med-arb
* figure would be a fourth price for a process priced twice already, and the
* first thing it would do is disagree with one of them.
*
* **`termsApply` SAYS "as they apply to that process on its own", NOT
* "to both phases".** The additional-party fee is a MEDIATION row; the
* arbitration card has no equivalent. Saying the terms apply to each phase as
* they apply to that process invents nothing; saying they apply throughout
* would invent an additional-party charge in the arbitral phase.
*
* INTERIM, set 2026-09-03, reviewed at the §12 R5 twelve-month fee review.
* `docs/07` §Med-arb billed by phase carries the rule and the same stamp.
*/
medArb: {
rule:
'Med-arb is billed by phase. The mediation phase is charged at the ' +
'mediation rates above. If the matter proceeds to arbitration, that ' +
'phase is charged at the arbitration rates above.',
noSeparateFee: 'There is no separate med-arb fee.',
termsApply:
'The additional-party and cancellation terms apply to each phase as ' +
'they apply to that process on its own.',
}, // [verified 2026-09-03 — Pouya, interim; R5]
cancellation: [
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
{ window: '15 to 30 days before', fee: '50% of the booked fee.' },
+5 -1
View File
@@ -56,7 +56,11 @@ const { preloadSerifItalic = false, ...seo } = Astro.props;
/* No SVG favicon. The mark is a shaded ribbon, not flat vector paths, so
there is no honest SVG of it to serve — see InfinityMark.astro and
AGENTS.md Q38. The .ico carries 16/32/48, and is what crawlers request
at the root regardless of what is declared here. */
at the root regardless of what is declared here.
The .ico is transparent and the touch icon is opaque cream ON PURPOSE —
do not change either to match the other:
docs/reference/brand-assets.md §The icon set. */
}
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+9 -2
View File
@@ -203,8 +203,15 @@ const designationLine = [
*
* **Q44 closed 2026-08-28.** Pouya re-confirmed all four as current — ADRIC,
* ADRIO, the three OBA sections, and the Canadian Tax Foundation — which
* discharges R10's prohibition and puts the group back on the page. §4 is
* re-stamped `[verified 2026-08-28 — Pouya]`.
* discharges R10's prohibition and puts the group back on the page.
*
* **RE-CONFIRMED AGAIN 2026-09-02 — R10's cutover fire.** All four current;
* §4 and `CREDENTIALS.memberships` re-stamped `[verified 2026-09-02 — Pouya]`.
* ⚠️ **THIS COMMENT IS A FOURTH STAMP-BEARING SITE and it carried the
* 2026-08-28 date in the present tense after the re-stamp** — `docs/06`'s R10
* instruction named three files and not this one, so the next fire would have
* missed it again (`adversarial-reviewer`, round 2). The instruction now names
* four.
*
* **DO NOT ADD A CURRENCY SENTENCE.** Not "renewed annually", not "current as
* of", not "listed as current", not a stamp date in the markup. His ruling is
+11 -5
View File
@@ -136,12 +136,18 @@ const PROCESSES = [
never appear in the same element, so no proximity grep reaches
it — and it was found by reading the rendered PDF. The scope
belongs on the arbitration clause alone, where Q39's legal gate
puts it. */
puts it.
⚠️ AND THE VERB IS `accept appointments`, NOT `act as`. §4
verifies exactly one practised role — "Mediator" — and says in
terms that "Arbitrator" as a practised role is NOT a row; what it
verifies is that appointments are ACCEPTED. `/` and `/about/`
carry the same construction. */
}
I act as a neutral as a mediator, as an arbitrator in commercial matters,
and in med-arb where the parties want one neutral across both phases.
I read the contract and the technical record underneath it rather than
either side's summary of them.
I act as a neutral. I accept appointments as a mediator, as an arbitrator
in commercial matters, and in med-arb where the parties want one neutral
across both phases. I read the contract and the technical record underneath
it rather than either side's summary of them.
</p>
<p>
I am {ROLE.title} at {BOUTIQUE}, with {ROLE.litigationLine} across
+64 -7
View File
@@ -38,15 +38,21 @@
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import Button from '../components/Button.astro';
import Undertaking from '../components/Undertaking.astro';
import ContactBand from '../components/ContactBand.astro';
import Eyebrow from '../components/Eyebrow.astro';
import SectionHeading from '../components/SectionHeading.astro';
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { pageGraph } from '../data/schema';
import { CONTACT, NO_RETAINER_NOTICE } from '../data/site';
import {
CONDUCT_UNDERTAKINGS,
CONTACT,
NO_RETAINER_NOTICE,
} from '../data/site';
import {
CONSENT_TEXT,
DECOY_CHECKBOX_FIELD,
HONEYPOT_FIELD,
INTAKE_ACTION,
INTAKE_FIELDS,
@@ -117,11 +123,20 @@ const hintId = (name: string) => `${name}-hint`;
</div>
<div class="prose">
<p class="statement">{NO_RETAINER_NOTICE}</p>
<p>I ask for the other parties and their counsel for one reason.</p>
{
/* RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — undertaking (g).
⚠️ THIS PAGE HAND-TYPED THE SAME PROPOSITION AS *"I cannot accept an
appointment before conflicts are checked"* UNTIL 2026-09-04, and it
survived the change set that struck the identical sentence from
`/legal/privacy/` — one file swept, its sibling missed, which is the
shape R8 exists for. §4 row (g) lists BOTH surfaces. */
}
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
<p>
I ask for the other parties and their counsel because I cannot accept
an appointment before conflicts are checked, and that check needs
names. Please keep the summary short and leave privileged or
confidential detail out of it — the call is for that.
That check needs names, and the call above is where it happens. Please
keep the summary short and leave privileged or confidential detail out
of it — the call is for that.
</p>
<p>
What is collected, where it is stored, how long it is kept, and how to
@@ -248,7 +263,13 @@ const hintId = (name: string) => `${name}-hint`;
the form: a browser that helpfully fills a plausible-looking field
would make a human look like a bot. */
}
<div class="honeypot" aria-hidden="true">
{
/* `hidden` ADDED 2026-09-04, for the reason spelled out on the decoy
below: a class-only rule leaves this field on screen wherever author
styles do not apply, and a visitor who fills it loses their inquiry
behind a success page. */
}
<div class="honeypot" hidden aria-hidden="true">
<label for={HONEYPOT_FIELD}>Company website</label>
<input
type="text"
@@ -287,6 +308,41 @@ const hintId = (name: string) => `${name}-hint`;
</p>
</div>
{
/* THE SECOND HONEYPOT — a decoy CHECKBOX. The mechanism, and the
limits of what the observed spam supports, are in `src/data/intake.ts`
and are not restated here. Four properties of the MARKUP, each of
which is what stops this field costing a real inquiry:
· its own CLASS NAME, not `.honeypot` — one selector must not
match both traps. They share a declaration block below, which is
presentation; what matters is that `.honeypot` does not select
this one;
· placed after the consent block, not beside the other honeypot;
· `hidden` as well as the CSS rule, so it stays hidden where
author styles do not apply;
· a label that tells a human not to tick it. With `hidden` in
place a human essentially cannot see it, so this is the last
line rather than the first — and it costs almost nothing,
because the PLAUSIBLE NAME is what a bot matches on and the name
is unchanged.
⚠️ NO `required`, AND NO `checked`. An unchecked box sends nothing,
so absence is the pass — and the handler tests for a NON-EMPTY value,
so an empty one passes too. */
}
<div class="optin-decoy" hidden aria-hidden="true">
<label for={DECOY_CHECKBOX_FIELD}>Leave this box unticked.</label>
<input
type="checkbox"
id={DECOY_CHECKBOX_FIELD}
name={DECOY_CHECKBOX_FIELD}
value="on"
tabindex="-1"
autocomplete="off"
/>
</div>
{
/* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`.
`.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw
@@ -505,7 +561,8 @@ const hintId = (name: string) => `${name}-hint`;
the input are belt and braces for the case where a future stylesheet
un-hides it. Do not swap this for `visibility` or an off-screen position:
an off-screen input is still focusable and still announced. */
.honeypot {
.honeypot,
.optin-decoy {
display: none;
}
+44 -6
View File
@@ -36,6 +36,17 @@
* sentence ships **adjacent to the overtime row**, not in a footnote. Same
* structural rule as `PROCESS_FRAMING` beside the five timings under Q43.
*
* ✅ **MED-ARB IS PRICED HERE AS OF 2026-09-03, AND IT CARRIES NO FIGURE.**
* Pouya's ruling closing D20 finding 10: it is billed **by phase**, each phase
* at the rates already on this page. The finding was that the hero promises
* *"Every figure is on this page"* while §4 Offerings carries a Med-Arb row
* that `docs/07` priced nowhere — a promise wider than the card. It is closed by
* pricing the offering, not by narrowing the promise, so the hero sentence is
* unchanged and is now true as written. **Do not give the section a rate row:**
* a med-arb figure would be a fourth price for a process priced twice, and the
* first thing it would do is disagree with one of them. `FEES.medArb` holds the
* three sentences; `docs/07` §Med-arb holds the rule. INTERIM, reviewed at R5.
*
* ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck
* rows in §4 Offerings — the first removed by Pouya from D14, the second by him
* as his own error in `docs/01`. **A rate on a fee page is an offer**, which is
@@ -131,12 +142,12 @@ const ARBITRATION_ROWS = [
},
{
item: 'Documents-only or expedited — simple',
detail: 'Flat fee, agreed in the first procedural order.',
detail: 'Flat fee.',
fee: money(FEES.arbitration.documentsOnlySimple),
},
{
item: 'Documents-only or expedited — complex',
detail: 'Flat fee. Which band applies is settled before the appointment.',
detail: 'Flat fee.',
fee: money(FEES.arbitration.documentsOnlyComplex),
},
];
@@ -240,8 +251,35 @@ const ARBITRATION_ROWS = [
</div>
</section>
{/* ---- 4. Other services ---------------------------------------------- */}
{/* ---- 4. Med-arb ------------------------------------------------------ */}
<section class="section section-alt reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="Med-arb"
level={2}
lede="One appointment, two processes. Each phase is charged at the rates for that process."
>
<span slot="heading">Billed by phase.</span>
</SectionHeading>
</div>
{
/* NO `<dl class="rates">` HERE, AND THE ABSENCE IS THE POINT — see the
header. Every other section on this page pairs an item with a figure;
this one has no figure of its own, and giving it a row would mean
inventing one. The three sentences come from `FEES.medArb` so the rule
lives beside the numbers it points at rather than in this template. */
}
<ul class="notes" role="list">
<li>{FEES.medArb.rule}</li>
<li>{FEES.medArb.noSeparateFee}</li>
<li>{FEES.medArb.termsApply}</li>
</ul>
</div>
</section>
{/* ---- 5. Other services ---------------------------------------------- */}
<section class="section reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
@@ -304,8 +342,8 @@ const ARBITRATION_ROWS = [
</div>
</section>
{/* ---- 5. Cancellation ------------------------------------------------ */}
<section class="section reveal">
{/* ---- 6. Cancellation ------------------------------------------------ */}
<section class="section section-alt reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
@@ -334,7 +372,7 @@ const ARBITRATION_ROWS = [
</div>
</section>
{/* ---- 6. Terms -------------------------------------------------------- */}
{/* ---- 7. Terms -------------------------------------------------------- */}
<section class="section section-inverse reveal">
<div class="wrap">
<div class="section-head">
+163 -97
View File
@@ -44,8 +44,12 @@
* record is ever deleted.** Only a record written with a near-future `ttl`
* and watched to vanish proves that. docs/05's definition of done carries
* "TTL set and verified by test record" and `docs/06`'s cutover checklist
* names this page as what that item protects. **Both halves before this page
* is public.** See the TODO(pouya) on the retention section below, and §9 Q60.
* names this page as what that item protects. ⚠️ **This read "both halves
* before this page is public" and the page went public first — Pouya's
* ruling of 2026-09-03: publish, then confirm the deletion, reading from
* 2026-09-04.** So the second half is now owed rather than pending, which is
* a weaker position and is recorded as one. See the comment on the retention
* section below, and §9 Q60.
*
* ⚠️ **NO LICENSURE CLAIM AND NO ANSWER TO THE CAPACITY QUESTION.** A privacy
* policy is where "legal advice" phrasing arrives by convention. §4 records
@@ -55,10 +59,16 @@
*/
import BaseLayout from '../../layouts/BaseLayout.astro';
import Eyebrow from '../../components/Eyebrow.astro';
import Undertaking from '../../components/Undertaking.astro';
import { getImage } from 'astro:assets';
import ogDefault from '../../assets/og-portrait.jpg';
import { pageGraph } from '../../data/schema';
import { ANALYTICS, CONTACT, SITE } from '../../data/site';
import {
ANALYTICS,
CONDUCT_UNDERTAKINGS,
CONTACT,
SITE,
} from '../../data/site';
import { INTAKE_FIELDS } from '../../data/intake';
const ldImage = await getImage({
@@ -81,7 +91,7 @@ const RETENTION_MONTHS = 24;
/** Bump this on ANY substantive edit. A privacy policy with a stale date is a
* policy a reader cannot tell they are reading an old version of. */
const LAST_UPDATED = '31 August 2026';
const LAST_UPDATED = '4 September 2026';
/* Rendered from the form's own field list, so the two cannot drift. `consent`
and the honeypot are absent from `INTAKE_FIELDS` deliberately and are
@@ -122,10 +132,30 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
<ul>
{COLLECTED.map((label) => <li>{label}</li>)}
</ul>
{
/* ⚠️ DO NOT WRITE "your IP address" HERE, AND DO NOT CONCLUDE ANYTHING
ABOUT WHETHER THE ADDRESS IDENTIFIES ANYONE. The handler stores
`requestContext.http.sourceIp` — behind the `/api/*` behaviour that
is a CloudFront edge, so the expected value is Amazon's. **Expected,
not measured:** `docs/09` Part 7.2 measures it at cutover and
enumerates three outcomes, one of which is that the reader's own
address does land. This copy therefore states only why the field is
kept, which is true in all three. A previous form hedged "usually
not yours" and then asserted "not precise enough to identify you" —
false in exactly the branch the hedge admitted. `claims-auditor`,
then `adversarial-reviewer` round 2. */
}
<p>
Submitting the form also records the date and time, your IP address
and your browser's user-agent string. Those three are kept for
investigating abuse of the form and are not used for anything else.
Submitting the form also records the date and time and your browser's
user-agent string. Those two are kept for investigating abuse of the
form and are not used for anything else.
</p>
<p>
It also records the network address the request arrived from. This
site sits behind a content delivery network, so that address is
normally the network's own rather than your connection's — which is
why it is kept simply because it arrives with the request, and not as
a way of identifying anyone.
</p>
<p>
Nothing else on this site collects personal information. There is no
@@ -136,22 +166,43 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
<p>
The form asks for the other parties to the dispute and their counsel.
That is information about people who have not filled in the form and
may not know it was sent. It is asked for one reason: I cannot accept
an appointment before conflicts are checked, and the check needs
names.
may not know it was sent. It is asked for one reason, and the reason
is a commitment rather than an observation.
</p>
{
/* `<Undertaking>` AND `CONDUCT_UNDERTAKINGS`, NEVER TYPED PROSE —
§4's third class, whose characteristic failure mode is that a
promise gets quietly smaller and nothing fails. Undertaking (g),
attested 2026-09-03.
⚠️ IT IS THE COMPONENT FOR THE REASON THE COMPONENT EXISTS: one
treatment on every page, so a reader can tell a promise from a
description. This shipped for one pass as an ordinary paragraph in
`&ldquo;`/`&rdquo;` — the only such entities in `src/`, and a
commitment set as body prose reads as another sentence about
process.
It REPLACED the hand-typed "I cannot accept an appointment before
conflicts are checked", which stated the same proposition as a
constraint; keeping both would have set the undertaking beside its
own paraphrase — the (e)/(f) treatment. */
}
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
<p>
Please give names and nothing more about them. The form asks you not
to include privileged or confidential detail anywhere in it, and the
summary field says so directly. There is deliberately no field for
amounts in dispute and no way to attach a document.
The check needs names. Please give names and nothing more about them.
The form asks you not to include privileged or confidential detail
anywhere in it, and the summary field says so directly. There is
deliberately no field for amounts in dispute and no way to attach a
document.
</p>
<h2>Why it is collected, and on what basis</h2>
<p>
To reply to your inquiry and to run a conflicts check. The basis is
your consent, which the form asks for explicitly with an unchecked box
you have to tick. The wording you agree to is on the form itself.
you have to tick. The wording you agree to is on the form itself, and
it names <strong>SML Company Ltd</strong>, the company that holds this
practice's systems.
</p>
<p>
It is not used for marketing. It is not sold, rented or shared with
@@ -162,31 +213,22 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
<p>
In a DynamoDB table in Amazon Web Services' Canada Central region, in
Canada. It is encrypted at rest. Two emails are sent when you submit
the form — a notification to me and a confirmation to you — using
Amazon Simple Email Service, also in the same Canadian region.
the form — a notification to the practice and a confirmation to you —
using Amazon Simple Email Service, also in the same Canadian region.
</p>
<p>
The table sits in an Amazon Web Services account that also runs
systems unrelated to this practice.
</p>
{
/* ⚠️ THIS PARAGRAPH REPLACED A FALSE ONE, AND IT IS THE MOST SERIOUS
THING FOUND IN THE STEP 710 REVIEW. It read: *"Amazon Web Services
is therefore a processor for this information. **No other third party
receives it.**"*
`AGENTS.md` §7 records mail hosting as **Google Workspace**, and D18
sends the notification to `info@smlcompany.ca`. So Google receives and
stores every submission — including the names of opposing parties and
their counsel, which is the most sensitive thing this form collects —
as a mail processor. The page's own next section already admitted it:
*"The notification sits in my mailbox."* That mailbox is Google's.
A reader making a PIPEDA access request was being told there was one
processor when there are two. This page's header comment sets the
standard the sentence failed: a statement that describes an intended
control rather than a real one is a false statement to the public in
a legal document, and it fails silently, because nothing breaks and
the sentence reads correctly.
Found by `adversarial-reviewer`, 2026-08-31. §7 is cited rather than
restated — no MX record here. */
/* ⚠️ TWO PROCESSORS, AND BOTH MUST BE NAMED. `AGENTS.md` §7 records
mail hosting as **Google Workspace** and D18 sends the notification to
`info@smlcompany.ca`, so Google receives and stores every submission —
including the opposing parties and their counsel, the most sensitive
thing this form collects. A reader making a PIPEDA access request
needs both names. This paragraph replaced one asserting *"No other
third party receives it"*; see entry (ao). §7 is cited, not restated —
no MX record here. */
}
<p>
Two companies therefore process it, and both are named because a
@@ -194,9 +236,10 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
>Amazon Web Services</strong
> stores the submission and sends the two emails, in Canada. <strong
>Google</strong
> receives the notification email, because my own mail is on Google Workspace
— so a copy of what you send, including any names you give me, sits in that
mailbox. If you reply to the confirmation, that reply goes there too.
> receives the notification email, because the practice's mail is on Google
Workspace — so a copy of what you send, including any names you give me,
sits in that mailbox. If you reply to the confirmation, that reply goes
there too.
</p>
<p>
The confirmation sent to you is delivered to whoever runs your email.
@@ -204,22 +247,23 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
what they keep.
</p>
<p>
No one else receives it. There is no CRM, no mailing list, no
analytics on the submission, and no assistant or outside
administrator.
No one else is sent it. There is no CRM, no mailing list and no
analytics on the submission. Who can read what is stored is a
different question from who it is sent to, and it is answered under
"Who can see it" below.
</p>
<h2>How long it is kept</h2>
{
/* TODO(pouya): has a test record been written to the intake table with a
near-future `ttl` and OBSERVED TO DISAPPEAR? AGENTS.md §9 Q60. The
sentence below asserts a MECHANISM, not just a period, and the
setting being on does not prove the mechanism runs. The table
setting is confirmed — §7 holds that status and this comment does
not restate it, because it did restate it once and went stale within
the day (§12 R19). Do not answer this from the handler code, which
only writes the attribute. This page must not go public until a
deletion has actually been seen. */
/* The sentence below asserts a MECHANISM, not just a period, and the
mechanism is still unobserved — AGENTS.md §9 Q60, open. **Pouya
ruled 2026-09-03 that the page publishes now and the deletion is
confirmed after launch**; the observation window opened 2026-09-02
and the earliest useful reading is 2026-09-04 (`docs/09` Part 10).
That decision is why this is no longer a `TODO(pouya)`. The table
setting lives in §7 and is deliberately not restated here — it was
once, and went stale within the day (§12 R19). Do not answer Q60
from the handler code, which only writes the attribute. */
}
<p>
<strong>{RETENTION_MONTHS} months from the date you send it</strong>,
@@ -229,50 +273,59 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
than necessary for that purpose.
</p>
<p>
Emails are a separate matter. The notification sits in my mailbox and
the confirmation sits in yours, and neither is deleted by that
mechanism.
Emails are a separate matter. The notification sits in the practice's
mailbox and the confirmation sits in yours, and neither is deleted by
that mechanism.
</p>
<h2>Who can see it</h2>
{
/* ⚠️ THIS SAID "Nobody else has access" AND THE SECTION TWO ABOVE HAD
JUST NAMED GOOGLE. The Google correction was applied to §Where it is
stored and not swept into the section actually headed with the
question a reader asks — so the page answered "who can see the names
of the opposing parties I gave you?" with *nobody else* under that
heading and *Google* under a different one. Fixing one section and
not the section that answers the same question is the sweep failure
`CLAUDE.md` describes. Found by `adversarial-reviewer` round 2. */
}
{
/* TODO(pouya): the sentence below is FALSE as at 2026-09-01 and this
page must not go public until you rule — §9 Q62. The AWS account has
an `admins` IAM group carrying `AdministratorAccess` with TWO
members, you and one other person, and
`simulate-principal-policy` returns **allowed** for
`dynamodb:GetItem`/`Query`/`Scan` on this table for both.
Evidence and commands:
`docs/reference/intake-table-access-verification.md`.
THE QUESTION: do you remove that access — which may be the same
co-administrator Q23's Gitea instance depends on, so it is not free
— or does this paragraph state the true number? Nothing here may be
softened into "authorised administrators": on this page a reader is
entitled to the specific, and a true vacancy is worse than a false
specific only in that it cannot be caught.
Raised by `claims-auditor`, D20 cutover audit, finding 8. */
/* ⚠️ THIS SECTION AND THE SENTENCES BELOW ANSWER THE SAME QUESTION
AND CHANGE TOGETHER — by OPENING PHRASE, never by count. **Here:**
"The record in the table", "The system that receives", "The
notification goes to", "The confirmation that went to you".
**§Where it is stored:** "In a DynamoDB table", "The table sits in an
Amazon Web Services account", "Two companies therefore process it",
"The confirmation sent to you", "No one else is sent it". **§How long
it is kept:** "Emails are a separate matter".
⚠️ **THIS SECTION STATES WHO, NOT HOW — Pouya's ruling, 2026-09-02.
NOT TO BE RESTORED HERE:** the measurement paragraph, the
root-credential sentence, the single-sign-on and federated-login
enumeration, the resource-policy clause, the "company that runs a
database" aside, the deploy-credential sentence and the three-copies
summary. All true, all still in `AGENTS.md` §7 and
`docs/reference/intake-table-access-verification.md`. **No human
headcount** — a simulation counts identities, not people.
⚠️ **SENTENCE 1 IS SCOPED TO THE STORED RECORD** (paragraph 3 names
administrative staff, who read the mailbox and **cannot** read the
table) **AND PREDICATED ON ADMINISTERING THE ACCOUNT** (what §7
measures). §4 carries the row and the bar: **never widen it to
running, founding, practising or acting.**
**TWO CLAIMS GO STALE ON THEIR OWN** — who administers the account,
and who reads `info@smlcompany.ca`. §7 holds both; §12 **R21** is the
trigger.
⚠️ **THERE ARE THREE COPIES AND THE THIRD IS THE READER'S OWN** — the
handler puts the whole submission into the confirmation it sends the
inquirer. Never write "the one other place a copy exists". */
}
<p>
I can. The table is reachable by the function that writes to it and by
one administrative account, which is mine — nobody else has access to
the table. There is no team, no assistant and no external
administrator.
The record in the table: me, and the small number of people who
administer the account it sits in with me.
</p>
<p>
The one other place a copy exists is the notification email, which
sits in the Google Workspace mailbox named above. So the honest answer
to "who can see this" is: me, and Google as the company that runs my
mail.
The system that receives what you send
<strong
>can only add a record — it cannot read back what is stored.</strong
>
</p>
<p>
The notification goes to the practice's mailbox, which is read by me
and by administrative staff and is hosted on Google Workspace — so
Google holds a copy of whatever you send me.
</p>
<p>
The confirmation that went to you sits with whoever runs your email.
That copy is in your hands rather than mine.
</p>
<h2>Cookies and analytics</h2>
@@ -283,15 +336,18 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
{ANALYTICS.provider === 'plausible' ? 'Plausible' : 'Fathom'},
which is cookieless and collects no personal information and no
cross-site identifiers. There is nothing to consent to and no
banner, because nothing is stored on your device.
banner, because it sets no cookies and stores no identifier on
your device.
</p>
) : (
<p>
<strong>This site sets no cookies and runs no analytics.</strong>
There is no tracking script on any page, nothing is stored on your
device, and there is therefore nothing to consent to and no
banner. If that changes, this page changes on the same day and its
last updated date moves with it.
There is no tracking script on any page, and there is therefore
nothing to consent to and no banner. If cookies or analytics are
ever introduced, this page changes on the same day and its last
updated date moves with it. Your browser does cache this site's
fonts, stylesheets and images for up to a year so a return visit
loads faster, and those are the same files for every visitor.
</p>
)
}
@@ -309,11 +365,21 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
delete it before the {RETENTION_MONTHS} months are up.
{' '}{CONTACT.responseTime}
</p>
{
/* ⚠️ THE CLAUSE THAT WAS HERE PROMISED TO DISCLOSE THE OUTCOME OF A
CONFLICTS CHECK — *"I will tell you what its outcome was rather than
pretending the inquiry did not happen"* — and that is an UNDERTAKING,
which §4 may publish only where Pouya has made it in terms. He had
not. D20 finding 13, and it is closed by his attestation of
2026-09-03, which covers RUNNING the check and says nothing about
reporting it. The sentence now states what deletion does not undo and
stops there. Do not restore the promise without a second attestation:
it is a different commitment from the one he made. */
}
<p>
Deletion removes the record. It does not retract the emails already
sent, and if a conflicts check has already been run I will tell you
what its outcome was rather than pretending the inquiry did not
happen.
sent, and it does not undo a conflicts check that has already been
run.
</p>
<h2>What an inquiry is not</h2>
+4 -4
View File
@@ -52,7 +52,7 @@ const ldImage = await getImage({
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
/** Bump on any substantive edit. See the note on the privacy page. */
const LAST_UPDATED = '31 August 2026';
const LAST_UPDATED = '3 September 2026';
---
<BaseLayout
@@ -152,9 +152,9 @@ const LAST_UPDATED = '31 August 2026';
belong to that institution and are marked as quotations.
</p>
<p>
Links out go to sources — statutes, regulators, tribunals and
institutions. I do not control those sites and am not responsible for
what they say.
Links out go to an institution's published rules and to my LinkedIn
profile. I do not control those sites and am not responsible for what
they say.
</p>
<h2>Changes</h2>
+19 -25
View File
@@ -25,7 +25,7 @@ import Undertaking from '../components/Undertaking.astro';
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { medArbGraph } from '../data/schema';
import { CONDUCT_UNDERTAKINGS, DESIGNATIONS_HELD_LINE } from '../data/site';
import { CONDUCT_UNDERTAKINGS } from '../data/site';
const ldImage = await getImage({
src: ogDefault,
@@ -61,7 +61,7 @@ const graph = medArbGraph({
<BaseLayout
title="Med-Arb · Pouya Lajevardi · What It Is and When It Fits"
description="Med-arb is mediation that converts to arbitration if it does not resolve. What it is, how it differs from arb-med, the fairness objection, and when it fits."
description="Med-arb is mediation that converts to arbitration if it does not resolve. What it is, that it is not arb-med, the fairness objection, and when it fits."
jsonLd={graph}
>
{/* ---- 1. Hero -------------------------------------------------------- */}
@@ -238,38 +238,32 @@ const graph = medArbGraph({
{/* ---- 6. Why this practice -------------------------------------------- */}
{
/* ⚠️ THIS SECTION MUST NOT ANSWER THE ADRIC COMPETENCE QUOTATION ABOVE IT.
For one pass it did: the heading read "Med-arb asks one neutral to be
competent at both" — a restatement, in this site's voice, of ADRIC's
"requires a high level of practitioner competence" quoted in the section
immediately above — and the designations then answered it by adjacency.
**The source answers its own sentence with a DIFFERENT designation**: the
same ADRIC page points at the Chartered Med-Arb and a Med-Arb Foundational
Course, and `docs/reference/adrio-designations.md` Finding 3 calls the
Q-level designations "an intermediate step". So the page was borrowing a
third party's competence standard and supplying an answer that party does
not give. `claims-auditor`, round-2 audit, 2026-08-30 — the gloss lens.
The heading and lede now state what this practice holds and offers, with
no competence proposition and no bridge to the quotation above. */
/* ⚠️ THIS SECTION MAKES NO CLAIM ABOUT COMPETENCE AND NO CLAIM ABOUT WHAT
MED-ARB IS, AND BOTH SILENCES ARE DELIBERATE. §Rules above quotes ADRIC
requiring "a high level of practitioner competence", and ADRIC answers its
own sentence with the Chartered Med-Arb and a Foundational Course —
neither held, and C.Med-Arb is struck from this site entirely (§4). So
anything in here that reads as meeting that standard is borrowing a third
party's bar and answering it in this site's voice. That has now been
removed three times: from the heading (2026-08-30), from the definitional
gloss (Pouya's ruling, 2026-09-01) and from the bare designations line
that was left sitting directly beneath the quotation (2026-09-02).
**Do not refill this paragraph, and do not restate a designation here** —
/about/ publishes them, and the JSON-LD carries them. */
}
<section class="section section-alt reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading eyebrow="Why this practice" level={2}>
<span slot="heading">Both halves, in one practice.</span>
<span slot="heading">Both processes, in one practice.</span>
</SectionHeading>
</div>
<div class="prose">
<p>
{DESIGNATIONS_HELD_LINE}. Med-arb is not a third service bolted onto
mediation and arbitration; it is the two of them run under one
agreement, in the order the agreement sets.
</p>
<p>
I accept med-arb appointments now, in commercial matters. The section
above is the part to read before proposing one: the agreement does the
work, and it does it before the mediation starts.
I accept med-arb appointments now, in commercial matters. The three
commitments above are the part to read before proposing one: the
med-arb agreement does the work, and it does it before the mediation
starts.
</p>
<p class="onward">
<a href="/mediation/">Mediation &rarr;</a>
+10 -7
View File
@@ -220,15 +220,18 @@ const FORMATS = [
}
<Undertaking>{CONDUCT_UNDERTAKINGS.mediationCaucus}</Undertaking>
{
/* The without-prejudice question is answered by pointing, not by
characterising legal effect. AGENTS.md §4 bars this repository from
concluding a proposition of law, and docs/03's `[unestablished]`
pattern says to write around the capacity question. */
/* WITHOUT PREJUDICE IS ATTRIBUTED TO THE AGREEMENT, NEVER ASSERTED
AS LAW — and it may be narrowed but NOT deleted. §4 bars this
repository from concluding a proposition of law, and no extract
establishes the effect. But docs/01 §/mediation/ item 5 requires the
without-prejudice framing and docs/03 keeps the term as permitted,
so removing it breaches the spec that requires it. */
}
<p>
Mediation is conducted on a without-prejudice basis. What that means
for a particular file, and what survives it, is a question for each
party's own counsel rather than for the neutral.
Whether the session is without prejudice, and what that covers, is
settled by the agreement to mediate. What being without prejudice
means for a particular file, and what survives the session, is a
question for each party's own counsel rather than for the neutral.
</p>
</div>
</div>