chore: install toolchain, wire lint, add local deploy path

Answers four questions and starts build step 1.

Q22 — the scoped deploy user does not exist: aws iam get-user returns
NoSuchEntity. Recorded in §7 as NOT PROVISIONED and swept so that no file
describes it as existing. §10 records that user/pouya, the broadly-
permissioned personal user that has been authenticating to this account,
must never be used in CI; scripts/deploy-local.sh refuses to run as it.

Q23 — the Gitea instance reports 1.27.2, well above the vars-context floor,
so the first-step guard is belt-and-braces rather than load-bearing. What
remains is not a fact but a dependency: the instance is jointly administered,
so enabling Actions and registering a runner both need a second admin. Hence
npm run deploy (scripts/deploy-local.sh), which performs exactly what the
workflow performs — same guard, same three passes, same headers, same
invalidation. Documented as the current path, not as a workaround.

§10 gains the risk that follows: the deploy secret will live on jointly
administered infrastructure, where an instance admin can reach repo secrets.
That does not change the plan, but it makes the scoped IAM policy the actual
control between a shared Gitea instance and an AWS account holding another
business's client-database backups. Never widen it.

Q27 — response time is two business days, in site.ts with a derived short
form so the confirmation email cannot drift from the page.
Q28 — OBA sections confirmed, stamped "for now"; membership renews yearly,
tracked as R10.

Build step 1: dependencies installed and package-lock.json created, closing
the npm ci blocker. ESLint flat config and Prettier config added; npm run
lint, check and build all pass. Prettier deliberately excludes *.md and
tokens.css — reasons recorded in .prettierignore.

