feat: upgrade to Astro 7; harden the content schema; wire a11y linting

Amends D1 to pin the major explicitly (v7.x) rather than inherit it. The
^5.0.0 pin was recalled rather than checked and was two majors stale the day
it was written, which meant shipping a framework carrying high-severity XSS
advisories. CLAUDE.md now requires every version pin to be verified against
the registry, and R11 requires re-checking at each build-order boundary.

npm audit now reports 0 vulnerabilities, down from 16. Every Astro advisory
is cleared; the residual 10 all traced to @lhci/cli, which is removed — it
was the sole source of 7 high-severity findings, 0.15.1 is latest so there
was no clean upgrade, and it cannot run without pages or a lighthouserc.
Re-added at build step 7 with a freshly verified pin.

Content collections migrated to the Content Layer API: src/content.config.ts,
loader: glob(), z from astro/zod.

Two review passes found seven defects in the fix itself, all now closed:

- z.coerce.date() read an unquoted 20260801 as epoch milliseconds and
  yielded 1970-01-01 silently; the first replacement then accepted
  2026-13-45 as an Invalid Date and rolled 2026-02-30 over to 2026-03-02.
  Dates are now anchored, date-only, parsed as UTC and round-tripped.
- The title bound applied the SEO spec's 50-60 to the headline rather than
  the rendered <title>, which guaranteed 68-78 on every article and rejected
  all five planned launch headlines. Articles are now the documented
  exception: the headline is the <title>, no suffix.
- An article could ship an image with no alt text, or whitespace-only alt.
- Two schema comments asserted controls nothing enforced; both are now real
  refinements, each tested with a failing and a passing case.
- PRACTICE_SLUGS and PRACTICE_AREAS could drift silently; a compile-time
  check now catches both directions.
- eslint.config.js imported globals and @eslint/js undeclared, resolving by
  hoisting accident.
- scripts/deploy-local.sh claimed parity with CI while skipping npm run
  check and two credential guards — on the only path this site can ship
  today.

Accessibility linting is on (36 jsx-a11y rules) before step 1 writes the
layout. An earlier claim in §7 that none was possible was wrong twice, and
is corrected in AGENTS.md entry (t) along with the reasoning.

