Four rulings from Pouya, 2026-08-30, and their sweep. D20 — the review protocol. Per build step the review is `adversarial-reviewer` alone. `claims-auditor` no longer runs per step; it runs ONCE, at cutover, over the whole finished site, as a blocking item near the top of docs/06's checklist. `check:claims` is unchanged and still runs on every build and both deploy paths. The reasoning is recorded in full in AGENTS.md D20, as a calibration and not an erosion: nothing has shipped, so every claims finding so far has been about a page no visitor can reach, and one pass over twenty finished pages catches more than nine passes over drafts because it sees the site as a reader does. The /med-arb/ ADRIC gloss is the proof — no individual claim was false, the defect was adjacency, and adjacency does not exist until the pages sit next to each other. The code reviewer stays per step because what it catches compounds. What this costs is recorded honestly beside it, not summarised away. D17 and D19 amended to match. D19's two-round cap governs the per-step code review only; the single cutover claims pass runs until its findings are resolved, because there is no second pass behind it. Q56 — mediation is NOT scoped commercial. Thirteen shipped strings corrected across five files: page titles, meta descriptions, hero ledes, section ledes, the `Service` node's name and description, and `ProfessionalService`'s. §4's mediation row stays unscoped, and the reason now sits beside both rows so the asymmetry reads as designed: arbitration is scoped commercial because of a LEGAL GATE (Q39 — family arbitration in Ontario requires prescribed training); mediation has no such gate. `adversarial-reviewer` then found three surfaces the sweep had missed, the worst on /practice/ — "These describe the process the parties are choosing between, in commercial matters" scoped mediation with the two words never appearing in the same element, so no proximity grep reached it. Q55 — CLOSED WITHOUT BEING RESOLVED, and the difference is the ruling. The Q.Arb stamp is split: `[verified]` on the status, `[Pouya's stated basis]` on the date. The 2026-08-26 record is marked UNRECONCILED, permanently and on purpose. The date is not published and nothing depends on it. check:claims — FROZEN. Round 2 found five defects in round 1's own fixes to that script, two of which made it worse than before the pattern existed. A pattern is added only after a real breach reaches dist/, never speculatively, and each addition ships with a probe plus a negative fixture. No refactors, no coverage improvements. It is a tripwire, not a program. Two conventions into CLAUDE.md: sweep the VOCABULARY, not only the subject (`git grep 'Q.Arb'` is line-anchored and could not find ten lines entirely about Q.Arb that never name it); and agent definitions load at session start, so an edit to .claude/agents/*.md does not reach the session that made it. Verified: check 0 errors, lint 0, build 0 (12 pages), check:claims 0. Lighthouse not run — tool unavailable until build step 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
23 KiB
06 — Deployment and cutover
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
Gitea push to main
└─ Gitea Actions (act_runner)
├─ npm ci && npm run build → ./dist
├─ 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)
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
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.
One thing blocks the workflow, and it is not a fact to look up:
- Actions are not enabled and no runner is registered (Q23). The Gitea instance is jointly administered, so both need its second administrator.
✅
adr-sml-deployEXISTS — created 2026-08-26, Q22 closed 2026-08-28. DO NOT CREATE IT. This bullet said "adr-sml-deploydoes not exist —aws iam get-userreturnsNoSuchEntity… Create it from Create the user below" until 2026-08-28, and an operator following it would have created a second IAM user, or hand-provisioned one with a different scope from theadr-sml-deploy-minimalpolicy §7 now records. §7 carries the inventory and the eightsimulate-principal-policyresults.Found by
adversarial-reviewer: the Q22 flip to PROVISIONED was swept inAGENTS.mdand nowhere else, and the Change Log entry's sweep block covered the copy corrections only. Same shape as the SES-DKIM inversion — the stale copy instructed an action against a High-risk credential.Create the user below is retained as the record of how it was provisioned, not as an instruction. Read it that way.
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
AGENTS.md D3 as amended, 2026-08-26: self-hosted Gitea. The instance,
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
GitHub Actions syntax, so it is a near-direct port — the build steps, the
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
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 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:
- IAM → Users →
adr-sml-deploy. Programmatic access only — no console password, no MFA device, no group membership. - Attach this inline policy and nothing else. Substitute the real bucket name,
account ID, and distribution ID from
scripts/aws-discover.sh:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListSiteBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET_NAME"
},
{
"Sid": "WriteSiteObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
},
{
"Sid": "InvalidateOneDistribution",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
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.
- 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:
| Name | Value |
|---|---|
AWS_ACCESS_KEY_ID |
from the IAM user |
AWS_SECRET_ACCESS_KEY |
from the IAM user |
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.
| Variable | Value |
|---|---|
AWS_REGION |
AGENTS.md §7 — Region |
S3_BUCKET |
§7 — S3 bucket |
CLOUDFRONT_DISTRIBUTION_ID |
§7 — CloudFront |
INTAKE_ENDPOINT |
§7 — Intake API |
BOOKING_URL |
(empty — parked, R6) |
The same four values fill the IAM policy's BUCKET_NAME, ACCOUNT_ID and
DISTRIBUTION_ID placeholders. They are deliberately not restated here —
§7 is the single source of truth for operational facts, and the copy that goes
stale is always the one nobody re-reads. scripts/aws-discover.sh regenerates
them from AWS if §7 ever needs re-verifying.
Read this before creating the key. The AWS account is not a single-project account: it is shared with several unrelated sites and with a bucket whose name indicates another business's production client-database backups.
AGENTS.md§10 has the specifics and the account identifier; they are kept there rather than repeated here. A static deploy key for a marketing site lives in that same account, and the scoped policy is what keeps a compromised Gitea runner from reaching any of it. Do not widen it, and never put theuser/pouyacredentials in CI.
A runner must exist
Gitea Actions needs act_runner registered to this repository or its
organisation, and Actions enabled both site-wide in app.ini
([actions] ENABLED = true) and per-repository. Without a runner the workflow
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. 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
Rotate adr-sml-deploy quarterly. OIDC would have made this unnecessary;
with a static key it is a standing task:
- Create a second access key on the same user.
- Update the Gitea secrets.
- Run the workflow and confirm it succeeds.
- Delete the old key. Rotation that leaves the old key active is not rotation.
Set a calendar reminder. A key that is never rotated is the failure mode this whole section exists to bound.
Finding the AWS identifiers
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; no call creates or mutates anything.
chmod +x scripts/aws-discover.sh
./scripts/aws-discover.sh > aws-inventory.txt
The output contains resource names and IDs but no secrets.
Why OIDC would have been better — and why it is unavailable
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 the shared AWS account (
AGENTS.md§10).
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.
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
The mistake to avoid is caching HTML aggressively — a stale index page is a site that does not update.
| Pattern | Cache-Control |
|---|---|
*.html |
public, max-age=0, must-revalidate |
/_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=0, must-revalidate |
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 syncskips 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. 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.htmlwith response code 404, not 200. Returning 200 for a missing page tells crawlers every bad URL is real content, and it is the single most common misconfiguration in this stack. - Compression on. Response-headers policy from
05-backend-spec.md. - A CloudFront Function for trailing-slash normalisation, so
/aboutand/about/do not both resolve as separate indexable URLs.
Branch model
main is production; a push to main is what triggers a deploy. Work on
short-lived branches, open a PR, merge.
The CI pipeline has never run. Not for want of a lockfile — npm ci,
astro check and astro build all work now — but because the deploy user does
not exist (Q22) and Actions are not enabled with a runner registered (Q23).
Treat "every push deploys" as the design; today the path is npm run deploy.
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), so nothing gates a merge today.
npm run build, npm run check and npm run lint all run clean locally.
Lighthouse is not one of the checks that could be wired today. @lhci/cli
was removed on 2026-08-26 and there is no npm run lighthouse script any more —
AGENTS.md §7 records why and what re-adding it at build step 7 requires. Wire
the other four; do not write a workflow step that calls a script that does not
exist.
Tag every production deploy v<year>.<n> so a rollback has something to name.
Rollback
- Re-run the workflow at the last good tag, or
git revertand push, or- Restore from S3 object versioning — already Enabled on the site bucket
(
AGENTS.md§7). It is the difference between a rollback and a rebuild; do not turn it off.
Then invalidate /*.
Cutover checklist — D11 is a single shot, so run all of it
Content and compliance
⚠️ THE FIRST TWO ITEMS ARE THE PROJECT'S ONLY FULL CLAIMS PASS — D20, Pouya, 2026-08-30.
claims-auditorno longer runs per build step;/buildPhase 3 isadversarial-revieweralone. Everything the register is meant to prevent therefore lands here.AGENTS.mdD20 records both the reasoning and what deferring it costs — read it before treating either item as a formality, and do not tick one becausenpm run check:claimsis green. That script is a greppable tripwire; it cannot read a page.
claims-auditorrun over EVERY page indist/, findings resolved. This is the project's only full claims pass. Nothing publishes until it is clean. Not per-page in isolation — the pass exists here because the defects worth catching late are the ones that only exist once the pages sit next to each other. Give it the whole built site and the reading order a visitor takes.- Pouya has read every page against
AGENTS.md§4. The human pass. It is the other half of D20 and it is not delegable — his reading is what the per-step audit was traded for. - Memberships RE-CONFIRMED AGAIN, on the day of cutover —
AGENTS.md§12 R10, which is now an event trigger and cutover is one of its two events. Q44 closed 2026-08-28 and the group is published on/about/(ADRIC, ADRIO, the three OBA sections, the CTF,[verified 2026-08-28 — Pouya]), so this item is no longer "publish them" — it is "ask him again, then re-stamp §4 with the cutover date." Pouya declined renewal-date tracking, which is exactly why this sits on the checklist: there is no date on which anyone would otherwise re-check. OCNI lapsed quietly and §4 records it as "not current, do not publish" — found roughly a year late. A stamp is not a renewal receipt. Do NOT add a currency sentence to the page while you are here — his ruling is "list the memberships; promise nothing about their future state", and the struck sentence stays struck.memberOfIS EMITTED on/about/— Q53 answered 2026-08-28 and the withholding is dropped, so the graph asserts the same four memberships the page shows. This item therefore covers both: re-confirming before cutover meanssrc/data/schema.tsas well as the visible list, and they must not be allowed to diverge. §4 records yearly renewal for the OBA sections and the CTF only — it says nothing about ADRIC's or ADRIO's period, and an earlier version of this line asserted "all renew yearly", which §4 does not support. - The OBA sections stay listed; the LSO stays out — a check that nobody
has tidied the two into one list, not an open question.
AGENTS.mdQ51 answered 2026-08-28: the Law Society is the regulator, so membership is licensure; the OBA is a voluntary association, which admits members it does not license. Structural, and independent of eligibility details — which is what made the question unanswerable inside this repo before the ruling. R1 is still live: same page, same subject, different question - No
TODO(pouya)remains in any shipped page - No matter counts, rates, dollar figures, or testimonials anywhere
- Q.Arb described as held everywhere it appears —
Q.Arb (ADRIC / ADRIO), no acquisition date. Every stage form is barred: "commenced", "in progress", "pathway", "not yet" (amended 2026-08-29).npm run check:claimsenforces the stage words and a date near the designation, ondist/, in both deploy paths. It cannot catch a stage expressed without naming the designation; that gap isclaims-auditor's to close, and it is stated in the pattern itself - C.Med-Arb appears nowhere in
dist/— struck entirely 2026-08-29 /fees/carries the rates confirmed in D14 anddocs/07-fees.md, or the page does not ship- Privacy policy matches the backend as actually built
Technical
- Re-add
@lhci/cli(removed 2026-08-26 —AGENTS.md§7) with a pin verified against the registry that day, and alighthouserccarrying the budgets from04-seo-spec.md. This box gates the next one - Lighthouse ≥ 95 mobile on
/,/about/, a practice page, an article - Every page renders fully with JavaScript disabled
curlof each URL returns real content, not a shell- All internal links resolve; no orphan pages
- Sitemap generated and correct;
robots.txtserved, not 403 - Rich Results Test passes; OG previews render in LinkedIn and Slack
- OG cards are per-page, not one portrait on all nineteen —
AGENTS.mdQ40 / R15. The portrait is the decided card for/and/about/; every other page needs the generated typed card, built at step 7 with Insights. This blocks cutover. A link preview is the surface a general counsel actually sees when a colleague pastes the URL into Teams, and the interim makes nineteen unique titles look identical - 404 returns a 404 status
- Security headers present (
securityheaders.comA or better) - SES identities verified for sending — confirmed 2026-08-26, re-check at cutover:
aws sesv2 get-email-identity --email-identity smlcompany.caand confirmVerifiedForSendingStatus: true - SES bounce/complaint alarms actually notify someone —
AGENTS.md§7 records theses-alertsemail subscription as pending confirmation, and an unconfirmed SNS subscription drops every message. Confirm it, thenaws sns list-subscriptions-by-topicand check the ARN is notPendingConfirmation. (SES production access itself is granted — Q19 closed.) - Intake form tested end to end: DynamoDB record written to the intake table (
AGENTS.md§7), both emails delivered to a real inbox, TTL set - Booking link works, including the no-JavaScript fallback — conditional on R6; booking is parked and
BOOKING_URLis 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
Infrastructure
- S3 versioning enabled
- Bucket not publicly readable; OAC in force
- ACM certificate valid; Namecheap validation CNAME still present
- CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
- Billing budget/alarm still active —
aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)".docs/reference/AWS-Hosting-Guide.mdset up an AWS Budget, whichcloudwatch describe-alarmswill 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
- Live site fetched as an anonymous crawler to confirm indexable content
- LinkedIn profile and ADRIC/ADRIO listings updated to point here
- Archive the old single-file build to
_archive/— do not delete it AGENTS.mdChange Log entry recording the cutover