npm audit reports 7 high-severity advisories, all requiring an Astro major
upgrade. Not applied; escalated in AGENTS.md entry (s) as a decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
Pouya Lajevardi
2026-08-26 11:54:04 -04:00
co-authored by Claude Opus 5
parent 2b6176e4d7
commit 0d8b63380a
15 changed files with 13962 additions and 141 deletions
+1 -5
View File
@@ -4,10 +4,6 @@
"showThinkingSummaries": true, "showThinkingSummaries": true,
"effortLevel": "high", "effortLevel": "high",
"permissions": { "permissions": {
"deny": [ "deny": ["Read(./.env)", "Read(./.env.*)", "Read(./aws-inventory.txt)"]
"Read(./.env)",
"Read(./.env.*)",
"Read(./aws-inventory.txt)"
]
} }
} }
+17
View File
@@ -0,0 +1,17 @@
dist/
node_modules/
.astro/
package-lock.json
# Frozen historical record — reformatting would obscure what it originally said.
docs/reference/
# Markdown here is hand-maintained to an 80-column convention, and AGENTS.md is
# an append-only history whose tables Prettier would rewrite wholesale (an
# 892-line diff for no reading benefit). Prose wrapping is checked by eye.
*.md
# tokens.css aligns every custom property's comment into a column so the
# measured contrast ratios can be scanned down the page — see docs/02. Prettier
# collapses that alignment, which is the one thing the file is for.
src/styles/tokens.css
+16
View File
@@ -0,0 +1,16 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.astro",
"options": { "parser": "astro" }
},
{
"files": "*.md",
"options": { "proseWrap": "preserve" }
}
]
}
+148 -6
View File
@@ -155,11 +155,12 @@ since May.
| Bilingual English and Farsi | `[verified 2026-08-25 — strategy brief §I]` | | Bilingual English and Farsi | `[verified 2026-08-25 — strategy brief §I]` |
| Iranian-Canadian; cross-cultural fluency with diaspora business communities | `[verified 2026-08-25 — strategy brief §I]` | | Iranian-Canadian; cross-cultural fluency with diaspora business communities | `[verified 2026-08-25 — strategy brief §I]` |
| Operator of SML Company Ltd. alongside the practice | `[verified 2026-08-25 — strategy brief §I]` | | Operator of SML Company Ltd. alongside the practice | `[verified 2026-08-25 — strategy brief §I]` |
| Memberships: **ADRIC**, **ADRIO**, **OBA sections** | `[verified 2026-08-26 — Pouya]`. Which OBA sections is still `[assumed]` — the brief names Construction & Infrastructure, ADR, and Civil Litigation | | Memberships: **ADRIC**, **ADRIO**, and **OBA — Construction & Infrastructure, ADR, and Civil Litigation sections** | `[verified 2026-08-26 — Pouya]`, **for now**. Section membership renews yearly; this is a fact with a shelf life, tracked as R10 |
| ~~OCNI~~ | **Not current. Do not publish** `[verified 2026-08-26 — Pouya]` | | ~~OCNI~~ | **Not current. Do not publish** `[verified 2026-08-26 — Pouya]` |
| ~~LSO~~ | **Do not publish.** Listing the Law Society among memberships implies licensure, which D13 bars. Excluded deliberately, not by oversight `[verified 2026-08-26]` | | ~~LSO~~ | **Do not publish.** Listing the Law Society among memberships implies licensure, which D13 bars. Excluded deliberately, not by oversight `[verified 2026-08-26]` |
| Toronto, Ontario; by appointment | `[verified 2026-08-26]` | | 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]` | | Contact: `info@smlcompany.ca`; no public phone number; consultations by scheduled call | `[verified 2026-08-26 — Pouya]` |
| **Response time: inquiries answered within two business days** | `[verified 2026-08-26 — Pouya]`. A public commitment — it must read identically on `/contact/`, in the inquirer confirmation email, and in any bio |
| LinkedIn: `https://www.linkedin.com/in/pouyalajevardi/` | `[verified 2026-08-26 — Pouya]` | | LinkedIn: `https://www.linkedin.com/in/pouyalajevardi/` | `[verified 2026-08-26 — Pouya]` |
| ~~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* | | ~~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* |
@@ -271,7 +272,10 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| DNS | **Namecheap**, not Route 53 `[verified 2026-08-25 — AWS-Hosting-Guide.md]` | | 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]` | | 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]` | | 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`. 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]` | | CI/CD | **Gitea Actions**, `.gitea/workflows/deploy.yml`. Instance **git.larsnolden.com, version 1.27.2** `[verified 2026-08-26 — /api/v1/version]` — well above the ~1.21 floor for the `vars` context. The GitHub OIDC workflow is kept as `docs/reference/github-actions-oidc.yml.example`, outside `.github/workflows/` so Gitea cannot fall back to it. **No OIDC available** — Gitea is not an AWS OIDC provider |
| **Deploy credential — NOT PROVISIONED** | `aws iam get-user --user-name adr-sml-deploy` returns **NoSuchEntity** `[verified 2026-08-26 — Q22]`. The scoped IAM user does not exist, no access key has been issued, and the Gitea secrets are unset. **No file may describe this credential as existing.** Creation commands are with Pouya; this is a to-do, not an open question |
| **How deploys actually happen today** | **Locally, via `npm run deploy`** (`scripts/deploy-local.sh`) — same guard, same three sync passes, same cache headers, same invalidation as the workflow. Gitea Actions needs `[actions] ENABLED` in `app.ini` and a registered `act_runner`, both of which need the instance's second administrator (Q23). At this scale the pipeline changes only **how a deploy is triggered**, not what it does `[verified 2026-08-26]` |
| **Interim auth — do not repeat in CI** | Pouya has been authenticating as **`user/pouya`**, the broadly-permissioned personal user the Q10 inventory captured. Acceptable for interactive work at a keyboard; **never** as a CI credential — see §10 `[verified 2026-08-26]` |
| Analytics | **Plausible or Fathom** — cookieless, no personal data, no consent banner, EU-hosted `[verified 2026-08-26 — D15]` | | Analytics | **Plausible or Fathom** — cookieless, no personal data, no consent banner, EU-hosted `[verified 2026-08-26 — D15]` |
## 8. Design system ## 8. Design system
@@ -304,7 +308,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
| ~~Q25~~ | **ANSWERED 2026-08-26. Struck.** The §4 row permitting the boutique to be named is removed; D16 governs alone | — | | ~~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 | — | | ~~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 | — | | ~~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 | | ~~Q22~~ | **ANSWERED 2026-08-26 — it does not exist.** `aws iam get-user --user-name adr-sml-deploy` returns `NoSuchEntity`. Recorded in §7 as **NOT PROVISIONED**; creation commands are with Pouya, so it is now a **to-do**, not a question. Consequence while it is outstanding: deploys run locally (`npm run deploy`), and the quarterly rotation obligation in D3 still has no subject | — |
| 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 | | 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) | — | | ~~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 | — | | ~~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 | — |
@@ -317,10 +321,10 @@ 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 | — | | ~~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 | — | | ~~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/` | — | | ~~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 | | ~~Q27~~ | **ANSWERED 2026-08-26 — two business days.** A public commitment, so it must read identically everywhere it appears: `src/data/site.ts`, `/contact/`, the inquirer confirmation email, and any bio | — |
| **Q28** | **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 | | ~~Q28~~ | **ANSWERED 2026-08-26 — OBA Construction & Infrastructure, ADR, and Civil Litigation** `[verified 2026-08-26 — Pouya]`, **for now**: section membership renews yearly, so this is a fact with a shelf life. Tracked as R10 | — |
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — | | ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
| **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 | | **Q23** | **Is a Gitea `act_runner` registered, and are Actions enabled in `app.ini`?** *The version half is closed:* the instance reports **1.27.2** `[verified 2026-08-26 — /api/v1/version]`, so the `vars` context is supported and the first-step guard is belt-and-braces rather than load-bearing. **What remains is not a fact to look up but a dependency on a person:** git.larsnolden.com is **jointly administered**, and both enabling Actions and registering a runner need the second administrator. Until then `npm run deploy` is the path, and it is documented rather than treated as a workaround | How deploys are triggered — not what they do |
## 10. Risks ## 10. Risks
@@ -332,6 +336,8 @@ Nothing below can be invented. Each needs an answer from Pouya.
| Personal data in the intake pipeline without a retention policy | Medium — PIPEDA | `docs/05-backend-spec.md` sets retention, and `/legal/privacy/` states it | | 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 | | 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 | | 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 |
| **`user/pouya` is a broadly-permissioned personal IAM user, and it is what has been authenticating to this account.** Q22 confirms the scoped deploy user does not exist yet | **High** | Fine at an interactive keyboard; **never** in CI, never in a Gitea secret, never in `scripts/deploy-local.sh`. That script refuses to run if `sts get-caller-identity` returns `user/pouya`. Create `adr-sml-deploy` before the first automated deploy `[verified 2026-08-26]` |
| **The deploy secret will live on jointly-administered infrastructure.** git.larsnolden.com has a second administrator. An instance admin can generally reach repository secrets, or register a runner that receives them — that is inherent to how Actions runners are fed credentials, not a flaw in this setup | **High** | This does not change the plan; it changes **what the scoped IAM policy is for.** The policy is no longer hygiene — it is the actual control standing between a shared Gitea instance and an AWS account holding another business's client-database backups. Four actions, one bucket, one distribution. **Never widen it**, and treat any request to as a security decision, not a convenience one `[verified 2026-08-26]` |
| **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]` | | **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~~ | ~~High~~**RESOLVED 2026-08-26** | Q19 closed: production access granted in `ca-central-1`, confirmed in writing. Residual, tracked in §7: the `ses-alerts` SNS email subscription is **pending confirmation**, so the bounce and complaint alarms currently notify nobody `[verified 2026-08-26]` | | ~~SES is in the sandbox~~ | ~~High~~**RESOLVED 2026-08-26** | Q19 closed: production access granted in `ca-central-1`, confirmed in writing. Residual, tracked in §7: the `ses-alerts` SNS email subscription is **pending confirmation**, so the bounce and complaint alarms currently notify nobody `[verified 2026-08-26]` |
| ~~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]` | | ~~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]` |
@@ -374,6 +380,7 @@ never being raised again.
| 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 | | 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 | | R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for | | R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
| R10 | **OBA section membership renews yearly.** §4 verifies Construction & Infrastructure, ADR, and Civil Litigation as of 2026-08-26 — *for now* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at renewal, and before any page listing memberships ships |
| ~~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 | — | | ~~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 | — | | ~~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 | — |
@@ -381,6 +388,141 @@ never being raised again.
# Change Log # Change Log
## 2026-08-26 (s) — Q22/Q23/Q27/Q28 answered; toolchain installed; build step 1 begun
**Who:** Pouya answered four questions mid-session and corrected one earlier
instruction. Claude Code implemented and started build step 1.
### Q22 — ANSWERED, and the answer is that it does not exist
`aws iam get-user --user-name adr-sml-deploy` returns **`NoSuchEntity`**. §7 now
carries **Deploy credential — NOT PROVISIONED**, and it is a **to-do** rather
than a question: the creation commands are with Pouya. Swept by command; no file
describes the credential as existing.
**Pouya has been authenticating as `user/pouya`** — the broadly-permissioned
personal user the Q10 inventory captured. Recorded in §7 as interim, and in §10
as a **High** risk: fine at an interactive keyboard, never as a CI credential.
`scripts/deploy-local.sh` **refuses to run** if `sts get-caller-identity` returns
that ARN, so the rule is enforced rather than merely written down.
### Q23 — correction: the version is verified, and the blocker is a person
**Old → new:** entry (r) recorded the Gitea version `[assumed]` on Pouya's
instruction. It is now **1.27.2** at git.larsnolden.com
`[verified 2026-08-26 — /api/v1/version]`, comfortably above the ~1.21 floor for
the `vars` context. The first-step guard is therefore belt-and-braces rather than
load-bearing — which is the right outcome: it was built so the answer would not
matter, and now it does not.
**What remains is not a fact to look up.** The instance is **jointly
administered**, so enabling Actions in `app.ini` and registering an `act_runner`
both depend on a second administrator. Q23 is rewritten to say so.
**So the local path is documented, not treated as a workaround.**
`npm run deploy``scripts/deploy-local.sh` (new) performs *exactly* what the
workflow performs: same guard, same three sync passes in the same order with the
same cache headers, same invalidation. At this scale the pipeline changes only
**how a deploy is triggered**, not what it does. `docs/06` leads with it, and
both files say the script and the workflow are one artefact in two places.
### §10 — a new risk that changes what the IAM policy is for
The deploy secret will live in a repository on **jointly-administered
infrastructure**, where an instance admin can generally reach repo secrets or
register a runner that receives them. That is inherent to how Actions runners are
fed credentials, not a flaw in this setup, and it does not change the plan.
It changes the **reading** of the scoped policy. Four actions, one bucket, one
distribution is no longer hygiene — it is the actual control standing between a
shared Gitea instance and an AWS account holding another business's
client-database backups. §10 now says so, and says that any request to widen it
is a security decision rather than a convenience one.
### Q27 and Q28 — answered, and both are facts with obligations attached
**Q27 — two business days.** In `src/data/site.ts` as `responseTime`, with a
derived `responseTimeShort` so the confirmation email cannot drift from the page.
Added to §4 Verified as a **public commitment** that must read identically on
`/contact/`, in the inquirer email, and in any bio. `docs/05` says to render it
from `SITE`, never retype it. Swept for the superseded "one business day": no
occurrences.
**Q28 — OBA Construction & Infrastructure, ADR, and Civil Litigation**
`[verified 2026-08-26 — Pouya]`, **for now**. §4's row and `site.ts` both carry
the "for now". Section membership renews yearly, so this is a fact with a shelf
life — **R10** added, with OCNI as the precedent: §4 already records it as
lapsed and not publishable, which is exactly this failure arriving a year late.
### Build step 1 — started
**Dependencies installed; `package-lock.json` exists.** That alone closes a
blocker documented since entry (m): `npm ci` had no lockfile, so the CI pipeline
could not get past its first step.
**`npm run check` and `npm run build` both pass** — 0 errors, 0 warnings. Worth
recording because entry (m) F6 asserted both would *fail* on an empty
`src/pages/`. `adversarial-reviewer` doubted that and was right: `astro build`
emits zero pages and exits 0. **Correcting it here rather than editing (m).**
**ESLint and Prettier are now wired**, closing the "not yet wired" rows:
- `eslint.config.js` — flat config, `js.configs.recommended` +
`typescript-eslint` + `eslint-plugin-astro`. **`typescript-eslint` added as a
devDependency** because `.astro` frontmatter *is* TypeScript and the plugin
cannot parse a component without it. Deliberately not type-aware: `astro check`
already type-checks, and running both would be slower and would disagree at the
edges.
- `.prettierrc.json` — 80 columns, single quotes, `prettier-plugin-astro`.
- **`.prettierignore` excludes `*.md` and `src/styles/tokens.css`, deliberately.**
Prettier's reformat of `AGENTS.md` alone is an **892-line diff** with no reading
benefit, and it collapses the aligned comment column in `tokens.css` that lets
the measured contrast ratios be scanned down the page — the one thing that file
exists for. Both exclusions carry their reason in the file.
`npm run lint` now passes clean. `npm run lighthouse` remains unwired, and now
honestly so: there are no pages to measure.
**`npm audit` reports 16 vulnerabilities (7 high) — not fixed, flagged.** The
high-severity set is Astro ≤ 7.0.9 (XSS via `define:vars`, spread attribute
names, `transition:*` values, slot names; SSRF in a prerendered error page) plus
transitive `esbuild`/`sharp`, and a `cookie` advisory reached only through
`@lhci/cli`. `npm audit fix --force` wants **Astro 7.2.7, a major upgrade**, and
`@lhci/cli@0.1.0`, a catastrophic downgrade. Most of the Astro advisories need
authoring patterns this project does not use (no server islands, no
`define:vars`, no view transitions, static output, no spread props yet). **This
needs a deliberate decision, not an automated fix** — see the escalation below.
### Escalated
**Astro major upgrade.** The project pins `astro@^5.0.0`; the advisories are
fixed in 7.x. Doing it now, before any pages exist, is far cheaper than doing it
after — there is nothing to migrate. Doing it later means shipping on a
known-vulnerable major. This is a D-series decision (D1 names Astro but not a
version), so it is yours.
### Verification
`npm run check` — 0 errors, 0 warnings, 5 files. `npm run build` — completes,
0 pages, as expected. `npm run lint` — ESLint clean, Prettier clean.
`bash -n scripts/deploy-local.sh` — syntax OK. `node --check astro.config.mjs`
OK. `package.json` re-parsed as JSON after editing. Sweeps run as commands with
output read: `adr-sml-deploy` (no file claims it exists), `one business day` (no
occurrences), operational identifiers in `docs/*.md` (no matches).
**One error of mine, caught by my own sweep and recorded rather than quietly
fixed:** the three new §7 rows initially landed in the **§3 decisions table**,
overwriting D15's label, because the anchor I matched on (`| Analytics |`)
appears in both sections. The `adr-sml-deploy` sweep surfaced it. §3 is restored
`git diff` over the decisions table is empty — and the rows are in §7. An
anchor that is not unique is not an anchor.
**Closed:** Q22, Q27, Q28, and the version half of Q23. **Still open:** Q23
(runner registration — needs the second administrator), R6 (parked). **Added:**
R9 (SES subscription pending), R10 (OBA renewal).
---
## 2026-08-26 (r) — Q19 closed; SES monitoring recorded; §7 made the single source of operational truth ## 2026-08-26 (r) — Q19 closed; SES monitoring recorded; §7 made the single source of operational truth
**Who:** Pouya closed Q19, supplied the monitoring configuration, ruled on Q29, **Who:** Pouya closed Q19, supplied the monitoring configuration, ruled on Q29,
+3 -2
View File
@@ -104,9 +104,10 @@ npm run dev # local dev server
npm run build # static build to ./dist npm run build # static build to ./dist
npm run preview # serve ./dist locally npm run preview # serve ./dist locally
npm run check # astro check — type and template errors npm run check # astro check — type and template errors
npm run lint # eslint + prettier check — NOT YET WIRED, no eslint config npm run lint # eslint + prettier check
npm run format # prettier — rewrite files in place npm run format # prettier — rewrite files in place
npm run lighthouse # lighthouse CI — NOT YET WIRED, no lighthouserc npm run lighthouse # lighthouse CI — NOT YET WIRED, nothing to measure yet
npm run deploy # build + deploy from this machine (see docs/06)
``` ```
## Where things live ## Where things live
+27 -20
View File
@@ -22,9 +22,10 @@ npm run dev # http://localhost:4321
| `npm run build` | Static build to `./dist` | | `npm run build` | Static build to `./dist` |
| `npm run preview` | Serve the built site locally | | `npm run preview` | Serve the built site locally |
| `npm run check` | `astro check` — type and template errors | | `npm run check` | `astro check` — type and template errors |
| `npm run lint` | ESLint + Prettier check **not yet wired**, no ESLint config exists | | `npm run lint` | ESLint + Prettier check |
| `npm run format` | Prettier — rewrite files in place | | `npm run format` | Prettier — rewrite files in place |
| `npm run lighthouse` | Lighthouse CI — **not yet wired**, no `lighthouserc` exists | | `npm run lighthouse` | Lighthouse CI — **not yet wired**; nothing to measure until pages exist |
| `npm run deploy` | Build and deploy from this machine — see Deployment |
## Before you contribute ## Before you contribute
@@ -49,19 +50,24 @@ Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
## Deployment ## Deployment
`.gitea/workflows/deploy.yml` is the deploy pipeline — **Gitea Actions**, not **Today, deploys run locally: `npm run deploy`** (`scripts/deploy-local.sh`).
GitHub Actions. On a push to `main` it builds, syncs to S3, and invalidates It runs the same guard, the same three sync passes with the same cache headers,
CloudFront. and the same invalidation as the CI workflow — at this scale the pipeline
changes only *how* a deploy is triggered, not what it does. The script and
`.gitea/workflows/deploy.yml` are one artefact in two places: change one, change
both.
**It has never run green.** There is no `package-lock.json`, so `npm ci` exits `.gitea/workflows/deploy.yml` is the CI pipeline — **Gitea Actions**, not GitHub
at step one; `src/pages/` is empty, so there is nothing to build; and whether an Actions. **It has never run**, for two reasons that are not oversights:
`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: - The scoped IAM user does not exist. `aws iam get-user --user-name
it aborts the run, naming the variable, if `AWS_REGION`, `S3_BUCKET`, or adr-sml-deploy` returns `NoSuchEntity` (`AGENTS.md` Q22).
`CLOUDFRONT_DISTRIBUTION_ID` is empty — which is how a Gitea too old for the - Actions are not enabled and no runner is registered. The Gitea instance is
`vars` context manifests. jointly administered, so both need its second administrator (Q23).
Its **first** step is a guard: the run aborts, naming what is missing, if any
required variable or either AWS secret is empty. Only emptiness is tested and no
value is echoed.
The GitHub Actions equivalent, which uses OIDC role assumption, is kept as 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 `docs/reference/github-actions-oidc.yml.example` in case the project ever moves
@@ -70,13 +76,14 @@ 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 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. 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 **The pipeline is designed around a long-lived AWS credential, and it does not
AWS OIDC provider, so there is no role to assume: deploys are to authenticate as exist yet.** Gitea is not an AWS OIDC provider, so there is no role to assume:
a scoped IAM user, `adr-sml-deploy`, with its access key held in the deploys are *to* authenticate as a scoped IAM user, `adr-sml-deploy`, with its
repository's Gitea Actions secrets. Nothing in `AGENTS.md` records that user as access key in the repository's Gitea Actions secrets. `aws iam get-user`
created or that key as issued — `docs/06-deployment.md` is a procedure to confirms that user has not been created (Q22). In the meantime the local script
perform, not a record of one performed. Two things are meant to bound the risk, **refuses to run as `user/pouya`**, the broadly-permissioned personal user —
and neither is confirmed in place: see `AGENTS.md` §10. Two things are meant to bound the risk, and neither is in
place yet:
- **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket, - **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket,
`s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and `s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and
+8 -3
View File
@@ -106,9 +106,14 @@ personal information only as long as necessary. Whatever number ships must match
SES on submission: SES on submission:
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer. - **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
- **To the inquirer:** confirmation of receipt, expected response time, a repeat - **To the inquirer:** confirmation of receipt, the response-time commitment, a
of the no-retainer language, and a link to the privacy policy. This email is repeat of the no-retainer language, and a link to the privacy policy. This
the reason the form beats a `mailto:` link. email is the reason the form beats a `mailto:` link.
**The response time is a public commitment — two business days** (§4,
Q27). Render it from `SITE.responseTime` / `SITE.responseTimeShort` in
`src/data/site.ts`; never retype it. It must read identically here, on
`/contact/`, and in any bio.
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified **SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
recipients, so the inquirer confirmation works. See §7 for the account state. recipients, so the inquirer confirmation works. See §7 for the account state.
+30 -9
View File
@@ -26,10 +26,30 @@ DNS is at **Namecheap, not Route 53** `[verified 2026-08-25]`. Nothing in the
pipeline touches DNS. Certificate renewal is ACM-automatic as long as the pipeline touches DNS. Certificate renewal is ACM-automatic as long as the
validation CNAME stays in place at Namecheap — **do not delete it.** validation CNAME stays in place at Namecheap — **do not delete it.**
## Today, deploys run locally
**`npm run deploy`** (`scripts/deploy-local.sh`) is the current path. It runs
the same guard, the same three sync passes in the same order with the same
cache headers, and the same invalidation as the workflow — at this scale the
pipeline changes only **how a deploy is triggered**, not what it does. Treat the
script and the workflow as one artefact in two places: change one, change both.
Two things block the workflow, and neither is a fact to look up:
- **`adr-sml-deploy` does not exist** — `aws iam get-user` returns
`NoSuchEntity` (§7, Q22). Create it from *Create the user* below.
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
instance is jointly administered, so both need its second administrator.
The script **refuses to run as `user/pouya`** — the broadly-permissioned
personal user that has been authenticating to this account. See §10.
## CI runs on Gitea, not GitHub ## CI runs on Gitea, not GitHub
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**, repo `adr-sml`, `AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**. The instance,
local clone at `/Users/pouya/Dev/Websites/adr-sml`. version, and repository are recorded in §7 — the version is comfortably above
the floor for the `vars` context, so the first-step guard is belt-and-braces
rather than load-bearing.
**The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks **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 GitHub Actions syntax, so it is a near-direct port — the build steps, the
@@ -276,17 +296,18 @@ invalidation paths are a reliable source of confusing bugs.
`main` is production; a push to `main` is what triggers a deploy. Work on `main` is production; a push to `main` is what triggers a deploy. Work on
short-lived branches, open a PR, merge. short-lived branches, open a PR, merge.
**The pipeline has never completed a run.** There is no `package-lock.json`, so **The CI pipeline has never run.** Not for want of a lockfile — `npm ci`,
`npm ci` exits at step one; `src/pages/` is empty, so there is nothing to build; `astro check` and `astro build` all work now — but because the deploy user does
and no record exists of an `act_runner` being registered. Treat "every push not exist (Q22) and Actions are not enabled with a runner registered (Q23).
deploys" as the design, not as current behaviour. Treat "every push deploys" as the design; today the path is `npm run deploy`.
**Pull request checks — planned, not implemented:** `npm run build` · **Pull request checks — planned, not implemented:** `npm run build` ·
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` · `astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger** link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
(only `push` on `main` and `workflow_dispatch`), and neither `npm run lint` nor (only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
`npm run lighthouse` is wired — there is no ESLint config and no `lighthouserc`. `npm run build`, `npm run check` and `npm run lint` all run clean locally;
Nothing gates a merge today. `npm run lighthouse` still has no `lighthouserc` and nothing to measure until
pages exist.
Tag every production deploy `v<year>.<n>` so a rollback has something to name. Tag every production deploy `v<year>.<n>` so a rollback has something to name.
+41
View File
@@ -0,0 +1,41 @@
// ESLint 9 flat config. Scope is deliberately small: this project targets zero
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
// configuration, site data, and the occasional island. Rules exist to catch
// mistakes, not to impose style — Prettier owns formatting.
//
// `typescript-eslint` is here because .astro frontmatter IS TypeScript, so the
// plugin cannot parse a single component without it. It runs unconfigured for
// type-awareness on purpose: `astro check` already does the type checking, and
// duplicating it here would be slower and would disagree at the edges.
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import astro from 'eslint-plugin-astro';
export default [
{ ignores: ['dist/**', 'node_modules/**', '.astro/**', 'docs/reference/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs.recommended,
{
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: { ...globals.browser, ...globals.node },
},
rules: {
// A stray console.log in a static build is dead weight shipped to nobody.
'no-console': ['warn', { allow: ['warn', 'error'] }],
eqeqeq: ['error', 'always'],
'prefer-const': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
},
},
];
+13328
View File
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -4,7 +4,9 @@
"private": true, "private": true,
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto", "description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
"type": "module", "type": "module",
"engines": { "node": ">=22" }, "engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -12,21 +14,23 @@
"check": "astro check", "check": "astro check",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"format": "prettier --write .", "format": "prettier --write .",
"lighthouse": "lhci autorun" "lighthouse": "lhci autorun",
"deploy": "bash scripts/deploy-local.sh"
}, },
"dependencies": { "dependencies": {
"astro": "^5.0.0",
"@astrojs/mdx": "^4.0.0", "@astrojs/mdx": "^4.0.0",
"@astrojs/sitemap": "^3.2.0", "@astrojs/sitemap": "^3.2.0",
"astro": "^5.0.0",
"sharp": "^0.33.0" "sharp": "^0.33.0"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.0", "@astrojs/check": "^0.9.0",
"typescript": "^5.7.0", "@lhci/cli": "^0.14.0",
"prettier": "^3.4.0",
"prettier-plugin-astro": "^0.14.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-astro": "^1.3.0", "eslint-plugin-astro": "^1.3.0",
"@lhci/cli": "^0.14.0" "prettier": "^3.4.0",
"prettier-plugin-astro": "^0.14.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.68.0"
} }
} }
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Local deploy — the fallback while Gitea Actions is unavailable.
#
# Gitea Actions needs `[actions] ENABLED = true` in app.ini and a registered
# act_runner. The instance is jointly administered, so both depend on a second
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
# ships.
#
# It performs EXACTLY what .gitea/workflows/deploy.yml performs: the same guard,
# the same three sync passes in the same order with the same cache headers, and
# the same invalidation. At this scale the pipeline changes only how a deploy is
# TRIGGERED, not what it does — so this is a fallback, not a lesser path. Any
# change to one must be made to the other.
#
# Required environment (values are in AGENTS.md §7 — deliberately not restated
# here; §7 is the single source of truth for operational facts):
#
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID INTAKE_ENDPOINT
#
# Credentials: use the scoped deploy user. AGENTS.md Q22 records that it does
# NOT yet exist. NEVER run this as user/pouya — see AGENTS.md §10.
set -euo pipefail
missing=''
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
if [ -n "$missing" ]; then
echo "Not set:$missing" >&2
echo >&2
echo "Values are in AGENTS.md §7. An empty INTAKE_ENDPOINT does not fail the" >&2
echo "build — it ships a live contact form posting to nothing." >&2
exit 1
fi
export AWS_DEFAULT_REGION="$AWS_REGION"
echo "==> Identity check"
caller=$(aws sts get-caller-identity --query Arn --output text)
echo " $caller"
case "$caller" in
*:user/pouya)
echo >&2
echo "REFUSING: that is the broadly-permissioned personal user." >&2
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
echo "deploy user (Q22: not yet created)." >&2
exit 1
;;
esac
echo "==> Build"
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
PUBLIC_BOOKING_URL="${BOOKING_URL:-}" \
npm run build
echo "==> Pass 1/3 — hashed assets and fonts (immutable)"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
echo "==> Pass 2/3 — images"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "*.avif" --include "*.webp" --include "*.jpg" \
--include "*.png" --include "*.svg" \
--cache-control "public, max-age=604800" \
--no-progress
echo "==> Pass 3/3 — HTML and the rest (must-revalidate, --delete)"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
echo "==> Invalidate CloudFront"
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" >/dev/null
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
+4 -1
View File
@@ -1,7 +1,10 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
import { PRACTICE_AREAS } from '../data/site'; import { PRACTICE_AREAS } from '../data/site';
const practiceSlugs = PRACTICE_AREAS.map((a) => a.slug) as [string, ...string[]]; const practiceSlugs = PRACTICE_AREAS.map((a) => a.slug) as [
string,
...string[],
];
/** /**
* Insights. Content territories are set by the strategy brief §VII and * Insights. Content territories are set by the strategy brief §VII and
+25 -11
View File
@@ -32,14 +32,13 @@ export const CREDENTIALS = {
'Stitt Feld Handy — negotiation and ADR workshop series', 'Stitt Feld Handy — negotiation and ADR workshop series',
], ],
languages: ['English', 'Farsi'], languages: ['English', 'Farsi'],
/** The three bodies are [verified 2026-08-26]; **which** OBA sections is /** [verified 2026-08-26 — Pouya, AGENTS.md Q28] — and FOR NOW: OBA section
* [assumed] — §4 marks it so, the names come from the strategy brief and * membership renews yearly, so this is a fact with a shelf life (AGENTS.md
* Pouya has not confirmed them. NOT OCNI (lapsed) and NOT the Law Society — * R10). Re-confirm at renewal. NOT OCNI (lapsed) and NOT the Law Society —
* listing the LSO implies licensure, which D13 bars. Do not add either. */ * listing the LSO implies licensure, which D13 bars. Do not add either. */
memberships: [ memberships: [
'ADR Institute of Canada (ADRIC)', 'ADR Institute of Canada (ADRIC)',
'ADR Institute of Ontario (ADRIO)', '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', 'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
], ],
} as const; } as const;
@@ -70,10 +69,13 @@ export const CONTACT = {
phone: null as string | null, // [verified 2026-08-26] phone: null as string | null, // [verified 2026-08-26]
phoneFallback: 'By scheduled call', phoneFallback: 'By scheduled call',
location: 'Toronto · Ontario · By appointment', location: 'Toronto · Ontario · By appointment',
/** TODO(pouya): AGENTS.md Q27 — is a one-business-day response time a /** [verified 2026-08-26 — Pouya, AGENTS.md Q27]. A PUBLIC COMMITMENT: this
* commitment you want published? It is not in the §4 register, and it is a * wording must match /contact/, the inquirer confirmation email, and any
* service level a reader can hold you to. Left null until confirmed. */ * bio. Change it here and sweep — never edit one copy. */
responseTime: null as string | null, responseTime: 'Inquiries are answered within two business days.',
/** The same fact in sentence-fragment form, for the confirmation email and
* any inline use. Derived, so the two cannot drift. */
responseTimeShort: 'within two business days',
linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26] linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26]
/** Booking parked 2026-08-26 (AGENTS.md R6). Build /contact/ with the intake /** 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. */ * form and a reserved slot so an embed drops in later without a rebuild. */
@@ -135,12 +137,24 @@ export const FEES = {
} as const; } as const;
export const PRACTICE_AREAS = [ export const PRACTICE_AREAS = [
{ slug: 'construction', name: 'Construction & Infrastructure', chip: 'Construction' }, {
slug: 'construction',
name: 'Construction & Infrastructure',
chip: 'Construction',
},
{ slug: 'technology', name: 'Technology, AI & Data', chip: 'Technology' }, { slug: 'technology', name: 'Technology, AI & Data', chip: 'Technology' },
{ slug: 'energy', name: 'Energy, Grid & Regulatory', chip: 'Energy' }, { slug: 'energy', name: 'Energy, Grid & Regulatory', chip: 'Energy' },
{ slug: 'insurance', name: 'Insurance, SABS & LAT', chip: 'Insurance' }, { slug: 'insurance', name: 'Insurance, SABS & LAT', chip: 'Insurance' },
{ slug: 'shareholder', name: 'Shareholder & Family Business', chip: 'Shareholder' }, {
{ slug: 'cross-cultural', name: 'Cross-Border & Diaspora', chip: 'Cross-cultural' }, slug: 'shareholder',
name: 'Shareholder & Family Business',
chip: 'Shareholder',
},
{
slug: 'cross-cultural',
name: 'Cross-Border & Diaspora',
chip: 'Cross-cultural',
},
] as const; ] as const;
/** Seven items is the ceiling before a nav stops being scannable. */ /** Seven items is the ceiling before a nav stops being scannable. */
+191 -51
View File
@@ -12,32 +12,46 @@
@font-face { @font-face {
font-family: 'Instrument Serif'; font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400.woff2') format('woff2'); src: url('/fonts/instrument-serif-400.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap; font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB; unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
} }
@font-face { @font-face {
font-family: 'Instrument Serif'; font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2'); src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2');
font-weight: 400; font-style: italic; font-display: swap; font-weight: 400;
font-style: italic;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F; unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
} }
@font-face { @font-face {
font-family: 'Geist'; font-family: 'Geist';
src: url('/fonts/geist-variable.woff2') format('woff2-variations'); src: url('/fonts/geist-variable.woff2') format('woff2-variations');
font-weight: 300 600; font-style: normal; font-display: swap; font-weight: 300 600;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB; unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
} }
@font-face { @font-face {
font-family: 'Geist Mono'; font-family: 'Geist Mono';
src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations'); src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations');
font-weight: 400 500; font-style: normal; font-display: swap; font-weight: 400 500;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+2000-206F; unicode-range: U+0000-00FF, U+2000-206F;
} }
/* --- Reset ---------------------------------------------------------------- */ /* --- Reset ---------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; } *,
* { margin: 0; } *::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@@ -59,15 +73,48 @@ body {
min-height: 100vh; min-height: 100vh;
} }
img, picture, video, canvas, svg { display: block; max-width: 100%; } img,
img { height: auto; } picture,
input, button, textarea, select { font: inherit; color: inherit; } video,
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } canvas,
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; } svg {
display: block;
max-width: 100%;
}
img {
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
padding: 0;
}
/* --- Type ----------------------------------------------------------------- */ /* --- Type ----------------------------------------------------------------- */
h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; } h1,
h2,
h3,
h4 {
font-weight: var(--weight-normal);
text-wrap: balance;
}
.display { .display {
font-family: var(--font-serif); font-family: var(--font-serif);
@@ -76,7 +123,9 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
letter-spacing: var(--tracking-display); letter-spacing: var(--tracking-display);
} }
/* The one flourish the design allows. One italic phrase per headline, max. */ /* The one flourish the design allows. One italic phrase per headline, max. */
.display .it { font-style: italic; } .display .it {
font-style: italic;
}
.eyebrow { .eyebrow {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -89,17 +138,26 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
/* An eyebrow is a label, never the page's heading element. */ /* An eyebrow is a label, never the page's heading element. */
.eyebrow .dot { .eyebrow .dot {
display: inline-block; display: inline-block;
inline-size: 6px; block-size: 6px; inline-size: 6px;
block-size: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
margin-inline-end: var(--space-3); margin-inline-end: var(--space-3);
vertical-align: 0.15em; vertical-align: 0.15em;
} }
p { max-inline-size: var(--width-prose); } p {
max-inline-size: var(--width-prose);
}
a { color: var(--link); text-decoration-thickness: 1px; text-underline-offset: 0.2em; } a {
a:hover { color: var(--accent-hover); } color: var(--link);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--accent-hover);
}
/* --- Focus: visible, always. The previous build removed it globally. ------- */ /* --- Focus: visible, always. The previous build removed it globally. ------- */
@@ -108,7 +166,9 @@ a:hover { color: var(--accent-hover); }
outline-offset: 3px; outline-offset: 3px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
:focus:not(:focus-visible) { outline: none; } :focus:not(:focus-visible) {
outline: none;
}
.skip-link { .skip-link {
position: absolute; position: absolute;
@@ -122,28 +182,63 @@ a:hover { color: var(--accent-hover); }
transform: translateY(-200%); transform: translateY(-200%);
transition: transform var(--dur-fast) var(--ease); transition: transform var(--dur-fast) var(--ease);
} }
.skip-link:focus { transform: translateY(0); } .skip-link:focus {
transform: translateY(0);
}
::selection { background: var(--accent); color: var(--text-inverse); } ::selection {
background: var(--accent);
color: var(--text-inverse);
}
/* --- Layout --------------------------------------------------------------- */ /* --- Layout --------------------------------------------------------------- */
.wrap { inline-size: 100%; max-inline-size: var(--width-content); margin-inline: auto; padding-inline: var(--gutter); } .wrap {
.wrap-wide { max-inline-size: var(--width-wide); } inline-size: 100%;
.prose { max-inline-size: var(--width-prose); } max-inline-size: var(--width-content);
.section { padding-block: var(--section-y); } margin-inline: auto;
.section-alt { background: var(--bg-alt); } padding-inline: var(--gutter);
.section-inverse { background: var(--bg-inverse); color: var(--text-inverse); } }
.wrap-wide {
max-inline-size: var(--width-wide);
}
.prose {
max-inline-size: var(--width-prose);
}
.section {
padding-block: var(--section-y);
}
.section-alt {
background: var(--bg-alt);
}
.section-inverse {
background: var(--bg-inverse);
color: var(--text-inverse);
}
.section-inverse .eyebrow, .section-inverse .eyebrow,
.section-inverse .text-meta { color: var(--text-inverse-2); } .section-inverse .text-meta {
color: var(--text-inverse-2);
}
hr { border: none; border-block-start: 1px solid var(--border); } hr {
.rule-gold { border: none; border-block-start: 1px solid var(--rule); } border: none;
border-block-start: 1px solid var(--border);
}
.rule-gold {
border: none;
border-block-start: 1px solid var(--rule);
}
.visually-hidden { .visually-hidden {
position: absolute; inline-size: 1px; block-size: 1px; position: absolute;
padding: 0; margin: -1px; overflow: hidden; inline-size: 1px;
clip-path: inset(50%); white-space: nowrap; border: 0; block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
} }
/* --- Reveal --------------------------------------------------------------- /* --- Reveal ---------------------------------------------------------------
@@ -152,41 +247,86 @@ hr { border: none; border-block-start: 1px solid var(--border); }
If the observer never runs, every page reads normally. The previous build If the observer never runs, every page reads normally. The previous build
had this backwards and shipped a blank page to anything without JS. */ had this backwards and shipped a blank page to anything without JS. */
.js-reveal .reveal { opacity: 0; transform: translateY(20px); } .js-reveal .reveal {
opacity: 0;
transform: translateY(20px);
}
.js-reveal .reveal.is-in { .js-reveal .reveal.is-in {
opacity: 1; transform: none; opacity: 1;
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease); transform: none;
transition:
opacity var(--dur-reveal) var(--ease),
transform var(--dur-reveal) var(--ease);
}
.js-reveal .reveal-stagger > * {
opacity: 0;
transform: translateY(16px);
} }
.js-reveal .reveal-stagger > * { opacity: 0; transform: translateY(16px); }
.js-reveal .reveal-stagger.is-in > * { .js-reveal .reveal-stagger.is-in > * {
opacity: 1; transform: none; opacity: 1;
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease); transform: none;
transition:
opacity var(--dur-reveal) var(--ease),
transform var(--dur-reveal) var(--ease);
}
.js-reveal .reveal-stagger.is-in > *:nth-child(1) {
transition-delay: 0ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(2) {
transition-delay: 70ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(3) {
transition-delay: 140ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(4) {
transition-delay: 210ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(5) {
transition-delay: 280ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(6) {
transition-delay: 350ms;
} }
.js-reveal .reveal-stagger.is-in > *:nth-child(1) { transition-delay: 0ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(2) { transition-delay: 70ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(3) { transition-delay: 140ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(4) { transition-delay: 210ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(5) { transition-delay: 280ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(6) { transition-delay: 350ms; }
/* Stagger caps at six children by design. */ /* Stagger caps at six children by design. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; } html {
*, *::before, *::after { scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
.js-reveal .reveal, .js-reveal .reveal,
.js-reveal .reveal-stagger > * { opacity: 1 !important; transform: none !important; } .js-reveal .reveal-stagger > * {
opacity: 1 !important;
transform: none !important;
}
} }
/* --- Print: the About page will be printed by people evaluating an appointment */ /* --- Print: the About page will be printed by people evaluating an appointment */
@media print { @media print {
body { background: #fff; color: #000; font-size: 11pt; } body {
.site-header, .site-footer, .skip-link, .no-print { display: none !important; } background: #fff;
a[href^='http']::after { content: ' (' attr(href) ')'; font-size: 9pt; } color: #000;
.section { padding-block: var(--space-5); } font-size: 11pt;
}
.site-header,
.site-footer,
.skip-link,
.no-print {
display: none !important;
}
a[href^='http']::after {
content: ' (' attr(href) ')';
font-size: 9pt;
}
.section {
padding-block: var(--space-5);
}
} }