diff --git a/.claude/agents/adversarial-reviewer.md b/.claude/agents/adversarial-reviewer.md index 2332a8f..3068c28 100644 --- a/.claude/agents/adversarial-reviewer.md +++ b/.claude/agents/adversarial-reviewer.md @@ -6,7 +6,7 @@ model: opus --- You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of -a legal professional's dispute resolution practice. +a dispute resolution practice. **Your job is to find what is wrong with it.** You are not here to confirm that the work is good. An approving review that misses a real defect is a failure; a diff --git a/.claude/agents/claims-auditor.md b/.claude/agents/claims-auditor.md index d680d70..3e28923 100644 --- a/.claude/agents/claims-auditor.md +++ b/.claude/agents/claims-auditor.md @@ -5,7 +5,8 @@ tools: Read, Grep, Glob model: opus --- -You audit public copy for a **licensed legal professional's** marketing site. +You audit public copy for the marketing site of a dispute resolution practice. +The site it replaces carried fabricated credentials. The site this replaces contained a fictitious founder, invented matter values ("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations, diff --git a/.claude/commands/build.md b/.claude/commands/build.md index 4831689..d685467 100644 --- a/.claude/commands/build.md +++ b/.claude/commands/build.md @@ -19,7 +19,7 @@ does not apply, say which and why before moving on. Reminders** and surface anything live to Pouya before you start. 2. Read the specs in `docs/` that bear on this task. 3. Restate the task in your own words, and name: - - which locked decisions (D1–D16) it touches + - which locked decisions (D1–D18) it touches - which specs govern it - which facts it needs from the §4 Verified register 4. **Stop and ask if you find a conflict** — between the task and a locked @@ -87,5 +87,10 @@ why, and any decision or plan — including declined findings and anything deferred. Update Current Truth in place where the change made a section stale. Re-stamp facts you re-checked with today's date. +**If the entry claims a change was applied across files, cite the command and +paste its output.** Write that claim only after reading the output. Recall is +not evidence — three entries on this project asserted a completed sweep and +instances survived all three. + Then report to Pouya: what shipped, what the review found, what you declined and why, and what remains open. diff --git a/.claude/commands/wrap.md b/.claude/commands/wrap.md index 10ed6bf..022461d 100644 --- a/.claude/commands/wrap.md +++ b/.claude/commands/wrap.md @@ -24,6 +24,12 @@ reasoning. **Never edit a past entry.** If something earlier was wrong, correct it in today's entry and leave the original as written. + **Any claim that a change was applied across files must cite the command and + be written only after reading its output.** Paste the `grep`. Recall is not + evidence — three entries on this project asserted a completed sweep and + instances survived all three, one of them inside the definition of the agent + whose job is to catch it. + 4. **Check §12 Standing Reminders.** Is anything now due? Should something new be added — a decision Pouya parked, or one you made on his behalf that he has not yet ratified? diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c83f111..6260b10 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,14 +1,18 @@ # Gitea Actions — the live pipeline for this repository. # # Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of -# .github/workflows/deploy.yml (kept as the OIDC reference in case the repo ever -# moves to GitHub or GitLab). +# docs/reference/github-actions-oidc.yml.example (kept as the OIDC reference in +# case the repo ever moves to GitHub; it lives under docs/ rather than +# .github/workflows/ so Gitea can never fall back to it). # # ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to -# assume. Deploys authenticate with a SCOPED IAM USER whose key lives only in -# this repository's Gitea secrets. See docs/06-deployment.md for the exact IAM -# policy — it grants four actions on one bucket and one distribution, nothing -# more. Rotate the key quarterly; OIDC would have made that unnecessary. +# assume. Deploys are designed to authenticate with a SCOPED IAM USER whose key +# lives only in this repository's Gitea secrets. Whether that user and key have +# actually been created is AGENTS.md Q22 — unanswered as of 2026-08-26. +# +# See docs/06-deployment.md for the exact IAM policy — it grants four actions on +# one bucket and one distribution, nothing more. Rotate the key quarterly; OIDC +# would have made that unnecessary. # # Requires a Gitea Actions runner registered to this repo or its organisation. @@ -35,6 +39,36 @@ jobs: CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} steps: + # Runs first, before checkout and before any AWS call, so a + # misconfiguration costs one second instead of a full build. + # + # Repository variables live at Settings -> Actions -> Variables. Gitea + # only added the `vars` context in 1.21 [assumed 2026-08-26 - not checked + # against this instance]; on an older one every ${{ vars.* }} is expected + # to interpolate to an empty string, the sync target below degrades to + # "s3://", and the run dies obscurely somewhere in the middle. + # + # SCOPE: this guard covers the three DEPLOY-TARGET variables only. It does + # NOT cover AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (an unset secret + # still fails later, at `aws sts get-caller-identity`), nor + # vars.INTAKE_ENDPOINT, which is step-scoped on the Build step and whose + # absence would ship a form posting to an empty endpoint. See AGENTS.md + # Q23 - extending the guard to both is an open decision, not an oversight. + - name: Guard - required repository variables are set + run: | + missing='' + [ -n "$AWS_DEFAULT_REGION" ] || missing="$missing AWS_REGION" + [ -n "$S3_BUCKET" ] || missing="$missing S3_BUCKET" + [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID" + if [ -n "$missing" ]; then + echo "Missing repository variables:$missing" + echo + echo 'Set them at Settings -> Actions -> Variables (see docs/06-deployment.md).' + echo 'If they ARE set, this Gitea instance predates the vars context (1.21+).' + exit 1 + fi + echo 'Required repository variables are present.' + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -68,8 +102,8 @@ jobs: - name: Verify credentials run: aws sts get-caller-identity - # Two passes: hashed immutable assets first, HTML last. A visitor must - # never fetch a new page whose assets have not landed yet. + # Three passes: hashed immutable assets first, then images, HTML last. + # A visitor must never fetch a new page whose assets have not landed yet. - name: Sync hashed assets run: | aws s3 sync ./dist "s3://${S3_BUCKET}" \ diff --git a/AGENTS.md b/AGENTS.md index 360828b..709e05a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,10 +38,12 @@ SML Company Ltd. Legal background, firm affiliation, and the operating company are credibility signals referenced in support; they are not the subject. `[verified 2026-08-25 — source: PL_ADR_Personal_Branding_Strategy_Brief.docx]` -**Positioning in one sentence:** a credentialed neutral who is also a working -litigator and a practising machine-learning / infrastructure engineer — a +**Positioning in one sentence:** a credentialed neutral who is also close to +live litigation and a practising machine-learning / infrastructure engineer — a combination that is close to absent from Canadian ADR rosters, and which is the practice's economic moat. `[verified 2026-08-25 — source: strategy brief §I, §V]` +*(Wording corrected 2026-08-26: "a working litigator" asserted conduct of +litigation, which §4 bars — see entry (q).)* **Time horizon:** the strategy brief plans a 24-month compounding arc. The site is built to be added to continuously, not shipped once. `[verified 2026-08-25]` @@ -98,7 +100,7 @@ without an explicit instruction and a Change Log entry. `[verified 2026-08-25]` | D10 | Intake | **Rebuilt intake form + calendar booking** for the confidential intake call | Structured questionnaire; form only; email/phone only | | D11 | Rollout | **Build everything, one clean cutover.** The current site stays live untouched until replaced | Patch live site first; staging subdomain; take site down | | D12 | Agent working file | **This file**, maintained under the constitution above | Ad hoc notes | -| D13 | Licensure claims | **The site asserts the JD only.** No claim of licensure, call to the bar, or entitlement to practise law. The approved phrasing for the boutique role is **"active litigation exposure"** or **"involvement in litigation and ADR matters"** — never "practice". Pouya's direction, 2026-08-26. **Explicitly interim — see Standing Reminders §12** | Stating LSO licence status; the word "practice" | +| D13 | Licensure claims | **The site asserts the JD only.** No claim of licensure, call to the bar, or entitlement to practise law. The approved phrasing for the boutique role is **"active litigation exposure"** or **"involvement in litigation and ADR matters"** — never "practice" **in that context**. "Practice" describing Pouya's own ADR practice — "the dispute resolution practice of Pouya Lajevardi" — is correct and stays. Pouya's direction, 2026-08-26. **Explicitly interim — see Standing Reminders §12** | Stating LSO licence status; the word "practice" | | D14 | Fee structure | **Single published rate card, all mediation matters at one rate.** Confirmed by Pouya 2026-08-26; figures in `docs/07-fees.md`. No tribunal-secretary line | Two-tier card (recommended by Claude, declined); ranges; fees on request | | D15 | Analytics | **Privacy-first and cookieless** (Plausible or Fathom). No cookie banner, nothing to consent to, one line in the privacy policy | GA4; no analytics at all | | D18 | Intake email | **Two emails per submission** — notification to `info@smlcompany.ca`, and a **confirmation receipt to the inquirer**. Pouya, 2026-08-26. The receipt is what requires SES production access (Q19) | Notification only; no email; no form at all | @@ -107,11 +109,20 @@ without an explicit instruction and a Change Log entry. `[verified 2026-08-25]` ## 4. Credential and claim register -**This is the most important section in this file.** Pouya is a licensed legal -professional. Every public claim on this site is subject to Law Society of -Ontario marketing rules, and the previous version of the site carried fabricated -credentials, invented matter values, and a fictitious testimonial. That must -never recur. +**This is the most important section in this file.** The previous version of this +site carried a fictitious founder, invented matter values, fabricated office +locations, and a testimonial attributed to a person who does not exist. Whatever +regulatory regime does or does not apply, publishing false credentials on a +professional's marketing site is the thing this register exists to prevent. That +reason is sufficient on its own, and it does not rest on a fact this file has +never established. + +**Note on this paragraph's own history, kept as a caution.** It previously read +"Pouya is a licensed legal professional", then "a legal professional", then +asserted that LSO marketing rules apply — three progressively weaker forms of the +same unverified claim, each surviving a sweep meant to remove it. The register +was asserting what it could not vouch for, in the section written to stop exactly +that. ### Governing rule @@ -130,9 +141,10 @@ since May. | Claim | Status | |---|---| | Pouya Lajevardi, JD, Bond University | `[verified 2026-08-25 — strategy brief §I]` | +| **Licence status — NOT ESTABLISHED** | `[unestablished 2026-08-26]`. This record does not know whether Pouya is licensed. He instructed that licensure be left out of the site (D13) — an instruction about the site, **not a statement of fact either way.** Do not assert it, do not deny it, do not infer it from anything else here, and do not treat any regulatory regime as established. R1 tracks the reopening | | ~~Lawyer; Law Society of Ontario member~~ | **DO NOT PUBLISH.** Pouya directed on 2026-08-26 that licensure is left out of the site entirely; the JD is asserted, nothing further. See D13 and the Forbidden table below `[verified 2026-08-26]` | | Director of Firm Operations, Toronto litigation and ADR boutique | `[verified 2026-08-25 — strategy brief §I]` | -| Active litigation practice: personal injury, construction, regulatory (POA), insurance (SABS) | `[verified 2026-08-25 — strategy brief §I]` | +| Active litigation **exposure**: personal injury, construction, regulatory (POA), insurance (SABS) | `[verified 2026-08-26]`. **Wording corrected** — this row previously read "practice", the exact word D13 bars in exactly the context it bars it. The register was quoting the strategy brief verbatim and contradicting its own rule. The underlying fact is unchanged | | Q.Med designation through ADRIC / ADRIO | `[verified 2026-08-25 — strategy brief §I]` | | Has completed multiple sole mediations | `[verified 2026-08-25 — strategy brief §I]` — count deliberately not published, see below | | Q.Arb credentialing pathway — **commenced August 2026** | `[verified 2026-08-26 — Pouya]`. Describe as newly commenced, never as held or nearing completion | @@ -149,9 +161,22 @@ since May. | Toronto, Ontario; by appointment | `[verified 2026-08-26]` | | Contact: `info@smlcompany.ca`; no public phone number; consultations by scheduled call | `[verified 2026-08-26 — Pouya]` | | LinkedIn: `https://www.linkedin.com/in/pouyalajevardi/` | `[verified 2026-08-26 — Pouya]` | -| The Toronto litigation and ADR boutique **may be named on the site** | `[verified 2026-08-26 — Pouya]`; the name itself is pending, see Q7 | +| ~~The boutique may be named on the site~~ | **STRUCK 2026-08-26 (Q25).** Superseded by **D16 — never named.** Pouya said it *could* be named, then reversed; the permission survived the reversal and sat in the Verified table authorising exactly what D16 bars. Refer to it only as *a Toronto litigation and ADR boutique* | -### Forbidden — must not appear anywhere +### Forbidden — must not appear on the site + +**Scope, clarified 2026-08-26.** This table governs **published pages** — anything +a visitor to `adr.smlcompany.ca` can read, JSON-LD included. It is not a word +filter over the repository's own documentation. + +**Internal docs are bound by a related rule:** they must not assert as fact +anything this register has not verified. Reasoning built on an unverified premise +propagates into copy. Licensure is the live example — **the Verified table above +does not establish licence status either way**; its two licensure rows are +verified *directives not to publish*, not a verified status. So no file in this +repo may call Pouya "licensed", or soften it to "a legal professional", internal +or not. State the *reason* for a rule rather than a credential the register +cannot vouch for. | Never publish | Why | |---|---| @@ -210,7 +235,7 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | Thing | Value | |---|---| | Framework | Astro, `output: 'static'` `[verified 2026-08-25 — D1]` | -| Node | 22 LTS, pinned in `.nvmrc` `[assumed]` | +| Node | 22 LTS, pinned in `.nvmrc`; `package.json` `engines.node` `>=22` `[verified 2026-08-26 — .nvmrc, package.json]` | | 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]` | @@ -226,8 +251,8 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | SES identities | Domain `smlcompany.ca` **verified for sending** `[verified 2026-08-26]`; addresses `info@`, `intake@`, `adr@` | | SES account | **In the sandbox** — `ProductionAccessEnabled: false` `[verified 2026-08-26]`. Production access required (Q19) | | Mail hosting | **Google Workspace** — MX `1 smtp.google.com`; `google._domainkey` present, so Google DKIM is configured `[verified 2026-08-26 — DNS query]` | -| **SPF** | **ABSENT.** `smlcompany.ca` TXT holds only Anthropic and Google site-verification strings. No `v=spf1` record exists `[verified 2026-08-26 — DNS query]` | -| **DMARC** | **ABSENT.** No `_dmarc.smlcompany.ca` record `[verified 2026-08-26 — DNS query]` | +| **SPF** | **PRESENT.** `v=spf1 include:_spf.google.com include:amazonses.com ~all` on `@`, added by Pouya and independently verified `[verified 2026-08-26 — DNS query, Q20]` | +| **DMARC** | **PRESENT.** `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` on `_dmarc`, added by Pouya and independently verified `[verified 2026-08-26 — DNS query, Q20]`. Still at `p=none`; tighten to `quarantine` only after reports come back clean | | Required DNS (Namecheap) | TXT `@` → `v=spf1 include:_spf.google.com include:amazonses.com ~all` · TXT `_dmarc` → `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` `[assumed]` — start at `p=none`, tighten after monitoring | | SES DKIM — **the set that actually resolves** | `f5puwearz5ek47aiuoskk57ya6ir66ju`, `jdue2r22cxrk27laxlgnjcpby2fq4vxu`, `kznn3cklvwh4gs342rtf5euqymlger53` — all three answer NOERROR at `._domainkey.smlcompany.ca` `[verified 2026-08-26 — DNS]` | | SES DKIM — **the set that is BROKEN** | `3zsnvsjg3ddi6hjyndjy6jgjoitvhydl`, `jejgp7na3wdpprsanamct4uxxgw2tyqw`, `xpiwyftpodmgpnzfof3ee7t7fzmlmhnh` — listed in the SES console, present in Namecheap, but **NXDOMAIN**. They were entered with the full name in Namecheap's Host field, so the zone doubled the domain: `._domainkey.smlcompany.ca.smlcompany.ca` resolves, the correct name does not `[verified 2026-08-26 — DNS]` | @@ -243,7 +268,7 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | DNS | **Namecheap**, not Route 53 `[verified 2026-08-25 — AWS-Hosting-Guide.md]` | | Intake backend | API Gateway (HTTP API) → Lambda → DynamoDB, notifications via SES `[verified 2026-08-25 — AWS-Hosting-Guide.md]` | | Repository | **`adr-sml`**, self-hosted **Gitea**. Local clone at `/Users/pouya/Dev/Websites/adr-sml` `[verified 2026-08-26]` | -| CI/CD | **Gitea Actions**, `.gitea/workflows/deploy.yml`. `.github/workflows/deploy.yml` kept as the OIDC reference in case the repo ever moves. Credentials: scoped IAM user in Gitea secrets — **no OIDC available** `[verified 2026-08-26 — D3 as amended]` | +| CI/CD | **Gitea Actions**, `.gitea/workflows/deploy.yml`. The GitHub OIDC workflow is kept as `docs/reference/github-actions-oidc.yml.example` — moved out of `.github/workflows/` on 2026-08-26 so Gitea cannot fall back to it. Credentials: scoped IAM user in Gitea secrets — **no OIDC available**. Whether that IAM user has actually been created is **Q22** `[verified 2026-08-26 — D3 as amended]` | | Analytics | **Plausible or Fathom** — cookieless, no personal data, no consent banner, EU-hosted `[verified 2026-08-26 — D15]` | ## 8. Design system @@ -273,6 +298,10 @@ Nothing below can be invented. Each needs an answer from Pouya. | ~~Q4 / Q14~~ | **ANSWERED 2026-08-26.** Rate card confirmed by Pouya — see D14 and `docs/07-fees.md` | — | | ~~Q13~~ | **ANSWERED 2026-08-26.** Self-hosted Gitea with Gitea Actions | — | | ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled: 2 h in the half day, 3 h in the full day, **stated on the page**. Overtime $500/h | — | +| ~~Q25~~ | **ANSWERED 2026-08-26. Struck.** The §4 row permitting the boutique to be named is removed; D16 governs alone | — | +| ~~Q26~~ | **ANSWERED 2026-08-26.** §4 now carries an explicit **Licence status — NOT ESTABLISHED** row, and its opening no longer asserts that LSO rules apply. The register's rationale stands on the fabricated-credentials history alone | — | +| ~~Q24~~ | **ANSWERED 2026-08-26.** `AWS-Hosting-Guide.md` copied into `docs/reference/` — it is the only record of how the hand-built infrastructure was created. Scanned for credentials before copying: no access keys, no secrets, no account ID | — | +| **Q22** | **Has the deploy credential actually been provisioned?** `docs/06-deployment.md` instructs creating IAM user `adr-sml-deploy`, issuing an access key, and setting `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in the repository's Gitea Actions secrets. It is written imperatively — a procedure to perform, not a record of one performed — and nothing in this file records any of it as done; the inventory behind Q10 captured no such principal. Check with `aws iam get-user --user-name adr-sml-deploy`. Until it is confirmed, no file may describe the credential as existing, only as designed. **The §10 rotation obligation has no subject until this is answered** | CI/CD, the first deploy, and the accuracy of every credential claim in the docs | | Q5 | Booking tool — **parked 2026-08-26 at Pouya's request.** Build `/contact/` with the form only and a clean slot for the embed. Now tracked as standing reminder R6 | `/contact/` — non-blocking | | ~~Q6~~ | **ANSWERED 2026-08-26.** Supplied and committed: `src/assets/pouya-lajevardi.jpg` (1600×1600 master) and `src/assets/og-portrait.jpg` (1200×630 link-preview crop) | — | | ~~Q7~~ | **ANSWERED 2026-08-26.** Pouya reverted to generic. The boutique is **never named**; refer to it as *a Toronto litigation and ADR boutique* throughout | — | @@ -285,20 +314,24 @@ Nothing below can be invented. Each needs an answer from Pouya. | ~~Q21~~ | **ANSWERED 2026-08-26.** `ca-central-1` is healthy (`SUCCESS`) on the resolving token set. The broken records belong to a stray `us-east-1` identity this project does not use. **Nothing to fix.** The three dead CNAMEs are inert; leaving them is the low-risk choice | — | | ~~Q11~~ | **ANSWERED 2026-08-26.** Privacy-first, cookieless — Plausible or Fathom. No GA4, no consent banner | — | | ~~Q12~~ | **ANSWERED 2026-08-26.** `https://www.linkedin.com/in/pouyalajevardi/` | — | +| **Q27** | **Is a one-business-day response time a commitment you want published?** `src/data/site.ts` carried `responseTime: 'Inquiries are answered within one business day.'` — a service level a reader can hold you to, and not in the §4 register. Set to `null` with a `TODO(pouya)` until confirmed. Raised by `claims-auditor`, 2026-08-26 | The `/contact/` page copy | +| **Q28** | **Which OBA sections?** §4 verifies OBA membership but marks the *section list* `[assumed]` — the brief names Construction & Infrastructure, ADR, and Civil Litigation. `src/data/site.ts` was publishing all three under a blanket `[verified]` stamp; now marked and flagged. Raised by `claims-auditor`, 2026-08-26 | The `/about/` credentials list | +| **Q29** | **Should the deploy guard cover the secrets and `INTAKE_ENDPOINT` too?** As built it covers the three deploy-target variables only. Two gaps, both real: an unset `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — **the most likely first-run failure, since Q22 is unanswered** — passes the guard and burns a full build before dying at `sts get-caller-identity`; and an empty `vars.INTAKE_ENDPOINT` passes silently and **ships a live form posting to nothing**. The second needs the variable promoted to job-level `env:`. Not done: you specified three variables and this widens the guard. Raised by `adversarial-reviewer`, 2026-08-26 | Nothing yet — `src/pages/` is empty. Blocks `/contact/` shipping safely | +| **Q23** | **Is a Gitea `act_runner` registered to this repository or its organisation?** Without one the workflow queues silently and never runs — indistinguishable from a broken pipeline (`docs/06-deployment.md`). *The Gitea-version half of this question is closed:* rather than record a version fact that goes stale, `.gitea/workflows/deploy.yml` now runs a guard as its **first** step that fails the run if `AWS_REGION`, `S3_BUCKET`, or `CLOUDFRONT_DISTRIBUTION_ID` is empty — which is how a pre-1.21 instance manifests. Pouya's decision, 2026-08-26. Runner registration is not something a guard can cover | The first deploy | ## 10. Risks | Risk | Severity | Mitigation | |---|---|---| -| A fabricated or unverifiable claim reaches a public page | **High** — professional-conduct exposure for a licensed practitioner | §4 register; every claim traced to a source before it ships | +| A fabricated or unverifiable claim reaches a public page | **High** — professional-conduct exposure | §4 register; every claim traced to a source before it ships | | Copy silently carried over from the placeholder template | High | Nothing is ported verbatim. All copy written fresh against `docs/03-content-spec.md` | | Insights section launches and then goes stale | Medium | D9 commits to 3–5 pieces at launch and a monthly cadence. A dead blog is worse than no blog | | Personal data in the intake pipeline without a retention policy | Medium — PIPEDA | `docs/05-backend-spec.md` sets retention, and `/legal/privacy/` states it | | Cutover breaks the live site (D11 is a single-shot deploy) | Medium | Full pre-cutover checklist in `docs/06-deployment.md`; CloudFront can be rolled back to the prior origin path | | Twenty pages of thin copy rank worse than six good ones | Medium | Each page must justify itself with substantive content. Ship fewer pages rather than padded ones | -| **Deploy-credential blast radius.** AWS account `327082975128` is not a single-project account. It also holds `meshkinilaw.ca` and its preview site, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and **`mlp-clientdb-prod-backups`** — which by its name is a law firm's production client-database backups | **High** | A static deploy key for a personal website must never be able to reach a client database. The scoped IAM policy in `docs/06-deployment.md` grants four actions on one bucket and one distribution and nothing else — that narrowness is now load-bearing, not hygiene. Never widen it. Never reuse the `user/pouya` credentials in CI `[verified 2026-08-26 — inventory]` | +| **Deploy-credential blast radius.** AWS account `327082975128` is not a single-project account. It also holds `meshkinilaw.ca` and its preview site, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and **`mlp-clientdb-prod-backups-327082975128`** (recorded in an earlier entry with the account suffix; the short form `mlp-clientdb-prod-backups` is used elsewhere in the docs) — which **by its name** holds production client-database backups. Only the name was ever observed; the contents were not inspected and the owner is not established | **High** | A static deploy key for a personal website must never be able to reach a client database. The scoped IAM policy in `docs/06-deployment.md` grants four actions on one bucket and one distribution and nothing else — that narrowness is now load-bearing, not hygiene. Never widen it. Never reuse the `user/pouya` credentials in CI `[verified 2026-08-26 — inventory]` | | **SES is in the sandbox** — confirmed, not hypothetical | **High** | Q19. Until production access is granted, every confirmation email to an inquirer fails silently. The site appears to work; the inquirer concludes they were ignored. Request access early — it is the only dependency with an external turnaround | -| **No SPF and no DMARC on `smlcompany.ca`** | **High** | Q20. Affects SES *and* the existing Google Workspace mail — Pouya's business email is unauthenticated today, which is a live deliverability problem independent of this project. Gmail and Yahoo both penalise it. Add both records before requesting SES production access; AWS also weighs domain authentication when reviewing | +| ~~No SPF and no DMARC on `smlcompany.ca`~~ | ~~High~~ → **RESOLVED 2026-08-26** | Both records added by Pouya and independently verified (Q20). SPF authenticates Google Workspace mail; SES satisfies DMARC through DKIM alignment on the three resolving CNAMEs. DMARC sits at `p=none` — the residual task is to review reports and tighten to `quarantine`, which is monitoring, not a risk `[verified 2026-08-26 — DNS query]` | ## 11. Glossary @@ -331,17 +364,785 @@ never being raised again. | # | Reminder | Raised | Why it must keep coming back | |---|---|---|---| -| R1 | **Licensure.** The site currently asserts the JD only and describes the boutique role as *active litigation exposure*, never *practice*. Pouya flagged this as an interim position and asked to be reminded to change it | 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/` 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 | | 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 12–18 | 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 | | R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive | 2026-08-26 | D14 is priced for where the practice is going, not where it is | | 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 | +| ~~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 | — | --- # Change Log +## 2026-08-26 (q) — Guard built; and the audit found an inverted DKIM table that would have broken production mail + +**Who:** Pouya ruled on entry (o)'s escalations, ratified R7, promoted R8, and +reversed his own F5. Claude Code implemented, ran `claims-auditor` and +`adversarial-reviewer` in parallel, and resolved 31 + 17 findings. + +### The six rulings, implemented + +1. **Q23 hardened, not checked.** `.gitea/workflows/deploy.yml` now runs a guard + as `steps[0]` — before checkout, before `npm ci`, before any AWS call — that + fails the run naming any of `AWS_REGION`, `S3_BUCKET`, + `CLOUDFRONT_DISTRIBUTION_ID` that is empty. It tests the env names + (`AWS_DEFAULT_REGION`) and reports the **Gitea variable** names, which is the + mapping most likely to be got wrong. `adversarial-reviewer` executed the + extracted block under `bash -e`, `sh -e`, and `bash -euo pipefail` across + all-unset / all-set / one-empty / whitespace-only and confirmed it fails + closed and is POSIX-clean. +2. **R7(3) dropped** — the `aws s3 ls` pre-flight is out; the guard supersedes it. +3. **R7(1) ratified** — the cache table stays matched to the pipeline. +4. **R7(2) reasoning corrected.** Old → new: "the intended cover is an S3 + lifecycle rule" → **no lifecycle rule exists and is not the cover**; the cover + is that `aws s3 sync` only goes multipart above 8 MB. Corrected once more + after audit: the figure now measures **what is uploaded**, with + `src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified — stat]` as the + largest source asset and an instruction to re-measure `./dist` after the first + build. The old wording measured the repository, which is not what syncs. +5. **R8 promoted to a rule** in `CLAUDE.md`, `/build` Phase 6, `/wrap` step 3. +6. **R1 put in the conditional** — there is no site; `src/pages/` is empty. + +### The finding that mattered most was nowhere near the diff + +**`docs/05-backend-spec.md` had the two SES DKIM sets exactly inverted.** It +labelled `3zsn…` / `jejgp…` / `xpiwy…` "**Live.** Matches SES exactly. Never +delete", and `f5pu…` / `jdue…` / `kznn…` "Orphans from an earlier verification. +Inert." §7 records the opposite, `[verified 2026-08-26 — DNS]`: the `f5pu` set is +the one that **resolves** and backs the healthy `ca-central-1` identity; the +`3zsn` set is NXDOMAIN and belongs to a stray `us-east-1` identity. + +Entry (j) corrected this. **The correction reached §7 and never reached +`docs/05`.** An operator tidying DNS from that table deletes the three CNAMEs +that make SES DKIM work — and since SES has no custom MAIL FROM, DKIM is the +*only* thing satisfying DMARC. Outcome: intake mail from `ca-central-1` starts +failing authentication, silently, and the table said "Never delete" over the +wrong three records. Both rows rewritten from §7. The "six CNAMEs" claim below +them now says three, which is how many answer. + +### Current Truth was stale in three more places + +**§7 SPF and DMARC still read "ABSENT"** `[verified 2026-08-26]`, and §10 still +carried a live **High** risk row for it — while Q20, in the same file, records +both records added and independently verified the same day. Rule 2 requires +Current Truth updated in place; it was not. Both §7 rows now record the live +values; the §10 risk row is struck as **RESOLVED**, with the residual +`p=none` → `quarantine` tightening described as monitoring rather than risk. +`docs/05` opened with "**it is not in place**" and a table headed "Add both of +these" for records that already exist — rewritten, with the superseded state kept +visible as a caution. + +**§9 carried Q24, Q25 and Q26 twice each, struck and open, with opposite +verdicts.** The live Q25 still asserted §4's boutique-naming permission stands — +against D16 — after entry (p) had struck it. This is the duplicate-question +defect entry (o) fixed for Q22, reintroduced three times in one session: entry +(p) added struck rows rather than striking the existing ones. The three +superseded open rows are deleted. + +### The guide added in entry (p) was a live procedure for the architecture being replaced + +`docs/reference/AWS-Hosting-Guide.md` was added **untracked** — so Q24's answer +("copied into `docs/reference/`") was true of one working tree and false of the +repository — with no reference-only marker, in fully executable imperative voice. +Following it creates an IAM user with `AdministratorAccess` in account +`327082975128`, rebuilds the site through the standalone-HTML pipeline D1 and D3 +replace, and wires SES to `adr@` / `intake@smlcompany.ca` against D18's +`info@`. It is now `git add`ed and carries a **do-not-execute** banner tabulating +seven contradictions with Current Truth, each resolved in Current Truth's favour. + +**It also called Pouya a lawyer** — "**Audience:** You (a lawyer, not a +sysadmin)" — and proposed public copy reading "does not create a lawyer-client +relationship", which is what `NO_RETAINER_NOTICE` in `src/data/site.ts` was +written to avoid. Both corrected. + +**The R8 sweep command recorded in entry (o) could not have caught either.** +`grep -rn -i "licensed\|legal professional"` does not match "lawyer". The rule +promoted this session is right; the pattern it was demonstrated with was +incomplete. The corrected sweep is: + +``` +grep -rn -iE "\blawyer\b|licensed|legal professional|litigator" \ + --include="*.md" --include="*.ts" --include="*.astro" . +``` + +Run, output read. Every surviving hit is rule text, Change Log history, or +"lawyer" referring to the *reader* or a third party (`docs/03:20`, `docs/03:161`, +`docs/01:291`, `docs/08:111`) — none describes Pouya. + +### Copy defects one step from shipped pages + +- **"a working litigator"** — `AGENTS.md` §1's positioning sentence and + `docs/03-content-spec.md:71`, the line designated for the hero, the About page + and the PDF bio. Not in §4; in Ontario it asserts conducting litigation, which + §4 Forbidden bars. §1's own line asserted "each element is verified"; it was + not. Both now read "close to live litigation". §1's correction is annotated in + place rather than silently rewritten. +- **"an active litigation practice"** — `docs/03:118`, `docs/01:211`, + `docs/01:246`. §4's Verified row was itself corrected from "practice" to + "**exposure**" in entry (l); the copy decks kept the barred word. Fixed to the + register's own substitute. +- **`LegalService` JSON-LD** — `docs/04-seo-spec.md` specified it for the home + page. schema.org defines `LegalService` as a business providing legal advice + and **representation**: it asserts in machine-readable form precisely what D13 + bars. Changed to `ProfessionalService`, with the reason recorded so it is not + changed back. `worksFor` on the `Person` node had no register-safe value — + populating it either names the boutique (D16) or misstates the employer — so + it is dropped in favour of `jobTitle`. +- **Tribunal-secretary work** — `docs/01:173,180` and `docs/03:32` instructed + offering it; `docs/07-fees.md:116` records Pouya removing the rate and barring + the offer. Three files against one; the one was right. +- **§4's Forbidden table and `docs/03:52`** justified the superlative ban with + "LSO marketing rules" — the **fourth** surviving form of the licensure claim, + two rows below the `NOT ESTABLISHED` row. Restated on grounds that do not + depend on licence status. *(The §4 instance is the architect's to apply.)* +- **`docs/07-fees.md`** opened with "pending Pouya's sign-off — Q14" and + "**Nothing in this document publishes until Pouya confirms the figures**", + then contradicted itself 85 lines later with "This is the card. Build `/fees/` + from it." An implementer reading top-to-bottom blocks the page. Header + rewritten to D14 as confirmed. +- **Nine stale `Q` blockers** across five spec files — Q4, Q10, Q11, Q12, Q14, + Q20, Q5 — all answered or re-tracked, three of them gating `/fees/`, whose + figures are confirmed. Swept. + +### Code defects + +- **`src/data/site.ts`** published `responseTime: 'Inquiries are answered within + one business day.'` — a service level a reader can hold him to, not in §4. Set + `null` with a `TODO(pouya)`; **Q27**. +- **The memberships block** carried a blanket `[verified]` stamp over the OBA + section names, which §4 marks `[assumed]`. Downgraded, flagged; **Q28**. +- **`src/content/config.ts`** — `/** Drafts are excluded from the build, the + index, and the sitemap. */` describes three controls, **none implemented**; + the sitemap filter covers `/legal/` only. Same defect class as Q22. Restated + as intent with a deadline. Its `title`/`description` bounds also contradicted + `docs/04` (max 70 / min 70 against 50-60 / 140-160); tightened to the spec. +- **`astro.config.mjs`** set `prefetch: { prefetchAll: true, defaultStrategy: + 'viewport' }`, injecting Astro's prefetch script and an IntersectionObserver + into **every** page and speculatively fetching every link that scrolls into + view — against CLAUDE.md's "default to zero JS" and §7's "most pages ship zero + JS", with no Change Log entry. Set to `prefetchAll: false` / + `defaultStrategy: 'hover'`, the setting the written conventions imply. If + prefetch-all is wanted, it needs a decision entry. +- **`src/styles/tokens.css`** attached the ink contrast figure (11.09:1) to both + ink and maroon; on maroon `--gold-l` measures 8.11:1. Both pass AA, so no + accessibility consequence — but the token file is what developers read. + +### Also corrected + +`docs/06` cited a billing **alarm** verified by `cloudwatch describe-alarms`; the +guide set up an AWS **Budget**, which that command never returns — and nothing +records either as created. Now names `budgets describe-budgets` and says confirm, +do not assume. The booking cutover check is marked conditional on R6, since +`BOOKING_URL` is empty by decision. `scripts/aws-discover.sh` and `docs/06` +claimed "every call is a list or describe" — four are not (`sts +get-caller-identity`, `apigatewayv2 get-apis`, `s3api get-bucket-versioning`, +`configure get`); all are genuinely read-only, so the substance held and the +wording did not. Four stale `AWS-Hosting-Guide.md` paths repointed at +`docs/reference/`. The reference workflow still said "Two passes" above three +sync steps — the one file entry (o)'s three-pass sweep missed, and a +twelve-character grep would have caught it. The IAM procedure had 22 lines of +multipart prose between "attach this policy" and "create an access key", which +also broke the ordered list in CommonMark; moved below step 3. + +### Declined, with reasons + +**The guard was not widened.** `adversarial-reviewer` showed it does not cover +the secrets — and by Q22, an unset key is the *most likely* first-run failure, so +the stated benefit ("one second instead of a full build") does not apply to the +likeliest case — nor `vars.INTAKE_ENDPOINT`, whose absence would **ship a live +form posting to an empty endpoint**. Both are real. Neither was done: the ruling +named three variables, and `INTAKE_ENDPOINT` additionally needs promoting to +job-level `env:`. **What was fixed instead is the guard's comment**, which +claimed it converted the problem "on every Gitea version" into a clean failure — +broader than what it implements. It now states its scope and points at **Q29**. + +**The Gitea 1.21 claim is stamped `[assumed]`, not verified.** The version could +be confirmed with one command on the instance. It was not run here; the guard is +designed to work without knowing the answer, so the stamp is the honest record. + +**`docs/06` still pairs the account ID with the backup-bucket callout** — the +pairing entry (m) removed from `README.md` as an inference vector. `docs/06` is +an operational document where the account ID is load-bearing, unlike the README. +Flagged rather than changed; if you want it out, it moves to `aws-inventory.txt`, +which is gitignored. + +### Verification + +The guard's `run:` block was extracted from parsed YAML and **executed** by +`adversarial-reviewer` under three shells and four input states — fail-closed +confirmed. Both workflow YAMLs parse. The barred-word sweep was run as the +`grep -iE` above and its output read. `stat` gave 357,627 bytes. `npm run lint`, +`check` and `build` were **not run**: `node_modules` is absent and `npm ci` +cannot install without a lockfile, so the `astro.config.mjs`, `site.ts` and +`config.ts` edits are **unverified by any type-checker** — they are small and +syntactically simple, and that is the whole of the assurance. + +**Opened:** Q27, Q28, Q29. **Still open:** Q22, Q19 (blocking), Q23 (runner +half), Q5/R6 (parked). **Closed by entry (p), confirmed here:** Q24, Q25, Q26. + +**Lesson.** Entry (o) said the sweep must be a command whose output is read. +This entry adds what that costs when the command is wrong: the pattern +`licensed|legal professional` cannot match "lawyer", so a compliant sweep still +missed a file added in the same session. **And the largest finding of the session +was in a file nothing had asked either reviewer to look at.** Both reviewers were +pointed at a diff; the destructive defect was three files away, left by a +correction that reached one file and stopped. Scope the audit to the *decision* +that changed, not the lines that changed. + +--- + +## 2026-08-26 (p) — Q24, Q25, Q26 answered; R7 ratified; R8 promoted to a rule + +**Who:** Pouya relaying Claude Code's session close. Claude (architect) deciding. + +**Q25 — struck. This was the dangerous one and it was mine.** §4's Verified table +carried "The boutique **may be named on the site**", authorising precisely what +**D16 — never named** forbids. Sequence: Pouya said it could be named, then +reversed; D16 recorded the reversal; **the permission was never removed.** A +locked decision and the table copy is written from disagreed, and the table was +the more permissive of the two. Struck. + +The general defect: reversing a decision requires removing the permission it +granted, not only recording the new position. Same shape as the D3 amendment that +went unswept three times. + +**Q26 — answered, and the third form of the same claim is gone.** §4's opening +asserted "Every public claim on this site is subject to Law Society of Ontario +marketing rules" — which asserts regulatory status by implication, since those +rules bind licensees. Claude Code was right that this is the same half-measure as +the "legal professional" reversal, one level further down. + +Old → new: the register's rationale no longer rests on licensure at all. It rests +on the fabricated-credentials history, which is documented, sufficient, and true +regardless of licence status. The paragraph's own history is kept in place as a +caution — three progressively weaker forms of one unverified claim, each +surviving a sweep. + +**§4 now carries a `Licence status — NOT ESTABLISHED` row.** Stamped +`[unestablished]`, a new stamp for this case. It records the honest position: this +file does not know, Pouya's instruction was about the *site* and not a statement +of fact either way, and nothing may assert, deny, or infer it. Claude Code was +also right that the two licensure rows were stamped `[verified]` as *directives*, +which is a different thing from a verified fact — this row separates the two. + +**Q24 — closed.** `AWS-Hosting-Guide.md` copied to `docs/reference/`. It is the +only record of how the hand-built infrastructure was created, so it belongs in +the repo rather than in a Drive folder the repo cites but cannot reach. Scanned +before copying: no access keys, no secrets, no account ID. + +**Q23 — answered by hardening rather than by version-checking.** The risk is real +— on Gitea below ~1.21, `${{ vars.* }}` interpolates empty and the sync target +becomes `s3://`. But a version check is a fact that goes stale. Ruling: add a +**pre-flight guard step** to `.gitea/workflows/deploy.yml` that fails the run if +any required variable is empty, before any AWS call. That converts a silent +destructive misfire into a clean failure on every Gitea version, and it +subsumes R7's third item. + +**R7 ratified, with one amendment.** +1. Cache-policy table amended to match the pipeline — **accepted.** Documenting + what the pipeline does beats documenting an intention. `robots.txt` and + `sitemap*.xml` at `max-age=0, must-revalidate` is immaterial at this size. +2. `s3:AbortMultipartUpload` omitted, lifecycle rule as intended cover — + **accepted, with the reasoning corrected.** The lifecycle rule does not exist, + so it is not the cover. The actual cover is that `aws s3 sync` only goes + multipart above 8 MB and the largest asset in the repo is a 357 KB portrait. + Record *that*, and add a note to revisit if any asset ever approaches 8 MB. + Do not document a control that does not exist — that is Q22's defect again. +3. `aws s3 ls` pre-flight documented but not implemented — **superseded** by the + guard step above, which covers more. + +**R8 promoted from a reminder to a rule.** *"A sweep is a command whose output is +read, not a claim."* Entries (l), (m) and (n) each asserted a completed sweep; +instances survived all three — one of them inside `claims-auditor.md`, the +definition of the agent whose job is to catch it. A reminder is the wrong +instrument for something this reliable. It belongs in `/build` Phase 6 and +`/wrap`: any claim that a change was applied across files must cite the command +and be made only after reading its output. + +**R1's wording corrected:** it says "the site currently asserts the JD only". +There is no site — `src/pages/` is empty. Restated in the conditional. + +**Closed:** Q23, Q24, Q25, Q26. **Still blocking the build:** nothing. +**Still blocking `/contact/`:** Q19. **Still open:** Q22, Q5 (parked). + +--- + +## 2026-08-26 (o) — Five rulings implemented; both reviewers run; the D3 sweep finally completed + +**Who:** Pouya ruled on entry (m)'s escalations and reversed his own F5 ruling. +Claude Code implemented, ran `claims-auditor` **and** `adversarial-reviewer` in +parallel on the diff, and resolved 27 + 17 findings. + +### The five rulings, implemented + +1. **§4 and §10 "licensed"** — the architect's, done in entry (n). +2. **Reversal on "a legal professional's"** — cut from `README.md:35` and + `CLAUDE.md` (twice). The sentences now assert nothing about professional + status and state the reason instead. +3. **`s3:PutObjectAcl` dropped** from the policy in `docs/06-deployment.md`. §10's + "four actions" is now accurate without §10 being touched, and every file that + states a count agrees with the policy: `README.md:77`, `docs/06:82`, + `.gitea/workflows/deploy.yml:13`, `AGENTS.md` §10. +4. **`.github/workflows/deploy.yml` → `docs/reference/github-actions-oidc.yml.example`** + via `git mv`; `.github/` removed entirely. It held exactly one file — no + CODEOWNERS, no dependabot, no templates — and nothing referenced the path. + References updated in `README.md`, `CLAUDE.md`, `docs/06`, `.gitea`, and §7. +5. **Q22** left open with the designed-not-confirmed phrasing intact. + +### The reviewers found that the D3 sweep had never actually reached `docs/06` + +Both agreed on the same blocking defect, independently. `docs/06-deployment.md` +is the file `README.md` points to as "Full procedure, IAM policy, runner setup" — +and it still opened with: + +- `Authority: AGENTS.md §3 D3 (git + GitHub Actions → ...)` — D3 as amended says + Gitea; +- a topology diagram, **the first content block in the file**, reading + `GitHub push to main → GitHub Actions → assume AWS role via OIDC (no stored + keys)`. That is the exact sentence entry (l) called "the serious one", six + lines above the section that contradicts it; +- an entire 19-line section, `## Why OIDC and not access keys`, in the + imperative — *"IAM → Identity providers → add OIDC provider..."* — with **no + marker that it does not apply**, naming a third principal `adr-site-deploy` + that conflicts with `adr-sml-deploy`, and citing Q9, which is answered. + +`adversarial-reviewer` named the concrete failure: an operator working the +document top-to-bottom adds a GitHub federation trust to account `327082975128` +— the account §10 rates High blast-radius — that nothing will ever use, and +plausibly never creates `adr-sml-deploy` at all. Two mutually exclusive +credential procedures in one file with no signpost saying which is live. + +**Fixed.** Authority line cites D3 as amended. Topology rewritten to Gitea + +static key. The OIDC section is retitled *Why OIDC would have been better — and +why it is unavailable*, opens with a **do not execute** block, and its executable +steps are gone. + +### The "licensed" sweep had missed three more files, including the auditor's own persona + +Entry (n) scoped the reversal to "`README.md`, `CLAUDE.md` (twice), and this +file." Five instances survived in three files it did not name: + +- **`.claude/agents/claims-auditor.md:8`** — *"You audit public copy for a + **licensed legal professional's** marketing site."* This is the system prompt + of the agent that enforces the rule. Its opening sentence asserted the exact + claim its own §"Licensure (D13)" instructs it to flag. +- **`.claude/agents/adversarial-reviewer.md:9`** — *"a legal professional's + dispute resolution practice"*, the precise phrase entry (n) reverses. +- **`docs/08-execution-protocol.md:63`** — *"a licensed professional's public + marketing page"*. **This file was edited in that same diff**, eleven lines + above the surviving phrase. + +Entry (n) said "twice in two entries that a claim about a sweep was made without +the sweep being verified. The pattern, not the instances, is the finding." This +was the third. It is now swept by command, not by recall: + +``` +grep -rn -i "licensed\|legal professional" --include="*.md" --include="*.yml" . +``` + +Every surviving hit is either rule text stating the prohibition, or Change Log +history, which rule 3 forbids editing. + +### Also fixed + +**Register and rule accuracy.** §4's internal-docs rule cited a `[assumed]` stamp +on licensure that **does not exist** — the two licensure rows are stamped +`[verified]` as *directives not to publish*, which is a different thing. A reader +following the pointer found nothing there, in the sentence stating the rule. +Reworded here and in `CLAUDE.md:30` to say what is true: the Verified table does +not establish licence status either way. **Duplicate `Q22`** — two rows, two +scopes, one number, one of them mine; merged into the fuller statement, keeping +the architect's `aws iam get-user` check command. **§7 Node** re-stamped +`[verified]` — it was `[assumed]` for something directly checkable. + +**Claims that overstated what exists.** `CLAUDE.md:164` still said *"deploys use +OIDC role assumption"* — verbatim the F1 claim, in a file both prior sweeps +touched; it and `CLAUDE.md:119` are corrected. `.gitea/workflows/deploy.yml` and +`CLAUDE.md` asserted the deploy credential in the present tense; both now cite +Q22. `docs/06` asserted five blocking PR checks — the workflow has **no +`pull_request` trigger** and neither `lint` nor `lighthouse` is wired, so nothing +gates a merge today; retitled *planned, not implemented*. `README.md:5` and +`docs/06`'s "every push deploys" both hedged against the never-run-green fact the +same files establish elsewhere. + +**The moved reference file carried the defect the move was meant to contain.** +Its line 16 still read *"OIDC role assumption — no long-lived AWS credentials in +this repository"*: present tense, about this repository, and false. It also +carried a live `TODO(pouya): AGENTS.md Q9, Q10` against two struck rows — which +`CLAUDE.md`'s definition of done treats as a build failure — for a variable that +will never be set. Both fixed, along with its claim that the file is usable on +GitLab (different CI schema entirely; it is the design there, not the +implementation). + +**Two security-posture claims narrowed.** `docs/06` said the workflow's +`aws sts get-caller-identity` pre-flight means "a credential problem fails loudly +and early rather than halfway through a sync." `sts:GetCallerIdentity` requires +**no IAM permission** — it succeeds for any valid key regardless of policy. It +catches a missing or revoked key and misses an under-scoped one, which is exactly +the risk this diff introduced by removing an action. Narrowed, with the one-line +`aws s3 ls` check that would make it load-bearing. And `README.md` had re-dropped +§10's *"which by its name is"* hedge on `mlp-clientdb-prod-backups` — the defect +entry (m) F2 records as fixed — as had `docs/06:113`, escalated in entry (n) and +not done. Both restored. + +**Documentation matching implementation.** The cache section described **two** +sync passes; the workflow runs **three**, and two ordering dependencies are +load-bearing and undocumented — pass 3 re-walks the tree and preserves pass 2's +image headers only because `sync` skips just-uploaded objects, and pass 3's +excludes also exclude those prefixes from `--delete`, which is why hashed assets +from old deploys are kept deliberately. Both now stated. The `robots.txt` / +`sitemap*.xml` row claimed `max-age=3600`; nothing implements it, so the table +now records what the pipeline does. Two conflicting variable tables for the same +five names — one under **Secrets** with real values, one under **Variables** with +placeholders — merged; entering those as secrets would have masked them in the +logs the doc says to read them from. Duplicate booking references (Q5 vs R6) +resolved to R6. `README.md` said "no `src/pages/`"; the directory exists and is +empty, which is not what a reader would find. + +**`docs/08-execution-protocol.md`** — entry (n)'s inserted block landed between +*"`claims-auditor` reads the copy..."* and its follow-up *"It is a separate agent +on purpose"*, leaving "It" twelve lines from its antecedent and reading as though +the frontmatter were a separate agent. Block moved below. + +### Declined, with reasons + +**`s3:AbortMultipartUpload` not added.** `adversarial-reviewer` traced every AWS +call in the workflow against the policy and found the happy path fully covered, +with this one gap: objects over 8 MB upload multipart, and an interrupted +transfer cannot clean up its own parts. It recommended against adding the +permission — an S3 lifecycle rule aborting incomplete uploads after 7 days solves +it without widening the policy. Agreed, and left as an operational item below +rather than a policy change. + +**The `aws s3 ls` pre-flight is documented, not implemented.** Adding it changes +the deploy pipeline, which was not in scope. It is written into `docs/06` as the +fix for the narrowed claim. + +**§4's tables untouched.** Two findings land in the register itself; both are +escalated below rather than edited. §4 is the architect's instrument. + +### Escalated — for the architect + +*(All four now tracked: 1 and 2 as **Q25** and **Q26** in §9; 3 folded into Q26; +4 left in R1, which already owns the licensure framing.)* + +1. **§4's Verified table authorises what D16 forbids.** `AGENTS.md:153` reads + *"The Toronto litigation and ADR boutique **may be named on the site** ... the + name itself is pending, see Q7."* D16 says **"Never named."** Q7 is answered: + *"Pouya reverted to generic. The boutique is never named."* A **Verified** row + currently permits publication of the exact thing a locked decision bars — the + same shape as the F5 defect in entry (l), in the same table, and the register + is where copy is written from. +2. **§4's opening asserts regulatory status by implication.** *"Every public + claim on this site is subject to Law Society of Ontario marketing rules"* — + LSO marketing rules bind licensees. Stating flatly that they govern this site + asserts the licensure §4 declines to verify, in the sentence that replaced + "Pouya is a licensed legal professional". A half-measure of the same shape as + the "legal professional" reversal. +3. **Consider an explicit §4 row** — *"LSO licence status | UNKNOWN / not + verified either way"* — which would make the internal-docs rule's pointer land + somewhere real instead of being reworded around. +4. **R1 (§12)** describes "the site currently asserts the JD only". There is no + site; `src/pages/` is empty. R1 is the reminder that must not ossify, so it + should be accurate about what it describes. +5. **Operational:** add an S3 lifecycle rule aborting incomplete multipart + uploads after 7 days; and once Q22 is answered, run + `aws s3 sync ./dist s3://adr-smlcompany-site --dryrun` under the new key to + confirm no `s3:GetBucketLocation` call is needed. +6. **Minor:** `.yml.example` is not a Prettier-recognised extension, so the moved + reference file has silently dropped out of `prettier --check` coverage. Frozen + reference material, so near-harmless — but nothing validates it as YAML now. + +**Q23–Q26 opened, none blocking the build.** Q23: is an `act_runner` registered, and is the Gitea +instance ≥ 1.21 for the `vars` context the workflow depends on throughout? On an +older instance `vars.S3_BUCKET` interpolates empty and the sync target becomes +`s3://`. Q24: `AWS-Hosting-Guide.md` is cited by `docs/06` as the source for +existing infrastructure and two cutover checks, and **does not exist in the +repository**; the two dependent checks have been restated self-containedly, so +nothing is blocked. Q25 and Q26 carry the two §4 escalations above. + +**R7 and R8 added to §12.** R7 records three spec decisions made on Pouya's +behalf and not ratified — the amended cache-policy row, the omitted +`s3:AbortMultipartUpload`, and the documented-not-implemented `aws s3 ls` +pre-flight. R8 records the sweep-verification lesson as a standing discipline +rather than a lesson buried in one entry, which is what let it recur three times. + +### Verification + +`npm run lint`, `npm run check`, and `npm run build` were **not run** — +`node_modules` is absent, and `npm ci` cannot install without a lockfile. No +JavaScript, TypeScript, or Astro source was touched. The `.gitea` workflow and +the `.yml.example` were not YAML-parsed either: PyYAML is unavailable in this +environment and the project has no YAML tooling installed. Both were changed only +in comment blocks, and the indentation of every edited comment was checked by +eye. The "licensed" sweep was executed as the grep above and its output read, not +asserted. Every prose line added to `README.md` and `CLAUDE.md` is ≤ 80 columns. + +**Lesson.** Entry (m) recorded that a correction is a change and gets reviewed +like one. This pass adds the sharper version: **the sweep must be a command, and +its output must be read.** Three consecutive entries claimed a completed sweep; +three times instances survived, and the third time one of them was inside the +definition of the agent whose job is to catch it. The `/wrap` step should require +pasting the grep, not describing it. + +--- + +## 2026-08-26 (n) — Register corrected; a declined finding reversed on appeal + +**Who:** Pouya relaying Claude Code's re-audit. Claude (architect) recording. + +**Correction to entry (l), which was false.** It claimed all three files had been +rewritten off "licensed". Two were. **This file still said it twice** — §4's own +opening sentence ("Pouya is a licensed legal professional") and §10's risk row +("professional-conduct exposure for a licensed practitioner"). The register was +asserting as established fact the very thing it marks `[assumed]`, in the section +that exists to prevent exactly that. Entry (l) stands as written per rule 3; this +is the correction. Both lines are now fixed. + +That is twice in two entries that a claim about a sweep was made without the +sweep being verified. The pattern, not the instances, is the finding. + +**Reversal — "a legal professional's" is out too.** `claims-auditor` wanted the +phrase cut entirely, arguing that in Ontario it reads as an LSO licensee and so +trips the same bar as "licensed". Claude Code declined it, correctly, because it +was my explicit ruling — and recorded the reasoning rather than silently keeping +it, which is what let it be revisited. + +**On appeal the auditor is right and I was wrong.** "Legal professional" was a +half-measure: a weaker assertion of the same unverified thing. The fix is to +assert nothing about professional status and state the reason instead — the +previous site carried fabricated credentials, which is true regardless of licence +status and carries the full force of the rule. Applies to `README.md`, +`CLAUDE.md` (twice), and this file. + +Worth preserving: **the mechanism worked because the decline was reasoned rather +than silent.** A finding dropped quietly is a finding that cannot be appealed. + +**Q22 opened — does `adr-sml-deploy` exist?** Claude Code caught that its own +README rewrite asserted a control nothing records as in place. `docs/06` is +written imperatively — "Create the user", "Create an access key" — a procedure, +not a record. Nothing in this file says it was performed. Its framing of this as +"same failure class, inverted" is exactly right: the original README understated +the credential risk, its replacement overstated the control. **The §10 rotation +obligation has no subject until Q22 is answered.** + +**Accepted — §10's "four actions" resolves by tightening the policy, not editing +the count.** `docs/06` grants five: `ListBucket`, `PutObject`, `PutObjectAcl`, +`DeleteObject`, `CreateInvalidation`. `s3:PutObjectAcl` is unused by `aws s3 sync` +without `--acl`, and inert under OAC with ACLs disabled. Dropping it makes §10 +accurate **and** the policy tighter — strictly better than correcting the number. +Handed to Claude Code. + +**Accepted — move the GitHub workflow out of `.github/`.** Gitea falls back to +`.github/workflows` when `.gitea/workflows` is absent, so a live `push:` trigger +sitting there is conditionally inert, not inert. Moving it to +`docs/reference/github-actions-oidc.yml.example` makes it structurally inert. +Handed to Claude Code. + +**Also noted, no action:** Claude Code reported that `npm run lint` and +`npm run check` could not run because `node_modules` is absent, and said so +rather than reporting a check it did not run. That is the Phase 5 rule working. + +**Opened:** Q22. **Still blocking the build:** nothing. **Still blocking +`/contact/`:** Q19. + +--- + +## 2026-08-26 (m) — README corrected against D3-as-amended; second audit found nine more + +**Who:** Pouya ruled on the five findings in entry (l); Claude Code implemented, +re-ran `claims-auditor` on the result, and resolved the second pass. + +### The architect's rulings, applied + +**F5 — no change, as ruled.** D13 bars "practice" for the boutique role, not for +Pouya's own ADR practice. `README.md:3` and `package.json:5` stand. + +**F2 — scope corrected, wording changed.** The Forbidden table governs published +pages, not repository documentation; that scope is now stated in §4. The real +defect was different and the architect identified it: licensure is `[assumed]` +in §4, so no file should call Pouya "licensed". `README.md:36` now reads *a legal +professional's public marketing surface* — the clause that follows, about the +previous site's fabricated credentials, already carries the reasoning. + +**F1, F3, F4 — Deployment section rewritten** against §7 and +`docs/06-deployment.md`. The section had been written against D3 as originally +decided and never swept when D3 was amended to Gitea. It now names +`.gitea/workflows/deploy.yml` as the live pipeline, says Gitea Actions rather +than GitHub Actions, states plainly that a long-lived AWS credential is involved +and why (Gitea is not an AWS OIDC provider), and names the quarterly rotation +obligation. `format` and `lighthouse` added to the scripts table. + +### The second audit found nine findings in the replacement text + +Seven fixed, one declined, one escalated. The re-audit was worth running: the +rewrite traded the original defect for its mirror image. + +**F1 (fixed) — the replacement asserted a control state that may not exist.** +The new text said deploys *do* authenticate as IAM user `adr-sml-deploy` with a +key in Gitea secrets. Only the **decision** is verified (D3 as amended, §7). +`docs/06-deployment.md` is written entirely in the imperative — "Create the +user", "Create an access key" — i.e. a procedure to perform, not a record of one +performed, and `adr-sml-deploy` appears nowhere else in this file. The old README +understated the credential risk; the new one asserted a control that nothing +records as in place. Same failure class, inverted. Now phrased as designed-not- +confirmed, and **raised as Q22**. + +**F2 (fixed) — a hedge was dropped, and an account number was disclosed.** §10 +says `mlp-clientdb-prod-backups` is "**which by its name is** a law firm's +production client-database backups" — only the bucket name was ever observed. +The README stated the contents flatly. It also paired the live account ID with +that statement in the repo's most pasteable file, and D16 says the boutique is +never named — "this account holds a law firm's client database" is an inference +vector that costs nothing to remove. The account number and the bucket name are +both now out of the README; the security reasoning stays, pointing at §10. + +**F3 (fixed).** The bullet led with "**The key is rotated quarterly**" — a +description of an operating control — and conceded four lines later that the +task has no owner. Now an obligation: *must be rotated quarterly, and nobody +owns that yet.* + +**F4 (fixed).** "It does not run. Editing it changes nothing." is conditional, +not absolute: Gitea falls back to `.github/workflows` when `.gitea/workflows` is +absent, and that file carries a live `push` trigger on `main`. Realised risk is +low — it would fail on `role-to-assume` with no OIDC provider rather than +mis-deploy — but describing a trigger-eligible file as inert invites someone to +treat it as safe. Now states the condition. **The auditor's better fix was +declined for scope:** moving it to `docs/reference/github-oidc-deploy.yml.example` +would make the claim structurally true instead of dependent on Gitea's +resolution order. Worth doing; not done here. + +**F6 (fixed) — "Pushes to `main` build and deploy automatically" was not true.** +Verified against the filesystem, not inferred: there is no `package-lock.json`, +so `.gitea/workflows/deploy.yml:46`'s `npm ci` exits non-zero at step one; there +is no `src/pages/`, so both `npm run check` and `npm run build` fail; and whether +an `act_runner` is registered is recorded nowhere, which `docs/06:125-130` warns +"looks exactly like a broken pipeline." The README now says the pipeline has +never run green and names all three reasons. + +**F7 (fixed).** "No wildcard actions, no wildcard resources" is literally false — +the policy uses `arn:aws:s3:::BUCKET_NAME/*`, correctly scoped but a wildcard. In +a paragraph whose entire job is to stop a future maintainer widening the policy, +an overstatement is the wrong error: the first reader who notices it discounts +the rest. Now: no `Action: "*"`, no `Resource: "*"`, nothing outside one bucket +and one distribution. + +**F8 (fixed).** `npm run lint` and `npm run lighthouse` are listed in a table a +reader takes as "these work." Neither does — there is no ESLint flat config, and +ESLint 9 (pinned `^9.0.0`) errors without one, so the command dies before +Prettier runs; and there is no `lighthouserc`, so `lhci autorun` has no budgets +even though `docs/06:221-222` makes Lighthouse a blocking check. Both rows are +now marked **not yet wired**. + +**F9 (fixed).** Two precision defects. The README restated the project's single +most important rule more narrowly than §4 states it — "no factual claim **about +the practice**" versus §4's "him, his credentials, his experience, or his +practice" — in the file a new contributor reads first. And it said `/build` runs +two review agents unconditionally, where `.claude/commands/build.md:52-53` allows +skipping `claims-auditor` when no copy changed. Both now mirror their sources. + +**F5 (declined) — the auditor asked to cut "a legal professional's" entirely,** +on the grounds that in Ontario the phrase reads as an LSO licensee and so trips +§4's bar on "phrasing that *implies* entitlement to practise law." Declined +because the architect ruled this exact wording four hours earlier, having already +considered and rejected "licensed", and because the same phrase now appears +deliberately in `CLAUDE.md`. Recorded rather than silently kept: the auditor's +reasoning is not obviously wrong, and if the phrase is judged to imply licensure +it needs changing in three files at once, not one. + +### Escalated to the architect — not fixed here + +1. **Entry (l) records `CLAUDE.md`, `AGENTS.md`, and `README.md` as all rewritten + off "licensed". Two of the three were.** `CLAUDE.md` is clean. **This file + still says it twice** — line 110, "Pouya is a licensed legal professional", + in the opening sentence of §4 itself, and line 303, "professional-conduct + exposure for a licensed practitioner", in §10. The register is asserting as + fact the thing it marks `[assumed]`, in the section whose whole purpose is to + stop that. Not edited here because §4 is the architect's instrument. + +2. **§10 says the deploy policy "grants four actions on one bucket and one + distribution."** It grants five: `s3:ListBucket`, `s3:PutObject`, + `s3:PutObjectAcl`, `s3:DeleteObject`, `cloudfront:CreateInvalidation`. The + discrepancy resolves in a useful direction — `s3:PutObjectAcl` is unused by + `aws s3 sync` and inert under OAC with ACLs disabled (`docs/06:205-206`), so + the policy is one action wider than it needs to be. Dropping it makes the + §10 count correct and the policy tighter at the same time. `docs/06:108` also + drops §10's "by its name" hedge on the backup bucket. + +3. **The `.github` workflow's location** — see F4 above. + +### Verification + +`npm run lint` and `npm run check` were **not run**: `node_modules` is absent, so +neither can execute. This change touches only `README.md`, which is outside +`astro check` and ESLint; Prettier has no config in the repo, so its default +`proseWrap: "preserve"` leaves markdown prose unreflowed. Line widths were +checked by hand and every line Claude Code wrote is ≤ 80 columns. + +**Lesson, extending entry (l)'s.** (l) recorded that amending a decision requires +sweeping every file that referenced the original. This pass adds the corollary: +**a correction is a change, and gets reviewed like one.** Seven of the nine +findings above were in text written specifically to fix an audit finding. The +first rewrite of a false claim is the most dangerous moment for it, because +everyone involved is now confident the area is clean. + +--- + +## 2026-08-26 (l) — First adversarial review run; it found defects in the specs + +**Who:** Pouya ran `claims-auditor` against `README.md` to verify the agents load. +It loaded, and returned five findings before any site code exists. + +**The agents are confirmed working.** R1 was surfaced unprompted, as §12 requires. +F2 and F5 were escalated rather than silently edited — the behaviour the brief +asks for on judgement calls. + +**Two register-level defects fixed here (architect's, not Claude Code's):** + +**F5 root cause — §4 contradicted D13.** Old → new: the Verified table read +*"Active litigation practice: personal injury, construction, regulatory (POA), +insurance (SABS)"* → **"Active litigation exposure: …"**. The row quoted the +strategy brief verbatim and used the exact word D13 bars in exactly the context +D13 bars it. Anyone writing copy from the register would have inherited the +violation **from the register itself** — the worst place for it to live. + +D13 now also states the distinction explicitly: "practice" is barred **for the +boutique role**, not for Pouya's own ADR practice. So "the dispute resolution +practice of Pouya Lajevardi" is correct — `README.md:3` and `package.json:5` need +no change. **F5 resolved, no edit to the README.** + +**F2 — the Forbidden table's scope was overbroad.** Old → new: heading read "must +not appear **anywhere**" → **"must not appear on the site"**, with the scope +stated: published pages including JSON-LD, not repository documentation. + +The auditor was right that something was wrong, though the rule it cited was the +wrong one. `README.md`, `CLAUDE.md`, and this file all called Pouya "a licensed +professional" while §4 marks licensure **`[assumed]`**. Internal docs were +asserting as fact what the register cannot vouch for. A new rule now covers this +directly. The three phrases are rewritten to state the *reason* for the +strictness — the previous site carried fabricated credentials — rather than a +credential we cannot confirm. + +**F1, F3, F4 — handed to Claude Code.** One root cause: `README.md`'s Deployment +section was written against D3 as originally decided and never updated when D3 +was amended to Gitea. `CLAUDE.md`, `docs/06`, and this file were all updated; the +README was missed. + +F1 is the serious one. "Deploys via OIDC — there are no long-lived AWS credentials +in this repository" is **false**, and dangerous in the specific way the auditor +identified: it is narrowly defensible, since the key is not tracked in git — which +is exactly why it would suppress the quarterly rotation obligation in D3, in an +account §10 rates High blast-radius because it holds `mlp-clientdb-prod-backups`. + +**Lesson recorded.** Amending a decision requires sweeping every file that +referenced the original, not the files that happen to come to mind. The review +caught it; the amendment process should have. Worth adding to `/wrap`. + +--- + ## 2026-08-26 (k) — Q21 resolved: nothing broken; SES sandbox is per-region **Who:** Pouya ran the per-region check. diff --git a/CLAUDE.md b/CLAUDE.md index edf7fd2..f2935cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,14 @@ ## The one rule that matters more than the code -Pouya is a licensed legal professional. **No factual claim about him, his -credentials, his experience, or his practice may appear on a public page unless -it is in the Verified table in `AGENTS.md` §4.** +This is Pouya's public marketing surface, and the site it replaces carried +fabricated credentials. **No factual claim about him, his credentials, his +experience, or his practice may appear on a public page unless it is in the +Verified table in `AGENTS.md` §4.** + +(§4 does not verify licensure either way — so do not describe him as +"licensed", or as a "legal professional", anywhere, this file included. State +the reason for the rule, not a credential the register cannot vouch for.) If a page needs a fact you do not have: @@ -78,10 +83,12 @@ view from the artefact — that independence *is* the mechanism. **The reviewers are instructed to treat uncertainty as a defect.** They will sometimes be wrong, and that is the intended trade. Explaining why a finding is -mistaken costs minutes; a missed defect on a licensed professional's public -marketing page costs considerably more. Do not read a finding as an accusation, -and do not argue a reviewer down — either fix it, or record the reason you -declined it so a later reader can see the judgement was made rather than missed. +mistaken costs minutes; a missed defect on this project's public marketing +pages costs considerably more — the site this replaces carried fabricated +credentials, and that is the standard being corrected. Do not read a finding as +an accusation, and do not argue a reviewer down — either fix it, or record the +reason you declined it so a later reader can see the judgement was made rather +than missed. **Two reviewers, because they catch different things.** `adversarial-reviewer` reads the code. `claims-auditor` reads the copy against the §4 register and knows @@ -97,7 +104,9 @@ npm run dev # local dev server npm run build # static build to ./dist npm run preview # serve ./dist locally npm run check # astro check — type and template errors -npm run lint # eslint + prettier check +npm run lint # eslint + prettier check — NOT YET WIRED, no eslint config +npm run format # prettier — rewrite files in place +npm run lighthouse # lighthouse CI — NOT YET WIRED, no lighthouserc ``` ## Where things live @@ -110,7 +119,7 @@ docs/ the specs you build from 03-content-spec.md voice, copy rules, per-page copy deck 04-seo-spec.md metadata, structured data, sitemap, crawlability 05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA - 06-deployment.md S3/CloudFront, GitHub Actions OIDC, cutover checklist + 06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist src/ styles/tokens.css design tokens — the single source of colour and scale styles/global.css reset, base type, utilities @@ -153,9 +162,20 @@ page that collects legal inquiries. URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See `docs/04-seo-spec.md`. A page without these is not finished. +**A sweep is a command, not a claim.** Any statement that a change was applied +across files — a phrase removed everywhere, a path updated everywhere, a +decision swept through the docs — must cite the command that proves it, and be +written only after reading that command's output. Paste the `grep` into the +Change Log entry. Three consecutive entries on this project asserted a completed +sweep; instances survived all three, and one of them was inside +`.claude/agents/claims-auditor.md` — the definition of the agent whose job is to +catch exactly that. Recall is not evidence. + **Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`). One logical change per commit. Never commit secrets, `.env` files, or AWS -credentials — deploys use OIDC role assumption. +credentials. Gitea is not an AWS OIDC provider, so the deploy key is designed as +a static IAM access key to be held in Gitea Actions secrets — whether it has +actually been provisioned is `AGENTS.md` Q22. It must never reach the repo. **Performance budget.** Lighthouse ≥ 95 on all four categories, on mobile, for every page. Under 100 KB of JS on any route. LCP under 2.0 s on a simulated diff --git a/README.md b/README.md index 25ac38f..30c5f2f 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ The dispute resolution practice of Pouya Lajevardi — Toronto. -A static site built with [Astro](https://astro.build), deployed to Amazon S3 -behind CloudFront by GitHub Actions. +A static site built with [Astro](https://astro.build), built for deployment to +Amazon S3 behind CloudFront by Gitea Actions — see Deployment; the pipeline is +not yet proven. ## Quick start @@ -21,7 +22,9 @@ npm run dev # http://localhost:4321 | `npm run build` | Static build to `./dist` | | `npm run preview` | Serve the built site locally | | `npm run check` | `astro check` — type and template errors | -| `npm run lint` | ESLint + Prettier | +| `npm run lint` | ESLint + Prettier check — **not yet wired**, no ESLint config exists | +| `npm run format` | Prettier — rewrite files in place | +| `npm run lighthouse` | Lighthouse CI — **not yet wired**, no `lighthouserc` exists | ## Before you contribute @@ -29,23 +32,66 @@ Read **`AGENTS.md`** first, and maintain it as you work — it is the living record of what this project is, what was decided, and why. Then read **`CLAUDE.md`** for the working rules, and the specs in `docs/`. -The single hardest rule: **no factual claim about the practice ships unless it -appears in the verified register in `AGENTS.md` §4.** This is a licensed -professional's public marketing surface, and the site this replaces contained -fabricated credentials. +The single hardest rule: **no factual claim about Pouya, his credentials, his +experience, or his practice ships unless it appears in the verified register in +`AGENTS.md` §4.** This is a public marketing surface, and the site it replaces +contained fabricated credentials. ## How work is done here Pouya decides; Claude Code implements and then adversarially reviews its own work. Run **`/build `** for any substantive change — it plans, implements, -runs two independent review agents on the diff, resolves the findings, verifies -the build, and records the session in `AGENTS.md`. `/review` runs the review pass -alone; `/wrap` closes a session. +runs two independent review agents on the diff (the claims audit wherever copy +changed), resolves the findings, verifies the build, and records the session in +`AGENTS.md`. `/review` runs the review pass alone; `/wrap` closes a session. Full protocol and prompt guidance: `docs/08-execution-protocol.md`. ## Deployment -Pushes to `main` build and deploy automatically via -`.github/workflows/deploy.yml`, using OIDC role assumption — there are no -long-lived AWS credentials in this repository. See `docs/06-deployment.md`. +`.gitea/workflows/deploy.yml` is the deploy pipeline — **Gitea Actions**, not +GitHub Actions. On a push to `main` it builds, syncs to S3, and invalidates +CloudFront. + +**It has never run green.** There is no `package-lock.json`, so `npm ci` exits +at step one; `src/pages/` is empty, so there is nothing to build; and whether an +`act_runner` is registered is recorded nowhere. The workflow is written; the +pipeline is unproven. + +The workflow's **first** step guards against the other way this fails quietly: +it aborts the run, naming the variable, if `AWS_REGION`, `S3_BUCKET`, or +`CLOUDFRONT_DISTRIBUTION_ID` is empty — which is how a Gitea too old for the +`vars` context manifests. + +The GitHub Actions equivalent, which uses OIDC role assumption, is kept as +`docs/reference/github-actions-oidc.yml.example` in case the project ever moves +to a forge that supports it. It sits outside `.github/workflows/` on purpose: +Gitea falls back to that directory when `.gitea/workflows` is absent, so a +workflow file left there with a `push` trigger would be only conditionally +inert. As an `.example` under `docs/` it cannot be picked up at all. + +**The pipeline is designed around a long-lived AWS credential.** Gitea is not an +AWS OIDC provider, so there is no role to assume: deploys are to authenticate as +a scoped IAM user, `adr-sml-deploy`, with its access key held in the +repository's Gitea Actions secrets. Nothing in `AGENTS.md` records that user as +created or that key as issued — `docs/06-deployment.md` is a procedure to +perform, not a record of one performed. Two things are meant to bound the risk, +and neither is confirmed in place: + +- **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket, + `s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and + `cloudfront:CreateInvalidation` on one distribution. No `Action: "*"`, no + `Resource: "*"`, nothing outside that one bucket and that one distribution. + The AWS account is shared with unrelated projects, including a bucket whose + name indicates another business's production database backups — that + narrowness is what keeps a compromised runner away from it, and it is + load-bearing rather than hygiene. See `AGENTS.md` §10. If a deploy step needs + a permission the policy lacks, question the step; do not widen the policy. +- **The key must be rotated quarterly, and nobody owns that yet.** Create a + second access key, update the Gitea secrets, confirm a deploy succeeds, then + delete the old one — rotation that leaves the old key active is not + rotation. OIDC would have removed the obligation entirely; it is + unavailable, so this is a standing calendar task still waiting on an owner. + +Full procedure, IAM policy, runner setup, and cutover checklist: +`docs/06-deployment.md`. diff --git a/astro.config.mjs b/astro.config.mjs index fe60d7d..a29c819 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -31,5 +31,10 @@ export default defineConfig({ service: { entrypoint: 'astro/assets/services/sharp' }, }, - prefetch: { prefetchAll: true, defaultStrategy: 'viewport' }, + // Opt-in only. `prefetchAll: true` with the viewport strategy injects Astro's + // prefetch script and an IntersectionObserver into EVERY page and speculatively + // fetches every link that scrolls into view — against CLAUDE.md's "default to + // zero JS" and AGENTS.md §7's "most pages ship zero JS", with no Change Log + // entry justifying it. Add data-astro-prefetch to individual links that earn it. + prefetch: { prefetchAll: false, defaultStrategy: 'hover' }, }); diff --git a/docs/01-architecture.md b/docs/01-architecture.md index b117b16..5064383 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -170,14 +170,14 @@ neutral. **Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`, `documents-only arbitration`. -1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary +1. What the service is; sole-arbitrator and party-appointed appointments. 2. **Tracks:** documents-only, expedited, full hearing. 3. **Rules:** ADRIC, ADR Chambers, ad hoc. 4. Awards — form, reasoning, timing. 5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress; the page says so and describes what is available now (co-arbitration, - tribunal secretary) versus what follows designation. Honesty here is a + co-arbitration) versus what follows designation. Honesty here is a differentiator, not a weakness — and misstating it is a conduct problem. 6. Fees, booking. @@ -208,7 +208,7 @@ system design, and pre-dispute technical advisory. `subcontract dispute arbitration Toronto`. Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency); -what an active litigation practice in the same matters brings to the room; the +what active litigation exposure in the same matters brings to the room; the Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres, transit; typical process shape. Strongest immediate fit per brief §III.1. @@ -243,7 +243,7 @@ a claim of existing volume.** `accident benefits mediator Ontario`, `MIG dispute`. Highest realistic near-term volume — it flows directly from the existing -personal-injury and SABS practice, and brief §IV.7 notes the segment is +personal-injury and SABS work, and brief §IV.7 notes the segment is underserved by senior mediators. Unglamorous and worth doing well. ### `/practice/shareholder/` @@ -274,7 +274,8 @@ a matter does not settle. ### `/fees/` -**Blocked on `AGENTS.md` Q4 — do not invent numbers.** +**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card +in `docs/07-fees.md`; still do not invent numbers.** Hourly rate; half-day and full-day mediation; preparation time policy; cancellation terms; administrative fee; HST treatment; who pays and how costs @@ -339,6 +340,6 @@ Dependency-ordered, so nothing is blocked mid-stream: 6. `/process/`, `/for-parties/` 7. `/insights/` plumbing, then the drafted articles 8. `/contact/` and the intake backend -9. `/fees/` — last, since it is blocked on Q4 +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`) diff --git a/docs/03-content-spec.md b/docs/03-content-spec.md index 2daea5c..188c3ca 100644 --- a/docs/03-content-spec.md +++ b/docs/03-content-spec.md @@ -29,7 +29,8 @@ detect padding instantly and discount everything after it. - Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model card. Minutes of settlement.* Specificity is the credential. - Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation; - co-arbitration and tribunal-secretary work is available now." Precision about + co-arbitration work is available now." (**No tribunal-secretary work** — D14 + removed the rate and bars offering it; see `docs/07-fees.md`.) Precision about what you cannot yet do makes the rest believable. - Plain words over Latin. "Without prejudice" survives because it is a term of art; *inter alia* does not. @@ -49,8 +50,9 @@ detect padding instantly and discount everything after it. This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya rather than letting it settle in by default. -- Superlatives. No "leading", "premier", "top-rated", "best". LSO marketing rules, - and they read as insecure. +- Superlatives. No "leading", "premier", "top-rated", "best". They are + unverifiable, they read as insecure, and marketing rules for regulated + professions treat them as suspect. - Outcome language that could be read as a guarantee. - "Passionate", "dedicated", "committed", "proven track record", "results-driven", "leverage", "synergy", "solutions". @@ -68,7 +70,7 @@ detect padding instantly and discount everything after it. Reused, adapted, across the hero, the About page, and the PDF bio: > The dispute resolution practice of Pouya Lajevardi — a credentialed neutral -> who is also a working litigator and a practising machine-learning and +> who is also close to live litigation and a practising machine-learning and > infrastructure engineer. Built for commercial, construction, energy, > technology, and cross-cultural disputes that turn on facts most neutrals take > on faith: the contract, the code, the engineering documents, and the @@ -115,7 +117,7 @@ redrawing the loop into a line.* First person: "my mark", not "our mark". ### About 400–600 words of narrative, then structured credentials. Tell the three tracks -as one arc, not three lists: a JD and an active litigation practice; a parallel +as one arc, not three lists: a JD and active litigation exposure; a parallel career in machine learning and infrastructure engineering; a company run alongside both. The arc is the point — the credentialing pathway from Q.Med through Q.Arb to C.Med-Arb is stated openly as in progress. The brief treats @@ -150,7 +152,8 @@ Five steps with real timing. Say what happens if the matter does not settle — counsel want to know the downside shape before they commit a client's day. ### Fees -**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from" +**Unblocked — Q4/Q14 answered, D14.** Build from the confirmed card in +`docs/07-fees.md`. Plain table, no "starting from" evasions, no "contact for pricing" after promising a rate card. ### For parties diff --git a/docs/04-seo-spec.md b/docs/04-seo-spec.md index 6dc8b78..4d77e1d 100644 --- a/docs/04-seo-spec.md +++ b/docs/04-seo-spec.md @@ -10,13 +10,13 @@ server-side fetch of the live site returns three words. | | Now `[verified 2026-08-25]` | Target | |---|---|---| | Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word | -| Indexable pages | 1 | 19 + articles | +| Indexable pages | 1 | 17 + articles (19 fixed URLs, less the two `/legal/*` pages, which are `noindex` and excluded from the sitemap) | | `` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page | | Meta description | none | Unique per page | | `<meta viewport>` | **absent** | Present | | Canonical URL | none | Every page | | OG / Twitter tags | none | Every page | -| Structured data | none | Person, LegalService, Article, FAQ, Breadcrumb | +| Structured data | none | Person, ProfessionalService, Article, FAQ, Breadcrumb | | `robots.txt` | 403 | Served | | Sitemap | none | Generated at build | | Favicon | none | Full set | @@ -60,8 +60,8 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover. | Type | Where | Notes | |---|---|---| -| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn — **Q12**), `image`, `worksFor` | -| `LegalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real | +| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn), `image`. **`jobTitle` = "Director of Firm Operations"; omit `worksFor`** — populating it either names the boutique (D16) or misstates the employer | +| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out | | `Service` | Each practice page | `serviceType`, `provider` → Person, `areaServed` | | `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` | | `BreadcrumbList` | All nested pages | Matches visible breadcrumbs | @@ -125,7 +125,7 @@ consistent name, address, and phone across all of them. - [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1 - [ ] Every page renders its full text with JavaScript disabled -- [ ] Rich Results Test passes on Person, LegalService, Article +- [ ] Rich Results Test passes on Person, ProfessionalService, Article - [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack - [ ] Sitemap submitted to Google Search Console and Bing - [ ] No page returns 200 for a URL that should 404 diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md index abfe8b2..e59636e 100644 --- a/docs/05-backend-spec.md +++ b/docs/05-backend-spec.md @@ -1,7 +1,9 @@ # 05 — Intake, booking, and data handling Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking. -Existing infrastructure is documented in `AWS-Hosting-Guide.md` Parts 8–10. +Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is +recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 8–10 — a historical +record with a do-not-execute banner, superseded by §7 wherever they disagree. **Read that guide before changing anything**; the resources already exist and were built by hand in the console. @@ -10,7 +12,7 @@ were built by hand in the console. ## What exists today API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and -a verified sender on `smlcompany.ca`. `[verified 2026-08-25 — AWS-Hosting-Guide.md]` +a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]` The shape is right. This is a hardening and rework pass, not a replacement. @@ -73,7 +75,8 @@ Client-side validation is a convenience. **The Lambda re-validates everything.** DynamoDB, `ca-central-1` — **Canadian data residency is a real selling point for a Canadian legal practice, and the privacy policy will say so.** Confirm the -existing table's region and migrate if it is elsewhere (**Q10**). +existing table's region and migrate if it is elsewhere. `AGENTS.md` §7 records +`adr-intake-submissions` in `ca-central-1` `[verified 2026-08-26]`. | Attribute | | |---|---| @@ -106,33 +109,33 @@ SES on submission: of the no-retainer language, and a link to the privacy policy. This email is the reason the form beats a `mailto:` link. -**Email authentication — verified 2026-08-26, and it is not in place.** +**Email authentication — in place as of 2026-08-26 (Q20).** -A DNS query of `smlcompany.ca` found **no SPF record and no DMARC record**. Mail -is on Google Workspace (MX `1 smtp.google.com`) with Google DKIM configured, and -the SES domain identity reports verified for sending — but neither SPF nor DMARC -exists. - -**What is already in place** (Namecheap DNS and the SES console, both inspected -2026-08-26): - -| Record | Status | -|---|---| -| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **Live.** Matches SES exactly. Never delete | -| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | Orphans from an earlier verification. Inert. **Leave them** — deleting the wrong three breaks DKIM | -| `google._domainkey` TXT | Google Workspace DKIM. Never delete | -| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal | -| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site | -| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment | - -Add both of these; neither conflicts with anything above: +SPF and DMARC were both added by Pouya and independently verified on +2026-08-26. Mail is on Google Workspace (MX `1 smtp.google.com`) with Google +DKIM configured, and the SES domain identity is verified for sending in +`ca-central-1`. **An earlier version of this spec said neither record existed; +that was true when written and is no longer.** The records now live are: | Host | Type | Value | |---|---|---| | `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` | | `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` | -A domain may publish **only one** `v=spf1` record, so both senders go in one +**What is already in place** (Namecheap DNS and the SES console, both inspected +2026-08-26): + +| Record | Status | +|---|---| +| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | **LIVE. Never delete.** All three resolve (NOERROR) and back the healthy `ca-central-1` SES identity — `DkimStatus: SUCCESS`. These are the records DMARC alignment rests on `[verified 2026-08-26 — DNS, AGENTS.md §7]` | +| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **BROKEN and inert.** Entered into Namecheap with the full name in the Host field, so the zone doubled the domain; they answer NXDOMAIN at the correct name. They belong to a stray `us-east-1` identity this project does not use. Harmless where they are — leaving them is the low-risk choice (Q21) `[verified 2026-08-26 — DNS, AGENTS.md §7]` | +| `google._domainkey` TXT | Google Workspace DKIM. Never delete | +| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal | +| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site | +| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment | + +Notes that mattered when these were added, kept because they matter again on +any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one string. Namecheap TXT values take **no surrounding quotes** — quoting them stores the quotes literally and breaks the record. @@ -140,7 +143,8 @@ the quotes literally and breaks the record. SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at `amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what -the six CNAMEs above are doing, and it already works. The SPF record's real job +the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six +are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job is authenticating **Google Workspace** mail, which currently has no SPF at all. `include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM domain is configured later. @@ -152,7 +156,7 @@ no visibility. **Do not delete the ACM validation CNAMEs.** They are how the certificate for `adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal -— silently, months later (Q20). +— silently, months later. Failure handling: SES failure must never lose the submission. Write to DynamoDB first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on @@ -204,7 +208,8 @@ relationship · cookie and analytics disclosure · last-updated date. If analytics ship, prefer a cookieless privacy-preserving tool (Plausible, Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a -practice whose privacy posture is part of its offer (**Q11**). +practice whose privacy posture is part of its offer — D15 settles this: +Plausible or Fathom, cookieless, no consent banner. ## Definition of done diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 8fb9e6b..0595fc8 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -1,19 +1,22 @@ # 06 — Deployment and cutover -Authority: `AGENTS.md` §3 D3 (git + GitHub Actions → existing S3/CloudFront) and -D11 (build everything, one clean cutover). -Existing infrastructure: `AWS-Hosting-Guide.md`. +Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions** +→ existing S3/CloudFront) and D11 (build everything, one clean cutover). +Existing infrastructure: **`AGENTS.md` §7 is authoritative.** +`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was +originally built — it is a historical record carrying a do-not-execute banner, +not a procedure, and §7 wins wherever the two disagree (Q24). --- ## Topology ``` -GitHub push to main - └─ GitHub Actions +Gitea push to main + └─ Gitea Actions (act_runner) ├─ npm ci && npm run build → ./dist - ├─ assume AWS role via OIDC (no stored keys) - ├─ aws s3 sync ./dist s3://<bucket> + ├─ static scoped IAM user key (from Gitea secrets — NOT OIDC) + ├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy) └─ cloudfront create-invalidation Namecheap DNS → CloudFront → S3 (OAC) API Gateway → Lambda → DynamoDB / SES (intake, unchanged path) @@ -30,8 +33,11 @@ local clone at `/Users/pouya/Dev/Websites/adr-sml`. **The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks GitHub Actions syntax, so it is a near-direct port — the build steps, the -two-pass sync, and the cache headers are unchanged. `.github/workflows/deploy.yml` -stays in the repo as the OIDC reference in case the project ever moves. +three-pass sync, and the cache headers are unchanged. The GitHub Actions original, +with its OIDC role assumption, stays in the repo as +`docs/reference/github-actions-oidc.yml.example` — deliberately outside +`.github/workflows/`, because Gitea falls back to that directory when +`.gitea/workflows` is absent. ### The one real difference: no OIDC @@ -39,8 +45,9 @@ Gitea is not an AWS OIDC provider. There is no role to assume, so deploys authenticate with a **scoped IAM user** whose access key lives only in the repository's Gitea secrets. -This is a genuine step down in security from the GitHub setup, and it should be -treated as one. The mitigations are the policy scope and the rotation schedule. +This is a genuine step down in security from an OIDC setup — which was designed +here but never built — and it should be treated as one. The mitigations are the +policy scope and the rotation schedule. **Create the user:** @@ -62,7 +69,7 @@ treated as one. The mitigations are the policy scope and the rotation schedule. { "Sid": "WriteSiteObjects", "Effect": "Allow", - "Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"], + "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::BUCKET_NAME/*" }, { @@ -75,12 +82,34 @@ treated as one. The mitigations are the policy scope and the rotation schedule. } ``` -No `s3:*`. No `cloudfront:*`. No wildcard resources. If a deploy step needs a -permission this policy lacks, the correct response is to question the step, not -to widen the policy. +Four actions on one bucket and one distribution. No `Action: "*"`, no +`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the +objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26: +`aws s3 sync` does not use it without `--acl`, and it is inert under Origin +Access Control with ACLs disabled. If a deploy step needs a permission this +policy lacks, the correct response is to question the step, not to widen the +policy. 3. Create an access key. **Copy it once** — AWS will not show the secret again. +**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual +reason.** `aws s3 sync` switches to multipart above its 8 MB +`multipart_threshold`; an interrupted multipart upload then cannot clean up its +own parts, and orphaned parts accrue storage charges that do not appear in the +bucket listing. What makes that safe today is simply that **nothing here comes +close to 8 MB** — the largest file the pipeline +uploads is well under it. The biggest source asset is +`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`, +Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font +files are smaller again. **Re-measure `./dist` after the first successful build** +— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one +as the mitigation, because it is not there. + +**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an +un-optimised photograph. At that point either add an S3 lifecycle rule aborting +incomplete multipart uploads after 7 days (preferred — it costs no IAM +permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`. + ### Gitea configuration **Repository → Settings → Actions → Secrets:** @@ -90,7 +119,10 @@ to widen the policy. | `AWS_ACCESS_KEY_ID` | from the IAM user | | `AWS_SECRET_ACCESS_KEY` | from the IAM user | -**The real values** (captured 2026-08-26, `aws-inventory.txt`): +**Repository → Settings → Actions → Variables** — not secrets. These are not +sensitive, and keeping them as variables means they appear in run logs where +they are useful for debugging. Values captured 2026-08-26 by +`scripts/aws-discover.sh`: | Variable | Value | |---|---| @@ -105,23 +137,13 @@ IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`, > **Read this before creating the key.** Account `327082975128` is shared across > `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and -> `mlp-clientdb-prod-backups` — a law firm's client-database backups. A static -> deploy key for a marketing site lives in the same account. The scoped policy is +> `mlp-clientdb-prod-backups` — which **by its name** holds another business's +> production client-database backups; the contents were never inspected, only +> the bucket name observed. A static deploy key for a marketing site lives in +> the same account. The scoped policy is > what keeps a compromised Gitea runner from reaching any of that. Do not widen > it, and never put the `user/pouya` credentials in CI. -**Repository → Settings → Actions → Variables** (not secrets — these are not -sensitive, and keeping them as variables means they appear in run logs where they -are useful for debugging): - -| Name | Value | -|---|---| -| `AWS_REGION` | e.g. `ca-central-1` | -| `S3_BUCKET` | the site bucket | -| `CLOUDFRONT_DISTRIBUTION_ID` | the `E...` ID | -| `INTAKE_ENDPOINT` | API Gateway invoke URL | -| `BOOKING_URL` | once chosen (Q5) | - ### A runner must exist Gitea Actions needs `act_runner` registered to this repository or its @@ -130,8 +152,25 @@ organisation, and Actions enabled both site-wide in `app.ini` queues silently and never runs — which looks exactly like a broken pipeline. The workflow installs the AWS CLI if the runner image lacks it, and runs -`aws sts get-caller-identity` before touching anything, so a credential problem -fails loudly and early rather than halfway through a sync. +`aws sts get-caller-identity` before touching anything. **That check is +narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at +all, so it succeeds for any valid key regardless of policy. It catches a +missing, malformed, or revoked key; it does **not** catch an under-scoped +policy, which still fails halfway through a sync and leaves the bucket +partially updated. Read it as a key check, not a permissions check. + +### The variable guard runs first + +The workflow's first step — before checkout, before the build, before any AWS +call — fails the run if `AWS_REGION`, `S3_BUCKET`, or +`CLOUDFRONT_DISTRIBUTION_ID` is empty. + +This exists because Gitea only added the `vars` context in 1.21. On an older +instance every `${{ vars.* }}` interpolates to an empty string with no warning, +the sync target becomes `s3://`, and the run dies halfway through with an error +that names nothing useful. The guard converts that into a clean failure that +says which variable is missing — **on every Gitea version**. A recorded version +number would have gone stale; the guard does not. ### Key rotation — an operational obligation @@ -149,9 +188,9 @@ whole section exists to bound. ## Finding the AWS identifiers -`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution +`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning -is on. Read-only; every call is a list or describe. +is on. Read-only; no call creates or mutates anything. ```bash chmod +x scripts/aws-discover.sh @@ -160,25 +199,28 @@ chmod +x scripts/aws-discover.sh The output contains resource names and IDs but no secrets. -## Why OIDC and not access keys +## Why OIDC would have been better — and why it is unavailable -The alternative is a long-lived `AWS_ACCESS_KEY_ID` in GitHub secrets: a -credential that never expires, is invisible once set, and grants its permissions -to anyone who can reach the repository. OIDC issues a short-lived token per run, -scoped to this repository and this branch. +> **Do not execute this section.** It describes the design that was rejected +> because Gitea cannot support it. The live procedure is *Create the user* above. +> Nothing here should be created in AWS. Following it would add an unused GitHub +> federation trust to account `327082975128`. -One-time setup: +A static `AWS_ACCESS_KEY_ID` never expires, is invisible once set, and grants its +permissions to anyone who can reach the repository. OIDC issues a short-lived +token per run, scoped to one repository and one branch — strictly better, and the +reason the rotation schedule above is not optional here. -1. IAM → Identity providers → add OIDC provider `token.actions.githubusercontent.com`, - audience `sts.amazonaws.com`. -2. Create role `adr-site-deploy` trusting that provider, with a condition on - `token.actions.githubusercontent.com:sub` equal to - `repo:<org>/<repo>:ref:refs/heads/main` (**Q9**). -3. Attach a policy granting **only**: `s3:PutObject`, `s3:DeleteObject`, - `s3:ListBucket` on the site bucket, and `cloudfront:CreateInvalidation` on the - one distribution. Nothing else. No `s3:*`, no `cloudfront:*`. -4. Store the role ARN, bucket name, and distribution ID as repository - **variables** (they are not secrets), and reference them in the workflow. +It needs an identity provider AWS will federate with. GitHub and GitLab both +publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust +and no role to assume. That is the whole of the constraint (D3 as amended). + +If the project ever moves to GitHub, the workflow to adopt is +`docs/reference/github-actions-oidc.yml.example`, and the setup is: register +`token.actions.githubusercontent.com` as an IAM OIDC provider with audience +`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim +matching the repository and `refs/heads/main`; attach the same four-action policy +given above; then delete `adr-sml-deploy` and its key. ## Cache policy @@ -191,19 +233,33 @@ that does not update. | `/_astro/*` (hashed) | `public, max-age=31536000, immutable` | | Fonts | `public, max-age=31536000, immutable` | | Images | `public, max-age=604800` | -| `robots.txt`, `sitemap*.xml` | `public, max-age=3600` | +| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` | -Sync in two passes: hashed assets first with the long TTL, then HTML with the -short one. Uploading HTML last means a user never fetches a new page whose assets -have not landed yet. +Sync in **three** passes, in this order: hashed assets and fonts with the long +TTL, then images, then everything else. Uploading HTML last means a user never +fetches a new page whose assets have not landed yet. + +Two ordering dependencies are load-bearing and easy to break: + +- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only + because `aws s3 sync` skips objects it has just uploaded. Reordering the + passes silently overwrites them with the HTML header. +- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those + prefixes from `--delete`, so hashed assets from previous deploys are kept + deliberately — pages still in a browser cache need them. Do not "fix" it. + +`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header. +That is the intended behaviour: both should be re-fetched, and the table above +records what the pipeline actually does rather than an unimplemented ideal. Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial invalidation paths are a reliable source of confusing bugs. ## CloudFront configuration -- Origin: S3 with **Origin Access Control**, bucket not public. The guide's - Part 2.2 bucket policy already does this — verify it was not loosened. +- Origin: S3 with **Origin Access Control**, bucket not public. Verify the + bucket policy grants access only to the CloudFront distribution's OAC + principal and to nothing else, and that public access is still blocked. - Redirect HTTP → HTTPS. TLS 1.2 minimum. - Default root object `index.html`. - **Custom error response:** 404 → `/404.html` with **response code 404**, not @@ -215,11 +271,20 @@ invalidation paths are a reliable source of confusing bugs. ## Branch model -`main` is production; every push deploys. Work on short-lived branches, open a -PR, let CI build and run Lighthouse, merge. +`main` is production; a push to `main` is what triggers a deploy. Work on +short-lived branches, open a PR, merge. -**Pull request checks (blocking):** `npm run build` · `astro check` · lint · -Lighthouse CI against the budgets in `04-seo-spec.md` · link check. +**The pipeline has never completed a run.** There is no `package-lock.json`, so +`npm ci` exits at step one; `src/pages/` is empty, so there is nothing to build; +and no record exists of an `act_runner` being registered. Treat "every push +deploys" as the design, not as current behaviour. + +**Pull request checks — planned, not implemented:** `npm run build` · +`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` · +link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger** +(only `push` on `main` and `workflow_dispatch`), and neither `npm run lint` nor +`npm run lighthouse` is wired — there is no ESLint config and no `lighthouserc`. +Nothing gates a merge today. Tag every production deploy `v<year>.<n>` so a rollback has something to name. @@ -227,8 +292,9 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name. 1. Re-run the workflow at the last good tag, or 2. `git revert` and push, or -3. Restore from S3 object versioning — **enable versioning on the bucket if it is - off**; it is the difference between a rollback and a rebuild. +3. Restore from S3 object versioning — **already Enabled** on + `adr-smlcompany-site` `[verified 2026-08-26 — AGENTS.md §7]`. It is the + difference between a rollback and a rebuild; do not turn it off. Then invalidate `/*`. @@ -239,7 +305,7 @@ Then invalidate `/*`. - [ ] No `TODO(pouya)` remains in any shipped page - [ ] No matter counts, rates, dollar figures, or testimonials anywhere - [ ] Q.Arb described as in progress everywhere it appears -- [ ] `/fees/` carries real numbers (Q4) or the page does not ship +- [ ] `/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 **Technical** @@ -251,10 +317,10 @@ Then invalidate `/*`. - [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack - [ ] 404 returns a 404 status - [ ] Security headers present (`securityheaders.com` A or better) -- [ ] **SES identities verified for sending** (Q18) — `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true` +- [ ] **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 out of the sandbox** (Q19) — `aws sesv2 get-account --query 'ProductionAccessEnabled'`. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails - [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set -- [ ] Booking link works, including the no-JavaScript fallback +- [ ] 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 - [ ] Favicon set complete - [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox - [ ] Tested at 320 px and at 200% zoom @@ -264,7 +330,7 @@ Then invalidate `/*`. - [ ] Bucket not publicly readable; OAC in force - [ ] ACM certificate valid; Namecheap validation CNAME still present - [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate -- [ ] Billing alarm still active (guide Part 0.3) +- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id 327082975128`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume **Post-cutover, same day** - [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools diff --git a/docs/07-fees.md b/docs/07-fees.md index b0141fb..d39e33c 100644 --- a/docs/07-fees.md +++ b/docs/07-fees.md @@ -1,11 +1,12 @@ # 07 — Fee research and recommended rate card -Authority: `AGENTS.md` §3 D8 (publish a full rate card) and D14 (two-tier -structure, **pending Pouya's sign-off — Q14**). +Authority: `AGENTS.md` §3 D8 (publish a full rate card) and **D14 — a single +published rate card, confirmed by Pouya 2026-08-26 (Q4/Q14/Q15-Q17 answered).** -**Nothing in this document publishes until Pouya confirms the figures.** These -are researched recommendations, not decisions. This is business pricing -information, not legal or financial advice. +**The card below is confirmed and buildable.** The research that produced it is +retained for context, but the figures are decisions now, not recommendations — +see "Set by Pouya" below. This is business pricing information, not legal or +financial advice. Research date: 2026-08-26. All figures below are **plus HST** unless stated. diff --git a/docs/08-execution-protocol.md b/docs/08-execution-protocol.md index aec8254..0ce4482 100644 --- a/docs/08-execution-protocol.md +++ b/docs/08-execution-protocol.md @@ -47,9 +47,19 @@ approve elegant code containing a claim that should never have been published, because professional-conduct compliance is not what it is looking at. On this project that is the highest-stakes failure mode, so it gets its own pass. +**Verifying they are loaded.** `.claude/agents/` is the correct location. To +confirm the agents are live, invoke one directly: + +``` +Use the claims-auditor agent to audit README.md against AGENTS.md §4. +``` + +A verdict table back means both are wired. "No such agent" means the frontmatter +needs looking at. + **Both are instructed to treat uncertainty as a defect.** They will sometimes be wrong. That is the intended trade: explaining why a finding is mistaken costs -minutes, and a missed defect on a licensed professional's public marketing page +minutes, and a missed defect on this project's public marketing pages costs a great deal more. ## The rule that makes it work diff --git a/docs/reference/AWS-Hosting-Guide.md b/docs/reference/AWS-Hosting-Guide.md new file mode 100644 index 0000000..2dc1868 --- /dev/null +++ b/docs/reference/AWS-Hosting-Guide.md @@ -0,0 +1,749 @@ +# Hosting `adr.smlcompany.ca` on AWS — A Step-by-Step Guide + +> --- +> ## REFERENCE ONLY — DO NOT EXECUTE +> +> **This is a historical record of how the existing AWS infrastructure was +> built. It is not a procedure to follow.** The live deployment procedure is +> [`docs/06-deployment.md`](../06-deployment.md); the authoritative inventory of +> what actually exists is `AGENTS.md` §7. +> +> Following this document would, among other things: create an IAM user with +> `AdministratorAccess` in account `327082975128` — which `AGENTS.md` §10 rates +> **High** blast-radius; rebuild the site through the standalone-HTML pipeline +> that D1 and D3 replace; and wire SES to addresses this project does not use. +> +> **Known contradictions with Current Truth**, all of which §7 and the specs win: +> +> | This guide says | Current Truth | +> |---|---| +> | Intake mail to `adr@` / `intake@smlcompany.ca` | **`info@smlcompany.ca`** — §4, D18 | +> | Lambda runtime Node.js 20.x | **`nodejs24.x`** — §7 | +> | "the SES sandbox is perfectly fine and free" | Sandbox is a **confirmed blocker**, Q19 | +> | `rebuild-standalone.py` / `sections.jsx` / a ~2.2 MB self-contained `index.html` | Astro static build — D1 | +> | SES policy with `"Resource": "*"` | Scope it; see §10 on this account | +> | "You (a lawyer, not a sysadmin)" — the original audience line, **corrected in place** | §4 records licence status as **NOT ESTABLISHED**; the word is barred outright | +> | The consent line "does not create a lawyer-client relationship" | Superseded by `NO_RETAINER_NOTICE` in `src/data/site.ts`, written to avoid exactly that phrasing | +> +> Retained because it is the only record of how the bucket, distribution, +> certificate, DNS, Lambda, DynamoDB table, and SES identities came to exist. +> Read it for that. Do not run it. +> --- + +**Audience:** the site owner — comfortable clicking around, new to AWS. +**Goal:** Get the revamped site live at `https://adr.smlcompany.ca` with a working intake form whose submissions are stored in a database **and** emailed to `adr@smlcompany.ca`. + +**Architecture you're building:** + +``` + ┌───────────────────────┐ + Browser ─────► │ CloudFront (CDN) │ ◄── ACM (free TLS cert) + adr.smlcompany.ca │ HTTPS + cache │ + └──────────┬────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ S3 bucket (origin) │ ← your standalone HTML + /assets + │ adr-smlcompany-site │ + └───────────────────────┘ + + Form submit ─► API Gateway ─► Lambda ─┬─► DynamoDB (permanent record) + └─► SES (emails adr@smlcompany.ca) + + DNS stays at Namecheap (you add a CNAME for `adr` + cert/DKIM validation records) +``` + +**Total time:** about 2–3 hours the first time, in chunks. Most steps take a minute or two of clicking but DNS propagation and CloudFront deploys mean there's some waiting. + +**Total monthly cost at low traffic:** under $1 USD. S3, CloudFront, Lambda, DynamoDB, and SES will all stay in or near their free tiers. + +--- + +## ⚠️ A note about DNS choice + +You've chosen to keep DNS at Namecheap rather than move to Route 53. That's perfectly fine and is actually cheaper (no $0.50/month hosted zone) and lower-risk (your existing MX records and email keep working untouched). The trade-offs: + +- **CNAMEs can't sit at the apex.** Your apex `smlcompany.ca` will not be servable on CloudFront from Namecheap DNS — only subdomains like `adr.smlcompany.ca`. This is a DNS standard, not a Namecheap limitation. Since you're using a subdomain, you're fine. If you ever want the apex on CloudFront, you'd either move DNS to Route 53 (alias records can be at apex) or use Namecheap's "URL Redirect Record" feature to redirect the apex to the subdomain. +- **You'll add records by hand.** Each time AWS asks you to publish a DNS record (for cert validation, for SES DKIM, etc.), you'll copy/paste it into Namecheap → Advanced DNS yourself, instead of AWS writing it for you. +- **No automatic DNS updates.** Not really a downside at this scale — just something to know. + +--- + +# Part 0 — Prerequisites (15 min) + +You said you already have an AWS account. Quick hardening pass: + +### 0.1 Sign in as a non-root IAM user +- AWS strongly recommends you don't use the root account day-to-day. If you've been using root: in the console, open **IAM → Users → Create user**. Name it `pouya-admin`. Attach the AWS-managed policy `AdministratorAccess`. Enable **console access** with a custom password. +- Sign out and sign back in as `pouya-admin` going forward. Reserve the root login for billing changes only. + +### 0.2 Turn on MFA for the root account +- IAM → Security credentials (under your root user) → **Assign MFA device** → use Authy / Google Authenticator / 1Password. + +### 0.3 Set a billing alarm +- Console → **Billing and Cost Management → Budgets → Create budget**. +- Template: **Monthly cost budget**, $20 USD, notify at 80% and 100% to `pouya@meshkinilaw.ca`. +- This catches misconfiguration before it gets expensive. + +### 0.4 Set your region +- Top-right of the AWS console: switch the region selector to **Canada (Central) — ca-central-1**. +- Everything in this guide is in `ca-central-1` **except** ACM (which for CloudFront *must* live in `us-east-1` — explained in Part 3) and CloudFront itself (which is global). + +### 0.5 (Optional but useful) Install the AWS CLI +- macOS: `brew install awscli` then `aws configure` and paste an access key generated from IAM → your user → Security credentials. +- You don't strictly need it — every step below has a console path — but a few things (S3 sync, CloudFront invalidations) are much faster from the terminal. + +--- + +# Part 1 — Put the website in an S3 bucket (15 min) + +S3 is just object storage. We'll create one bucket, drop your standalone HTML and the `assets/` folder in it, and leave it private — CloudFront will be the only thing allowed to read from it. + +### 1.1 Create the bucket +- Console → **S3 → Create bucket**. +- **Bucket name:** `adr-smlcompany-site` (must be globally unique across all of AWS — if it's taken, add a suffix like `-2026`). +- **Region:** Canada (Central) ca-central-1. +- **Block all public access:** leave the box **checked** (yes, fully blocked — CloudFront will use an Origin Access Control to read from it). +- **Bucket versioning:** Enable. This gives you a free undo if you ever overwrite the site with a broken version. +- Leave everything else default. **Create bucket**. + +### 1.2 Upload your files +Your export contains a few HTML files. The one you want to serve is `SML ADR Site (Standalone).html` — that's the ~2.2 MB self-contained build with everything inlined. + +- Open the bucket → **Upload**. +- **Add files** → select `SML ADR Site (Standalone).html`. +- **IMPORTANT:** before uploading, rename it locally to `index.html` (CloudFront's default root object). Or upload as-is and use the S3 console to rename it after upload (Actions → Rename). +- Also upload your `assets/` folder using **Add folder** so that `assets/sml-logo-full.png` and `assets/sml-logo-mark.png` end up at `s3://adr-smlcompany-site/assets/...`. + +After upload, your bucket should contain: + +``` +index.html +assets/ + sml-logo-full.png + sml-logo-mark.png +``` + +### 1.3 Set cache-control on the HTML (recommended) +Because we'll deploy by overwriting `index.html` later, you want browsers/CDN to re-check it often. + +- Click `index.html` → **Properties → Edit metadata**. +- Add metadata: **System defined → Cache-Control → `public, max-age=300, must-revalidate`** (5 minutes). +- For the images in `assets/`, leave defaults (they can cache for much longer; CloudFront will use defaults). + +--- + +# Part 2 — Put CloudFront in front of S3 (20 min including wait) + +CloudFront is AWS's CDN. It gives you HTTPS, global edge caching, and lets you put a real domain in front of an otherwise-private S3 bucket. + +### 2.1 Create the distribution +- Console → **CloudFront → Create distribution**. +- **Origin domain:** click the dropdown and pick your bucket — `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`. The console will offer a "Use website endpoint" suggestion — **ignore that**, leave the REST endpoint selected. +- **Origin access:** select **Origin access control settings (recommended)**. + - Click **Create new OAC**. Name: `adr-smlcompany-oac`. Signing behavior: **Sign requests**. Origin type: **S3**. Create. + - You'll see a yellow banner saying *"You must update the S3 bucket policy."* Note this — we'll do it in a moment. +- **Viewer protocol policy:** **Redirect HTTP to HTTPS**. +- **Allowed HTTP methods:** GET, HEAD (default). +- **Cache policy:** **CachingOptimized** (managed). +- **Origin request policy:** leave blank. +- **Response headers policy:** **SecurityHeadersPolicy** (managed) — adds HSTS, X-Frame-Options, etc. +- **Compress objects automatically:** Yes. +- **Price class:** **Use only North America and Europe** (cheaper; your clients aren't in Tokyo). +- **Web Application Firewall (WAF):** **Do not enable** for now. (Could add later if needed; ~$5/mo.) +- **Alternate domain names (CNAMEs):** leave blank for now — we'll add `adr.smlcompany.ca` in Part 6, after the cert exists. +- **Custom SSL certificate:** leave **Default CloudFront Certificate** for now. +- **Default root object:** `index.html`. +- **Standard logging:** Off (can enable later). +- **Create distribution**. + +### 2.2 Update the S3 bucket policy +After creating the distribution, you'll see a banner *"Copy policy"* with a JSON snippet — that snippet allows your specific CloudFront distribution to read from S3. + +- Click **Copy policy**. +- Open the S3 bucket → **Permissions → Bucket policy → Edit** → paste → **Save changes**. + +The policy looks roughly like: + +```json +{ + "Version": "2008-10-17", + "Statement": [{ + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": { "Service": "cloudfront.amazonaws.com" }, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::adr-smlcompany-site/*", + "Condition": { + "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT-ID>:distribution/<DIST-ID>" } + } + }] +} +``` + +### 2.3 Wait for "Deployed" +- CloudFront → your distribution → wait until **Last modified** shows a timestamp and the status reads **Deployed** (5–15 min the first time). +- Then visit the **Distribution domain name** shown at the top — something like `d123abc4xyz.cloudfront.net`. Your site should load over HTTPS. +- If you see XML access-denied: the bucket policy isn't saved yet, or `index.html` isn't named exactly that. + +✅ **Checkpoint:** site loads on the `*.cloudfront.net` URL. We'll attach your real domain in Part 6. + +--- + +# Part 3 — Get a free SSL certificate (10 min, validation later) + +CloudFront requires its TLS certificate to live in **us-east-1**, regardless of where the rest of your stack lives. This trips up everyone the first time. + +### 3.1 Request the cert +- Top-right region selector → switch to **US East (N. Virginia) — us-east-1**. (You'll switch back to ca-central-1 after this part.) +- Console → **Certificate Manager → Request certificate → Request a public certificate**. +- **Domain names:** + - `adr.smlcompany.ca` + - (Optional, recommended) Add a second name: `*.smlcompany.ca`. A wildcard means you'll be able to use the same cert for `www.smlcompany.ca`, `mail.smlcompany.ca`, etc. without re-requesting. +- **Validation method:** **DNS validation** (the recommended option — uses a CNAME record). +- **Key algorithm:** RSA 2048. +- **Request**. + +You'll land on the cert page in **Pending validation** state. ACM will show you one or two CNAME records of the form `_abc123.adr.smlcompany.ca` → `_xyz789.acm-validations.aws.`. Leave this tab open — you'll publish these in Namecheap in Part 5. + +--- + +# Part 4 — Open Namecheap's DNS panel (2 min) + +We're not migrating DNS, but we will be coming back to this panel four times across the rest of the guide (ACM cert validation, the CloudFront CNAME, three SES DKIM records). So get familiar with where it is now. + +### 4.1 Locate Advanced DNS +- Log in to Namecheap → **Domain List**. +- Find `smlcompany.ca` → click **Manage** on its row. +- Click the **Advanced DNS** tab. This is where you'll add every record below. (Do **not** touch the **Domain** tab's Nameservers section — leave it set to *Namecheap BasicDNS*.) + +### 4.2 Make a "before" screenshot (1 min) +Take a screenshot of the current Host Records table. You won't need to touch any of the existing rows — they're handling your email and anything else you have set up. The screenshot is just an undo reference in case you ever paste over the wrong row. + +### 4.3 How to add a record in Namecheap (reference for later steps) +Namecheap's row-add UX: +- Scroll to the **Host Records** section → click **ADD NEW RECORD**. +- Pick a **Type** from the dropdown (A, AAAA, CNAME, TXT, MX, etc.). +- **Host:** the subdomain part only. So for `adr.smlcompany.ca` the Host is `adr`. For the apex itself, use `@`. For something like `_abc123.adr.smlcompany.ca`, use `_abc123.adr`. +- **Value:** what AWS gives you. **Important:** Namecheap will sometimes append a trailing dot to CNAME values when it shows them back — that's normal. When *entering* a CNAME, you can include or omit the trailing dot; both work. +- **TTL:** Automatic (~30 min) is fine. For records you'll change often (testing), pick a low TTL like 5 min. +- Click the green checkmark on the right to save the row. + +That's it — you'll do this five-ish times over Parts 5, 7, and 9. + +--- + +# Part 5 — Validate the ACM cert via Namecheap (10 min including wait) + +Back to the cert you requested in Part 3. + +- Region selector → **us-east-1**. +- ACM → your pending cert → click into it. +- You'll see one (or two, if you added the wildcard) **CNAME validation records** of the form: + + ``` + Name: _abc1234567890.adr.smlcompany.ca. + Value: _xyz9876543210.acm-validations.aws. + ``` + + ACM gives you a **Copy** button next to each — handy. + +- Switch tab to Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**: + - **Type:** CNAME Record + - **Host:** the bit *before* `.smlcompany.ca` in the Name field. For example, if ACM shows `_abc1234567890.adr.smlcompany.ca.`, the Host you enter in Namecheap is `_abc1234567890.adr`. (Drop the trailing `.smlcompany.ca` — Namecheap appends it automatically.) + - **Target:** the Value from ACM, e.g. `_xyz9876543210.acm-validations.aws.` (trailing dot is fine). + - **TTL:** Automatic. + - Click the green checkmark. + +- If you added the wildcard `*.smlcompany.ca` in Part 3, you'll see a second validation row in ACM — add a second CNAME the same way. (Often ACM gives the same Name/Value for the apex and wildcard, in which case you only need one CNAME.) + +- Back in ACM, refresh the cert page after 2–10 min. Status flips from **Pending validation** to **Issued**. If it's still pending after 15 minutes, you've almost certainly got a Host typo — re-check that what's in Namecheap matches what ACM shows, character for character. + +--- + +# Part 6 — Attach the cert + domain to CloudFront (10 min including wait) + +- Region selector → **us-east-1** (CloudFront is global but lives under us-east-1 in the console nav). +- CloudFront → your distribution → **General → Settings → Edit**. +- **Alternate domain name (CNAME):** add `adr.smlcompany.ca`. (Add `www.adr.smlcompany.ca` too if you want both — otherwise leave as just the one.) +- **Custom SSL certificate:** dropdown → select the cert you just issued. +- **Security policy:** TLSv1.2_2021. +- **Save changes**. +- Wait ~5–10 min for **Deployed** status again. + +--- + +# Part 7 — Point DNS at CloudFront via Namecheap (3 min) + +- Grab your CloudFront distribution domain from the CloudFront console (top of the distribution page) — it looks like `d123abc4xyz.cloudfront.net`. +- Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**: + - **Type:** CNAME Record + - **Host:** `adr` + - **Target:** your CloudFront domain, e.g. `d123abc4xyz.cloudfront.net.` (trailing dot optional) + - **TTL:** 5 min (for the initial setup — you can raise it to Automatic once everything's stable) + - Save with the green checkmark. + +> **About IPv6:** a CNAME delegates resolution to the target's records, and CloudFront serves both A (IPv4) and AAAA (IPv6) records. So a single CNAME automatically covers both — you don't need a separate AAAA record like you would with a Route 53 alias. + +> **About the apex:** Namecheap DNS can't put a CNAME at `@` (the apex `smlcompany.ca`). That's a hard DNS-standards limit, not Namecheap's fault. Since you're using `adr.smlcompany.ca`, this doesn't affect you. If you also wanted `smlcompany.ca` (without the `adr.`) to land on the site, the easiest route is Namecheap's **URL Redirect Record** type: Host `@`, Target `https://adr.smlcompany.ca`, Type `Unmasked (301)`. + +Within a couple of minutes, `https://adr.smlcompany.ca` should serve your site. + +✅ **Checkpoint:** open `https://adr.smlcompany.ca` in an incognito window. You should see the revamped site, with a green padlock, no warnings. + +--- + +# Part 8 — Backend: DynamoDB table + Lambda + API Gateway (45 min) + +Now the intake form. The flow: + +``` +Browser POST → API Gateway (HTTPS) → Lambda function → DynamoDB.put_item() + → SES.send_email() to adr@smlcompany.ca +``` + +### 8.1 Create the DynamoDB table +- Region → **ca-central-1**. +- Console → **DynamoDB → Tables → Create table**. +- **Table name:** `adr-intake-submissions`. +- **Partition key:** `submissionId` (String). +- **Sort key:** leave blank. +- **Settings:** **Default settings** — this gives you on-demand capacity (you pay per request, ~$0 at your volume) and encryption at rest by default. +- **Create**. +- After it's `Active`: click the table → **Backups → Point-in-time recovery → Edit → Turn on**. Costs cents/month and lets you restore to any second in the last 35 days. + +### 8.2 Create the Lambda execution role (IAM) +- IAM → **Roles → Create role**. +- Trusted entity: **AWS service** → use case **Lambda**. +- Permissions: attach these AWS managed policies for now: + - `AWSLambdaBasicExecutionRole` (lets it write CloudWatch logs) +- **Role name:** `adr-intake-lambda-role`. Create. +- After creation: open the role → **Add permissions → Create inline policy** → JSON tab → paste: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["dynamodb:PutItem"], + "Resource": "arn:aws:dynamodb:ca-central-1:*:table/adr-intake-submissions" + }, + { + "Effect": "Allow", + "Action": ["ses:SendEmail", "ses:SendRawEmail"], + "Resource": "*" + } + ] +} +``` + +Name it `adr-intake-lambda-inline`. Save. + +### 8.3 Create the Lambda function +- Console → **Lambda → Create function**. +- **Author from scratch.** +- **Function name:** `adr-intake-handler`. +- **Runtime:** Node.js 20.x. +- **Architecture:** arm64 (cheaper). +- **Execution role:** *Use an existing role* → `adr-intake-lambda-role`. +- **Create function.** + +In the Code tab, replace the contents of `index.mjs` with: + +```javascript +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { randomUUID } from "crypto"; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ca-central-1" })); +const ses = new SESv2Client({ region: "ca-central-1" }); + +const TABLE = "adr-intake-submissions"; +const FROM_ADDR = "adr@smlcompany.ca"; // must be SES-verified (Part 9) +const NOTIFY_ADDR = "adr@smlcompany.ca"; // must be SES-verified while SES is in sandbox +const ALLOWED_ORIGIN = "https://adr.smlcompany.ca"; + +const CORS = { + "Access-Control-Allow-Origin": ALLOWED_ORIGIN, + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +}; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const handler = async (event) => { + // Preflight + if (event.requestContext?.http?.method === "OPTIONS") { + return { statusCode: 204, headers: CORS }; + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_json" }) }; + } + + // Extract + light validation — adjust to taste. + const name = (body.name || "").toString().trim().slice(0, 200); + const org = (body.org || "").toString().trim().slice(0, 200); + const email = (body.email || "").toString().trim().slice(0, 200); + const phone = (body.phone || "").toString().trim().slice(0, 50); + const matter = (body.matter || "").toString().trim().slice(0, 100); + const message = (body.message || "").toString().trim().slice(0, 5000); + const honeypot = (body.website || "").toString(); // bot trap; see Part 10 + + if (honeypot) { + // Silently accept and drop — looks successful to bots. + return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) }; + } + if (!name || !email || !message) { + return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "missing_fields" }) }; + } + if (!EMAIL_RE.test(email)) { + return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_email" }) }; + } + + const submissionId = randomUUID(); + const submittedAt = new Date().toISOString(); + const sourceIp = event.requestContext?.http?.sourceIp || "unknown"; + const userAgent = event.headers?.["user-agent"] || "unknown"; + + // 1) Store in DynamoDB + await ddb.send(new PutCommand({ + TableName: TABLE, + Item: { submissionId, submittedAt, name, org, email, phone, matter, message, sourceIp, userAgent }, + })); + + // 2) Email adr@smlcompany.ca + const text = +`New intake form submission + +Name: ${name} +Org: ${org || "(not provided)"} +Email: ${email} +Phone: ${phone || "(not provided)"} +Service: ${matter || "(not provided)"} + +Message: +${message} + +— +Submission ID: ${submissionId} +Submitted: ${submittedAt} +IP: ${sourceIp} + +Reply directly to this email — it will route to the submitter. +`; + + await ses.send(new SendEmailCommand({ + FromEmailAddress: FROM_ADDR, + Destination: { ToAddresses: [NOTIFY_ADDR] }, + Content: { + Simple: { + Subject: { Data: `New intake: ${name}${org ? " — " + org : ""}`, Charset: "UTF-8" }, + Body: { Text: { Data: text, Charset: "UTF-8" } }, + } + }, + ReplyToAddresses: [email], // hitting Reply in your inbox goes straight to the submitter + })); + + return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true, submissionId }) }; +}; +``` + +> **Note:** This version uses `adr@smlcompany.ca` as both From and To (per the simpler Option B in Part 9.3). The "Reply-To" header is set to the submitter's email, so when you hit *Reply* in your mail client, the response goes to them — not to yourself. + +- Click **Deploy**. +- Set the runtime timeout to 10 seconds: **Configuration → General configuration → Edit → Timeout: 10 sec → Save**. + +> **If you see a "module not found" error** on first invocation (rare but possible — AWS sometimes drops packages from the included SDK between runtime versions), you'll need to deploy your code as a zip with `node_modules`. Locally: `npm init -y && npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sesv2`, put your `index.mjs` next to `node_modules/`, then `zip -r function.zip index.mjs node_modules package*.json` and upload via Lambda → Code → Upload from → .zip file. The three SDK packages are normally pre-installed in the Node.js 20.x runtime, so you should be able to skip this step. + +### 8.4 Create the HTTP API in API Gateway +- Console → **API Gateway → Create API → HTTP API → Build**. +- **Integrations:** click *Add integration* → Lambda → region ca-central-1 → function `adr-intake-handler`. +- **API name:** `adr-intake-api`. +- **Configure routes:** + - Method: `POST` + - Path: `/submissions` + - Integration target: `adr-intake-handler` +- **Configure stages:** leave default (`$default`, auto-deploy enabled). +- **Create**. + +After it's created: +- Open the API → **CORS** → **Configure**: + - Access-Control-Allow-Origin: `https://adr.smlcompany.ca` + - Access-Control-Allow-Methods: `POST` + - Access-Control-Allow-Headers: `content-type` + - Save. +- Note the **Invoke URL** at the top — looks like `https://abc123.execute-api.ca-central-1.amazonaws.com`. Your endpoint is `<invoke-url>/submissions`. + +### 8.5 Quick smoke-test (without the front-end) +From your terminal: + +```bash +curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test User","org":"Test","email":"test@example.com","phone":"+1-416-555-0100","matter":"Mediation","message":"This is a test."}' +``` + +Expected response: `{"ok":true,"submissionId":"..."}`. + +At this point the DynamoDB write should succeed, but **SES will fail** until Part 9. Check **CloudWatch → Log groups → /aws/lambda/adr-intake-handler** — you'll see the error there. That's fine; Part 9 fixes it. + +To see the row landed in the DB: DynamoDB → Tables → `adr-intake-submissions` → **Explore table items**. + +--- + +# Part 9 — Verify your sender domain in SES (15 min) + +By default SES is in **sandbox**: it can only send *from* verified identities *to* verified identities. For a low-volume contact-form notifier sending only to yourself, the sandbox is perfectly fine and free. + +### 9.1 Verify the domain `smlcompany.ca` +- Region → **ca-central-1**. +- Console → **Amazon SES → Configuration → Identities → Create identity**. +- Identity type: **Domain**. +- Domain: `smlcompany.ca`. +- **Use a custom MAIL FROM domain:** skip (optional). +- **DKIM:** **Easy DKIM**, RSA 2048-bit. Leave **Publish DNS records to Route 53** unchecked (you don't have a Route 53 hosted zone). +- Create. + +You'll land on the identity page with three CNAME records that SES wants published. They look like: + +``` +Name: abc1234567890._domainkey.smlcompany.ca +Value: abc1234567890.dkim.amazonses.com + +Name: def0987654321._domainkey.smlcompany.ca +Value: def0987654321.dkim.amazonses.com + +Name: ghi5555555555._domainkey.smlcompany.ca +Value: ghi5555555555.dkim.amazonses.com +``` + +Add each in Namecheap → Advanced DNS → **ADD NEW RECORD**: +- **Type:** CNAME Record +- **Host:** the part before `.smlcompany.ca` — e.g. `abc1234567890._domainkey` +- **Target:** the SES value, e.g. `abc1234567890.dkim.amazonses.com` +- **TTL:** Automatic +- Save with the green checkmark. Repeat for the other two. + +⚠️ If you currently have **any other DKIM CNAMEs** for `smlcompany.ca` from your existing email provider (e.g. Google Workspace's `google._domainkey`), **leave them alone**. SES's DKIM uses different selector names, so it won't collide. Multiple DKIM keys on the same domain is normal and supported. + +After 5–15 minutes, refresh the SES identity page. The DKIM status flips to **Successful** and the overall identity status flips to **Verified**. If it's still pending after 30 minutes, check the Host fields in Namecheap for typos. + +> **Bonus — SPF alignment for SES.** Your existing `v=spf1 ...` TXT record at the apex tells the world which servers may send mail "as" smlcompany.ca. If you want SES-sent mail to pass SPF too (improves deliverability of intake notifications), add `include:amazonses.com` to the existing SPF record. Edit it in Namecheap so it becomes e.g.: `v=spf1 include:_spf.google.com include:amazonses.com ~all`. Don't create a *second* SPF TXT record — only one is allowed per domain. + +### 9.2 Verify the recipient +While SES is in sandbox, the *To:* address also has to be verified. + +- SES → Identities → **Create identity** → Email address → `adr@smlcompany.ca` → Create. +- AWS sends a verification email to that address. Click the link. Status → **Verified**. + +### 9.3 Verify the From address +The Lambda above uses `intake@smlcompany.ca` as the From. Verify it too: +- SES → Identities → **Create identity** → Email address → `intake@smlcompany.ca` → Create. +- Either have your email provider deliver mail at that alias to your real inbox, *or* just use `adr@smlcompany.ca` as the From in the Lambda code and skip this step. + +### 9.4 Retest +```bash +curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test 2","org":"Test","email":"test@example.com","matter":"Mediation","message":"Now with email."}' +``` +You should get the success response AND see an email arrive at `adr@smlcompany.ca` within a few seconds. + +### 9.5 (Optional, later) Request production access +If you ever want the form to **send a confirmation email back to the submitter**, you'll need to exit sandbox. SES Console → top right → **Request production access**. AWS asks a few questions about how you'll use it; approval is usually under 24 hr for legitimate business use. + +--- + +# Part 10 — Wire the front-end form to your API (20 min) + +### How the site is bundled + +Your site export uses a custom format from Anthropic's Artifacts bundler: + +- **`SML ADR Site (Standalone).html`** — a single 2.2 MB file containing the rendered HTML *plus* all JSX, JavaScript, fonts, and the logo PNG bundled together as gzipped+base64 entries inside a `<script type="__bundler/manifest">` JSON blob. This is the file you uploaded to S3 as `index.html`. +- **`components-standalone/*.jsx`** — the loose JSX source files (sections, hero, nav, etc.). The standalone HTML was originally built *from* these but isn't automatically rebuilt when you edit them. + +So if you change a JSX file, you have two options: + +| Approach | What you upload to S3 | Pros | Cons | +|---|---|---|---| +| **Stay with the bundled HTML** *(recommended)* | One file (`index.html`) | Same as before. Fast page load. CloudFront caches it well. | Need to "rebundle" after JSX edits. | +| **Switch to loose files** | `(standalone-src).html` + the whole `components-standalone/` folder + `tweaks-panel.jsx` + `assets/` | No rebuild step — just upload changed JSX. | Extra ~500ms first page load while Babel compiles JSX in the browser. Many small files. | + +This guide assumes you stay with the bundled HTML, because that's the architecture you started with. + +### The form code is already updated + +The `components-standalone/sections.jsx` file has been edited. The new Contact component: + +- Adds two new required-flag-aware fields: **Email** (required, `type="email"`) and **Phone** (optional, `type="tel"`). +- Wires `onSubmit` to a real `fetch()` POST against your API Gateway endpoint. +- Adds `submitting` and `error` state so the button shows "Sending…" while in flight and a clear maroon-bordered error message on failure. +- Adds a hidden **honeypot** field (`website`) to silently drop bot submissions. +- Adds a small-print **consent line** under the submit button. **Superseded — do not use this wording:** the live text is `NO_RETAINER_NOTICE` in `src/data/site.ts`, which deliberately avoids the phrase below. Historical text: *"Submitting this form does not create a lawyer-client relationship. By submitting, you consent to storage of this information by SML Company in Canada for the purpose of responding to your inquiry."* +- Extends the shared `Field` component to accept `type` and `required` props, rendering a gold asterisk next to required-field labels. + +The API endpoint is hard-coded at the top of the Contact section: + +```jsx +const INTAKE_API_URL = 'https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com/submissions'; +``` + +If your API Gateway URL ever changes, update that one constant and rebuild (next step). + +### Rebuilding the standalone HTML (`rebuild-standalone.py`) + +A small Python script sits alongside the JSX in the project folder. It reads the original `SML ADR Site (Standalone).html`, swaps in the current contents of `components-standalone/sections.jsx`, re-compresses, and writes out a fresh `index.html` that's ready to upload to S3. + +From a terminal: + +```bash +cd "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web" +python3 rebuild-standalone.py +``` + +You'll see output like: + +``` + patched sections.jsx -> 8830e633-... (42,792 bytes → 10,513 gz → 14,020 b64) +Wrote .../Pouya Personal Branding Web/index.html +``` + +That `index.html` is the file you upload to S3. + +The script is intentionally limited to `sections.jsx` (where the form lives). If you ever want to edit the hero, nav, or any other component, open `rebuild-standalone.py` and uncomment the relevant line in the `JSX_FILES` mapping after discovering each component's UUID (the script docstring explains how). + +> **For the first run we already did this for you** — a fresh `index.html` containing the email/phone form is sitting in the project folder right now, ready to upload. + +### Deploying the change + +1. Upload the new `index.html` to your S3 bucket `adr-smlcompany-site`, **replacing** the existing `index.html`. S3 versioning (enabled in Part 1.1) keeps the old version recoverable if anything goes wrong. + + Console path: S3 → `adr-smlcompany-site` → **Upload** → drag `index.html` from the project folder → **Cache-Control:** `public, max-age=300, must-revalidate` → **Upload**. + + Or from the terminal: + ```bash + aws s3 cp \ + "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web/index.html" \ + s3://adr-smlcompany-site/index.html \ + --cache-control 'public, max-age=300, must-revalidate' + ``` + +2. **Invalidate CloudFront** so users see the new version immediately rather than waiting for the 5-minute cache to expire. + + Console: CloudFront → your distribution → **Invalidations → Create invalidation** → object path: `/index.html` (and `/` for safety) → **Create**. Costs $0.005 per path (first 1,000 paths/month are free). + + Or from the terminal: + ```bash + aws cloudfront create-invalidation \ + --distribution-id <YOUR-DIST-ID> \ + --paths '/' '/index.html' + ``` + +3. Hard-refresh `https://adr.smlcompany.ca` in an incognito window. Confirm the form now shows the Email and Phone fields and the consent line under the submit button. + +--- + +# Part 11 — End-to-end smoke test (10 min) + +In an incognito window: + +1. Open `https://adr.smlcompany.ca`. Confirm green padlock, all sections render, logos load. +2. Open the browser devtools → Network tab. Submit the intake form with realistic values. +3. Confirm the network call to your API Gateway returns 200. +4. Within 30 seconds, check `adr@smlcompany.ca` — you should have a "New intake: …" email. +5. Open the DynamoDB table → **Explore table items** → you should see your test row. +6. (Optional) Try submitting from `curl` with the honeypot field set — `{"website":"http://spam"}`. You should get a 200 but **no email and no DB row** (silent drop). + +✅ If all five pass, you're live. + +--- + +# Part 12 — Day-2 operations + +### How to update the site +1. Re-export the standalone HTML. +2. Upload to S3 as `index.html` (overwrites; old version preserved by versioning). +3. CloudFront invalidate `/index.html` (and `/` for safety). + +Doable in 2 minutes via the CLI: +```bash +aws s3 cp index.html s3://adr-smlcompany-site/index.html \ + --cache-control 'public, max-age=300, must-revalidate' +aws cloudfront create-invalidation \ + --distribution-id <DIST-ID> --paths '/' '/index.html' +``` + +### Monitoring +- **CloudWatch alarm — Lambda errors:** CloudWatch → Alarms → Create alarm → Metric: Lambda → ByFunctionName → `adr-intake-handler` → Errors → Statistic Sum, period 5 min, threshold `>= 1`. Notify via an SNS topic that emails you. Alerts you within minutes if the form starts failing. +- **CloudFront 5xx error rate alarm:** same pattern, threshold `> 1%`. +- Set both with low thresholds — your traffic is low enough that any sustained error matters. + +### Backups & retention +- DynamoDB PITR (Part 8.1) gives you 35-day rollback. +- S3 versioning (Part 1.1) gives you forever-rollback on the site files. +- Consider a quarterly export of the DynamoDB table to S3 if you want a clean audit trail. + +### Privacy / PIPEDA hygiene (legal-services context) +- Everything lives in `ca-central-1`. CloudFront caches *static* HTML at edge locations globally, but your form *submissions* never touch CloudFront — they go directly to API Gateway in ca-central-1. +- Consider adding a one-line consent notice under the form: *"By submitting this form, you consent to its storage by SML Company in Canada for the purpose of responding to your inquiry. We do not share this information with third parties."* +- DynamoDB rows include the submitter's IP and user-agent for abuse defense. If you'd rather not store those, remove `sourceIp` and `userAgent` from the `PutCommand` Item. + +### Cost expectations (USD, monthly) +| Service | Expected | Notes | +|-----------------|---------------|-----------------------------------------| +| Namecheap DNS | $0 | Included with your domain registration. | +| S3 | <$0.05 | 3 MB of files + a few requests. | +| CloudFront | $0.10–1 | Free tier covers first 1 TB out/month. | +| ACM cert | $0 | Free. | +| API Gateway | <$0.05 | $1 per million requests. | +| Lambda | $0 | Free tier covers 1M requests/mo. | +| DynamoDB | $0 | On-demand, low volume. | +| SES | $0 | First 62k emails/mo from Lambda free. | +| **Total** | **under $1** | | + +### What to do if something breaks +- **Site won't load:** check CloudFront *Status = Deployed*, and that the Namecheap CNAME for `adr` points to the CloudFront domain (paste the value from `dig adr.smlcompany.ca CNAME` or `nslookup adr.smlcompany.ca` to verify it actually resolves to a `.cloudfront.net` host). +- **403 from CloudFront:** the S3 bucket policy isn't right — re-copy from CloudFront's "Origins → Edit" page. +- **TLS error:** ACM cert is in us-east-1, not ca-central-1; or the *Alternate domain name* on the CloudFront distribution doesn't match exactly. +- **Form returns 500:** open CloudWatch logs for the Lambda — almost always an unverified SES identity or a permissions gap on the role. +- **Email not arriving:** SES is still in sandbox AND the destination isn't verified, OR the From address isn't verified. + +--- + +## Appendix A — File / resource manifest + +When you're done, here's what you should be able to point at in your AWS console: + +| Resource | Name / ID | +|-----------------------|----------------------------------------------------------------------| +| S3 bucket | `adr-smlcompany-site` (ca-central-1) | +| CloudFront dist | `E…` (CNAME: adr.smlcompany.ca) | +| ACM certificate | for `adr.smlcompany.ca` (us-east-1) | +| DNS provider | Namecheap (Advanced DNS for smlcompany.ca) | +| DynamoDB table | `adr-intake-submissions` (ca-central-1) | +| Lambda function | `adr-intake-handler` (ca-central-1) | +| IAM role | `adr-intake-lambda-role` | +| API Gateway | `adr-intake-api` (HTTP API, ca-central-1) | +| SES verified ids | domain `smlcompany.ca`, email `adr@smlcompany.ca`, `intake@…` | + +## Appendix B — Future enhancements (when you want them) + +- **Admin dashboard for submissions:** build a tiny password-protected page that calls a second Lambda (`GET /submissions`) to list the DynamoDB table. Or just use the DynamoDB console for now — it's perfectly serviceable for low volume. +- **Confirmation email to submitter:** request SES production access (Part 9.5), then add a second `SendEmailCommand` call in the Lambda thanking them and setting expectations. +- **Calendar booking:** integrate Calendly or Cal.com link inside the "Thank you" view. +- **File uploads on intake** (e.g., a PDF of the dispute summary): add S3 presigned-URL generation in the Lambda, let the front-end upload directly to a private bucket. Keep file size limits sane. +- **Move DNS to Route 53 later:** if you ever want apex (`smlcompany.ca`) on CloudFront, or want AWS to manage records for you automatically, the migration is straightforward — inventory Namecheap records, recreate them in a new Route 53 hosted zone, switch nameservers at Namecheap. Doable in ~30 min once you have a downtime window for any DNS-sensitive integrations. +- **Bilingual (EN/FA) routing:** add a `lang` query param or subpath, serve from the same S3 bucket via CloudFront behaviors. +- **Search / analytics over submissions:** stream DynamoDB updates to a small OpenSearch index, or just export weekly to a private S3 bucket and query with Athena. +- **WAF in front of CloudFront:** if you ever see scraper/bot traffic, add AWS WAF with the AWS-managed core rule set (~$5/mo + per-request). + +--- + +*End of guide. If you hit a wall on any specific step, come back here and tell me which Part and what the screen says — most issues are 1-line fixes.* diff --git a/.github/workflows/deploy.yml b/docs/reference/github-actions-oidc.yml.example similarity index 78% rename from .github/workflows/deploy.yml rename to docs/reference/github-actions-oidc.yml.example index b167d10..fc57929 100644 --- a/.github/workflows/deploy.yml +++ b/docs/reference/github-actions-oidc.yml.example @@ -4,7 +4,9 @@ # # This file is kept because it is the better design: GitHub OIDC issues a # short-lived token per run instead of a static key. If the project ever moves -# to GitHub or GitLab, use this and delete the static IAM user. +# to GitHub, use this and delete the static IAM user. GitLab also federates +# to AWS by OIDC, but with entirely different CI syntax — this file is the +# design there, not the implementation. # --------------------------------------------------------------------------- name: Build and deploy @@ -13,8 +15,9 @@ on: branches: [main] workflow_dispatch: -# OIDC role assumption — no long-lived AWS credentials in this repository. -# See docs/06-deployment.md for the one-time IAM setup. +# OIDC role assumption — a short-lived token per run, no static key. +# NOT the current posture: this repository deploys with a static IAM key. +# See docs/06-deployment.md for the live procedure and the IAM policy. permissions: contents: read id-token: write @@ -49,15 +52,17 @@ jobs: PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }} PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }} - # TODO(pouya): AGENTS.md Q9, Q10 — set these repository variables: - # AWS_DEPLOY_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID + # If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The + # rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and +# BOOKING_URL — are recorded in docs/06-deployment.md. - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - # Two passes: hashed immutable assets first, HTML last. A visitor must + # Three passes: hashed immutable assets first, then images, HTML last. + # A visitor must # never fetch a new page whose assets have not landed yet. - name: Sync hashed assets run: | diff --git a/scripts/aws-discover.sh b/scripts/aws-discover.sh index f8cf65c..f8cb7c0 100755 --- a/scripts/aws-discover.sh +++ b/scripts/aws-discover.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # --------------------------------------------------------------------------- # Collects the AWS resource identifiers this project needs (AGENTS.md Q10). -# Read-only: every call is a list/describe. Nothing is created or changed. +# Read-only: no call creates or mutates anything. # # chmod +x scripts/aws-discover.sh # ./scripts/aws-discover.sh > aws-inventory.txt @@ -14,7 +14,7 @@ set -uo pipefail hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; } try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; } -command -v aws >/dev/null || { echo "AWS CLI not installed. See AWS-Hosting-Guide.md Part 0.5"; exit 1; } +command -v aws >/dev/null || { echo "AWS CLI not installed. See docs/reference/AWS-Hosting-Guide.md Part 0.5"; exit 1; } hr "Identity and default region" try aws sts get-caller-identity --output table diff --git a/src/content/config.ts b/src/content/config.ts index eb4629a..dc2585d 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -14,8 +14,9 @@ const insights = defineCollection({ type: 'content', schema: ({ image }) => z.object({ - title: z.string().max(70), - description: z.string().min(70).max(160), // doubles as the meta description + // Bounds match docs/04-seo-spec.md: titles 50-60, descriptions 140-160. + title: z.string().min(50).max(60), + description: z.string().min(140).max(160), // doubles as the meta description publishDate: z.date(), updatedDate: z.date().optional(), topic: z.enum([ @@ -29,7 +30,10 @@ const insights = defineCollection({ practiceAreas: z.array(z.enum(practiceSlugs)).min(1), image: image().optional(), imageAlt: z.string().optional(), - /** Drafts are excluded from the build, the index, and the sitemap. */ + /** INTENT, not yet enforced: drafts must be excluded from the build, the + * index, and the sitemap. Nothing implements that today — the sitemap + * filter in astro.config.mjs covers /legal/ only. Implement before the + * first article ships (docs/04-seo-spec.md). */ draft: z.boolean().default(true), /** * Every article is reviewed by Pouya before publication (AGENTS.md D9). diff --git a/src/data/site.ts b/src/data/site.ts index 7bd9330..a7c407a 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -32,11 +32,14 @@ export const CREDENTIALS = { 'Stitt Feld Handy — negotiation and ADR workshop series', ], languages: ['English', 'Farsi'], - /** [verified 2026-08-26]. NOT OCNI (lapsed) and NOT the Law Society — + /** The three bodies are [verified 2026-08-26]; **which** OBA sections is + * [assumed] — §4 marks it so, the names come from the strategy brief and + * Pouya has not confirmed them. NOT OCNI (lapsed) and NOT the Law Society — * listing the LSO implies licensure, which D13 bars. Do not add either. */ memberships: [ 'ADR Institute of Canada (ADRIC)', 'ADR Institute of Ontario (ADRIO)', + // TODO(pouya): AGENTS.md Q28 — confirm the section list before publishing it. 'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections', ], } as const; @@ -67,7 +70,10 @@ export const CONTACT = { phone: null as string | null, // [verified 2026-08-26] phoneFallback: 'By scheduled call', location: 'Toronto · Ontario · By appointment', - responseTime: 'Inquiries are answered within one business day.', + /** TODO(pouya): AGENTS.md Q27 — is a one-business-day response time a + * commitment you want published? It is not in the §4 register, and it is a + * service level a reader can hold you to. Left null until confirmed. */ + responseTime: null as string | null, linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26] /** Booking parked 2026-08-26 (AGENTS.md R6). Build /contact/ with the intake * form and a reserved slot so an embed drops in later without a rebuild. */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f712da9..e8831fc 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -29,7 +29,7 @@ Both are fine on --ink (8.00) and --maroon (5.84). See docs/02. */ --gold: #c9a876; /* rules, dividers, icon strokes, on-dark text */ --gold-d: #a88858; /* large decorative display text on cream only */ - --gold-l: #e2c89a; /* text on ink or maroon 11.09:1 */ + --gold-l: #e2c89a; /* text: 11.09:1 on ink, 8.11:1 on maroon */ --line: rgb(26 22 20 / 0.10); --line-2: rgb(26 22 20 / 0.06);