feat: build steps 7a-10 — the site is complete and reviewable at 22 pages
Steps 7a through 10 as one authorised run. Nothing deployed (D11).
7a Lighthouse returns as `lighthouse@13.4.1` + `chrome-launcher`, NOT
`@lhci/cli`. AGENTS.md §7's advisory attribution was wrong: the carriers
were @lhci/cli's own `tmp` and @puppeteer/browsers' `extract-zip`, not
Lighthouse, which audits clean. A deliberate deviation from R11's literal
trigger, recorded with what it costs. Local gate; CI has no Chrome.
7b OG card generator (satori + sharp) discharges R15 — 20 typed cards plus
per-article cards; the portrait stays on / and /about/ by Q40. Insights
plumbing: ArticleCard, Prose, the index, the article route, articleGraph,
and /'s section 7. Card copy is constrained structurally because text in a
JPEG cannot be grepped by check:claims: every headline IS its page's <h1>,
enforced by `npm run og:proof`.
7c Five drafted launch articles, draft: true / reviewedByPouya: false. An
independent compliance audit returned 76 findings and 57 unsourced
assertions; all blocking and should-fix applied.
8 /contact/, the intake form, and backend/intake/ (undeployed). Plain HTML
POST to a same-origin /api/intake with a 303 redirect, so the form works
with zero JavaScript. docs/05 records three deliberate deviations.
9 /fees/ on Q59's ruling — overtime runs from the session cap, and the
reservation point ships adjacent to the rate. One-page PDF bio discharges
R16; /bio/ is its source, so the circulated artefact stays inside the
review apparatus.
10 /legal/privacy/ and /legal/terms/, written to the backend as built. Three
of the policy's statements are derived and cannot drift.
Also: /about/'s inverse credentials band (approved at step 6); Q59 closed;
R15 and R16 discharged; and a fix to shipped copy — /practice/energy/ asserted
the absence of a regulation the source extract says must not be asserted.
Review: adversarial-reviewer, two rounds (D20/D19). Round 1 returned 16
findings including two blocking — an invisible ghost button on /fees/ at
1.00:1 that Lighthouse scored 100, and a privacy policy that named one data
processor when there are two. All 16 acted on.
Lighthouse, 22 pages, mobile: performance 99-100, accessibility 100,
best practices 100, SEO 100 on every indexable page, CLS 0.000.
AGENTS.md entry (ah) has the detail, including four of my own verification
commands that were wrong and what each of them nearly caused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
6cfe69033f
commit
210bc25a26
+65
-8
@@ -49,7 +49,26 @@ decision, not an aesthetic one.
|
||||
/legal/terms/ Terms of use
|
||||
```
|
||||
|
||||
Nineteen fixed URLs plus one per article.
|
||||
Nineteen fixed URLs plus one per article — **and three more added at build steps
|
||||
8 and 9, all three `noindex` and all three excluded from the sitemap in
|
||||
`astro.config.mjs`.** They are utilities rather than pages in the IA above, which
|
||||
is why they are listed here rather than in it:
|
||||
|
||||
```
|
||||
/contact/received/ The intake form's success landing page
|
||||
/contact/could-not-send/ Its failure landing page
|
||||
/bio/ Source of the one-page PDF (R16)
|
||||
```
|
||||
|
||||
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
|
||||
the form is a plain POST and the handler answers `303 See Other` to a real URL —
|
||||
`docs/05` §Build step 8 has the reasoning. `/bio/` exists so the PDF circulated
|
||||
with an appointment proposal is a *rendering of a reviewed page* rather than a
|
||||
document outside this project's review apparatus.
|
||||
|
||||
**So: 22 built pages plus one per published article.** `npm run lighthouse`
|
||||
enumerates them from `dist/` rather than from this list, which is why this count
|
||||
being stale could never make the gate miss a page.
|
||||
|
||||
### URL rules
|
||||
|
||||
@@ -191,8 +210,30 @@ to an appointment. This page carries the verifiable record.
|
||||
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
||||
an asset for circulation with appointment proposals.
|
||||
|
||||
> **The PDF bio ships at BUILD STEP 9, alongside `/fees/` — deferred by Pouya
|
||||
> 2026-08-28 (Q45), tracked as `AGENTS.md` §12 **R16**.** His reasoning: it is a
|
||||
> ✅ **SHIPPED AT BUILD STEP 9, 2026-08-31. R16 / Q45 DISCHARGED.**
|
||||
> `public/pouya-lajevardi-bio.pdf` exists, is committed, and this page links it
|
||||
> between the biography and the credentials.
|
||||
>
|
||||
> **The two decisions R16 left open are both taken, and the second makes the
|
||||
> first safe.** *(a)* Neither "generated at build" nor "authored once": the bio
|
||||
> is a **page**, `src/pages/bio.astro`, so every line is reviewed by the same
|
||||
> apparatus as every other page — and `npm run bio:pdf` renders the PDF from the
|
||||
> built page through the Chrome that Lighthouse already requires, so it adds no
|
||||
> dependency. It is **not** part of `astro build`, because CI has no Chrome.
|
||||
> *(b)* It carries **nothing the site does not** — every line renders from
|
||||
> `CREDENTIALS`, `ROLE`, `BOUTIQUE`, `PRACTICE_AREAS`, `FEES` and `CONTACT`. No
|
||||
> matter list (which R16 correctly said would collide with §4 Forbidden), no
|
||||
> referees, no figure that is not on `/fees/`.
|
||||
>
|
||||
> ⚠️ **`npm run bio:pdf` asserts ONE PAGE and writes nothing if the count is
|
||||
> wrong.** And reading the rendered PDF caught a breach the source review had
|
||||
> not: its opening clause scoped **mediation** commercial, which Q56 leaves
|
||||
> unscoped deliberately. Nothing in the build regenerates the PDF — `docs/06`'s
|
||||
> cutover checklist carries the re-render.
|
||||
|
||||
> *Original deferral note, kept because its reasoning is why this is R16 rather
|
||||
> than a to-do.* **The PDF bio ships at BUILD STEP 9, alongside `/fees/` —
|
||||
> deferred by Pouya 2026-08-28 (Q45), tracked as `AGENTS.md` §12 **R16**.** His reasoning: it is a
|
||||
> derived artefact, so building it before `/about/` and `/fees/` are final means
|
||||
> building it twice, and an appointment proposal needs the fee card as much as
|
||||
> the bio. The two decisions below are **not** settled by the deferral and travel
|
||||
@@ -620,8 +661,24 @@ Dependency-ordered, so nothing is blocked mid-stream:
|
||||
4. `/mediation/`, `/arbitration/`, `/med-arb/`
|
||||
5. `/practice/` and the six area pages
|
||||
6. `/process/`, `/for-parties/`
|
||||
7. `/insights/` plumbing, then the drafted articles
|
||||
8. `/contact/` and the intake backend
|
||||
9. `/fees/` — last, though no longer blocked: D14 confirmed the card
|
||||
10. `/legal/*` — written to match the backend as actually built
|
||||
11. Audit and cutover (`06-deployment.md`)
|
||||
7. ✅ `/insights/` plumbing, then the drafted articles — **built 2026-08-31.**
|
||||
Step 7a returned Lighthouse (`AGENTS.md` §7, R11); 7b built the OG card
|
||||
generator (R15) and the Insights routes; 7c drafted the five launch articles.
|
||||
**The section is not live and cannot be**: D9 and `src/content.config.ts`
|
||||
between them mean an article publishes only when Pouya sets both flags, and
|
||||
`SiteHeader` keeps Insights out of the nav until two are live
|
||||
8. ✅ `/contact/` — **the page is built; the pipe behind it is not.** The handler
|
||||
is written (`backend/intake/`) and undeployed, and the CloudFront `/api/*`
|
||||
behaviour it posts to does not exist yet. Both are cutover items, and `docs/05`
|
||||
§Build step 8 records three deliberate deviations from that spec
|
||||
9. ✅ `/fees/` — **built 2026-08-31 on Q59's ruling**, which settled where the
|
||||
overtime hour starts (the session cap) and supplied the reservation point that
|
||||
answers the rate card's arithmetic anomaly. The PDF bio shipped with it (R16)
|
||||
10. ✅ `/legal/privacy/` and `/legal/terms/` — **built 2026-08-31, written to the
|
||||
backend as actually built.** Three of the privacy policy's statements are
|
||||
DERIVED — the collected-data list from `INTAKE_FIELDS`, the retention period
|
||||
from the handler's own figure, the analytics paragraph from
|
||||
`ANALYTICS.installed` — so they cannot drift from the implementation
|
||||
11. Audit and cutover (`06-deployment.md`) — **not started. Nothing is deployed.**
|
||||
`claims-auditor`'s single pass over the whole finished site (D20) is a blocking
|
||||
item there and has not run
|
||||
|
||||
+69
-21
@@ -88,16 +88,38 @@ So:
|
||||
|
||||
| Pages | Card |
|
||||
|---|---|
|
||||
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer |
|
||||
| Every other page | Generated at build with `satori` or `astro-og-canvas`, using the site's own type and palette: display headline on cream, infinity mark, designation line |
|
||||
| Each article | Per-article card from the same generator — the reason the two jobs are one build |
|
||||
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer. Resolved from `PORTRAIT_PAGES` in `src/data/og-cards.ts`, not from a per-page prop |
|
||||
| Every other page | **Generated at build by `src/pages/og/[...slug].jpg.ts`** from `satori` + `sharp`, in the site's own type and palette: display headline on cream, infinity mark, designation line |
|
||||
| Each article | Per-article card from the same endpoint — the reason the two jobs were one build |
|
||||
|
||||
**Until step 7 every page shares the portrait, and that is a RECORDED interim
|
||||
that blocks cutover, not build step 3.** It is tracked as **R15** in
|
||||
`AGENTS.md` §12 with its removal trigger, because a link preview nobody on the
|
||||
team ever sees is exactly the kind of interim that becomes permanent by
|
||||
never being raised. The dependency choice is made against R11 on the day, not
|
||||
recalled from this paragraph.
|
||||
✅ **BUILT — step 7b, 2026-08-31. R15 IS DISCHARGED.** `satori@0.33.4` was chosen
|
||||
over `astro-og-canvas@0.13.0` (both 0 vulnerabilities, verified that day): `sharp`
|
||||
is already a dependency to rasterise satori's SVG, so it adds one library rather
|
||||
than a CanvasKit wasm blob, and it renders with this site's own fonts and tokens
|
||||
rather than approximating them.
|
||||
|
||||
**Four things about the implementation are load-bearing and are not style
|
||||
choices.** Each is recorded because a later reader would otherwise "tidy" it:
|
||||
|
||||
1. **Colours are parsed out of `src/styles/tokens.css` at build time**, not
|
||||
copied into the generator. `CLAUDE.md` requires every colour to come from a
|
||||
token; the alternative was a duplicated hex table, which is the SES-DKIM shape.
|
||||
A missing token throws rather than falling back.
|
||||
2. **The fonts are `@fontsource`'s static `.woff` cuts, not `public/fonts/`.**
|
||||
satori parses TTF/OTF/WOFF and not WOFF2, and decompressing the site's own
|
||||
subset **variable** Geist to TTF *throws* inside satori's `opentype.js` fork —
|
||||
Fontsource's subsetting drops the `name` records the `fvar` table points at.
|
||||
Same typeface, same upstream version, same weight; build-time only.
|
||||
3. **Every card's headline is its page's own `<h1>`, character for character, and
|
||||
`npm run og:proof` enforces it** against the built HTML. This is a compliance
|
||||
mechanism, not a convenience: **text baked into a JPEG cannot be grepped by
|
||||
`npm run check:claims`**, which under D20 is the only per-step claims control
|
||||
there is. A card must not carry a claim its page does not already make in
|
||||
auditable HTML. The same check confirms every page's `og:image` resolves to a
|
||||
file that exists — a 404 preview is invisible from inside the repo.
|
||||
4. **A page with no card entry is a BUILD ERROR, not a fallback to the portrait.**
|
||||
R15's failure mode was never the wrong image; it was the wrong image shipping
|
||||
*invisibly* and reading as intentional. A silent fallback recreates it exactly.
|
||||
|
||||
## Structured data
|
||||
|
||||
@@ -167,16 +189,41 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
|
||||
| CLS | < 0.05 |
|
||||
| INP | < 150 ms |
|
||||
| JS per route | < 100 KB |
|
||||
| Lighthouse (mobile) | ≥ 95 all four categories — **not measurable until step 7, see below** |
|
||||
| Lighthouse (mobile) | ≥ 95 all four categories — **measurable again as of 2026-08-31, see below** |
|
||||
|
||||
> ⚠️ **Lighthouse verification is UNAVAILABLE until build step 7.** `@lhci/cli`
|
||||
> was removed on 2026-08-26 — it was the sole source of all 10 `npm audit`
|
||||
> findings (7 high), `0.15.1` is `latest` so there was no clean upgrade, and it
|
||||
> could not run at all with no pages and no `lighthouserc`. The budget below is
|
||||
> not suspended; the tool that measures it is absent. Re-add at step 7 under
|
||||
> `AGENTS.md` R11, checking for a patched release rather than assuming `0.15.1`
|
||||
> is still the ceiling. Until then, a run that skips this is skipping something
|
||||
> known — not something forgotten. `AGENTS.md` §7 has the state.
|
||||
> ✅ **THE INSTRUMENT IS BACK — build step 7a, 2026-08-31. `npm run lighthouse`,
|
||||
> and it is `lighthouse` rather than `@lhci/cli`.** R11's re-add trigger said to
|
||||
> put `@lhci/cli` back; this is a deliberate deviation from its literal wording
|
||||
> and `AGENTS.md` §7 records both the reason and what it costs.
|
||||
>
|
||||
> **The reason is that §7's advisory attribution was wrong, and it was the
|
||||
> attribution that made the tool look unusable.** §7 recorded the ten findings as
|
||||
> arriving *"via `lighthouse → puppeteer-core → extract-zip`"*. Measured from two
|
||||
> probe lockfiles: `@lhci/cli@0.15.1` carries 10 (7 high) and pins **lighthouse
|
||||
> 12.6.1**, and the two high carriers are `tmp@0.1.0` — *its own direct
|
||||
> dependency* — and `extract-zip@2.0.1` via `@puppeteer/browsers`.
|
||||
> `lighthouse@13.4.1` standalone is 109 packages, and both are **absent**:
|
||||
> `npm audit` returns 0. So Lighthouse was never the carrier, and the budget was
|
||||
> unmeasurable for five days on a cause nobody re-derived.
|
||||
>
|
||||
> **What it does not do: run in CI.** Standalone Lighthouse drives an installed
|
||||
> browser and the Gitea runner has none (§7, Q23). So it is a local gate plus a
|
||||
> blocking item on `docs/06`'s cutover checklist, and it is deliberately not
|
||||
> wired into `npm run build` or either deploy path — a check described as running
|
||||
> where it cannot is the defect Q22 turned out to be.
|
||||
>
|
||||
> ⚠️ **THE ACCESSIBILITY CATEGORY IS MEASURED WITH `prefers-reduced-motion`
|
||||
> FORCED, and that is a deviation that has to travel with the number.** Measured
|
||||
> twice per condition on `/process/`: motion on gives **96** with
|
||||
> `color-contrast` failing on **24 nodes**; motion off gives **100** with 0. The
|
||||
> 24 were the scroll-driven reveal caught mid-flight — axe reported foregrounds
|
||||
> like `#d0cbc4` on `#f8f4ed`, and neither is in this palette; they are the real
|
||||
> colours blended toward the background by an in-progress `opacity` keyframe. A
|
||||
> category reporting 24 known-false nodes on ten of fourteen pages cannot surface
|
||||
> the twenty-fifth real one. The reduced-motion rendering is the branch
|
||||
> `global.css` ships for a real user setting, and it is the one where every
|
||||
> element sits at its final colour. Palette ratios are computed in
|
||||
> `docs/02-design-system.md`; `scripts/lighthouse.mjs` carries the measurement.
|
||||
|
||||
How: static HTML, self-hosted preloaded subset fonts, AVIF/WebP with explicit
|
||||
dimensions, critical CSS inlined, no third-party scripts on any page except the
|
||||
@@ -200,6 +247,7 @@ nothing more.
|
||||
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
||||
- [ ] Sitemap submitted to Google Search Console and Bing
|
||||
- [ ] No page returns 200 for a URL that should 404
|
||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
|
||||
— **blocked until `@lhci/cli` is re-added at step 7.** Do not tick this box
|
||||
from a manual Chrome DevTools run and call it the same check
|
||||
- [ ] Lighthouse ≥ 95 mobile on **every built page** — `npm run lighthouse`,
|
||||
which enumerates `dist/` rather than taking a list, so the set cannot go
|
||||
stale as pages are added. Do not tick this box from a manual Chrome
|
||||
DevTools run and call it the same check
|
||||
|
||||
+121
-9
@@ -16,6 +16,98 @@ a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
|
||||
|
||||
The shape is right. This is a hardening and rework pass, not a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Build step 8, as actually built — 2026-08-31
|
||||
|
||||
**What is in the repository:** `/contact/` with the intake form, two
|
||||
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
|
||||
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built
|
||||
`adr-intake-handler` §7 records.
|
||||
|
||||
**What is NOT done, and the form does not work until it is.** Nothing on this
|
||||
project deploys before cutover (D11), so: the handler is not deployed, and the
|
||||
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
|
||||
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
|
||||
as the form for exactly this reason.
|
||||
|
||||
### The form is a plain HTML POST, and it answers 303
|
||||
|
||||
The site ships **zero** JavaScript (§7 — none, not "minimal"), so the form is a
|
||||
`<form method="post">` and the handler replies **303 See Other** to a page on the
|
||||
site. That buys three things with no script anywhere: it works with JavaScript
|
||||
disabled, which is the failure this whole project exists to fix; the visitor never
|
||||
sees a raw JSON body rendered as a page; and a refresh cannot resubmit, because
|
||||
the browser lands on a GET.
|
||||
|
||||
Two pages exist for the two outcomes — `/contact/received/` and
|
||||
`/contact/could-not-send/`. Both are `noindex` and both are excluded from the
|
||||
sitemap in `astro.config.mjs`. **The failure page names no field**, because the
|
||||
handler deliberately does not return the error list (an enumeration of the
|
||||
validation rules is a gift to whoever is probing them) and because a static page
|
||||
cannot read `?error=` without script.
|
||||
|
||||
### It posts to `/api/intake`, not to the execute-api hostname
|
||||
|
||||
Same-origin, with a CloudFront behaviour routing `/api/*` to the HTTP API origin
|
||||
§7 records. Four consequences, and the fourth is the one that matters day to day:
|
||||
`form-action 'self'` alone satisfies the CSP below; there is no cross-origin POST
|
||||
to reason about; the endpoint id stays out of the HTML and out of the repo; and
|
||||
**submitting the form from `astro dev` does nothing**, because there is no
|
||||
`/api/` route locally. Under the alternative, clicking Submit on a laptop would
|
||||
write a real DynamoDB record and send two real emails.
|
||||
|
||||
### ⚠️ Three deviations from this spec, each deliberate
|
||||
|
||||
**1. The 3-second timestamp check is NOT implemented.** It cannot be, and
|
||||
implementing it would produce a control that does nothing. The check needs to know
|
||||
when the form was *served to that visitor*; `/contact/` is a static file cached at
|
||||
the CloudFront edge, so a build-time timestamp is the same value for every visitor
|
||||
and is hours or days old. `now − served` is therefore always large, and the check
|
||||
passes for a bot exactly as it passes for a human. A per-visitor token needs a
|
||||
dynamic origin or client-side script, and the site has neither by design.
|
||||
|
||||
A control that exists on paper and not in fact is worse than a stated gap — that
|
||||
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
|
||||
said out loud, and the load is carried by the honeypot, the `Origin` check, the
|
||||
API Gateway rate limit and server-side validation.
|
||||
|
||||
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
|
||||
is a top-level navigation: it is exempt from CORS preflight, so an
|
||||
`Access-Control-Allow-Origin` setting cannot stop another site posting a form
|
||||
here. The handler compares `Origin` (falling back to `Referer`, which Firefox
|
||||
sends where it omits `Origin`) against the site origin and refuses anything else.
|
||||
The CORS restriction in this spec is still right — it governs *scripted* calls to
|
||||
the endpoint — but it is a different control and was being relied on for this one.
|
||||
|
||||
**3. There is no `mailto:` fallback, because there is nothing to fall back FROM.**
|
||||
This spec's definition of done asks that the form "degrades to a `mailto:`
|
||||
fallback with JavaScript disabled". The form never used script, so it does not
|
||||
degrade. The email address is published on `/contact/` regardless, and the failure
|
||||
page routes to it.
|
||||
|
||||
### Two field tables, cross-checked
|
||||
|
||||
`src/data/intake.ts` builds the form. `backend/intake/fields.mjs` is what the
|
||||
handler validates against. **The duplication is architectural**, because this
|
||||
spec's own rule is that the Lambda re-validates everything: a server validating
|
||||
against a list the client shipped it is asking the caller what the rules are. And
|
||||
the Lambda is a separately deployed zip that cannot import from `src/`.
|
||||
|
||||
**`npm run check:intake` is what keeps them honest** — it imports both and asserts
|
||||
they agree on every field name, on which are required, on every length cap, and on
|
||||
every closed option set. Probed with three deliberate mismatches (a changed cap, a
|
||||
dropped field, a changed option); each was caught, exit 1.
|
||||
|
||||
### Analytics: decided, not installed
|
||||
|
||||
D15 chose Plausible. **§7 records that no script is on any page**, and
|
||||
`ANALYTICS.installed` in `src/data/site.ts` is `false`. `/legal/privacy/` renders
|
||||
its analytics paragraph from that flag, so today the policy says the site sets no
|
||||
cookies and runs no analytics — which is the fact. **Flipping the flag is a change
|
||||
to a published disclosure**, not a config edit: the policy changes on the same
|
||||
build and its last-updated date moves with it.
|
||||
|
||||
## What this data actually is
|
||||
|
||||
The form collects, in a live legal dispute: the inquirer's identity and contact
|
||||
@@ -192,6 +284,16 @@ DLQ depth ≥ 1.
|
||||
|
||||
## Booking
|
||||
|
||||
**PARKED — R6, and `/contact/` ships without it.** Pouya parked the booking tool
|
||||
on 2026-08-26; build step 8 shipped the form and no embed. The "reserved slot"
|
||||
`docs/01` asks for is `CONTACT.bookingUrl` being `null`: nothing renders, and a
|
||||
URL there brings the block back without a rebuild of the page.
|
||||
|
||||
**Nothing on `/contact/` mentions booking**, deliberately — a page that says
|
||||
"book a call" with no way to book one is worse than a page that says to email.
|
||||
D10 committed to booking because it removes the back-and-forth that loses
|
||||
appointments, so the form alone is a partial answer and R6 stays live.
|
||||
|
||||
An embedded scheduler for the 30–45 minute confidential intake call
|
||||
(**Q5** — tool not yet chosen).
|
||||
|
||||
@@ -231,6 +333,13 @@ Content-Security-Policy: default-src 'self'; img-src 'self' data:;
|
||||
base-uri 'self'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
⚠️ **`form-action` IS NOW `'self'` ALONE, and that is tighter than the line
|
||||
above.** Build step 8 posts the intake form to the same-origin path `/api/intake`
|
||||
rather than to the execute-api hostname, so no third-party origin needs to appear
|
||||
in the policy. Drop `<api-endpoint>` from `form-action` when the policy is
|
||||
written. `frame-src <booking-provider>` is also unnecessary while R6 keeps the
|
||||
embed parked — add it with the embed, not before.
|
||||
|
||||
Tighten CSP once the booking provider is chosen. `unsafe-inline` on styles is
|
||||
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
|
||||
nonce for the reveal script.
|
||||
@@ -253,13 +362,16 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] Server-side validation independent of the client
|
||||
- [ ] Honeypot and timing checks live; rate limit configured
|
||||
- [ ] CORS restricted to the production origin
|
||||
- [ ] TTL set and verified by test record
|
||||
- [ ] KMS encryption and PITR enabled
|
||||
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
|
||||
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
|
||||
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
|
||||
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||||
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months. The handler writes the `ttl` attribute; TTL must also be **enabled on the table**, and §7 does not record that it is. Until this is verified the page promises a mechanism that may not run
|
||||
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
|
||||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||||
- [ ] DLQ and CloudWatch alarm configured
|
||||
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
|
||||
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
|
||||
- [ ] Privacy policy matches the implementation line for line
|
||||
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
|
||||
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
|
||||
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
|
||||
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and `RESPONSE_TIME` set. It throws at cold start on any missing one, deliberately
|
||||
|
||||
+77
-19
@@ -321,11 +321,13 @@ link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
||||
(only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
|
||||
`npm run build`, `npm run check` and `npm run lint` all run clean locally.
|
||||
|
||||
**Lighthouse is not one of the checks that could be wired today.** `@lhci/cli`
|
||||
was removed on 2026-08-26 and there is no `npm run lighthouse` script any more —
|
||||
`AGENTS.md` §7 records why and what re-adding it at build step 7 requires. Wire
|
||||
the other four; do not write a workflow step that calls a script that does not
|
||||
exist.
|
||||
**Lighthouse is still not one of the checks that can be wired — and the reason
|
||||
changed on 2026-08-31.** `npm run lighthouse` exists again (step 7a, `AGENTS.md`
|
||||
§7), so the blocker is no longer a missing script: it is that standalone
|
||||
Lighthouse drives an **installed browser** and the runner has none. Wiring it
|
||||
would need Chrome on the `act_runner` host (Q23). Wire the other four; do not
|
||||
write a workflow step that shells out to a browser that is not there. The gate
|
||||
lives locally and on the cutover checklist below.
|
||||
|
||||
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
@@ -399,31 +401,87 @@ Then invalidate `/*`.
|
||||
expressed without naming the designation; that gap is `claims-auditor`'s to
|
||||
close, and it is stated in the pattern itself
|
||||
- [ ] **C.Med-Arb appears nowhere in `dist/`** — struck entirely 2026-08-29
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
||||
- [ ] Privacy policy matches the backend as actually built
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship. **And the reservation sentence sits adjacent to the overtime row** — Q59, ruled 2026-08-31: a reader who takes the $500 and skips *"a full day reserves the day; half-day overtime is subject to availability"* has read a different offer, and the arithmetic then makes the full-day rate look strictly worse
|
||||
- [ ] Privacy policy matches the backend as actually built. **Three of its statements are derived and cannot drift** — the collected-data list from `INTAKE_FIELDS`, the retention period from the handler's own figure, the analytics paragraph from `ANALYTICS.installed`. **If analytics are installed, that flag flips and the policy's last-updated date moves on the same build**; a policy naming a processor that processes nothing is a false disclosure
|
||||
- [ ] **`/insights/` — check which state it is in.** While no article is published the page emits `noindex` (derived from the collection) and `SiteHeader` keeps Insights out of the primary nav until **two** are live. Both clear themselves on publication. D9 means the flip is Pouya's: `src/content.config.ts` refuses `draft: false` without `reviewedByPouya: true`
|
||||
- [ ] **The five drafted articles are `draft: true` and therefore invisible to `check:claims`**, which reads `dist/`. They were checked once, on 2026-08-31, by temporarily publishing all five and running it — clean on 27 pages. **Re-run it that way before any of them publishes**, because a draft that changes in between is unchecked
|
||||
|
||||
**Technical**
|
||||
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
|
||||
verified against the registry that day, and a `lighthouserc` carrying the
|
||||
budgets from `04-seo-spec.md`. This box gates the next one
|
||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
||||
- [ ] **`npm run lighthouse` on the full built site**, from a machine with
|
||||
Chrome — it is not in CI and cannot be (see above). Read the exit status.
|
||||
Every category ≥ 95 on every page; the accessibility figure is measured
|
||||
with `prefers-reduced-motion` forced and that qualification is reported
|
||||
with it (`AGENTS.md` §7)
|
||||
- [ ] Every page renders fully with JavaScript disabled
|
||||
- [ ] `curl` of each URL returns real content, not a shell
|
||||
- [ ] All internal links resolve; no orphan pages
|
||||
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
||||
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
||||
- [ ] **OG cards are per-page, not one portrait on all nineteen** — `AGENTS.md`
|
||||
Q40 / **R15**. The portrait is the decided card for `/` and `/about/`; every
|
||||
other page needs the generated typed card, built at step 7 with Insights.
|
||||
**This blocks cutover.** A link preview is the surface a general counsel
|
||||
actually sees when a colleague pastes the URL into Teams, and the interim
|
||||
makes nineteen unique titles look identical
|
||||
- [ ] **`npm run og:proof`** — every page's `og:image` resolves to a file in
|
||||
`dist/`, and every generated card's headline is its page's own `<h1>`.
|
||||
R15 is discharged (step 7b) so this is a regression check rather than a
|
||||
blocker on the interim; it stays on the list because a broken preview image
|
||||
is invisible from inside the repo, and because card text cannot be reached
|
||||
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
|
||||
contact sheet** as well — the check compares strings, not rendering
|
||||
- [ ] 404 returns a 404 status
|
||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
||||
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [ ] Intake form tested end to end: DynamoDB record written to the intake table (`AGENTS.md` §7), both emails delivered to a real inbox, TTL set
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
|
||||
IT DOES — build step 8 shipped the page and not the pipe.**
|
||||
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
|
||||
`AGENTS.md` §7 records. The form posts to the same-origin path
|
||||
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
|
||||
so there is no cross-origin POST, so the endpoint id stays out of the
|
||||
HTML, and so submitting the form from a laptop does nothing. Without
|
||||
this behaviour the form 404s.
|
||||
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
|
||||
replacing the hand-built `adr-intake-handler` §7 records. It needs
|
||||
`INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and
|
||||
`RESPONSE_TIME` set; it throws at cold start on any missing one, on
|
||||
purpose. `RESPONSE_TIME` must be `CONTACT.responseTime` verbatim — it is
|
||||
a public commitment (§4, Q27) and must read identically on `/contact/`,
|
||||
in the confirmation email, and in the bio.
|
||||
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
|
||||
(`docs/05`). Not expressible in handler code, and it is carrying load
|
||||
the unimplementable 3-second timing check was specified to carry — see
|
||||
`docs/05` §Three deviations.
|
||||
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
|
||||
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
|
||||
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
|
||||
by the database rather than by someone remembering to do it"* after 24
|
||||
months. The handler writes the `ttl` attribute; **TTL must also be enabled
|
||||
on the table**, and §7 does not record that it is. Until this is verified,
|
||||
that page promises a mechanism that may not run — which on a privacy policy
|
||||
is a false statement to the public. `aws dynamodb describe-time-to-live
|
||||
--table-name <the table §7 records>` and confirm
|
||||
`TimeToLiveStatus: ENABLED`, then write a record with a near-future `ttl`
|
||||
and confirm it disappears.
|
||||
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
|
||||
on names, requiredness, caps and option sets. They are two independent
|
||||
tables on purpose (`docs/05`); this is what stops them drifting
|
||||
- [ ] **Both POST-redirect-GET landing pages reachable and `noindex`** —
|
||||
`/contact/received/` and `/contact/could-not-send/`. Both are excluded from
|
||||
the sitemap in `astro.config.mjs`
|
||||
- [ ] **Regenerate and re-commit the bio PDF** — `npm run build && npm run bio:pdf`.
|
||||
Nothing in the build does this, so a change to `/bio/`, to §4, to the rate
|
||||
card or to the print styles leaves the committed PDF stale. It asserts one
|
||||
page and writes nothing if the count is wrong (R16). ⚠️ **It is not
|
||||
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
|
||||
identical content differ in digest and every re-render is a binary diff.
|
||||
Re-commit it when something actually changed, and say what in the message
|
||||
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**, via a CloudFront response-headers
|
||||
policy. **This is the PDF half of a decision already taken for the page.**
|
||||
`/bio/` is `noindex` and excluded from the sitemap because it is a condensed
|
||||
duplicate of `/about/` and `/fees/`, and *"two URLs competing on the same
|
||||
content is the one thing `docs/04` is most concerned with."* The committed
|
||||
PDF is a rendering of that same page, it is linked from `/about/` which is
|
||||
`index,follow`, `robots.txt` allows everything, and **Google indexes PDFs** —
|
||||
so the duplicate the page-level `noindex` prevents arrives as a file
|
||||
instead. A `Disallow` will not do it: a blocked URL can still be listed.
|
||||
Found by `adversarial-reviewer`, 2026-08-31
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
- [ ] Tested at 320 px and at 200% zoom
|
||||
|
||||
+43
-10
@@ -141,13 +141,37 @@ sells an uncapped one. See §All parameters confirmed below.
|
||||
`src/data/site.ts` means the **session** and is corrected 3.5 → 3 and 7 → 6, and
|
||||
**`/fees/` is unblocked for build step 9** on the question Q58 asked.
|
||||
|
||||
⚠️ **BUT WHERE OVERTIME BEGINS IS STILL NOT STATED, AND IT IS NOT SAFE TO INFER
|
||||
— §9 Q59, OPEN.** A first pass at this paragraph asserted that *"the overtime
|
||||
rate begins after 3 h and 6 h of session respectively"*. **That is a derived fee
|
||||
term, not part of the ruling**, and `adversarial-reviewer` was right to strike
|
||||
it: this file is the authority on money and `/fees/` is now cleared to publish
|
||||
from it. Nothing in the card, in Q58's ruling, or in Q15–Q17's answer says when
|
||||
the hourly rate starts.
|
||||
✅ **WHERE OVERTIME BEGINS — RULED. Q59, Pouya, 2026-08-31. IT RUNS FROM THE
|
||||
SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the
|
||||
billed envelope. The two candidates were the session cap (3 h / 6 h) and the
|
||||
envelope (5 h / 9 h), and this file could not choose between them — a fee term is
|
||||
a fact we do not have, not an inference. A first pass at this paragraph asserted
|
||||
the session cap as applied fact and `adversarial-reviewer` struck it in the same
|
||||
change set that wrote it; the strike was right, and the ruling has now supplied
|
||||
the value the strike was waiting for.
|
||||
|
||||
⚠️ **AND THE RULING'S SECOND HALF IS THE PART THAT MATTERS MOST, BECAUSE IT
|
||||
ANSWERS THE ARITHMETIC ANOMALY BELOW RATHER THAN RESTATING IT.** His words:
|
||||
|
||||
> "a full day reserves the day; half-day overtime is subject to availability"
|
||||
|
||||
**The full-day fee buys the DAY, not six hours of it.** That is what a reader
|
||||
doing the arithmetic in the table below is missing: `2000 + 500 × 3 = 3500`
|
||||
against `4000` looks like a $500 penalty for booking properly, and it is not —
|
||||
the two are different products. Half-day overtime depends on the time after the
|
||||
session still being free, and on a booked day it is not.
|
||||
|
||||
**So the reservation sentence is published ADJACENT TO THE OVERTIME ROW on
|
||||
`/fees/`, not in a footnote**, and it is rendered from
|
||||
`FEES.mediation.reservation` rather than retyped. Structurally the same rule as
|
||||
`PROCESS_FRAMING` beside the five timings under Q43: a reader who takes the
|
||||
number and skips the framing has read a different offer.
|
||||
|
||||
⚠️ **THE ANOMALY IS NOT CLOSED BY THIS.** The gap is still in D14's own figures —
|
||||
the half-to-full step is $2,000 and three hours of overtime is $1,500 — and the
|
||||
reservation point explains what the gap buys without removing it. It stays on
|
||||
**§12 R5**'s 12-month review, and §Recorded dissent below carries the table for
|
||||
that review to test against.
|
||||
|
||||
**And the reason it cannot be quietly chosen is that the choice is visible in the
|
||||
arithmetic.** Take the trigger as the session cap. The half-day route costs
|
||||
@@ -182,9 +206,18 @@ not uniformly better either** — the gap stays at $2,000 through five hours and
|
||||
$1,500 at six, worse than the session-cap trigger there, but it closes to **zero**
|
||||
from nine hours on, where the session-cap trigger holds a permanent $500. So the
|
||||
two triggers trade one band against another and neither removes the anomaly. **It
|
||||
is not a defect this file can fix by picking a trigger, which is why the trigger
|
||||
goes to Pouya and the step goes to R5** — see §Recorded dissent below, where it is
|
||||
written out for the 12-month review rather than left in this footnote.
|
||||
was never a defect this file could fix by picking a trigger** — which is why the
|
||||
trigger went to Pouya and the step went to R5.
|
||||
|
||||
**Both halves came back. He ruled the session cap AND supplied the reservation
|
||||
point**, which is the answer the arithmetic alone cannot give: the table compares
|
||||
prices for two things that are not the same product. Read the table as a price
|
||||
comparison and the full-day rate looks strictly worse; read it knowing a full day
|
||||
reserves the day and half-day overtime is subject to availability, and the
|
||||
$2,000-to-$500 spread is the price of certainty rather than a mistake. The
|
||||
anomaly stays on R5 because the *size* of that spread is still a judgement about
|
||||
D14's figures, and it is largest at three to five hours — the band a half-day
|
||||
booking actually overruns into.
|
||||
|
||||
### Arbitration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user