Opens Q30 and Q31 for two unregistered claims in src/data/site.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
Pouya Lajevardi
2026-08-26 14:10:09 -04:00
co-authored by Claude Opus 5
parent 0d8b63380a
commit 7514a49803
15 changed files with 3684 additions and 5959 deletions
+321 -6
View File
@@ -88,7 +88,7 @@ without an explicit instruction and a Change Log entry. `[verified 2026-08-25]`
| # | Decision | Chosen | Rejected alternatives |
|---|---|---|---|
| D1 | Framework | **Astro**, static output | Next.js; patching the single-file build; hand-written HTML |
| D1 | Framework | **Astro, static output, v7.x.** *Amended 2026-08-26:* the major is now pinned deliberately rather than inherited. The original `^5.0.0` pin was **recalled, not checked, and was two majors stale the day it was written**. Upgrading costs three config files now and compounds with every page; the v5 advisories were **high-severity XSS in the framework rendering a public site**, and "we don't use those authoring patterns" is a claim about all future code, not just today's. Starting two majors behind means being three behind at launch | Next.js; patching the single-file build; hand-written HTML; staying on v5 |
| D2 | Content scope | **Full re-architecture** — new IA, new pages, all copy rewritten | Copy rewrite on existing structure; audit only; technical layer only |
| D3 | Hosting & CI/CD | **Git repo + Gitea Actions → existing S3 + CloudFront.** *Amended 2026-08-26:* self-hosted **Gitea**, repo `adr-sml`. Gitea Actions uses GitHub Actions syntax, so the workflow ports nearly as is — but Gitea is not an AWS OIDC provider, so deploys authenticate with a **scoped IAM user**, rotated quarterly | GitHub Actions + OIDC; Terraform/CDK IaC; Amplify; manual deploys |
| D4 | Languages | **English only** | Full EN/FA bilingual; EN + one Farsi page; EN with FA scaffolding |
@@ -188,7 +188,7 @@ cannot vouch for.
| Testimonials, endorsements, quotes from counterparties | None exist. The prior site's testimonial was fabricated |
| "Since 2009", "sixteen years", London / New York offices, Co. № 07452218 | Artefacts of the placeholder template. All false |
| The name "S. M. Lawrence" | Fictitious founder from the template |
| Guarantees of outcome, or superlatives ("best", "leading", "top-rated") | LSO marketing rules |
| Guarantees of outcome, or superlatives ("best", "leading", "top-rated") | Unverifiable as written, and they read as insecure to the audience this site is for. Marketing rules for regulated professions also treat them as suspect. *(Reason restated 2026-08-26: this row previously read "LSO marketing rules", which asserts the regulatory status the row above marks NOT ESTABLISHED. It was the last surviving instance, flagged in entries (o) and (q) and answered in Q26 as already fixed — it was not. **Restated by Claude Code; the rule itself is unchanged and the restatement needs Pouya's ratification.**)* |
| The word **"lawyer"** used of Pouya; "called to the bar"; "licensed"; "my law practice"; "my litigation practice"; any post-nominal implying a licence | D13. The site asserts the JD and nothing further |
| Any phrasing that *implies* entitlement to practise law without saying so — "acts for clients", "represents parties", "my clients", "legal advice" | Same rule. Implication is the risk, not just the word. Describe the role factually instead: *Director of Firm Operations at a Toronto litigation and ADR boutique*, and the matter types worked on |
| Q.Arb described as held, imminent, or "nearly complete" | It commenced August 2026 |
@@ -235,11 +235,13 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| Thing | Value |
|---|---|
| Framework | Astro, `output: 'static'` `[verified 2026-08-25D1]` |
| Node | 22 LTS, pinned in `.nvmrc`; `package.json` `engines.node` `>=22` `[verified 2026-08-26 — .nvmrc, package.json]` |
| Framework | **Astro 7.2.7**, `output: 'static'` `[verified 2026-08-26npm view astro version, D1 as amended]`. `@astrojs/mdx` 7.0.8, `@astrojs/sitemap` 3.7.3, `sharp` 0.35.4 |
| Lint toolchain | ESLint **10.9.1**, `@eslint/js` 10.0.1, `globals` 17.11.0, `eslint-plugin-astro` **3.1.0**, `eslint-plugin-jsx-a11y` 6.10.2, `typescript-eslint` 8.68.0, `typescript` 5.9.3 — **every pin at the registry's `latest` except `typescript`** `[verified 2026-08-26 — npm view, all 14 pins]`. `@eslint/js` and `globals` are declared explicitly; before 2026-08-26 `eslint.config.js` imported them and they resolved only by npm hoisting accident. **Accessibility linting is on**`flat/jsx-a11y-recommended`, 36 rules `[verified 2026-08-26 — 7 rules fired on a deliberately inaccessible .astro file]`. `eslint-plugin-jsx-a11y@6.10.2` declares a stale `eslint ^3..^9` peer range; a one-line `overrides` entry in `package.json` resolves it. ⚠️ **`eslint-plugin-astro@3.1.0` declares `node ^22.22.3 \|\| ^24.16.0 \|\| >=26.3.0`**, which excludes Pouya's Node 25.6.0 — `npm install` prints EBADENGINE there. Dev-time only, and `nvm use` (Node 22 LTS, per `.nvmrc`) clears it. `typescript` is held at 5.x: `typescript-eslint` peers `<6.1.0` and `@astrojs/check` peers `^5 \|\| ^6`, so TypeScript 7.0.2 is unusable here `[verified 2026-08-26 — npm view]` |
| Lighthouse CI | **Not installed.** `@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`), 0.15.1 is `latest` so there was no clean upgrade, and it cannot run at all — no pages, no `lighthouserc`. Removed 2026-08-26; **`npm audit` now reports 0 vulnerabilities**. Re-add at build step 7 with a freshly verified pin (R11) `[verified 2026-08-26 — npm audit]` |
| Node | **22 LTS line**, `.nvmrc` holds `22` so it tracks the latest 22.x rather than freezing at a floor. `package.json` declares **`engines.node >=22.13.0`** and **`engines.npm >=9.6.5`** — the intersection of every dependency's own floor: Astro 7 and `@astrojs/mdx` 7 need `>=22.12.0`, ESLint 10 needs `^20.19.0 \|\| ^22.13.0 \|\| >=24` `[verified 2026-08-26 — npm view astro@7.2.7 engines, npm view eslint@10.9.1 engines]`. **Pouya's machine reports Node v25.6.0** `[verified 2026-08-26 — node -v]`, an odd-numbered non-LTS line; it satisfies the floor, but `nvm use` keeps local and CI on the same runtime |
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
| Client JS | Astro islands only, where genuinely needed. Target: most pages ship zero JS `[verified 2026-08-25]` |
| Content | Astro content collections, MDX for Insights `[verified 2026-08-25]` |
| Content | Astro **Content Layer API** collections, MDX for Insights. Config lives at **`src/content.config.ts`**; collections declare a `loader`, not a `type`; `z` imports from `astro/zod`. **Astro 5 introduced** the Content Layer API and this location; **Astro 6 removed** the legacy `src/content/config.ts` fallback — moving the file back now raises `LegacyContentConfigError` `[verified 2026-08-26 — reproduced by moving it back]` |
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap` `[verified 2026-08-25]` |
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
@@ -324,6 +326,8 @@ Nothing below can be invented. Each needs an answer from Pouya.
| ~~Q27~~ | **ANSWERED 2026-08-26 — two business days.** A public commitment, so it must read identically everywhere it appears: `src/data/site.ts`, `/contact/`, the inquirer confirmation email, and any bio | — |
| ~~Q28~~ | **ANSWERED 2026-08-26 — OBA Construction & Infrastructure, ADR, and Civil Litigation** `[verified 2026-08-26 — Pouya]`, **for now**: section membership renews yearly, so this is a fact with a shelf life. Tracked as R10 | — |
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
| **Q30** | **What is SML Company Ltd.'s jurisdiction of incorporation, and should it appear in the footer?** `src/data/site.ts` carried `entity: 'SML Company Ltd. · Ontario, Canada'`. §4 verifies "Operator of SML Company Ltd." and "Toronto, Ontario" — the latter about **Pouya**, not the company's incorporation. Jurisdiction dropped pending an answer; the operator fact stays. Raised by `claims-auditor`, 2026-08-26 | The site-wide footer |
| **Q31** | **Plausible or Fathom?** D15 records the choice as "Plausible **or** Fathom" — undecided. `src/data/site.ts` had `provider: 'plausible'`, a guessed value, in a file whose own header says not to guess one to make the build pass. Set to `null` with a `TODO(pouya)`. Raised by `claims-auditor`, 2026-08-26 | Analytics; the privacy-policy line that names the processor |
| **Q23** | **Is a Gitea `act_runner` registered, and are Actions enabled in `app.ini`?** *The version half is closed:* the instance reports **1.27.2** `[verified 2026-08-26 — /api/v1/version]`, so the `vars` context is supported and the first-step guard is belt-and-braces rather than load-bearing. **What remains is not a fact to look up but a dependency on a person:** git.larsnolden.com is **jointly administered**, and both enabling Actions and registering a runner need the second administrator. Until then `npm run deploy` is the path, and it is documented rather than treated as a workaround | How deploys are triggered — not what they do |
## 10. Risks
@@ -373,7 +377,7 @@ never being raised again.
| # | Reminder | Raised | Why it must keep coming back |
|---|---|---|---|
| R1 | **Licensure.** No site exists yet — `src/pages/` is empty. **As specified**, the copy will assert the JD only and describe the boutique role as *active litigation exposure*, never *practice*. Pouya flagged that as an interim position and asked to be reminded to change it. §4 now records licence status as `[unestablished]`, which is the honest state, not a resolution | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
| R1 | **Licensure.** No site exists yet — `src/pages/` does not exist. **As specified**, the copy will assert the JD only and describe the boutique role as *active litigation exposure*, never *practice*. Pouya flagged that as an interim position and asked to be reminded to change it. §4 now records licence status as `[unestablished]`, which is the honest state, not a resolution | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
| R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set |
| R3 | **Indigenous engagement practice page.** Deliberately omitted at launch (§6). Revisit at month 1218 | 2026-08-26 | The strategy brief rates it the most valuable single niche. Omission is a timing call, not a permanent one |
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
@@ -381,6 +385,8 @@ never being raised again.
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
| R10 | **OBA section membership renews yearly.** §4 verifies Construction & Infrastructure, ADR, and Civil Litigation as of 2026-08-26 — *for now* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at renewal, and before any page listing memberships ships |
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7 | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
| R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `<em>a</em><strong>b</strong>` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again |
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
| ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — |
@@ -388,6 +394,315 @@ never being raised again.
# Change Log
## 2026-08-26 (t) — Astro 5 → 7. The review found a defect in the fix, twice
**Who:** Pouya approved the upgrade and set the version-pin rule. Claude Code
implemented under `/build`; `adversarial-reviewer` and `claims-auditor` both ran
and both failed the first attempt.
**This entry resolves the escalation left open at the end of entry (s).** That
entry recorded the upgrade as *proposed, not done*; it is done now.
### D1 amended — the major is pinned, not inherited
Old → new: *"Astro, static output"***"Astro, static output, v7.x"**, with the
reasoning in the decision itself so the next reader knows the version was chosen.
Pouya's own account of the original pin, recorded at his instruction: `^5.0.0`
was **recalled, not checked, and was two majors stale on the day it was
written.** That is a different failure class from the sweep problem in entries
(l) through (o) — not a change that failed to propagate, but a fact asserted
that one command would have settled.
Verified with `npm view`, and the staleness was not confined to Astro:
| | was pinned | registry `latest` |
|---|---|---|
| `astro` | `^5.0.0` | **7.2.7** |
| `@astrojs/mdx` | `^4.0.0` | **7.0.8** — three majors |
| `eslint` | `^9.0.0` | **10.9.1**; npm flags 9.x deprecated |
| `sharp` | `^0.33.0` | 0.35.4 |
| `@lhci/cli` | `^0.14.0` | 0.15.1 |
**The rule is now in `CLAUDE.md`**: a version pin is verified against the
registry, never recalled. **R11** requires re-checking currency at every phase
boundary in the build order — ten remain.
A caveat the rule needs, learned here: **verified does not mean latest.**
`typescript` 7.0.2 exists and is unusable — `typescript-eslint` peers `<6.1.0`
and `@astrojs/check` peers `^5 || ^6`. `@eslint/js` latest is 10.0.1, but on
ESLint 9 it must track 9.x exactly. The check tells you the number; you still
have to know why you chose it. Both holds are now recorded in §7 so the next
agent running R11 does not re-derive them or break the build discovering them.
**The rule was then not applied to its own manifest**, which the second review
caught: `eslint-plugin-astro` was left at `^1.3.0` while §7 asserted a hold at
1.7.0, and `typescript` at `^5.7.0` against an installed 5.9.3. Both re-pinned.
Every one of the twelve pins is now verified against `npm view`.
### The migration
Astro 5 introduced the Content Layer API and `src/content.config.ts`; **Astro 6
removed the legacy `src/content/config.ts` fallback**. Verified by moving the
file back and reproducing `LegacyContentConfigError` — an earlier draft of this
entry attributed the move to Astro 6, which `claims-auditor` caught as a version
fact recalled rather than checked, *in the change that raised recalling-versions
to a rule.*
`type: 'content'``loader: glob(...)`, `z` from `astro/zod`. `schema: ({ image
}) => …` still works — verified, not assumed.
**An empty build proves nothing**, so a throwaway page and article exercised
`getCollection`, `render(entry)`, `entry.id`, MDX rendering, the `image()`
helper resolving to a hashed asset, and sitemap emission at `/sitemap-0.xml`
with no trailing-slash conflict. Then deleted.
### `compressHTML` — held at `true`, and the first rationale was wrong
Astro 7 changed the default to `'jsx'`. I asserted this would eat spaces in
prose, measured **MDX**, and found no difference — the premise was unsupported
as written. Measuring the `.astro` path instead:
```
'jsx': <em>inline</em><strong>pair</strong> ← space silently deleted
true: <em>inline</em> <strong>pair</strong>
```
Real hazard, wrong file. `adversarial-reviewer` reproduced both outputs
independently. The comment now names `.astro` templates specifically and records
that MDX is unaffected, so nobody inherits the original claim. **R12** makes the
deviation revisitable only with a measurement.
### What the review caught — seven defects in the fix itself
**Both reviewers, independently:** two source files cited *this entry* before it
existed. Current Truth had been rewritten in place with no append-only record,
and `astro.config.mjs` pointed at it as the authority for a measurement. Rules
13 of this file's own constitution, broken in the change that adds a rule about
not recalling things. Writing this entry is the fix.
**`z.coerce.date()` turned a plausible typo into 1970.** Reproduced: unquoted
`publishDate: 20260801` — valid YAML, the obvious slip for `2026-08-01` — coerces
from epoch milliseconds to **1970-01-01** with no error, and would ship as
`datePublished` in the article's JSON-LD and sort the piece last in a
reverse-chronological index. The reviewer also tested my justification for the
coercion and found it false: plain `z.date()` accepts unquoted YAML dates fine.
Replaced with a bounded union — `z.date()` or an ISO-shaped string — which
accepts both real forms and rejects numbers.
**The title bound made the SEO spec unsatisfiable.** `docs/04-seo-spec.md` puts
5060 characters on the *rendered* `<title>`, whose pattern is
`"<headline> · Pouya Lajevardi"`. The suffix is 18 characters, so applying 5060
to the headline guaranteed a rendered title of **6878** — over the spec's own
ceiling on every article — while `.min(50)` hard-failed the build on a good short
headline (`"What Med-Arb Actually Is"`). The schema now enforces the *rendered*
length, adds an optional `seoTitle` override, and reports the computed title in
the failure message. `docs/01-architecture.md` updated to match.
**An article could ship an image with no alt text**`image` and `imageAlt` were
independently optional, and the schema is the only gate that exists before the
`/insights/` route is written. Now coupled.
**Two comments asserted controls that did not exist**: "every piece must link to
a practice-area page" (`.min(1)` checks a *declaration*, not a link) and "an
article with `draft:false` and `reviewedByPouya:false` is a bug" (nothing
enforced it). The second is now true — a refinement fails the build. All four
invariants were then tested, each with a failing and a passing case; each fires.
**The schema diverged from two specs**`docs/01` and `docs/02` both specify
`topics[]` plural and `readingTime`; the collection had singular `topic` and no
reading time. Pre-existing, carried through a wholesale rewrite without notice.
Fixed to the specs.
**`eslint.config.js` imported two undeclared packages**, one resolving by
hoisting accident: `globals` was 14.0.0 at the root against 16.5.0 nested under
the Astro plugin, so which major linting used depended on npm's hoisting.
### The second review pass found six more, four of them in the first round's fixes
The loop requires re-review after material fixes, and this is why.
**`frontmatterDate` accepted impossible dates** — the fix for the 1970 defect
introduced its own. The regex was unanchored at the end and nothing checked the
parsed result, so `2026-13-45` and `2026-08-01 nonsense` both **passed**, each
producing an `Invalid Date` bound for `datePublished` in the article's JSON-LD;
and `2026-02-30` silently rolled over to **2026-03-02** — a wrong date shipped
with no error, which is worse than a failed build. The comment claimed it
accepted "both real forms and nothing else." Now anchored, date-only, parsed as
UTC and **round-tripped** so the day that comes back must be the day written.
A time component is rejected rather than guessed at: quoted
`2026-08-01T10:00:00` parses as local time while the unquoted YAML form parses
as UTC, so the same frontmatter would mean different instants on a laptop and on
a CI runner.
**The title rule rejected all five planned launch articles.** The arithmetic was
right and the rule was still wrong. Every headline in `docs/03-content-spec.md`'s
launch slate is 5067 characters; with the ` · Pouya Lajevardi` suffix they
render at 6885 and **5 of 5 fail**, making `seoTitle` mandatory on every article
— a second title field to hand-keep in sync forever, on the highest-volume
content type. Two specs contradicted each other and the schema enforced one.
Resolved by making articles the documented exception: **an article's headline is
its `<title>`, with no suffix.** Under that rule 4 of 5 launch headlines pass and
`seoTitle` is a genuine exception. `docs/04-seo-spec.md` — which owns the title
rule and had not been swept — now states it.
**`PRACTICE_SLUGS` and `PRACTICE_AREAS` could drift silently.** Splitting one
derived source into two literals bought back the literal types but lost the
guarantee: deleting an area while leaving its slug declared passed `astro check`
cleanly, so an article could name a practice area with no page, no nav child and
no chip. My first completeness check was written as a conditional type and
**did not fire** — verified by deleting a row and getting 0 errors. Replaced with
an `AssertNever` constraint that does: a missing area now fails with
`ts(2344)`, and a typo'd slug fails with `ts(2820)` plus a "did you mean".
Zero runtime cost.
**Three narrower schema gaps**, each reproduced: `imageAlt: " "` passed, so an
image could ship with a whitespace-only accessible name — the exact thing the
coupling check exists to prevent; duplicate `topics` and `practiceAreas` passed
and would render duplicate pills; and `seoTitle` was bounded *and* re-checked,
so one mistake produced two errors. Trimmed, de-duplicated, and the field bound
dropped so the refinement is the single check.
**`scripts/deploy-local.sh` claimed to do "EXACTLY" what CI does.** It did not:
the workflow guards six values, the script guarded four, and the workflow runs
`npm run check` before building while the script ran neither check nor `npm ci`.
A local deploy — currently the **only** way this site ships, since Q22 and Q23
are open — was skipping the type and template gate. Guards brought to parity,
`npm run check` added, and the claim rewritten to name the two remaining
differences instead of denying them.
**Sweep misses:** `README.md` stated an engines floor of `>=22.12.0` after
`package.json` had moved to `>=22.13.0` for ESLint 10 — a duplicated version fact
of exactly the kind §7 exists to prevent, now replaced with a pointer.
`package-lock.json`'s root `engines` still carried the pre-bump value because the
bump was made without reinstalling. `engines.node` was described as "the
intersection of every dependency's floor" and was not one — `>=22.13.0` admits
Node 23.x, which ESLint 10 excludes; it now reads `^22.13.0 || >=24`.
`eslint.config.js` still called itself an "ESLint 9 flat config", and
`.gitea/workflows/deploy.yml` still carried the Gitea version as `[assumed]`
after Q23 verified it as 1.27.2.
**Three claims in `src/data/site.ts` bound for public pages**, all pre-existing
and all found by `claims-auditor`: `entity: 'SML Company Ltd. · Ontario, Canada'`
asserted a jurisdiction of incorporation §4 does not verify (**Q30**);
`provider: 'plausible'` was a guessed value where D15 records the choice as
undecided, in a file whose own header says not to guess one to make the build
pass (**Q31**); and `NO_RETAINER_NOTICE` was missing the third element
`docs/01-architecture.md` requires — that submitting the form does not itself
create a conflict check. The notice is fixed; the other two are nulled with
`TODO(pouya)` and numbered.
**One finding declined.** `claims-auditor` reported that no build order exists in
the repository and that R11's "ten more boundaries" was therefore untraceable. It
does exist — `docs/01-architecture.md` §Build order, eleven numbered steps. The
audit grepped lowercase `"build order"` and missed the capitalised heading. R11
now carries the file and section so the next reader does not repeat the search.
### Declined, with reasons
**Accessibility linting is ON, and I was wrong about it twice.**
`adversarial-reviewer` recommended `eslint-plugin-astro@3.1.0` for the
`eslint-plugin-jsx-a11y` peer. I tested that upgrade, hit ERESOLVE (v3 requires
`eslint >=10` *and* peers `jsx-a11y >=6.10.2`, whose latest peers `eslint ^3..^9`),
and concluded it was "not installable" — recording in §7 that
**"there is no automated accessibility linting"**.
Wrong the first time: **the already-installed `eslint-plugin-astro@1.7.0` ships
36 `jsx-a11y/*` rules and a `flat/jsx-a11y-recommended` config.** I had tested a
different upgrade path and generalised its failure to the capability. The stale
peer range is a *declaration*, not a runtime incompatibility.
Wrong the second time: having added a one-line `overrides` entry to fix that
declaration, I retested v3.1.0 — **and it installs cleanly.** The ERESOLVE was
never about v3; it was about the missing `overrides`. So the plugin is now on the
current major, not held two behind, and there is no stale pin left in the
manifest except `typescript`, whose hold is forced by peer ranges.
Verified by execution at each step — rules fired on a deliberately inaccessible
`.astro` file: `html-has-lang`, `alt-text`, `heading-has-content`,
`click-events-have-key-events`, `no-static-element-interactions`,
`no-noninteractive-tabindex`, `tabindex-no-positive`. Those are the checks
`docs/02-design-system.md` §Accessibility floor names.
One cost, recorded rather than discovered later: v3.1.0 declares
`node ^22.22.3 || ^24.16.0 || >=26.3.0`, which **excludes Node 25.6.0** — so
`npm install` prints EBADENGINE on Pouya's machine. Dev-time only; `nvm use`
clears it, and `.nvmrc` already says 22.
This mattered more than the ERESOLVE did. **§7 is the single source of
operational truth, and it carried a false capability claim on the eve of the
step that writes every landmark, heading and focus state**, under a CLAUDE.md
rule that accessibility is a build requirement rather than a polish pass.
**`@lhci/cli` advisories carried, not fixed.** All ten remaining findings trace
to it alone; 0.15.1 *is* `latest`, so there is no clean upgrade, and
`npm audit fix --force` installs `@lhci/cli@0.1.0` — a fourteen-minor downgrade,
which is not a fix. Dev-only, never on the CloudFront origin.
**Two `Consider` items taken rather than deferred**, both in files already being
edited: `image.service` was dead configuration (it set Astro's own default) under
a comment describing a convention it did not enforce — deleted, convention stays
in `CLAUDE.md`; and `sitemap({ lastmod: new Date() })` stamped every URL with the
build time, telling crawlers all 17 pages changed whenever one did, which spends
the signal `docs/04-seo-spec.md` wants. Removed, with step 7 named as the place
to reinstate it per-entry from `updatedDate`.
**Two `Consider` items declined:** the six extraneous `@img`/`@emnapi` packages
are cosmetic and `npm ci --dry-run` is clean; and no `.npmrc` with
`engine-strict` was added — the engines floor is a declaration, and making it
fatal is a decision with its own blast radius.
### `npm audit` — the actual result, and it is now zero
```
after the upgrade: 10 vulnerabilities (2 low, 1 moderate, 7 high)
npm audit --omit=dev: found 0 vulnerabilities
after removing @lhci/cli: found 0 vulnerabilities
```
**Every Astro advisory is cleared** — zero findings against `astro`,
`@astrojs/mdx`, `@astrojs/sitemap` or `sharp`. `adversarial-reviewer` confirmed
the rationale independently by installing `astro@^5.0.0` in a scratch directory
and reproducing **8 high-severity advisories, 6 of them distinct XSS**.
`npm ls` traced all 10 residual findings to `@lhci/cli` alone. It was first
carried as acceptable dev-only risk; on the second pass that was reconsidered
and it is now **removed**. The reasoning: 0.15.1 *is* `latest`, so there was no
clean upgrade; `npm audit fix --force` would install `@lhci/cli@0.1.0`, a
fourteen-minor downgrade; and the tool **cannot run at all today** — no pages,
no `lighthouserc`. Carrying seven high-severity advisories for an unusable tool
is not a trade worth making when deferring it to step 7 costs nothing. Recorded
in §7; R11 forces a fresh pin when it returns.
**A discrepancy with entry (s), stated rather than quietly dropped.** Entry (s)
recorded 16 vulnerabilities and attributed the high-severity set to Astro ≤
7.0.9. After the upgrade the high count was still 7, all from `@lhci/cli` — so
(s)'s attribution cannot have been right as written. (s) is append-only and
stands; this is the correction. What is verifiable now is the audit output
above.
### Verification
Run, not asserted: `npm run check` (0 errors, 0 warnings, **0 hints** — three
`ZodIssueCode is deprecated` hints appeared mid-change and were fixed to the
string literal), `npm run build`, `npm run lint` (ESLint clean, Prettier clean).
`npm audit` and `npm audit --omit=dev` as above. `npm install --dry-run` for both
candidate lint upgrades. Each of the four schema invariants exercised with a
failing and a passing article. A deliberately broken `.astro` file linted to
confirm the plugin is live under ESLint 10. `node -v` → v25.6.0.
Not run: Lighthouse (removed — see above), the no-JavaScript render check (no
pages), link resolution (no pages). Those belong to build step 1
(`docs/01-architecture.md` §Build order), which entry (s) began and which
continues after this.
**Still open:** Q23 (runner registration — needs the second administrator), R6.
**Standing reminders surfaced at the start of this build:** R1 (licensure — the
layout about to be written is what carries it), R9 (the SES alarms still notify
nobody until the `ses-alerts` subscription is confirmed), R6, R10.
---
## 2026-08-26 (s) — Q22/Q23/Q27/Q28 answered; toolchain installed; build step 1 begun
**Who:** Pouya answered four questions mid-session and corrected one earlier