diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4eba68a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: quality + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + ASTRO_TELEMETRY_DISABLED: "1" + steps: + - name: Check out source + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Use pinned Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Type-check + run: npm run check + - name: Unit tests + run: npm test + - name: Production build + run: npm run build diff --git a/.github/workflows/post-deploy-smoke.yml b/.github/workflows/post-deploy-smoke.yml new file mode 100644 index 0000000..92b4430 --- /dev/null +++ b/.github/workflows/post-deploy-smoke.yml @@ -0,0 +1,59 @@ +name: Post-deploy smoke + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + +jobs: + sentinel: + name: production sentinel + if: >- + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Refuse an unverified main deployment + if: github.event.workflow_run.conclusion != 'success' + run: | + echo "CI quality did not succeed for this main commit. Pages may still have attempted deployment." + exit 1 + - name: Confirm verified main CI + if: github.event.workflow_run.conclusion == 'success' + run: echo "CI quality succeeded for this main commit." + smoke: + name: production smoke + needs: sentinel + if: >- + needs.sentinel.result == 'success' && + github.event.workflow_run.conclusion == 'success' + concurrency: + group: production-smoke + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out deployed commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + - name: Use pinned Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Wait for deployed SHA and smoke production + run: >- + npm run smoke -- + --environment production + --base-url https://ratemyplace.org + --expected-release ${{ github.event.workflow_run.head_sha }} + --wait-for-release-ms 600000 diff --git a/.gitignore b/.gitignore index 2ce469e..593fc4c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ playwright/.auth/ # Claude Code worktrees .claude/worktrees/ +# Agent implementation worktrees +.worktrees/ + # Google Drive sync artifacts .tmp.driveupload/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..5b54067 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.16.0 diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index a090e91..8e21a95 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -47,13 +47,20 @@ Tenants can submit honest, anonymous reviews and see aggregated scores for build - ✓ Cross-view data consistency E2E (search ↔ detail ↔ profile) — v1.5.0 - ✓ Standardized rate-limit response headers (Retry-After, X-RateLimit-*) — v1.5.0 - ✓ Shared EmptyState component for consistent messaging — v1.5.0 +- ✓ Landlord and property-manager aggregate scores withheld until three approved reviews, from one shared threshold constant — post-v1.5.0 ### Active -- [ ] Component refactors (3 files >700 LOC: ReviewEditForm, BuildingsTable, ReviewsTable) -- [ ] Convert admin dispute-upheld email to fireAndForget (`disputes/[id].ts` follow-up from v1.5.0) -- [ ] Apply `isValidEmail` primitive to signup.ts (consistency follow-up) -- [ ] Email unsubscribe management before scaling notification emails +- [x] Review and approve the v1.6.0 "Trust + Density" design — approved 2026-08-27. +- [ ] Decompose approved v1.6 phases into reviewable implementation plans. +- [ ] Execute trust controls before the bounded review-density pilot. + +### Preserved carry-over backlog + +- [ ] Convert the admin dispute-upheld email path to `fireAndForget`. +- [ ] Apply the shared `isValidEmail` primitive to signup while Phase 25 touches that flow. +- [ ] Specify unsubscribe/opt-out behavior before any new non-transactional notification-email program. +- [ ] Revisit the large-component refactors after v1.6 unless a touched phase makes a smaller extraction necessary. ### Out of Scope @@ -61,7 +68,8 @@ Tenants can submit honest, anonymous reviews and see aggregated scores for build - Delayed posting — deferred - Landlord response features (direct rebuttals on reviews) — explicitly excluded from MVP - Real-time push notifications — Cloudflare Workers stateless; polling sufficient -- Email unsubscribe management — track in v1.5.0 before scaling notification emails +- New non-transactional notification-email programs until opt-out and unsubscribe behavior is separately specified +- Component refactors unrelated to a touched v1.6 surface - Stress testing — deferred from v1.3.0, lower priority than user-facing features ## Latest Shipped: v1.5.0 "Closed Loops" (2026-04-29) @@ -70,23 +78,27 @@ Tenants can submit honest, anonymous reviews and see aggregated scores for build ## Next Milestone -**v1.6.0** — Planning to be initiated via `/gsd:new-milestone`. Carry-over candidates: component refactors (>700 LOC files: ReviewEditForm, BuildingsTable, ReviewsTable); admin `disputes/[id].ts` fireAndForget conversion; signup.ts validation consistency; email unsubscribe management. +**v1.6.0 "Trust + Density"** — Approved phases 22–30 close release, privacy, verification-document, contribution, review-integrity, account-rights, accessibility, and discoverability gaps before a bounded review-density pilot. Apartment/unit numbers remain optional private moderation data entered by the reviewer, owner-editable/exportable while the review is owned, and visible to authorized admins, never on public surfaces. Account deletion removes the user link but retains reviews, their private unit numbers, and their score contribution as permanently ownerless records. + +- Roadmap: `.planning/milestones/v1.6.0-ROADMAP.md` +- Requirements: `.planning/milestones/v1.6.0-REQUIREMENTS.md` +- Approved design: `docs/superpowers/specs/2026-08-26-trust-density-design.md` ## Context - **Tech stack**: Astro 5 + Cloudflare Pages + D1 (SQLite) + Lucia Auth + Tailwind CSS 4 + Resend - **Current version**: v1.5.0 "Closed Loops" (shipped 2026-04-29) - **Production URL**: ratemyplace.org -- **Codebase**: ~28,000 LOC (TypeScript/TSX/Astro), 322+ unit tests, 18 test files -- **Database tables**: 14 (users, sessions, reviews, buildings, landlords, property_managers, email_verification_tokens, rate_limits, disputes, audit_logs, contact_messages, notifications, saved_buildings, bug_reports) -- **Migrations**: 24 (most recent: 0024_perf_indexes.sql for hot-path index) +- **Codebase**: Strict TypeScript/TSX/Astro with Vitest unit tests and Playwright E2E tests +- **Database**: Cloudflare D1; inspect the current schema rather than relying on a copied table count +- **Migrations**: Source-controlled through 0028; production 0025–0027 were dashboard-applied and 0028 was executed remotely outside Wrangler migration tracking, so ledger reconciliation is required before any new remote migration - **Admin pages**: Dashboard, Users, Reviews, Buildings, Landlords, Managers, Verification, Disputes, Audit Log, Contact - **Survey items**: 32 total — 27 scored rating items (Unit 10 + Building 9 + Landlord 8, OHQS/PHQS-adapted) + 5 ancillary items (would_recommend, tenure_months, move_out_year, accepts_housing_vouchers, safely_lit_at_night) - **Runtime typing**: All Cloudflare Pages secrets declared in `App.Platform.env`; zero `(context.locals as any).runtime` casts in `src/` ## Constraints -- **Platform**: Cloudflare Workers (no Node.js APIs, React 18 only) +- **Platform**: Cloudflare Pages SSR on the Workers runtime (no Node.js APIs, React 18 only), plus a separately deployed scheduled Worker where v1.6 requires Cron Triggers - **Email**: Resend (selected and integrated) - **Database**: D1 (SQLite) — single-region, no transactions across requests @@ -100,7 +112,7 @@ Tenants can submit honest, anonymous reviews and see aggregated scores for build | Web Crypto API for tokens | Cross-environment compatibility (Workers + Node.js) | ✓ Good | | 64-char alphanumeric tokens | 381 bits entropy, URL-safe | ✓ Good | | Graceful email failure | Signup succeeds even if email fails | ✓ Good | -| Best-effort audit logging | Audit failures don't break admin actions | ✓ Good | +| Atomic destructive-action audit logging | Current helper is best-effort; v1.6 makes every destructive admin D1 mutation, required audit row, and durable external-cleanup intent one batch, while remote cleanup remains non-blocking | ⚠ Planned; closes a non-negotiable audit gap | | UNIQUE constraint on dispute review_id | One dispute per review, enforced at DB level | ✓ Good | | Structured JSON logging | Machine-parseable logs for Cloudflare dashboard | ✓ Good | | CityAdapter pattern for enrichment | Extensible multi-city support without modifying dispatcher | ✓ Good (v1.4.0) | @@ -114,4 +126,4 @@ Tenants can submit honest, anonymous reviews and see aggregated scores for build | EmptyState .astro + .tsx byte-identical twins | Same DOM from SSR and React-island consumers | ✓ Good (v1.5.0) | --- -*Last updated: 2026-04-29 after v1.5.0 "Closed Loops" milestone* +*Last updated: 2026-08-27 after v1.6.0 "Trust + Density" design approval* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2158d0c..58fa878 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -9,6 +9,7 @@ - ✅ **v1.3.0 Battle Tested** — Phases 4-9 (shipped 2026-03-10) - ✅ **v1.4.0 Open Doors** — Phases 10-15 (shipped 2026-03-22) - ✅ **v1.5.0 Closed Loops** — Phases 16-21 (shipped 2026-04-29) +- 📝 **v1.6.0 Trust + Density** — Phases 22-30 (design approved; implementation planning) ## Phases @@ -74,6 +75,25 @@ See: `.planning/milestones/v1.5.0-ROADMAP.md` +
+📝 v1.6.0 Trust + Density (Phases 22-30) — PLANNED + +- [ ] Phase 22: Release, Operations, and Migration Safety +- [ ] Phase 23: Privacy Contract and Data Minimization +- [ ] Phase 24: Verification-Document Lifecycle +- [ ] Phase 25: Contribution Continuity and Trust Copy +- [ ] Phase 26: Review Integrity and Moderation Priority +- [ ] Phase 27: Account Rights and Erasure Recovery +- [ ] Phase 28: Accessible Contribution +- [ ] Phase 29: Discoverability Baseline +- [ ] Phase 30: Review-Density Pilot + +- Roadmap: `.planning/milestones/v1.6.0-ROADMAP.md` +- Requirements: `.planning/milestones/v1.6.0-REQUIREMENTS.md` +- Design: `docs/superpowers/specs/2026-08-26-trust-density-design.md` + +
+ ## Progress | Phase | Milestone | Plans Complete | Status | Completed | @@ -99,6 +119,15 @@ See: `.planning/milestones/v1.5.0-ROADMAP.md` | 19. D1 Index Migration | v1.5.0 | 2/2 | Complete | 2026-04-29 | | 20. Critical-Flow E2E Coverage | v1.5.0 | 2/2 | Complete | 2026-04-29 | | 21. Quality Cleanup | v1.5.0 | 2/2 | Complete | 2026-04-29 | +| 22. Release, Operations, and Migration Safety | v1.6.0 | — | Planned | — | +| 23. Privacy Contract and Data Minimization | v1.6.0 | — | Planned | — | +| 24. Verification-Document Lifecycle | v1.6.0 | — | Planned | — | +| 25. Contribution Continuity and Trust Copy | v1.6.0 | — | Planned | — | +| 26. Review Integrity and Moderation Priority | v1.6.0 | — | Planned | — | +| 27. Account Rights and Erasure Recovery | v1.6.0 | — | Planned | — | +| 28. Accessible Contribution | v1.6.0 | — | Planned | — | +| 29. Discoverability Baseline | v1.6.0 | — | Planned | — | +| 30. Review-Density Pilot | v1.6.0 | — | Planned | — | --- -*Roadmap updated: 2026-04-29 — v1.5.0 "Closed Loops" milestone complete* +*Roadmap updated: 2026-08-27 — v1.6.0 "Trust + Density" design approved; implementation planning in progress* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index 45af10d..ff8ea2a 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -125,8 +125,20 @@ - Custom domain: `ratemyplace.org` (DNS CNAME to Cloudflare) **CI Pipeline:** -- Not detected - No GitHub Actions, GitLab CI, or other CI service configured -- Local npm scripts for build/test: `npm run build`, `npm test`, `npm run e2e` +- **Workflow 1 — CI** (`.github/workflows/ci.yml`): runs for pull requests and pushes to + `main`. Its stable check name is **`quality`** and it runs `npm ci`, `npm run check`, + `npm test`, and `npm run build` with read-only repository permissions. +- **Workflow 2 — Post-deploy smoke** (`.github/workflows/post-deploy-smoke.yml`): every + qualifying internal `main` CI completion runs a non-cancellable `sentinel` job. It + explicitly fails red when `quality` did not succeed and explicitly passes on success; + it never checks out, installs dependencies, or runs smoke. The separate, success-only + `smoke` job needs that sentinel and alone owns cancellable `production-smoke` + concurrency. It then waits for Cloudflare Pages to serve the exact commit SHA and runs + the read-only production smoke suite. +- The repository workflows do not deploy or roll back Cloudflare Pages. A `main` branch + ruleset/required-check activation is not asserted here; Task 7 must verify that external + configuration separately. +- Local checks use `npm ci`, `npm run check`, `npm test`, and `npm run build`. ## Environment Configuration diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index 76af769..d2354a7 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -92,7 +92,7 @@ ratemyplace-boston/ - Public: `index.astro` (home), `search.astro`, `[slug].astro` (building detail), `profile.astro`, `about.astro`, `methodology.astro` - Auth: `auth/signin.astro`, `signup.astro`, `forgot-password.astro` - Admin: `admin/index.astro`, `admin/reviews.astro`, `admin/buildings.astro`, `admin/landlords.astro`, `admin/users.astro`, `admin/disputes.astro`, `admin/audit.astro` - - API: 47 endpoints total (auth, reviews, admin CRUD, disputes, contact, bug reports) + - API: Auth, reviews, admin CRUD, disputes, contact, bug reports, and read-only release health **`src/components/`** - Purpose: Reusable React islands and Astro static components diff --git a/.planning/milestones/v1.6.0-REQUIREMENTS.md b/.planning/milestones/v1.6.0-REQUIREMENTS.md new file mode 100644 index 0000000..988174c --- /dev/null +++ b/.planning/milestones/v1.6.0-REQUIREMENTS.md @@ -0,0 +1,147 @@ +# Requirements: RateMyPlace Boston — v1.6.0 "Trust + Density" + +**Defined:** 2026-08-26 +**Status:** APPROVED — implementation planning +**Core value:** Renters can contribute trustworthy, anonymous housing reviews through a safe and accessible flow, while the platform keeps every privacy and integrity promise it makes. + +**Sources:** `MASTER.md`, the August 2026 source audit, the named-party minimum-review rollout, and `docs/superpowers/specs/2026-08-26-trust-density-design.md`. + +--- + +## Global constraints + +- Tenant anonymity remains a safety feature. Exact tenancy dates and timestamps never reach public surfaces. +- `reviews.unit_number` remains an optional private moderation field entered by the reviewer and stored in D1. The submitting reviewer may read, edit, and export it while they own the review; authorized admins may read it for moderation. It is never shown to anyone else or included in public HTML, public APIs, search, maps, OG metadata, structured data, logs, analytics, or audit payloads. If the account is deleted, the retained review keeps its private unit number for moderation but has no owner who can redisplay or edit it. +- No scoring weight, scoring formula, recency band, survey item, or building-score visibility rule changes in this milestone. +- `NAMED_PARTY_MIN_REVIEWS` remains the single threshold source for landlord and property-manager public score visibility, including future metadata. +- Automated integrity signals prioritize human moderation; they never automatically reject or remove a review. +- No per-user analytics, cross-session tracking, raw search-query tracking, or campaign attribution linked to a user, review, address, or IP. +- Every schema change follows `migrations/AGENTS.md`; production migration history must be reconciled before any new remote migration is applied. +- Every public route selects explicit columns. Every API route continues to enforce its own auth, validation, and rate-limiting requirements. +- A destructive admin state change and its required audit row commit in the same D1 batch or neither commits. Remote cleanup such as R2 deletion remains post-commit, durable, and non-blocking. +- Account deletion hard-deletes the account, authentication data, and other private account-owned rows, but retains each review and its score contribution with `reviews.user_id = NULL`. The retained review is permanently ownerless and cannot be reclaimed or edited through an owner route. Individual review deletion remains a separate hard-delete operation. +- Every implementation release must pass `npm run check`, `npm test`, and `npm run build`; affected user flows also require targeted Playwright and pre-deploy QA. + +## Phase 22 — Release, operations, and migration safety + +- [ ] **SAFE-01:** Add an `npm run check` script that runs `astro check`. +- [ ] **SAFE-02:** Add GitHub CI that runs install, type-check, unit tests, and production build for pull requests and pushes to `main`. +- [ ] **SAFE-03:** Configure required branch checks before merging to auto-deployed `main`. +- [ ] **SAFE-04:** Perform a read-only production schema and migration-ledger audit covering migrations 0025–0028 before authoring migration 0029. +- [ ] **SAFE-05:** Record a read-only production baseline for duplicate reviews, duplicate pending verifications, private profile fields, verification rows, and every R2 object prefix, lifecycle rule, and bucket lock without reading document bytes. +- [ ] **SAFE-06:** Update the existing smoke test to target an explicit environment and verify the currently valid critical public, auth, API, release, and health responses. SEO-specific checks are added only when Phase 29 ships them. +- [ ] **SAFE-07:** Document and rehearse D1 recovery against a non-production database using synthetic manifest/receipt fixtures. Phase 27 reruns the drill with the implemented receipt protocol before its restore guarantee activates. Production Time Travel restore remains an explicit destructive-action gate. +- [ ] **SAFE-08:** After separate action-time approval, reconcile only source-verified out-of-band migrations 0025–0028 into the production ledger, confirm Wrangler sees 0001–0028 as applied, update root/nested agent guidance, and retire the stale sync script before migration 0029 is authored. +- [ ] **SAFE-09:** Establish typed infrastructure events, privacy-safe D1 threshold counters plus a deduplicated alert outbox, a minimal release/health contract, Pages deployment notifications, scheduled synthetic smoke, and a separately deployed maintenance-Worker shell. The Worker evaluates counters and retries a tested maintainer webhook; independent GitHub smoke uses a dedicated machine-health route and detects D1, Worker-heartbeat, and webhook-backlog failures without weakening admin auth. Define tested detection/delivery objectives, short retention for counters/delivered rows, and pending/dead-letter escalation. No destructive task is enabled by default, and Cloudflare traffic error-rate alerts remain conditional on account-plan capability. +- [ ] **SAFE-10:** Inventory every production and preview hostname. Protect `/admin/*` and `/api/admin/*` on the custom domain and production `pages.dev` host, protect immutable preview deployments, redirect the production `pages.dev` host to the canonical domain for ordinary traffic, and prove direct-host requests cannot bypass Access. Preview deployments receive no production D1/R2 bindings or secrets. Every application-level `isAdmin` check remains mandatory. +- [ ] **SAFE-11:** Inventory every destructive admin route and make its D1 mutation, required audit insert, and any required durable external-cleanup intent one atomic batch. A unique request-scoped audit operation token is inserted conditionally from the same old-state predicate and gates every downstream statement, so a stale/missing target produces zero mutations and zero audit rows. Injected stale predicates and audit constraint/write failures leave the action unchanged and return a generic error; post-commit remote cleanup remains best-effort, observable, and retriable. Update `src/lib/AGENTS.md` from its legacy best-effort rule in the same release, after the atomic paths and tests exist. + +## Phase 23 — Privacy contract and data minimization + +- [ ] **PRIV-01:** Normalize optional `unit_number` input in shared library code: trim, blank-to-null, reject control characters, and cap at 32 characters. +- [ ] **PRIV-02:** Preserve `unit_number` in authenticated create, owner edit/export, and authorized-admin moderation flows while denying every other authenticated reader. +- [ ] **PRIV-03:** Add public-surface regression coverage proving `unit_number` never appears in public route payloads, SSR output, search, maps, metadata, logs, analytics, or audit payloads. +- [ ] **PRIV-04:** Replace raw rate-limit identifiers with endpoint-purpose-scoped HMAC-SHA-256 keys using a dedicated Cloudflare secret; production fails closed if the secret is missing, and one digest cannot correlate the same client across endpoint purposes. +- [ ] **PRIV-05:** Remove raw client IPs from ordinary application logs. Every persisted admin-IP field—including `audit_logs.admin_ip` and the verification-document access journal—is a separately disclosed, access-controlled security record, is nullable, and is cleared by scheduled maintenance after 180 days while the non-IP evidence remains. +- [ ] **PRIV-06:** Remove Google OAuth `profile` scope and stop writing new Google-sourced reviewer names and avatars while preserving email-only authentication. Existing profile values and manual display-name behavior are outside this approval and remain unchanged unless separately approved. +- [ ] **PRIV-07:** Correct false current-state claims in `MASTER.md`, `/privacy`, `/about`, and account settings immediately. Verification-lifecycle and account-rights copy co-deploys with Phases 24 and 27 respectively, or remains explicitly labeled planned until then. +- [ ] **PRIV-08:** Purge legacy raw rate-limit keys after HMAC deployment without weakening fail-closed behavior. + +## Phase 24 — Verification-document lifecycle + +- [ ] **VER-01:** Register an opaque R2 key, upload lifecycle state, and expiring upload lease in a non-cascading D1 object registry before writing document bytes to R2. +- [ ] **VER-02:** Use opaque object keys that contain no user ID, review ID, address, email, or filename; do not store original filenames in R2 custom metadata. +- [ ] **VER-03:** Persist every document-deletion intent in a D1 outbox before a verification decision, review deletion, building deletion, or account deletion/unlink can remove its lookup row. Jobs have no cascading foreign key to the user, review, or verification row and retain only the opaque key plus non-identifying job state. +- [ ] **VER-04:** Commit the conditional moderation decision, required audit row, and deletion intent atomically; then attempt R2 deletion immediately and durably retry transient failures without undoing the recorded decision. Every real upload uses conditional create. If a writer may still finish, deletion installs a same-key zero-byte fence so late writes cannot recreate document bytes; fence cleanup is separately tracked and bounded. +- [ ] **VER-05:** Extend the Phase 22 maintenance Worker to reconcile pending uploads, process due deletion jobs, and report aged failures. +- [ ] **VER-06:** Enforce a 30-day application retention limit for undecided verification documents and configure a prefix-scoped lifecycle backstop. Reconcile every legacy `users/` object and older pending row, deploy missing-object handling, and obtain separate approval before enabling the rule; public copy does not imply a hard R2 completion time. +- [ ] **VER-07:** Permit document streaming only when `moderation_state = pending` and `object_state = stored`; processed, upload-incomplete, expired, or deletion-pending documents never stream bytes. +- [ ] **VER-08:** Add a strict verification-document access journal distinct from the destructive-action audit row. Insert `authorized` before R2 fetch, update to `served` before streaming bytes, and record missing/error outcomes accurately; any write required before serving failure blocks document access. Its nullable admin IP follows the same 180-day clearing rule as `audit_logs.admin_ip`. +- [ ] **VER-09:** Return verification documents with private no-store and content-safety headers. +- [ ] **VER-10:** Enforce at most one active verification upload per review and exactly one terminal approve/reject transition under concurrency. A decision compare-and-set requires both pending moderation and a stored object; parent deletion or account-unlink preparation retains the non-cascading upload tombstone and converges the exact R2 key to a tracked zero-byte fence or confirmed sensitive-byte absence. Race/crash tests cover immediately after PUT, upload versus decision, review/building deletion, account deletion/unlink, replacement upload, fence win/loss, and failed promotion. +- [ ] **VER-11:** Clear R2 keys, original filename, content type, and size metadata after confirmed deletion while retaining the non-sensitive decision record. +- [ ] **VER-12:** Surface Worker heartbeat, unresolved deletion jobs, orphan findings, and oldest pending moderation age behind the admin perimeter; the Phase 22 alert channel receives aged failures. + +## Phase 25 — Contribution continuity and trust copy + +- [ ] **AUTH-01:** Add one shared validator for same-origin internal return paths; reject absolute, protocol-relative, backslash-containing, oversized, or malformed targets. +- [ ] **AUTH-02:** Preserve the selected review target through password sign-in, signup, Google OAuth, already-authenticated redirects, and email verification. Bind OAuth navigation data to the specific OAuth attempt so concurrent tabs cannot overwrite one another. +- [ ] **AUTH-03:** Gate new/edit review pages, `POST /api/reviews`, and `PATCH /api/reviews/[id]` on verified email, returning a clear 403 from each API and covering accounts that become unverified after initial submission. +- [ ] **AUTH-04:** Provide a resend/verification state that retains the safe review target without placing an address or raw private value in logs or analytics. +- [ ] **AUTH-05:** Align the signup UI and API on an eight-character minimum password. +- [ ] **AUTH-06:** Replace universal "Verified tenants" language with accurate distinctions between email-verified accounts, moderated reviews, and optional residency-document verification. + +## Phase 26 — Review integrity and moderation priority + +- [ ] **INT-01:** Enforce one review claim per account/building for the account's lifetime, including after an individual review is deleted; account deletion removes the claim. +- [ ] **INT-02:** Return a friendly 409 for duplicate submissions. A live claimed review includes an edit-existing path; a lifetime claim whose review was deleted returns a distinct consumed-claim explanation and no dead edit link. +- [ ] **INT-03:** Require concurrent duplicate submissions to produce exactly one claim and one review. +- [ ] **INT-04:** Put the new-claim limit and window in exported constants and enforce five new review claims per account daily through one atomic conditional insert; do not duplicate the literals in a SQL trigger. The design's deterministic implementation interpretation is a rolling 24-hour window, recorded as an implementation assumption rather than an additional owner-approved product decision. +- [ ] **INT-05:** Flag 5 or more reviews for one building in 24 hours and 10 or more reviews for one landlord in 24 hours for human review. Recompute the bounded 24-hour landlord signal when an admin links or changes a building's landlord. +- [ ] **INT-06:** Detect likely PII and profanity in user-written review fields through deterministic shared-library signals. +- [ ] **INT-07:** Persist signal codes without copying matched PII or profanity snippets; show the codes only in the admin moderation queue. +- [ ] **INT-08:** Preserve human moderation, due process, existing scoring, and existing building-score behavior. +- [ ] **INT-09:** Make advisory signal generation durable without blocking renter submission: review creation commits with a pending scan job for an explicit content revision, immediate scanning is attempted, and the Worker retries failures with a structured alert. Approval is one conditional D1 transition requiring a completed scan for the review's current revision; edit-versus-approve races cannot publish newly edited unscanned content. Signal results never decide approval/rejection. +- [ ] **INT-10:** Use a zero-gap production cutover: deploy a compatible submission gate/dual writer, backfill every user-linked legacy review claim with the source review's original `created_at`, leave ownerless reviews unclaimed, enqueue every existing pending review for scanning, close submissions briefly for final catch-up/parity, drain scans, enable enforcement, and reopen only after separate approval and complete parity checks. + +## Phase 27 — Account rights and erasure recovery + +- [ ] **RIGHT-01:** Add purpose-bound reauthentication valid for ten minutes: current-password verification for password accounts and a fresh Google OAuth round trip for OAuth-only accounts. OAuth reauth is bound to the initiating user, session, purpose, and nonce, and succeeds only when Google's verified `sub` equals the provider ID already attached to that current account. +- [ ] **RIGHT-02:** Add a versioned, authenticated, rate-limited JSON export generated directly in the response with no R2 staging artifact. +- [ ] **RIGHT-03:** Include the owner's account data, reviews, saved buildings, notifications, votes, prospectively linked authenticated contact/bug reports, verification decision metadata, and private `unit_number`. +- [ ] **RIGHT-04:** Exclude credentials, sessions, tokens, raw IPs, rate-limit rows, audit internals, R2 keys, internal admin notes, and other users' data from exports. +- [ ] **RIGHT-05:** Permit an authenticated owner to permanently delete an owned review after reauthentication; update public aggregates from the remaining reviews. +- [ ] **RIGHT-06:** Permit an ordinary user to hard-delete the account after reauthentication and typed confirmation while retaining each review with `user_id = NULL`, unchanged content/moderation state/private unit number/score contribution, no owner-edit capability, and no reclaim path. +- [ ] **RIGHT-07:** Enqueue every associated R2 object before unlinking reviews or deleting the account, invalidate every session, clear the cookie, and keep retry jobs alive after the account row is gone. Preserve only non-sensitive verification decision/badge state on retained reviews. +- [ ] **RIGHT-08:** Reject self-service deletion for admin accounts until privileges have been revoked through the audited admin flow. +- [ ] **RIGHT-09:** Create required purpose-bound receipts in a separate private store for every direct or cascading review deletion and every account deletion/unlink operation. Review receipts protect owner deletion, admin review deletion, and parent-building cascade; account receipts protect deletion of the account plus author unlinking across the frozen review set and never classify retained reviews as deleted. Before writing `prepared`, atomically create a D1 intent that quarantines the target from new writes and records its version plus complete descendant digest; the commit batch must revalidate that frozen set. Receipts contain only a versioned HMAC identity, request ID, kind, state marker, and timestamps. +- [ ] **RIGHT-10:** Define and test prepared/committed/aborted receipt keys, conditional-create writes, prefix-scoped R2 Bucket Lock protection, lifecycle/reconciliation backstops, confirmation retries, and HMAC key rotation/retirement after inventory proves a version empty. Protection ends only after `operation_committed_at + reverified Time Travel maximum + two days`. There is no renewable receipt state: an alerted privacy-first resolver must produce committed or aborted within seven days of initial preparation, well before the prepared marker can expire. Forced completion requires separate action-time approval and an atomic version/descendant recheck plus required recovery audit; abort atomically removes the quarantine only after non-commit is proven. Crash-injection tests cover review deletion and account-unlink boundaries; no ambiguous receipt silently reactivates deleted data or removed ownership, and no receipt becomes an indefinite tombstone. +- [ ] **RIGHT-11:** Implement and test an explicit deletion/retention matrix for every user/review-linked table, including nullable review authorship, ownerless-review public/admin queries, ownership/edit denial, aggregates, disputes, contact/bug reports, moderator foreign keys, verification rows, claims, audit payloads, and future lifecycle/outbox rows. +- [ ] **RIGHT-12:** Retain only minimized, documented security/audit exceptions after deletion and create no indefinite email tombstone or general-purpose identity hash. Inventory identified legacy target-user IDs, titles, and free text; then perform one separately action-time-approved, idempotent, itself-audited minimization operation that preserves non-identifying action evidence. +- [ ] **RIGHT-13:** Make production restore an ordered, fail-closed runbook: activate and verify an external-to-D1 write freeze across every hostname and scheduled writer; drain in-flight writes; capture a minimized, HMAC-only pre-restore membership/deletion manifest in private R2 with bounded retention; restore; apply the current migration chain so review authorship is nullable with `ON DELETE SET NULL`; replay type-specific receipts and manifest differences so review receipts remove reviews while account receipts remove restored accounts and re-null retained-review ownership; run schema/data/R2/smoke checks; then reopen only under separate approval. The restore guarantee is not declared active until every deletion/unlink path has emitted receipts for a full Time Travel window plus the safety margin, unless complete legacy coverage is separately proven. + +## Phase 28 — Accessible contribution + +- [ ] **A11Y-01:** Establish an automated axe baseline plus manual keyboard, NVDA, 200% zoom, contrast, and 375px checks for home, search, auth, review, building, and representative admin flows. +- [ ] **A11Y-02:** Add a skip link, main target, visible focus treatment, mobile-menu state, and reduced-motion handling. +- [ ] **A11Y-03:** Give both address autocompletes complete combobox/listbox semantics, announcements, keyboard selection, and accessible clear behavior. +- [ ] **A11Y-04:** Render each rating question as a native labeled radio group without changing survey wording or scoring. +- [ ] **A11Y-05:** Announce step progress, validation errors, and focus changes predictably in the review form. +- [ ] **A11Y-06:** Implement one reusable accessible dialog pattern with initial focus, focus trap, Escape close, labeling, and focus restoration. +- [ ] **A11Y-07:** Convert public disclosures and admin expanders to native buttons with `aria-expanded` and `aria-controls`. +- [ ] **A11Y-08:** Require zero serious or critical automated accessibility findings on the public contribution path. + +## Phase 29 — Discoverability baseline + +- [ ] **SEO-01:** Add the approved 1200×630 default social image and verify it returns 200 in production. +- [ ] **SEO-02:** Configure the production site URL and add canonical URL, absolute OG metadata, `og:url`, site name, complete Twitter metadata, and optional `noindex` support to the base layout. +- [ ] **SEO-03:** Noindex auth, profile, admin, search-query variants, and other private or low-value surfaces. +- [ ] **SEO-04:** Add `robots.txt` and a dynamic sitemap containing only allowlisted public pages and entities with approved public content. +- [ ] **SEO-05:** Add Organization, WebSite, WebPage, and Breadcrumb structured data without publishing aggregate ratings in v1.6. Serialize every JSON-LD node through one helper that escapes HTML-significant characters and U+2028/U+2029 before `set:html`. +- [ ] **SEO-06:** Add SSR tests proving metadata and sitemap output contain no hidden named-party score, internal ID, private unit number, or other PII. +- [ ] **SEO-07:** Extend production smoke with healthy-mode sitemap entity coverage, default social-image, canonical, robots, and noindex checks while preserving a separately tested static-only sitemap fallback for D1 outages. + +## Phase 30 — Review-density pilot + +- [ ] **PILOT-01:** Select one fixed Boston geography or building list; a partner may distribute the request but is never a cohort-tracking key. Do not add another city. +- [ ] **PILOT-02:** Record the pre-pilot baseline for approved reviews, buildings with at least one review, buildings with at least three reviews, named parties reaching the display threshold, pending count, and oldest moderation age. +- [ ] **PILOT-03:** Add share/request-review controls with Web Share support and a copy-link fallback on approved public entity pages. +- [ ] **PILOT-04:** Reuse the existing threshold/count UI and shared constant to add a factual deficit-specific request action (for example, one or two more reviews needed) on landlord and property-manager pages without changing building-score behavior. +- [ ] **PILOT-05:** Pre-register duration, targets, moderation capacity, privacy handling, and stop/go criteria before outreach begins. +- [ ] **PILOT-06:** Measure outcomes from approved review and entity data. Any optional funnel counters are daily aggregates with no user, review, IP, address, or cross-session identifier. +- [ ] **PILOT-07:** Record the expand/iterate/stop decision from approved-review density rather than clicks or signups. + +## Explicitly out of scope + +- Public or cross-tenant display of apartment/unit numbers. +- Scoring weights, aggregation formulas, recency behavior, survey questions, or building-score thresholds. +- Automated review approval/rejection, automated verification decisions, OCR, or third-party identity verification. +- Landlord response features, real-time push notifications, or a public campaign-management portal. +- Additional cities, neighborhood SEO farms, or advocacy exports before the pilot decision. +- Per-user analytics, behavioral profiles, tracking cookies, or address-level campaign attribution. +- Replacing Astro, Lucia, D1, R2, Resend, or Cloudflare Pages. +- Large component refactors unless a touched component cannot safely accept the scoped change. + +--- + +*Requirements status changes only after source verification and review. Production configuration and remote migrations always require a separate action-time approval.* diff --git a/.planning/milestones/v1.6.0-ROADMAP.md b/.planning/milestones/v1.6.0-ROADMAP.md new file mode 100644 index 0000000..2442eaa --- /dev/null +++ b/.planning/milestones/v1.6.0-ROADMAP.md @@ -0,0 +1,247 @@ +# Roadmap: v1.6.0 "Trust + Density" + +**Created:** 2026-08-26 +**Status:** APPROVED — implementation planning +**Milestone goal:** Close the remaining privacy, document-lifecycle, review-integrity, contribution, release-safety, accessibility, and discoverability gaps before deliberately increasing review volume. + +**Requirements:** `.planning/milestones/v1.6.0-REQUIREMENTS.md` +**Design:** `docs/superpowers/specs/2026-08-26-trust-density-design.md` + +## Sequencing principle + +Trust controls precede growth. Release and migration safety precede schema changes. The contribution path becomes continuous and accessible before a density pilot sends renters into it. Independent work may be developed in parallel, but every phase has its own review, test, deploy, and production-action gate. + +Effort bands are planning ranges for one maintainer, not calendar promises: + +- **S:** 1–2 focused engineering days +- **M:** 3–5 focused engineering days +- **L:** 6–10 focused engineering days +- **XL:** 11–20 focused engineering days + +## Phase summary + +| Phase | Name | Effort | Depends on | Primary outcome | +|---|---|---:|---|---| +| 22 | Release, operations, and migration safety | L–XL | v1.5.0 shipped | Required CI, authoritative migration state, health/alert foundation, admin perimeter, smoke and recovery baseline | +| 23 | Privacy contract and data minimization | L | 22 | Private unit-number boundary is enforced; raw rate-limit IP storage/logging ends; current public policy matches reality | +| 24 | Verification-document lifecycle | XL | 22–23 | Every verification-document object is tracked, deletion failures are durable/retriable, and every document view is strictly journaled/no-store | +| 25 | Contribution continuity and trust copy | M–L | 22–23 | Review intent survives every auth path and verified-email enforcement is consistent server-to-UI | +| 26 | Review integrity and moderation priority | XL | 22, 25 | One account/building claim, tighter daily limit, and durable human-review velocity/content signals | +| 27 | Account rights and erasure recovery | XL | 24–26 | Self-service export/review deletion/account deletion with review anonymization, explicit cross-table retention, and restore-safe review-erasure/account-unlink handling | +| 28 | Accessible contribution | L | 24–26 | Public contribution and touched admin paths are keyboard/screen-reader usable with an automated regression gate | +| 29 | Discoverability baseline | M | 23, named-party threshold shipped | Working social preview, canonicals, public-only sitemap and privacy-safe structured data | +| 30 | Review-density pilot | M build + 4–6 weeks operating | 24–29 | One bounded cohort produces measurable approved-review density without per-user analytics | + +The phase bands span **60–120 focused engineering days**; the working expectation is **75–90 days** if Phase 22 confirms the current production assumptions. Calendar duration also includes a receipt-coverage warm-up of up to 32 days (overlapped with Phases 28–29 where possible) and the pilot observation period. This is a program of independently reviewed releases, not one long-lived branch or one migration train; Phase 22 and Phase 27 are explicitly split into multiple sub-releases, and the estimate is revisited after Phase 22 makes production state authoritative. + +## Phase details + +### Phase 22: Release, operations, and migration safety + +**Goal:** No v1.6 change reaches production without required verification, and no new migration is authored against an assumed production state. + +**Requirements:** SAFE-01–SAFE-11 + +**Success criteria:** + +1. A deliberately failing pull request cannot satisfy the required CI check; a clean branch passes `npm run check`, `npm test`, and `npm run build`. +2. Production schema and Wrangler history are verified through `0028`; after a separate approval, exactly those verified out-of-band rows are reconciled and the stale sync script is retired before `0029` is authored. +3. Read-only baselines record duplicate reviews, duplicate active verifications, private profile fields, D1 verification records, and every R2 prefix/lifecycle/lock without copying document bytes. +4. The smoke test targets an explicit environment and checks currently valid release, health, public, auth, and API behavior rather than future SEO assets. +5. A synthetic remote D1 database has been created, restored with synthetic manifest/receipt fixtures, verified, and removed under separate action gates; Phase 27 repeats the drill with the real receipt implementation, and no production restore occurs during this phase. +6. Privacy-safe D1 counters and a deduplicated alert outbox are evaluated by the scheduled Worker and delivered to a tested maintainer webhook; a narrow machine-only health route lets independent GitHub smoke detect D1 outage, stale heartbeat, and webhook backlog without satisfying or weakening Lucia admin auth. Critical detection/delivery objectives and counter/delivered/dead-letter retention pass failure tests before document retries depend on the channel. +7. Cloudflare Access rejects unauthorized admin page and API requests on the custom domain, production `pages.dev` hostname, and every immutable preview URL. The production `pages.dev` hostname redirects ordinary traffic to the canonical domain, previews have no production bindings, and Lucia plus `isAdmin` remain independently enforced. +8. Every existing destructive admin mutation and its required audit row commit atomically through a request-scoped audit operation token that gates all downstream statements. Stale/concurrent losers create zero mutations and zero audits; an injected audit failure changes no domain state; external cleanup remains post-commit, visible, and retriable. The nested library guidance changes only with this tested cutover. + +**Planned plan documents/sub-releases:** release CI; health/alerts; atomic destructive-admin auditing; migration-ledger reconciliation and synthetic recovery; Cloudflare Access and hostname activation. + +### Phase 23: Privacy contract and data minimization + +**Goal:** Necessary private data remains useful for moderation while public and operational boundaries are enforced in code and accurately described. + +**Requirements:** PRIV-01–PRIV-08 + +**Success criteria:** + +1. Apartment/unit number remains optional, private, normalized, collected, owner-editable/exportable, and admin-visible, but recursive tests find no `unit_number` key or value on any public surface. It becomes admin-only if account deletion leaves the review ownerless. +2. Repeated requests still share the correct endpoint bucket, but D1 and structured logs contain no raw reviewer IP and digest suffixes cannot correlate a client across endpoints. +3. Missing HMAC configuration fails closed in production. +4. Google OAuth authenticates with verified email without writing new Google-sourced profile name/avatar data; legacy values and manual display-name behavior remain unchanged pending any separate decision. +5. Acting-admin IP remains a disclosed, access-controlled security field for 180 days, after which scheduled maintenance clears it while retaining the non-IP audit evidence. +6. `MASTER.md`, `/privacy`, `/about`, and account settings correct current falsehoods immediately; verification and account-rights copy remains marked planned until it co-deploys with Phases 24 and 27. + +**Planned plan document:** privacy boundary, HMAC rate limits, and policy truth. + +### Phase 24: Verification-document lifecycle + +**Goal:** Sensitive verification documents cannot become invisible orphans, survive a decision unnoticed, or be viewed without a durable access record. + +**Requirements:** VER-01–VER-12 + +**Success criteria:** + +1. Every verification-document R2 object has a durable, non-cascading D1 lifecycle record and upload lease before upload begins. +2. Upload and final-state failure injection cannot create untracked document bytes; every real PUT is conditional, and deletion fences the exact key while a writer may still finish. +3. Every decision/deletion path records deletion intent before a D1 cascade can erase the lookup row, and no outbox FK can cascade with the source entity. +4. A moderation decision, required audit row, and deletion intent commit in one D1 batch. A transient R2 delete failure preserves that committed decision, returns deletion-pending state, retries automatically, and raises an aged-job alert without exposing document metadata. +5. Concurrent uploads produce at most one active verification; concurrent approve/reject requests produce exactly one terminal decision, and only from `moderation=pending` plus `object=stored`. +6. Upload-versus-decision/deletion race tests prove a PUT cannot finish as untracked document bytes: the non-cascading registry survives parent deletion, fence-versus-PUT races converge to a tracked zero-byte fence or confirmed absence, and failed post-PUT promotion/crash is recovered from the durable tombstone. +7. Only documents with pending moderation and a stored object are viewable; the strict access journal records `authorized` before R2 fetch and `served` before streaming so missing/error objects are not mislabeled. Responses are private/no-store. +8. A scheduled Worker retries due jobs every five minutes, reconciles D1 and R2 daily, and writes an observable heartbeat. +9. Before any lifecycle rule is enabled, every legacy `users/` key and older pending row is reconciled, missing-object handling is deployed, and the exact affected set is approved. Undecided documents enter deletion at 30 days and may be re-uploaded; public policy distinguishes the application limit from R2's asynchronous removal. + +**Planned plan documents:** verification lifecycle/data migration; maintenance Worker and reconciliation; audited document access. + +### Phase 25: Contribution continuity and trust copy + +**Goal:** A renter who starts a review can authenticate and verify email without losing the selected building, while every trust statement remains technically true. + +**Requirements:** AUTH-01–AUTH-06 + +**Success criteria:** + +1. Password sign-in, signup, Google OAuth, and email verification return the renter to the identical validated internal review target, including concurrent multi-tab OAuth attempts. +2. External/protocol-relative/malformed redirect values fall back safely and never create an open redirect. +3. Unverified users cannot render, create, or edit a review; verified users resume without reselecting the building. +4. New/edit pages and both create/edit APIs enforce the same verified-email requirement, including after an account email is changed. +5. Password minimum copy and validation agree at eight characters. +6. Public copy distinguishes account email verification from optional proof-of-residency verification. + +**Planned plan document:** review auth continuity and email-verification enforcement. + +### Phase 26: Review integrity and moderation priority + +**Goal:** Thin-data reputations cannot be cheaply manipulated, and suspicious submissions receive human attention without automated censorship. + +**Requirements:** INT-01–INT-10 + +**Success criteria:** + +1. Production duplicates are audited and resolved deliberately before the constraint lands. +2. Concurrent account/building submissions create exactly one durable claim and one review; the other request receives a useful 409 with an edit link only when the claimed review still exists. +3. Deleting an individual review does not permit a second review for the same account/building; deleting the account removes its claim even though the review remains ownerless. +4. Five new claims per account daily is enforced by an atomic conditional insert using exported constants, with no duplicated trigger literal. The implementation plan uses a rolling 24-hour window as its explicit deterministic interpretation of "daily." +5. The fifth building submission and tenth landlord submission in a rolling 24 hours, plus likely-PII and profanity signals, are stored as codes without copied snippets and appear in the admin queue; landlord-link changes recompute only the bounded recent window. +6. Claim/limit infrastructure fails closed, while an advisory-scan failure leaves the renter's submission pending, preserves a durable revision-bound scan job, alerts, and retries; approval atomically requires a completed scan for the current content revision, not any particular signal result. +7. Signals never automatically reject, hide, publish, or delete a review. +8. Scoring, building visibility, and named-party threshold behavior remain unchanged. +9. Production cutover preserves each legacy review timestamp, creates one claim per user-linked review owner/building pair while leaving ownerless reviews unclaimed, gives every existing pending review a completed or pending scan job, and closes/reopens submission under an explicit gate with zero parity gaps. + +**Planned plan document:** review claims, limits, velocity, and moderation signals. + +### Phase 27: Account rights and erasure recovery + +**Goal:** Users can retrieve their data, delete individual reviews, and delete an account while retaining ownerless reviews; a later D1 restore cannot silently revive deleted reviews, deleted accounts, or removed author links. + +**Requirements:** RIGHT-01–RIGHT-13 + +**Success criteria:** + +1. A recently reauthenticated user can download a direct JSON export containing only the documented allowlist, including their private unit numbers. +2. An ordinary user can hard-delete an owned review, or delete the account while retaining every review unchanged except `user_id = NULL`; retained reviews stay score-bearing, cannot be reclaimed or owner-edited, and their unit numbers become admin-only. +3. Every session and private account-owned row is removed. Every associated verification object is absent or durably pending deletion, while non-sensitive verification decision state may remain with a retained review. +4. A D1 failure leaves the account intact; an R2 failure after D1 commit never resurrects the account or author link and remains retriable. +5. A table-by-table contract proves what cascades, is retained, or is nulled for nullable review authors, ownership/edit denial, aggregates, disputes, prospectively linked contact/bug reports, moderator references, verification data, claims, and audit history; admin self-deletion is rejected. Identified legacy audit payloads are minimized only through the separately approved, itself-audited operation. +6. Every direct and cascading review deletion—including owner/admin review and building deletion—uses a versioned D1 erasure intent plus review receipt. Account deletion uses a distinct account/unlink intent and receipt whose frozen descendant digest proves that all owned reviews were unlinked without deleting them. Prepared markers cannot renew and reach committed/aborted within seven days; forced resolution has a separate action-time approval, atomic frozen-set recheck, and required recovery audit. +7. A production restore runbook activates an external-to-D1 write freeze across all hostnames/writers, drains requests, stores a bounded HMAC-only live manifest outside D1, restores, reapplies the nullable-author schema before receipt replay, then removes restored reviews for review receipts and re-nulls ownership for account receipts. Integrity/smoke checks precede a separately approved reopen; a crash leaves traffic closed and the manifest durable. +8. The restore guarantee activates only after every deletion/unlink path has emitted receipts for a full recovery window plus margin, or complete legacy coverage is proven. Until then, restore remains an incident-specific manual gate. +9. Crash injection at every R2/D1/response boundary plus HMAC-key rotation, retention, manifest-expiry, and type-specific replay tests prove receipt confirmation and recovery remain durable without indefinite pseudonymous tombstones. + +**Planned plan documents/sub-releases:** export and reauthentication; nullable-review-author/table-by-table retention contract; review-erasure and account-unlink ledger; full restore replay and warm-up activation. + +### Phase 28: Accessible contribution + +**Goal:** Renters can complete the critical public journey by keyboard and assistive technology without changing the rating instrument. + +**Requirements:** A11Y-01–A11Y-08 + +**Success criteria:** + +1. Home, search, auth, review, and building flows have zero serious or critical automated axe findings. +2. Address autocomplete exposes label, expanded state, results, active option, selection, and clear behavior. +3. Every rating question announces its full question, choices, and selected value through native radio semantics. +4. Step progress, validation errors, and focus changes are announced predictably. +5. Dialogs trap/restore focus, close on Escape, and expose name/state. +6. Keyboard, NVDA, 200% zoom, contrast, reduced motion, and 375px checks pass using seed data rather than real verification documents. + +**Planned plan documents:** accessibility baseline and public funnel; dialogs, disclosures, and admin controls. + +### Phase 29: Discoverability baseline + +**Goal:** Public pages are shareable and indexable without broken assets, duplicate URLs, private data, or hidden named-party ratings. + +**Requirements:** SEO-01–SEO-07 + +**Success criteria:** + +1. The default 1200×630 social image returns 200 and renders correctly in preview tools. +2. Every indexable page has a unique title, description, canonical URL, absolute social metadata, and correct index policy. +3. Healthy-mode sitemap XML returns 200 and contains only allowlisted public routes and entities with approved content; a separately tested D1-outage mode returns static routes only. +4. Auth, profile, admin, API, search-query variants, and private/low-value pages are excluded. +5. Initial structured data contains Organization, WebSite, WebPage, and Breadcrumb information only; aggregate rating markup is deferred, and a shared serializer prevents ``/HTML-significant or U+2028/U+2029 breakout from named-party fields. +6. Metadata and sitemap tests reject unit numbers, internal IDs, private data, and hidden named-party scores. + +**Planned plan document:** social metadata, canonicals, robots, sitemap, and structured data. + +### Phase 30: Review-density pilot + +**Goal:** Prove that one bounded outreach effort can increase approved-review density without compromising privacy, moderation capacity, or data integrity. + +**Requirements:** PILOT-01–PILOT-07 + +**Success criteria:** + +1. The written pilot brief names the Boston geography/building list, any distribution partner, baseline, duration, outcome targets, moderation capacity, privacy treatment, and stop/go rule before outreach. +2. Share links survive the complete auth and verification path. +3. Landlord and property-manager pages reuse the current approved-count/threshold UI and add factual one-more/two-more review request actions; building behavior remains unchanged. +4. Reporting measures approved additions, buildings reaching one/three reviews, named parties reaching threshold, and moderation backlog from aggregate data. +5. No metric row contains a user, review, IP, raw address, search query, or cross-session identifier. +6. Expansion, iteration, or stop is recorded from approved-review density rather than clicks or signups. +7. No additional city launches during the pilot. + +**Planned artifacts:** pilot implementation plan; operating brief and post-pilot decision record. + +## Execution order + +```text +22 Release/operations/migration safety -> 23 Privacy contract +23 -> 24 Verification lifecycle, 25 Contribution continuity, 29 Discoverability +25 -> 26 Review integrity +24 + 25 + 26 -> 27 Account rights and erasure recovery +25 -> 28 Public-funnel accessibility +24 + 26 -> 28 Touched admin/dialog accessibility +24 + 25 + 26 + 27 + 28 + 29 -> 30 Review-density pilot +``` + +Phase 27 is an intentional pilot gate: the project closes its promised account export/deletion lifecycle before deliberately increasing contribution volume. + +## Production and external-action gates + +Separate action-time approval is required before: + +- Changing GitHub repository rulesets or secrets. +- Creating Cloudflare tokens, alert destinations, Access policies, Worker services, Cron Triggers, R2 lifecycle rules, or the private erasure-ledger bucket. +- Creating or changing R2 Bucket Locks, the production `pages.dev` redirect, Pages preview-access policy, or preview binding/secret isolation. +- Creating, restoring, or deleting the synthetic remote D1 recovery database. +- Mutating the production migration ledger. +- Applying any remote migration or production data repair. +- Closing or reopening the production review-submission gate for integrity cutover. +- Deleting confirmed R2 orphans or purging legacy rate-limit rows. +- Merging or pushing `main`. +- Rolling back a Pages deployment. +- Restoring production D1 with Time Travel. +- Entering the production maintenance fence; pausing scheduled writers or deploy hooks; replacing an expiring restore manifest; and, under a separate approval after verification, reopening traffic and resuming each writer/deploy path. +- Invoking a privacy-first resolver for an ambiguous prepared erasure, force-completing its frozen deletion, or manually aborting and removing its write quarantine. +- Minimizing identified legacy audit payloads in production, even though the policy choice is approved; the exact dry-run inventory and affected rows must be shown first, and the operation must audit itself. +- Starting external partner outreach for the density pilot. + +## Deferred beyond v1.6 + +- New cities, multilingual support, advocacy exports, neighborhood content farms, landlord replies, and real-time notifications. +- OCR, automated verification decisions, automated moderation decisions, and third-party identity verification. +- Broad component refactors unrelated to a touched v1.6 surface. +- Public aggregate-rating structured data; revisit only after the metadata implementation and named-party privacy tests are stable. + +--- + +*Roadmap approved: 2026-08-27. Each phase receives dedicated implementation plans before execution.* diff --git a/AGENTS.md b/AGENTS.md index 22b62ad..600f78a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,14 +76,19 @@ Runtime env vars are reached through `getEnv(context)` in `src/lib/runtime.ts` ```bash npm run dev # Astro dev server -npm test # Vitest — 389 tests, ~13s +npm run check # Astro/TypeScript diagnostics +npm test # Vitest unit suite npm test -- scoring # filter by name npm run build # production build npm run e2e # fresh local D1 + seed + build + Playwright npm run db:setup # db:fresh then db:seed (local D1 only) ``` -Run `npm test` and `npm run build` before declaring work complete. Both are fast. +`npm run smoke` has no default target. Supply an explicit `--environment` and +`--base-url`; preview and production also require a 40-character +`--expected-release` SHA. See [`docs/runbooks/release-smoke.md`](docs/runbooks/release-smoke.md). + +Run `npm run check`, `npm test`, and `npm run build` before declaring work complete. ## Conventions @@ -105,7 +110,7 @@ Commit prefixes: `feat:` `fix:` `docs:` `chore:` `refactor:`. ## API routes (`src/pages/api/`) -47 endpoints. Nothing is protected implicitly — every route handles its own auth, +Nothing is protected implicitly — every route handles its own auth, validation, and rate limiting. A missing check is a live vulnerability, not a style issue. ### Checklist for every new endpoint @@ -119,6 +124,11 @@ validation, and rate limiting. A missing check is a live vulnerability, not a st - [ ] Parameterized queries — never string interpolation - [ ] Audit log if it is a destructive admin action +**Narrow exception:** the input-free, read-only `GET`/`HEAD /api/health` endpoint has no +D1-backed application limiter, so cookie-free release monitoring stays independent of D1. +This exception applies to no other public endpoint and does not authorize a custom edge +rate rule; any such edge configuration needs separate approval. + ### Getting the database ```typescript diff --git a/README.md b/README.md index 7ce3185..020439d 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,15 @@ Tenants rate their apartment unit, building, and landlord using a structured sur ## Development ```bash -# Install dependencies -npm install +# Install the locked dependency set +npm ci # Start dev server npm run dev +# Run Astro/TypeScript diagnostics +npm run check + # Run tests npm test diff --git a/astro.config.mjs b/astro.config.mjs index aeb0a1c..847e421 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,6 +7,13 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@astrojs/react'; +const COMMIT_SHA = /^[0-9a-f]{40}$/i; +const releaseCandidate = + process.env.CF_PAGES_COMMIT_SHA ?? process.env.GITHUB_SHA ?? ''; +const buildReleaseId = COMMIT_SHA.test(releaseCandidate.trim()) + ? releaseCandidate.trim().toLowerCase() + : 'unknown'; + // https://astro.build/config export default defineConfig({ output: 'server', @@ -18,6 +25,9 @@ export default defineConfig({ }, vite: { + define: { + __RMP_BUILD_RELEASE_ID__: JSON.stringify(buildReleaseId), + }, plugins: [tailwindcss()], server: { watch: { @@ -30,4 +40,4 @@ export default defineConfig({ }, integrations: [react()] -}); \ No newline at end of file +}); diff --git a/docs/runbooks/release-smoke.md b/docs/runbooks/release-smoke.md new file mode 100644 index 0000000..4bf838c --- /dev/null +++ b/docs/runbooks/release-smoke.md @@ -0,0 +1,97 @@ +# Release smoke runbook + +Use this runbook to verify a known local build, immutable Pages preview, or production +release. The smoke suite is read-only: it does not submit Turnstile, interact with Maps, +create data, or deploy or roll back a release. + +## Run a smoke check + +Install the locked dependency set before running the commands below. Start the local +server separately when checking a local origin. + +```powershell +npm ci +npm run smoke -- --environment local --base-url http://127.0.0.1:8788 +``` + +For an atomic Pages preview, use the deployment URL shown by Cloudflare Pages and the PR +head SHA. The preview hostname must be its current hash-based form, not a mutable branch +alias. + +```powershell +npm ci +$previewOrigin = 'https://1a2b3c4d.ratemyplace-64y.pages.dev' +$prHeadSha = (git rev-parse HEAD).Trim() +npm run smoke -- --environment preview --base-url $previewOrigin --expected-release $prHeadSha +``` + +For production, use the merged `main` SHA and the canonical production origin. + +```powershell +npm ci +git fetch origin main +$mainSha = (git rev-parse origin/main).Trim() +npm run smoke -- --environment production --base-url https://ratemyplace.org --expected-release $mainSha +``` + +`npm run smoke` deliberately has no default target. A default could hit an obsolete +preview or the wrong release. Targets must be origins only: local accepts `localhost`, +`127.0.0.1`, or `[::1]` over HTTP or HTTPS (with an explicit port if needed); preview +accepts only `https://<8-hex>.ratemyplace-64y.pages.dev`; production accepts only +`https://ratemyplace.org`. Preview and production require a 40-character hexadecimal +`--expected-release` SHA. Local may omit it, though it may be supplied for an exact +build-release check. + +## What the health check means + +The public `GET /api/health` response is non-sensitive and is exactly: + +```json +{ "status": "ok", "release": "" } +``` + +`HEAD /api/health` returns the same successful headers without a body. The route handler +does no D1 work. Cookie-free monitor and smoke requests take middleware's no-session path, +so they do not perform Lucia session validation. That independence is intentionally +narrow: an unrelated caller that sends a session cookie can still cause the normal +middleware session validation and its D1 lookup before the route handler runs. + +This input-free, read-only `GET`/`HEAD` endpoint is the sole exception to the usual +D1-backed application rate limit. Keeping it free of an application limiter prevents the +cookie-free release monitor from depending on D1. It does not relax rate-limit expectations +for any other public endpoint. Cloudflare's ordinary edge protections remain in front of +the route; a custom edge rate rule is a separate configuration change that requires its +own approval. + +## CI and post-deploy contract + +The stable GitHub check name is **`quality`**. CI runs locked installation, diagnostics, +unit tests, and a production build for pull requests and `main` pushes. This workflow +exists in the repository, but this runbook does not claim that a `main` ruleset or required +check has been activated; that is an external verification and approval task. + +For every qualifying internal `main` CI completion, the post-deploy workflow runs a +non-cancellable `sentinel` job. A non-successful CI completion explicitly makes that job +red; a successful completion explicitly passes it. The sentinel has no checkout, +dependency installation, or smoke step, so failed CI never performs those actions. + +Only the separate, success-gated `smoke` job needs the passing sentinel. It alone owns the +cancellable `production-smoke` concurrency group, then waits for Cloudflare Pages, +verifies that `/api/health` reports the triggering release SHA, and runs the full +read-only smoke suite. A failed post-deploy smoke means the release must not be called +healthy. It does not cause an automatic rollback. + +## Failure triage and approval boundary + +Keep investigation read-only and work in this order: + +1. Compare the expected release SHA with the actual `/api/health` release. +2. Inspect the corresponding Cloudflare Pages deployment. +3. Inspect the failed probe and its response class. +4. Re-run the same read-only smoke command against the explicit target. +5. Request separate approval before any rollback. + +SAFE-09 is only partially addressed here. A GitHub failure notification is best-effort +until Phase 22B adds independent counters, an outbox, webhook delivery, and machine-only +health. This release does not provide custom alerts, independent machine health, or +automatic rollback. diff --git a/docs/superpowers/plans/2026-08-27-phase-22a-release-safety.md b/docs/superpowers/plans/2026-08-27-phase-22a-release-safety.md new file mode 100644 index 0000000..5bf1c2e --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-phase-22a-release-safety.md @@ -0,0 +1,986 @@ +# Phase 22A Release Safety Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make type-checking and tests mandatory before merge, expose a minimal non-sensitive deployed-release health contract, and replace the unsafe implicit smoke target with a deterministic read-only suite that verifies the exact deployed commit. + +**Architecture:** Cloudflare Pages remains the only deployer. A stable GitHub Actions `quality` job verifies every pull request and `main` push. For each qualifying internal `main` completion, a second workflow runs a non-cancellable `sentinel` job that explicitly fails red for unsuccessful quality or explicitly passes when quality succeeds; a separate success-only `smoke` job needs that sentinel and alone owns cancellable `production-smoke` concurrency while it polls a cookie-free public release endpoint until Pages serves the same commit SHA before executing the read-only smoke contract. The Pages build injects a sanitized release constant explicitly through Vite `define`; the smoke runner accepts only an explicit environment, allowlisted origin, and—for non-local targets—40-character release SHA, so it cannot silently hit an obsolete preview or mistake the previous production release for the new one. + +**Tech Stack:** Astro 5 SSR, TypeScript strict mode, Cloudflare Pages, GitHub Actions, Node.js 22.16.0, Vitest 4, native `fetch`. + +**Spec:** `docs/superpowers/specs/2026-08-26-trust-density-design.md` — Phase 22 CI/deploy path and initial public release/health contract; requirements SAFE-01, SAFE-02, SAFE-03, SAFE-06, and only the minimal public-health slice of SAFE-09. + +## Global Constraints + +- Work on `codex/phase-22a-release-safety` from the approved planning baseline; do not implement on `main`. +- Do not stage, edit, or delete the pre-existing untracked `src/pages/api/admin/verification/[id] (1).ts`. +- Cloudflare Pages Git integration remains the sole deployer. No workflow may call `wrangler pages deploy`, use a Cloudflare API token, or mutate D1/R2. +- The public health route handler returns only `{ status, release }` and performs no D1 work, rate-limit write, authentication, provider call, or operational-detail query. Cookie-free monitor/smoke requests take the existing middleware's no-session path and therefore make no Lucia/D1 query; callers that deliberately present a session cookie may still trigger the global session lookup, and this plan makes no broader D1-health claim. +- Smoke is read-only. It does not submit Turnstile forms, create data, exercise Google Places/Maps, or follow an admin/auth redirect and call the destination a pass. +- Do not add sitemap, robots, canonical, or social-image checks before Phase 29. +- `quality` is the stable required-check name. Renaming it is a repository-rules change and requires the same explicit review as changing the ruleset. +- Pushing the branch/opening a PR, activating the `main` ruleset, merging, and any rollback are separate action-time approval gates. No task below implies approval for the next external action. +- SAFE-09 remains open after this sub-release: counters, alert outbox, machine-only ops health, webhook delivery, and the maintenance Worker are Phase 22B. + +--- + +## Current Source Map + +| File | Current behavior | Phase 22A responsibility | +|---|---|---| +| `package.json` | Has build/test/smoke scripts, but no `check` | Add `check: astro check`; keep existing commands intact | +| `.node-version` | Absent; local Node is 24 while current Pages v3 default is 22.16.0 | Pin one version used by Pages and GitHub Actions | +| `.github/` | Absent | Add least-privilege CI and post-deploy smoke workflows | +| `scripts/smoke-test.ts` | Defaults to an obsolete preview URL, follows redirects, checks mutable copy, has no timeout/API/release/health assertions | Reduce to a CLI adapter over tested library code | +| `astro.config.mjs` | Does not inject a release constant | Read the Pages build variable, sanitize it, and inject one server build constant through Vite `define` | +| `src/lib/release.ts` | Absent | Validate and expose the explicitly injected Pages commit SHA | +| `src/lib/health.ts` | Absent | Build the narrow typed public health payload | +| `src/pages/api/health.ts` | Absent | Return no-store JSON without touching D1 | +| `src/lib/smoke.ts` | Absent | Own argument/target validation, probes, timeouts, release wait, and response assertions | +| `src/env.d.ts` | Types Cloudflare runtime bindings only | Type the single injected build-release constant used by server code | +| `AGENTS.md`, `README.md`, `.planning/codebase/INTEGRATIONS.md` | Document test/build but say no CI and omit `check` | Document the new verified behavior | + +## Interfaces Locked by This Plan + +```typescript +// src/lib/release.ts +export function normalizeReleaseId(value: unknown, fallback: 'development' | 'unknown'): string; +export const RELEASE_ID: string; + +// src/lib/health.ts +export interface PublicHealth { + status: 'ok'; + release: string; +} +export function buildPublicHealth(release?: string): PublicHealth; + +// src/lib/smoke.ts +export type SmokeEnvironment = 'local' | 'preview' | 'production'; +export interface SmokeOptions { + environment: SmokeEnvironment; + baseUrl: URL; + expectedRelease?: string; + waitForReleaseMs: number; + requestTimeoutMs: number; +} +export interface SmokeProbeResult { + name: string; + path: string; + status: number; + ok: boolean; + detail: string; + durationMs: number; +} +export function parseSmokeArgs(args: string[]): SmokeOptions; +export function validateSmokeTarget(environment: SmokeEnvironment, value: string): URL; +export function runSmoke(options: SmokeOptions, dependencies?: SmokeDependencies): Promise; +``` + +The release SHA source order at build time is `CF_PAGES_COMMIT_SHA`, then `GITHUB_SHA`. `astro.config.mjs` accepts only exactly 40 hexadecimal characters, normalizes the value to lowercase, and injects `__RMP_BUILD_RELEASE_ID__` with Vite `define`; it does not broaden `envPrefix` or expose environment objects. Local development falls back to `development`; a production-mode build without a valid SHA exposes `unknown`, which deliberately cannot satisfy post-deploy commit matching. + +--- + +## Task 1: Pin the build runtime and add the type-check command + +**Files:** + +- Create: `.node-version` +- Modify: `package.json` + +- [ ] **Step 1: Prove the command is currently absent** + +Run: + +```powershell +npm run check +``` + +Expected: FAIL with `Missing script: "check"`. + +- [ ] **Step 2: Add the Node pin** + +Create `.node-version` with exactly: + +```text +22.16.0 +``` + +This matches the current Cloudflare Pages v3 build-image default and is recognized by Pages. Re-verify the supported version immediately before implementation against Cloudflare's official build-image documentation: . + +- [ ] **Step 3: Add the package script** + +In `package.json`, add this script next to `build`: + +```json +"check": "astro check" +``` + +Do not add a dependency: `@astrojs/check` is already installed and locked. + +- [ ] **Step 4: Run the new gate** + +Run: + +```powershell +$env:ASTRO_TELEMETRY_DISABLED='1'; npm run check +``` + +Expected: PASS with 0 errors. The orientation baseline on 2026-08-27 reported 22 non-failing hints; hint cleanup is not part of this release. + +- [ ] **Step 5: Commit the isolated foundation** + +```powershell +git add .node-version package.json +git commit -m "chore: add type-check release gate" +``` + +--- + +## Task 2: Add a build-time release identifier and public health contract + +**Files:** + +- Modify: `astro.config.mjs` +- Modify: `src/env.d.ts` +- Create: `src/lib/release.ts` +- Create: `src/lib/health.ts` +- Create: `src/pages/api/health.ts` +- Create: `src/lib/__tests__/release.test.ts` +- Create: `src/lib/__tests__/health.test.ts` + +- [ ] **Step 1: Write failing release normalization tests** + +Create `src/lib/__tests__/release.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { normalizeReleaseId } from '../release'; + +describe('normalizeReleaseId', () => { + const sha = 'A'.repeat(40); + + it('accepts only a full hexadecimal commit SHA and lowercases it', () => { + expect(normalizeReleaseId(sha, 'unknown')).toBe('a'.repeat(40)); + }); + + it.each([undefined, null, '', 'abc123', 'g'.repeat(40), 'a'.repeat(41)])( + 'uses the explicit fallback for %j', + (value) => { + expect(normalizeReleaseId(value, 'unknown')).toBe('unknown'); + } + ); + + it('supports both declared safe fallbacks', () => { + expect(normalizeReleaseId(undefined, 'development')).toBe('development'); + expect(normalizeReleaseId(undefined, 'unknown')).toBe('unknown'); + }); +}); +``` + +- [ ] **Step 2: Write failing health payload and route tests** + +Create `src/lib/__tests__/health.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { buildPublicHealth } from '../health'; +import { GET, HEAD } from '../../pages/api/health'; + +describe('public health contract', () => { + it('contains only generic status and release', () => { + expect(buildPublicHealth('a'.repeat(40))).toEqual({ + status: 'ok', + release: 'a'.repeat(40), + }); + }); + + it('returns no-store JSON without internal fields', async () => { + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(Object.keys(body).sort()).toEqual(['release', 'status']); + expect(body.status).toBe('ok'); + expect(typeof body.release).toBe('string'); + }); + + it('supports a bodyless HEAD probe with the same cache policy', async () => { + const response = await HEAD(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.text()).toBe(''); + }); +}); +``` + +- [ ] **Step 3: Run the tests to prove the modules are missing** + +Run: + +```powershell +npm test -- release health +``` + +Expected: FAIL because `release`, `health`, and `/api/health` do not exist. + +- [ ] **Step 4: Type the injected build constant** + +Add to `src/env.d.ts`, outside the `App` namespace: + +```typescript +declare const __RMP_BUILD_RELEASE_ID__: string; +``` + +- [ ] **Step 5: Inject a sanitized build-time release constant** + +In `astro.config.mjs`, before `defineConfig`, add: + +```javascript +const COMMIT_SHA = /^[0-9a-f]{40}$/i; +const releaseCandidate = + process.env.CF_PAGES_COMMIT_SHA ?? process.env.GITHUB_SHA ?? ''; +const buildReleaseId = COMMIT_SHA.test(releaseCandidate.trim()) + ? releaseCandidate.trim().toLowerCase() + : 'unknown'; +``` + +Add this property to the existing `vite` object without changing its plugins or watcher configuration: + +```javascript +define: { + __RMP_BUILD_RELEASE_ID__: JSON.stringify(buildReleaseId), +}, +``` + +Cloudflare supplies `CF_PAGES_COMMIT_SHA` to the Pages **build** environment, while Astro 5 private SSR environment references are not a reliable way to preserve a build-only value in the deployed Worker. Vite `define` performs an explicit build replacement. Do not add `envPrefix`, inject `process.env`, or make the value client-readable. Official references: and . + +- [ ] **Step 6: Implement release normalization** + +Create `src/lib/release.ts`: + +```typescript +const COMMIT_SHA = /^[0-9a-f]{40}$/i; + +export function normalizeReleaseId( + value: unknown, + fallback: 'development' | 'unknown' +): string { + if (typeof value !== 'string') return fallback; + const normalized = value.trim().toLowerCase(); + return COMMIT_SHA.test(normalized) ? normalized : fallback; +} + +const injectedRelease = typeof __RMP_BUILD_RELEASE_ID__ === 'string' + ? __RMP_BUILD_RELEASE_ID__ + : undefined; + +export const RELEASE_ID = normalizeReleaseId( + injectedRelease, + import.meta.env.DEV ? 'development' : 'unknown' +); +``` + +The `typeof` guard keeps direct Vitest imports safe because `vitest.config.ts` intentionally uses `configFile: false` and therefore does not inherit `astro.config.mjs`; the built Worker still receives the explicit replacement. Do not add the release constant to the general Vitest config, because the built-Worker proof below is the authoritative injection test. + +- [ ] **Step 7: Implement the typed payload and thin route** + +Create `src/lib/health.ts`: + +```typescript +import { RELEASE_ID } from './release'; + +export interface PublicHealth { + status: 'ok'; + release: string; +} + +export function buildPublicHealth(release: string = RELEASE_ID): PublicHealth { + return { status: 'ok', release }; +} +``` + +Create `src/pages/api/health.ts`: + +```typescript +import { buildPublicHealth } from '../../lib/health'; + +const headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', +} as const; + +export async function GET(): Promise { + return new Response(JSON.stringify(buildPublicHealth()), { + status: 200, + headers, + }); +} + +export async function HEAD(): Promise { + return new Response(null, { status: 200, headers }); +} +``` + +This public, input-free GET/HEAD endpoint deliberately has no application rate-limit row: making the cookie-free release probe depend on D1 would defeat its purpose and would persist another IP-derived key immediately before Phase 23 replaces that pattern. Task 5 records this one exact route as a narrow exception in `AGENTS.md`; it does not weaken the checklist for any other public endpoint. Cloudflare's ordinary edge protections remain in front of the route, and any future custom edge rate rule remains a separately approved configuration action. + +- [ ] **Step 8: Verify targeted behavior and type-checking** + +Run: + +```powershell +npm test -- release health +$env:ASTRO_TELEMETRY_DISABLED='1'; npm run check +``` + +Expected: targeted tests PASS; check reports 0 errors. + +- [ ] **Step 9: Prove the injected SHA through a built Worker** + +In terminal A, build with a known 40-character fixture and start the local Worker: + +```powershell +$syntheticRelease = '0123456789abcdef0123456789abcdef01234567' +$previousCloudflareSha = $env:CF_PAGES_COMMIT_SHA +try { + $env:CF_PAGES_COMMIT_SHA = $syntheticRelease + npm run build + if ($LASTEXITCODE -ne 0) { throw 'Synthetic-release build failed' } +} finally { + if ($null -eq $previousCloudflareSha) { + Remove-Item Env:CF_PAGES_COMMIT_SHA + } else { + $env:CF_PAGES_COMMIT_SHA = $previousCloudflareSha + } +} +npx wrangler pages dev ./dist --port 8788 +``` + +In terminal B, make a cookie-free request and assert the exact compiled value: + +```powershell +$syntheticRelease = '0123456789abcdef0123456789abcdef01234567' +$health = Invoke-RestMethod -Uri http://127.0.0.1:8788/api/health +if ($health.status -ne 'ok' -or $health.release -ne $syntheticRelease) { + throw 'Built Worker did not expose the injected release SHA' +} +``` + +Expected: the assertion is silent and successful. Stop terminal A after the probe. This is required evidence; a unit test that imports `release.ts` is not a substitute for proving the built Worker contains the Pages build value. + +- [ ] **Step 10: Commit** + +```powershell +git add astro.config.mjs src/env.d.ts src/lib/release.ts src/lib/health.ts src/pages/api/health.ts src/lib/__tests__/release.test.ts src/lib/__tests__/health.test.ts +git commit -m "feat: add public release health contract" +``` + +--- + +## Task 3: Replace the implicit smoke target with a tested explicit contract + +**Files:** + +- Create: `src/lib/smoke.ts` +- Create: `src/lib/__tests__/smoke.test.ts` +- Modify: `scripts/smoke-test.ts` + +- [ ] **Step 1: Write failing argument and origin tests** + +Create `src/lib/__tests__/smoke.test.ts` and cover these exact cases: + +```typescript +import { describe, expect, it, vi } from 'vitest'; +import { + parseSmokeArgs, + runSmoke, + validateSmokeTarget, + type SmokeDependencies, +} from '../smoke'; + +describe('smoke target authority', () => { + it('has no implicit target', () => { + expect(() => parseSmokeArgs([])).toThrow('Missing --environment'); + }); + + it('accepts only the canonical production origin', () => { + expect(validateSmokeTarget('production', 'https://ratemyplace.org').origin) + .toBe('https://ratemyplace.org'); + expect(() => validateSmokeTarget('production', 'https://example.com')).toThrow(); + expect(() => validateSmokeTarget('production', 'https://ratemyplace.org/search')).toThrow(); + }); + + it('restricts preview to this Pages project', () => { + expect(validateSmokeTarget('preview', 'https://1a2b3c4d.ratemyplace-64y.pages.dev').hostname) + .toBe('1a2b3c4d.ratemyplace-64y.pages.dev'); + expect(() => validateSmokeTarget('preview', 'https://ratemyplace-64y.pages.dev')).toThrow(); + expect(() => validateSmokeTarget('preview', 'https://codex-phase-22a.ratemyplace-64y.pages.dev')).toThrow(); + expect(() => validateSmokeTarget('preview', 'https://attacker.pages.dev')).toThrow(); + expect(() => validateSmokeTarget('preview', 'https://1a2b3c4d.ratemyplace-64y.pages.dev:8443')).toThrow(); + }); + + it('requires a full SHA outside local mode', () => { + expect(() => parseSmokeArgs([ + '--environment', 'production', + '--base-url', 'https://ratemyplace.org', + ])).toThrow('Missing --expected-release'); + }); + + it('allows an explicit local development port', () => { + expect(validateSmokeTarget('local', 'http://127.0.0.1:8788').origin) + .toBe('http://127.0.0.1:8788'); + }); +}); +``` + +Also test the injected runner: + +- a redirect to `/` or another origin fails protected-route validation; +- a same-origin `/auth/signin?...` redirect passes; +- any redirect from an ordinary public, API, or health probe fails rather than being followed; +- release mismatch fails after the configured wait budget; +- a 500, missing security header, non-JSON API response, or HTML error sentinel yields a failed probe; +- all expected responses produce only successful results; +- the request timeout aborts a hanging fetch; +- `waitForReleaseMs` is bounded to 600,000 and `requestTimeoutMs` to 30,000. + +Use a fake `fetch` and fake `sleep`/`now` through `SmokeDependencies`; tests must not access the network or wait in real time. + +- [ ] **Step 2: Run the tests to prove the module is missing** + +Run: + +```powershell +npm test -- smoke +``` + +Expected: FAIL because `src/lib/smoke.ts` does not exist. + +- [ ] **Step 3: Implement strict CLI parsing and target validation** + +In `src/lib/smoke.ts`, implement `parseSmokeArgs` for flags only: + +```text +--environment local|preview|production required +--base-url URL required +--expected-release SHA required for preview/production; 40 hexadecimal characters +--wait-for-release-ms MILLISECONDS integer 0 through 600000; default 0 +--request-timeout-ms MILLISECONDS integer 1000 through 30000; default 10000 +``` + +Reject duplicates, unknown flags, missing values, credentials, fragments, queries, and non-root paths. Production and preview also reject non-default ports; local targets permit an explicit development port. Target rules are: + +```typescript +production: protocol === 'https:' && hostname === 'ratemyplace.org' && port === '' +preview: protocol === 'https:' && port === '' && + hostname matches /^[0-9a-f]{8}\.ratemyplace-64y\.pages\.dev$/ +local: protocol is http/https && hostname is localhost, 127.0.0.1, or [::1] +``` + +The preview rule accepts the current hash-based atomic Pages deployment form and rejects the bare production `pages.dev` hostname plus mutable branch aliases. Re-verify the hash-label shape against Cloudflare's preview-deployment documentation immediately before implementation; if the platform format has changed, update this reviewed validator and its tests rather than widening it to a suffix rule. Cloudflare distinguishes atomic hash URLs from branch aliases: . + +Normalize the expected SHA to lowercase only after the full-SHA regex passes. Local mode may accept an optional valid expected SHA so the built-Worker injection test can exercise exact release matching; it does not require one for ordinary local use. + +- [ ] **Step 4: Implement the read-only probe table** + +The probe contract is fixed for Phase 22A: + +```typescript +const htmlPaths = [ + '/', '/about', '/contact', '/guidelines', '/map', '/methodology', + '/privacy', '/search', '/terms', '/auth/signin', '/auth/signup', +]; + +const protectedPaths = ['/profile', '/review/new', '/admin']; + +const apiProbes = [ + { path: '/api/buildings?q=__rmp_smoke_no_match__', status: 200, key: 'buildings' }, + { path: '/api/reviews/user', status: 401, key: 'error' }, + { path: '/api/admin/reviews?limit=1', status: 401, key: 'error' }, +]; +``` + +Assertions: + +- Every fetch uses `redirect: 'manual'`. HTML paths return 200 directly, `text/html`, contain document/html structure, and contain none of `Internal Server Error`, `Application error`, or `500 Error`; any 3xx is a failure. +- `/profile`, `/review/new`, and `/admin` use `redirect: 'manual'`, return 3xx, and resolve to the same-origin `/auth/signin` path. A redirect to `/` is a failure. +- API probes return the exact status without redirecting, JSON content type, a JSON object, and the named top-level key. +- `/api/health` returns 200 without redirecting and `{ status: 'ok', release }`; when `expectedRelease` exists it must match exactly. +- The home response includes `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, a `Content-Security-Policy`, and `Referrer-Policy`. +- Every request uses a per-request abort timeout and `cache: 'no-store'`. + +- [ ] **Step 5: Implement bounded release polling** + +Before running the full suite, `runSmoke` requests `/api/health`. If the release is not the expected SHA and `waitForReleaseMs > 0`, retry every 10 seconds until match or deadline. Network/5xx/malformed responses during this period are retryable only until the same deadline. Once the release matches, run every probe. A deadline failure returns a failed `release` result containing expected and actual release IDs but no response body or internal provider detail. + +The dependency seam is: + +```typescript +export interface SmokeDependencies { + fetch: typeof fetch; + now: () => number; + sleep: (milliseconds: number) => Promise; +} +``` + +Default it to global `fetch`, `Date.now`, and a small `setTimeout` promise; unit tests inject all three. + +- [ ] **Step 6: Reduce the script to CLI/output responsibilities** + +Rewrite `scripts/smoke-test.ts` so it only: + +1. calls `parseSmokeArgs(process.argv.slice(2))`; +2. prints the explicit environment and origin; +3. calls `runSmoke`; +4. prints one line per result with status/duration/detail; +5. sets `process.exitCode = 1` when any probe fails; +6. sets `process.exitCode = 2` for invalid configuration. + +Never log response bodies, headers containing credentials, query values other than the fixed smoke sentinel, or a stack trace for ordinary probe failure. + +- [ ] **Step 7: Verify the tests and negative CLI behavior** + +Run: + +```powershell +npm test -- smoke +npm run smoke +npm run smoke -- --environment production --base-url https://example.com --expected-release aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +``` + +Expected: tests PASS; both CLI calls fail fast with exit code 2 and make no network request. + +- [ ] **Step 8: Verify against a local built Worker** + +In terminal A, retain the build-time release fixture from Task 2 so the full local smoke proves exact-SHA matching as well as route behavior: + +```powershell +npm run db:setup +$syntheticRelease = '0123456789abcdef0123456789abcdef01234567' +$previousCloudflareSha = $env:CF_PAGES_COMMIT_SHA +try { + $env:CF_PAGES_COMMIT_SHA = $syntheticRelease + npm run build + if ($LASTEXITCODE -ne 0) { throw 'Synthetic-release build failed' } +} finally { + if ($null -eq $previousCloudflareSha) { + Remove-Item Env:CF_PAGES_COMMIT_SHA + } else { + $env:CF_PAGES_COMMIT_SHA = $previousCloudflareSha + } +} +npx wrangler pages dev ./dist --port 8788 +``` + +In terminal B: + +```powershell +$syntheticRelease = '0123456789abcdef0123456789abcdef01234567' +npm run smoke -- --environment local --base-url http://127.0.0.1:8788 --expected-release $syntheticRelease +``` + +Expected: every probe passes and `/api/health` matches the synthetic build SHA. A separate ordinary local run without `--expected-release` may accept `unknown` or `development`, but it is not the build-injection proof. + +- [ ] **Step 9: Commit** + +```powershell +git add src/lib/smoke.ts src/lib/__tests__/smoke.test.ts scripts/smoke-test.ts +git commit -m "test: make smoke targets explicit" +``` + +--- + +## Task 4: Add least-privilege CI and post-deploy commit verification + +> **Final-review RULING-002 — documentation correction:** The originally planned +> single-job, workflow-level `production-smoke` concurrency model was corrected after +> final review. The verified implementation keeps the sentinel outside cancellable +> concurrency and gives that concurrency only to the successful smoke job. This preserves +> visibility for failed CI completions; it is not a new product feature. + +**Files:** + +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/post-deploy-smoke.yml` +- Create: `src/lib/__tests__/workflowContracts.test.ts` + +- [ ] **Step 1: Write a failing repository-level workflow contract test** + +Create `src/lib/__tests__/workflowContracts.test.ts` using `readFileSync` and `resolve(process.cwd(), ...)`. Assert: + +- `.github/workflows/ci.yml` exists, is named `CI`, has a `quality` job named `quality`, triggers on pull request and `main` push, grants only `contents: read`, disables checkout credential persistence, and runs `npm ci`, `npm run check`, `npm test`, and `npm run build`; +- `.github/workflows/post-deploy-smoke.yml` uses `workflow_run` for `CI`. Its + non-cancellable `sentinel` job runs for every qualifying internal `main` push + completion, explicitly fails red when quality did not succeed, explicitly passes on + success, and never checks out, installs dependencies, or smokes. A separate success-only + `smoke` job needs the sentinel, alone owns cancellable `production-smoke` concurrency, + and passes `workflow_run.head_sha` as `--expected-release` while targeting exactly + `https://ratemyplace.org`; +- neither workflow contains `wrangler pages deploy`, `CLOUDFLARE_API_TOKEN`, `pull_request_target`, or `permissions: write-all`. + +Run: + +```powershell +npm test -- workflowContracts +``` + +Expected: FAIL because `.github/workflows` is absent. + +- [ ] **Step 2: Create the stable CI workflow** + +Create `.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: quality + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + ASTRO_TELEMETRY_DISABLED: "1" + steps: + - name: Check out source + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Use pinned Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Type-check + run: npm run check + - name: Unit tests + run: npm test + - name: Production build + run: npm run build +``` + +GitHub's official Node workflow guidance uses `setup-node` plus `npm ci`, tests, and build: . Astro documents `astro check` as intended for CI: . + +- [ ] **Step 3: Create post-deploy polling and smoke** + +Create `.github/workflows/post-deploy-smoke.yml`: + +```yaml +name: Post-deploy smoke + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + +jobs: + sentinel: + name: production sentinel + if: >- + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Refuse an unverified main deployment + if: github.event.workflow_run.conclusion != 'success' + run: | + echo "CI quality did not succeed for this main commit. Pages may still have attempted deployment." + exit 1 + - name: Confirm verified main CI + if: github.event.workflow_run.conclusion == 'success' + run: echo "CI quality succeeded for this main commit." + smoke: + name: production smoke + needs: sentinel + if: >- + needs.sentinel.result == 'success' && + github.event.workflow_run.conclusion == 'success' + concurrency: + group: production-smoke + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out deployed commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + - name: Use pinned Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Wait for deployed SHA and smoke production + run: >- + npm run smoke -- + --environment production + --base-url https://ratemyplace.org + --expected-release ${{ github.event.workflow_run.head_sha }} + --wait-for-release-ms 600000 +``` + +The workflow does not deploy. Cloudflare Pages Git integration can attempt a deployment independently for every `main` push, so a failed quality run must produce a red, non-cancellable sentinel rather than a skipped post-deploy result. Only after that sentinel explicitly passes can the separate, cancellable smoke job run; on success, its health-SHA poll proves which commit is being tested. Cloudflare documents that Git integration owns automatic Pages deployments and check runs: . + +- [ ] **Step 4: Run the workflow contract and full local gates** + +Run: + +```powershell +npm test -- workflowContracts +$env:ASTRO_TELEMETRY_DISABLED='1'; npm run check +npm test +npm run build +``` + +Expected: workflow contract PASS; check reports 0 errors; all unit tests PASS; build completes. + +- [ ] **Step 5: Commit** + +```powershell +git add .github/workflows/ci.yml .github/workflows/post-deploy-smoke.yml src/lib/__tests__/workflowContracts.test.ts +git commit -m "chore: add CI and post-deploy verification" +``` + +--- + +## Task 5: Document the operator contract and update repository guidance + +**Files:** + +- Create: `docs/runbooks/release-smoke.md` +- Modify: `AGENTS.md` +- Modify: `README.md` +- Modify: `.planning/codebase/INTEGRATIONS.md` + +- [ ] **Step 1: Create the runbook** + +`docs/runbooks/release-smoke.md` must record: + +- the purpose and non-sensitive `{ status, release }` health contract; +- the exact cookie-free monitor assumption: the route handler performs no D1 work, while an unrelated caller that supplies a session cookie may trigger normal middleware session validation; +- the narrow `/api/health` GET/HEAD application-rate-limit exception and the fact that any custom edge rule is separately approved; +- exact local, preview, and production smoke commands; +- accepted hostnames, including the current hash-based atomic preview form, and why no default exists; +- the `quality` required-check name; +- the post-deploy sequence: every qualifying internal `main` CI completion runs a + non-cancellable sentinel; failure makes it red, success explicitly passes it, and the + separate success-only smoke job then proceeds through Pages deploy → release-SHA match → + full smoke under its own cancellable `production-smoke` concurrency; +- that smoke is read-only and excludes Turnstile submission/Maps interaction; +- that post-deploy failure stops the release from being called healthy but does not trigger automatic rollback; +- triage order: compare expected/actual SHA, inspect Pages deployment, inspect failed probe, rerun read-only smoke, then request separate approval for any rollback; +- SAFE-09 limitation: GitHub failure notification is best-effort until Phase 22B adds independent counters/outbox/webhook/machine health. + +- [ ] **Step 2: Update canonical commands** + +In `AGENTS.md`: + +- add `npm run check # Astro/TypeScript diagnostics` to Commands; +- change completion guidance to require `npm run check`, `npm test`, and `npm run build`; +- document that `npm run smoke` has no default and requires explicit environment/origin (and SHA outside local). +- document one narrow endpoint-checklist exception: input-free, read-only `GET`/`HEAD /api/health` has no D1-backed application limiter so cookie-free release monitoring stays independent of D1; this exception applies to no other public endpoint and does not authorize a custom edge rule. + +In `README.md`, use `npm ci` for locked setup and list `npm run check`, `npm test`, and `npm run build`. + +In `.planning/codebase/INTEGRATIONS.md`, replace “No CI” with the exact two-workflow model and stable `quality` check. Do not claim the `main` ruleset is active until Task 7 verifies it externally. + +- [ ] **Step 3: Scan for stale smoke/CI claims** + +Run: + +```powershell +rg -n -S "b3b57132|No GitHub Actions|no CI|npm run smoke" AGENTS.md README.md scripts docs/runbooks .planning/codebase/INTEGRATIONS.md +``` + +Expected: no obsolete preview default or “no CI” claim; all smoke examples are explicit. + +- [ ] **Step 4: Commit** + +```powershell +git add AGENTS.md README.md .planning/codebase/INTEGRATIONS.md docs/runbooks/release-smoke.md +git commit -m "docs: document release safety controls" +``` + +--- + +## Task 6: Pre-external-action verification + +**Files:** none. + +- [ ] **Step 1: Verify only intended files are tracked** + +Run: + +```powershell +git status --short +git diff --check main...HEAD +git diff --stat main...HEAD +``` + +Expected: the untracked duplicate verification route remains untracked and absent from `git diff`; no whitespace errors. + +- [ ] **Step 2: Run all local gates from a clean install-compatible state** + +Run: + +```powershell +$env:ASTRO_TELEMETRY_DISABLED='1'; npm run check +npm test +npm run build +``` + +Expected: all three PASS. + +- [ ] **Step 3: Run the complete pre-deploy QA subset** + +- Repeat the local smoke command from Task 3. +- At 375, 768, and 1280px, verify `/`, `/auth/signin`, `/auth/signup`, `/profile` denial, `/review/new` denial, and `/admin` denial have no leaked `undefined`/`null`/internal fields. +- Directly request `/api/health`, `/api/reviews/user`, and `/api/admin/reviews?limit=1`; verify exact public/401 contracts. +- Confirm no response exposes secrets, table names, counts in health, stack traces, or the private `unit_number` field. + +- [ ] **Step 4: Present the diff and request approval before pushing** + +Stop and show: + +- commit list; +- full file inventory; +- check/test/build/local-smoke results; +- exact branch name and proposed PR title; +- confirmation that no Cloudflare, D1, R2, GitHub ruleset, or production state changed. + +Do not push or open a PR without action-time approval. + +--- + +## Task 7: Activate CI and required checks under separate approvals + +**Files:** + +- Create after verification: `.planning/milestones/v1.6.0-phases/22-release-operations-migration-safety/22A-VERIFICATION.md` +- Modify after all evidence passes: `.planning/milestones/v1.6.0-REQUIREMENTS.md` + +- [ ] **Step 1: After push approval, push the feature branch and open the PR** + +Proposed PR title: + +```text +chore: add Phase 22A release safety gates +``` + +Wait for the `quality` check and Cloudflare preview check. If `quality` fails, fix on the branch and repeat local verification; do not weaken the check. + +- [ ] **Step 2: Smoke the hash-based atomic preview at the exact SHA** + +Open the successful Cloudflare Pages check, follow it to deployment details, and copy the hash-based atomic deployment URL—not the bare production hostname or mutable branch alias. Then resolve the PR head SHA from the checked-out branch: + +```powershell +$previewOrigin = Read-Host 'Immutable Cloudflare Pages deployment URL' +$prHeadSha = (git rev-parse HEAD).Trim() +npm run smoke -- --environment preview --base-url $previewOrigin --expected-release $prHeadSha +``` + +Expected: all read-only probes pass. Turnstile/Maps are explicitly not inferred from preview. + +- [ ] **Step 3: Display the exact proposed `main` ruleset and request separate approval** + +The proposed ruleset is: + +- target: branch `main` only; +- enforcement: active; +- require pull request before merge, with **zero** required approving reviews for the single-maintainer repository; +- require status check `quality`, expected source GitHub Actions, strict/up-to-date branch; +- block force pushes and branch deletion; +- no routine bypass actor. + +GitHub requires the status check to have run recently before it can be selected. Verify the existing rulesets read-only, show any conflict, then stop for approval. Official behavior: . + +- [ ] **Step 4: After ruleset approval, activate and prove it** + +Activate through GitHub repository settings or a narrowly scoped `gh api` call. Re-read the saved ruleset and attach its exact JSON/screenshot evidence to the verification record. Open a temporary PR whose `quality` job deliberately fails without changing production code, verify merge is blocked, then close the PR without merging. Do not leave a failing commit on the implementation branch. + +- [ ] **Step 5: Request merge approval** + +Show: + +- green `quality` and Pages preview checks; +- preview smoke tied to the exact SHA; +- active ruleset evidence and blocked-merge proof; +- final diff and commits. + +Do not merge until explicitly approved. + +- [ ] **Step 6: After merge approval, verify production by exact SHA** + +The post-deploy workflow should wait for Pages to serve the merged SHA and then run smoke. Independently confirm its GitHub run is green, update the local remote-tracking ref, and run: + +```powershell +git fetch origin main +$mainSha = (git rev-parse origin/main).Trim() +npm run smoke -- --environment production --base-url https://ratemyplace.org --expected-release $mainSha +``` + +If either fails, do not mark the release healthy. Diagnose read-only; rollback remains a separate approval gate. + +- [ ] **Step 7: Record evidence and close only completed requirements** + +Create `22A-VERIFICATION.md` with commit SHA, Node/npm versions, CI URL/result, ruleset evidence, preview hostname/SHA/result, production Pages deployment/SHA, post-deploy workflow result, independent production-smoke result, and any expected warnings. + +Only after all evidence exists, mark SAFE-01, SAFE-02, SAFE-03, and SAFE-06 complete in `.planning/milestones/v1.6.0-REQUIREMENTS.md`. Leave SAFE-09 unchecked and label its public-health slice complete in the evidence record. + +Commit the evidence separately: + +```powershell +git add .planning/milestones/v1.6.0-REQUIREMENTS.md .planning/milestones/v1.6.0-phases/22-release-operations-migration-safety/22A-VERIFICATION.md +git commit -m "docs: record Phase 22A release safety activation" +``` + +Because this evidence commit occurs after the first production merge, it requires its own branch/PR/check/merge approval cycle; never push it directly to `main`. + +--- + +## Final Verification Matrix + +| Requirement | Automated evidence | External evidence | +|---|---|---| +| SAFE-01 | `npm run check` exists and returns 0 errors; CI runs it | Green `quality` check | +| SAFE-02 | Workflow contract test; CI runs install/check/test/build | Green PR and `main` workflow runs | +| SAFE-03 | Stable `quality` job name | Active ruleset plus blocked failing-PR proof | +| SAFE-06 | Smoke parser/runner tests; local full smoke | Immutable-preview and production smoke tied to exact SHAs | +| SAFE-09 slice | Health payload/route tests; release mismatch test | Production health reports merged SHA | + +## Self-Review Checklist + +- [ ] Every spec requirement in scope maps to a task and evidence row. +- [ ] SAFE-04/05/07/08/10/11 and the rest of SAFE-09 remain outside this plan. +- [ ] Scan the plan for unresolved drafting markers or symbolic command values; resolve each from named evidence before handoff. +- [ ] Names are consistent: workflow `CI`, required job/check `quality`, endpoint `/api/health`, fields `status` and `release`, flag `--expected-release`. +- [ ] A built Worker, not only a unit import, exposes the synthetic SHA injected through `astro.config.mjs`. +- [ ] Preview validation rejects the production Pages hostname, branch aliases, credentials, and non-default ports; every ordinary probe rejects redirects. +- [ ] Every qualifying internal `main` quality completion runs a non-cancellable sentinel, + including an explicitly red result on quality failure; only the separate success-only + smoke job owns cancellable `production-smoke` concurrency. +- [ ] No workflow has write permission, deployment credentials, `pull_request_target`, or a Cloudflare deployment command. +- [ ] No step treats a successful redirect follow, old release, mutable marketing phrase, or preview-only widget failure as production proof. +- [ ] No external action appears before an explicit action-time approval step. diff --git a/docs/superpowers/specs/2026-08-26-trust-density-design.md b/docs/superpowers/specs/2026-08-26-trust-density-design.md new file mode 100644 index 0000000..1cb27ac --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-trust-density-design.md @@ -0,0 +1,520 @@ +# Trust + Density Design + +**Date:** 2026-08-26 +**Milestone:** v1.6.0 "Trust + Density" +**Status:** Approved by owner 2026-08-27 +**Requirements:** `.planning/milestones/v1.6.0-REQUIREMENTS.md` +**Roadmap:** `.planning/milestones/v1.6.0-ROADMAP.md` + +## Purpose + +RateMyPlace already has the core product: structured tenant reviews, public building and named-party pages, moderation, disputes, account management, and verification. v1.6 closes the gaps between the product's privacy/integrity promises and its enforceable behavior, repairs the contribution path, adds release and accessibility guardrails, fixes basic discoverability, and then runs one bounded review-density pilot. + +This is a program of independently deployable subsystems. It is not one feature branch, one database migration, or one production action. + +## Current source anchors + +The design is based on the current source, not the older claims in `MASTER.md`: + +| Area | Current source evidence | +|---|---| +| Private unit number | `ReviewForm.tsx` and `POST /api/reviews` collect it; the owner edit route/form and admin review API/table read it. Public building, landlord, and property-manager detail queries still select broad `r.*` rows, so v1.6 replaces those with explicit public columns even though templates do not currently render the value. `MASTER.md` still promises a future column drop. | +| IP and profile data | `src/lib/rateLimit.ts` persists the identifier supplied by endpoints, several auth routes include raw client IP in structured error context, and `src/lib/audit.ts` persists `admin_ip`. Google OAuth currently requests `openid email profile` and writes name/avatar. | +| Verification lifecycle | `src/pages/api/verification/upload.ts`, `src/lib/storage.ts`, and `src/pages/api/admin/verification/[id].ts` implement upload/view/decision and immediate best-effort deletion, but there is no durable deletion queue, scheduled retry, or R2 reconciliation. | +| Contribution continuity | `src/pages/review/new.astro` retains a target only for the initial sign-in link; signup, Google callback, and email verification ultimately redirect to home/profile. `POST /api/reviews` checks authentication but not `email_verified`. | +| Review integrity | `POST /api/reviews` has an hourly account limiter but the schema has no account/building uniqueness authority, durable claim, daily allowance, velocity signal, or content-priority signal. | +| Trust copy | `src/pages/index.astro` says “Verified tenants,” while residency-document verification is optional. Signup HTML advertises and enforces six characters while the API requires eight. | +| Release and discovery | `package.json` has no `check` script, no GitHub workflow is present, and the repository has neither the referenced default social image nor sitemap/robots routes. | +| Accessibility | The source has isolated labels and controls, but no automated axe gate; the two address-autocomplete implementations, rating controls, dialogs, disclosures, and focus transitions require end-to-end semantics review. | + +## Product decisions + +### Confirmed direction + +1. Apartment/unit number remains optional, is entered by the reviewer, is stored privately for moderation, and is never publicly displayed or used for scoring, grouping, search, metadata, analytics, or audit evidence. +2. Email verification authorizes review contribution. Proof-of-residency verification remains optional and controls only the verification badge. +3. Reviewer rate-limit identifiers are stored as endpoint-purpose-scoped HMACs, not raw IPs. Reviewer IP remains request-local for Turnstile and is not retained; an acting admin's IP is separately retained for authorized admin-action auditing under the disclosed retention policy. +4. Verification deletion is attempted immediately. A remote-storage failure does not undo the moderation decision; it creates durable, visible retry work until absence is confirmed. +5. Verification documents have no backup or restoration path. A lost pending document is re-uploaded rather than restored from a private copy. +6. Ordinary users receive working export, individual-review deletion, and account-deletion paths rather than unqualified promises for unbuilt capabilities. Account deletion retains score-bearing reviews as permanently ownerless records. +7. One account/building review authority and an edit-existing path prevent duplicate contribution. +8. Automated PII, prohibited-language, and velocity signals prioritize human moderation only. +9. v1.6 introduces no per-user behavioral analytics and no external review-text classifier. +10. The density pilot uses one bounded Boston geography/building list and launches only after the full trust/accessibility gate. +11. A destructive admin D1 mutation never commits without its required audit row; remote side effects remain durable post-commit work and do not roll back an already audited decision. + +### Approved owner decisions (2026-08-27) + +- the owner may see, edit, and export their own unit number after submission, while authorized admins may view it for moderation; +- Google OAuth drops `profile` scope and stops writing new Google-sourced reviewer name/avatar data; existing profile values and manual display-name behavior remain unchanged unless separately approved; +- undecided verification documents enter deletion after 30 days; +- admin audit IP is cleared after 180 days; +- five new claims per account daily, with human-review velocity flags at the fifth building or tenth landlord submission; this design interprets "daily" as a rolling 24-hour window for deterministic enforcement; +- an account/building claim survives individual review deletion but is removed with the account; +- account deletion hard-deletes the account and private account-owned data but retains each review with `user_id = NULL`, unchanged content, private unit number, moderation state, and score contribution; the retained review is permanently ownerless and cannot be reclaimed or owner-edited; +- identified legacy audit JSON is minimized through a separately action-time-approved, itself-audited operation that preserves non-identifying action evidence. + +## Cross-cutting invariants + +- `ITEM_WEIGHTS`, `RECENCY_BANDS`, the aggregation formula, the 32-item instrument, and building-score behavior do not change. +- `NAMED_PARTY_MIN_REVIEWS` remains the only threshold source for landlord and property-manager public score visibility, including SSR, APIs, metadata, and future structured data. +- Exact tenancy dates, exact submission timestamps, reviewer identity, and private moderation fields never reach public surfaces. +- Public routes select explicit columns and serialize narrow public view models. `SELECT *` is never used at a public boundary. +- Every new endpoint follows the repository auth, content-type, rate-limit, Turnstile, validation, parameterization, response, and audit rules. +- Every destructive admin route includes the domain mutation, required audit insert, and any required external-cleanup intent in one D1 batch. Failure of any required statement rolls the batch back; only the remote cleanup attempt is best-effort after commit. +- Migration history/files are append-only once applied. Schema evolution is tested against a fresh local D1 and reaches production only through the documented expand/deploy/verify/contract sequence after live-schema and ledger verification. +- Every phase passes `npm run check`, `npm test`, and `npm run build`; touched flows receive targeted component, route, SQL, SSR-leak, and Playwright coverage. +- External configuration and production mutation remain separate action-time approval gates. +- Public documentation never describes a planned control as already built. Phase 23 corrects current falsehoods; verification and account-rights copy co-deploys with Phases 24 and 27. + +## 1. Private apartment/unit-number boundary + +### Access matrix + +| Context | Read | Write | Status and notes | +|---|---:|---:|---| +| Review create form for signed-in reviewer | Yes | Yes | Confirmed; optional input is necessary to collect it | +| Owner's authenticated review edit flow | Yes | Yes | Approved; ownership is checked before any response/update | +| Owner's authenticated data export | Yes | No | Approved; response is private/no-store | +| Authorized admin review detail | Yes | No | Confirmed moderation purpose; explicit admin check | +| Public HTML/API/search/map/OG/JSON-LD | No | No | Confirmed; property is absent, not present as `null` | +| Logs, analytics, integrity flags, audit old/new JSON | No | No | Confirmed; audit may record that a private field changed, never its value | +| Another user's authenticated response/export | No | No | Confirmed; cross-user access is forbidden | + +### Normalization + +Shared library logic trims the value, converts blank/whitespace-only input to `NULL`, rejects control characters, and caps length at 32 characters. Create and edit routes use that one helper. The field remains unrelated to scoring and review-claim uniqueness. + +Public regression tests inspect complete JSON objects and complete rendered SSR HTML—including serialized island props, scripts, metadata, map payloads, and structured data—for both the `unit_number` property name and a distinctive fixture value. This makes the privacy boundary enforceable rather than dependent on today's templates. + +Retention is tied to the review lifecycle. Individual review deletion removes the value. Account deletion retains it with the otherwise unchanged ownerless review, after which it is admin-only because no account can satisfy ownership. It is never copied into an immutable audit snapshot. + +## 2. IP pseudonymization and profile minimization + +### Rate-limit identifiers + +Add a Worker-compatible shared helper that produces: + +```text +{endpoint}:v1:{base64url(HMAC-SHA-256( + RATE_LIMIT_HMAC_KEY, + "rate-limit:v1:" + endpoint + ":" + canonical_identifier +))} +``` + +`RATE_LIMIT_HMAC_KEY` is a dedicated Cloudflare secret with at least 32 random bytes. IPv4, IPv6, authenticated user IDs, and the development fallback all pass through the helper. The endpoint remains a queryable prefix and is also inside the digest, so the same client cannot be correlated across endpoint purposes by comparing suffixes. + +Production behavior is fail-closed: an absent or malformed secret returns service unavailable rather than falling back to a raw identifier. Rotation uses a new version prefix and intentionally resets active windows at an approved off-peak time; dual-key lookup is unnecessary at current scale. + +Raw reviewer IPs are removed from structured application logs. Log events keep request ID, endpoint, subsystem, outcome, and normalized error category. `audit_logs.admin_ip` becomes nullable and remains an access-controlled accountability field for 180 days, after which scheduled maintenance clears the IP while retaining the admin user, action, entity, and timestamp. + +Legacy raw `rate_limits` rows are purged only after HMAC code is live. Expired HMAC rows continue to be removed opportunistically and receive a daily physical cleanup. + +### Google profile data + +Google OAuth requests `openid email`, verifies Google's `email_verified` claim, and writes only the provider ID and normalized email required for authentication. New Google-sourced name/avatar writes stop. Existing profile values and manual display-name behavior are unchanged by this approval; any later retention, migration, or deletion decision for them requires separate owner approval. + +## 3. Verification-document lifecycle + +### State model + +Moderation state and object state are separate: + +```text +Moderation: pending -> approved | rejected | expired + +Object: pending_upload -> stored -> delete_pending -> deleted + \-> upload_failed +``` + +At most one active (`pending_upload` or `stored`) verification exists per review. Object lifecycle lives in a non-cascading registry that holds the opaque key, state, upload token hash, and lease expiry independently of the user/review/moderation row. Terminal moderation transitions use compare-and-set semantics requiring both `moderation_state = pending` and `object_state = stored`, so a document cannot be decided while its PUT is in flight and concurrent approve/reject requests cannot both succeed. + +New object keys are opaque random identifiers: + +```text +verifications/{128-bit-random-id} +``` + +They contain no user ID, review ID, address, email, or filename. R2 custom metadata never stores the original filename. D1 may retain the original filename only while the document is operationally pending; it is cleared after confirmed deletion. + +### Upload flow + +1. Authenticate the reviewer and verify review ownership. +2. Apply rate limit before body processing. +3. Validate file size and MIME type. +4. Generate verification ID and opaque key. +5. Insert the non-cascading D1 object-registry row as `pending_upload` with a bounded upload lease, and link the pending moderation row to it. +6. Upload bytes to R2 with conditional create (`If-None-Match: *` through `R2PutOptions.onlyIf`), so an existing fence can never be overwritten by document bytes. +7. Conditionally promote the row to `stored`. +8. Return success only after `stored` is durable. + +A D1 insert failure performs no R2 write. An R2 failure marks `upload_failed` and permits retry. A conditional-put precondition failure means deletion already fenced the key; it never retries against that key. The request retains the opaque key until the final compare-and-set succeeds. If that promotion does not update exactly one pending-upload row—because of a concurrent review/building deletion, account-unlink quarantine, replacement, or D1 error—the request attempts to upsert a deletion job and immediately delete; regardless of that request's fate, the independent registry remains durable for reconciliation. A crash immediately after PUT therefore leaves `pending_upload`, not untracked document bytes. The Workers binding's conditional-put result is checked explicitly rather than assuming success: [R2 Workers API conditional operations](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#conditional-operations). + +Review/building deletion or account-unlink preparation marks the registry `delete_pending` but cannot cascade it away. If a PUT might still be in flight, the worker conditionally creates a zero-byte upload-fence object **at the exact opaque key** with fixed non-sensitive metadata. If the fence wins, every real conditional PUT loses. If conditional fence creation loses because document bytes won first, the worker deletes those bytes and retries until a head read confirms the fence; it never treats a transient absence as terminal. Upload completion observes the deletion request and deletes rather than promoting for moderation. Once the fence is confirmed, sensitive bytes are gone and cannot reappear; the tenant-linked moderation record clears its object metadata, while an unlinked fence-cleanup job retains only the opaque key. The fence remains for 30 days—far beyond an invocation—and is then removed by application cleanup plus the prefix lifecycle/reconciliation backstop. Replacement uploads always receive a new random key. Fence-win, document-win, immediate-post-PUT crash, retry, and cleanup are required concurrency tests against real R2 semantics before activation. + +### Decision and deletion flow + +The decision path commits one D1 batch containing the required audit row, two-state conditional moderation transition, review verification state, and deletion-outbox job. A unique request-scoped operation ID is inserted into the audit row through a conditional `INSERT ... SELECT` with the pending/stored predicate; every later statement is gated by existence of that exact audit operation ID and reuses only the same bound predicate values. D1 executes the batch sequentially without an interleaving request. If the predicate matches zero rows, no operation guard exists, every downstream effect is zero, the route returns 409, and no audit is written. If the audit insert or any other required statement fails, the whole decision rolls back and the route emits a structured server error. Only after commit is R2 deletion attempted before the response; that remote cleanup remains best-effort and cannot undo the now-audited decision. + +- Confirmed deletion records `document_deleted_at`, marks the tenant-linked record `deleted`, and clears key/filename/content-type/size metadata. A normal stored object compacts the outbox after confirmed absence; a pending-upload race compacts only the document-deletion work and leaves its non-cascading, opaque fence-cleanup job until the fence expires. +- Object-not-found is idempotent success only when no writer can remain; otherwise the worker installs/confirms the exact-key fence. +- A transient failure keeps the decision, marks `delete_pending`, and returns HTTP 202 with explicit deletion-pending state. Request `waitUntil` may finish only work already attempted in that invocation; the persisted outbox—not the execution context—is the authority for every later retry. +- A concurrent stale decision or a decision against `pending_upload` returns 409 and creates no audit event. + +Review/building deletion and account deletion/unlink include every associated deletion key in durable D1 work before cascades can remove verification lookup rows; destructive admin variants include their required audit row in the same batch. The independent registry/upload tombstone survives until sensitive bytes are absent, the exact key is fenced where necessary, and bounded fence cleanup is confirmed. + +### Retry and reconciliation + +The separately deployed maintenance Worker is scaffolded in Phase 22 with a narrow D1 heartbeat record, alert delivery, and no destructive job enabled by default. Phase 24 adds the verification-bucket binding and these jobs: + +- every five minutes: claim and process due deletion jobs in bounded batches; +- daily: reconcile registered D1 states against an R2 listing and expire old pending submissions; +- every run: write a heartbeat and aggregate outcome. + +Retries are idempotent with exponential backoff capped at 24 hours. Three failed attempts or 60 minutes pending creates an admin-visible warning and critical operational event. Document-deletion jobs remain active until sensitive bytes are absent or replaced by a confirmed fence. Fence-cleanup jobs retain only the opaque key until lifecycle/application deletion and confirmed absence; completed rows then discard the key and retain only non-identifying outcome/timing fields for 30 days. + +Undecided documents enter deletion after 30 days and move to `expired`; the reviewer may submit a replacement. Before a lifecycle rule is enabled, the project inventories current `users/...` keys, reconciles every legacy row/object (especially anything already older than 30 days), deploys missing-object handling, and obtains separate approval for the exact affected set. New opaque `verifications/...` keys receive a prefix-scoped 30-day lifecycle rule only after those preconditions pass; legacy prefixes are handled deliberately rather than exposed to a surprise bucket-wide expiry. Because R2 lifecycle removal is asynchronous, public copy describes 30 days as the application retention limit and says physical deletion may complete shortly afterward. R2 document bytes are never copied into backups, D1 exports, incident archives, or analytics. + +### Strictly journaled, no-store viewing + +The view route executes: + +```text +authenticate -> require moderation=pending and object=stored +-> insert access outcome=authorized +-> fetch R2 +-> update access outcome=served +-> stream private/no-store response +``` + +A dedicated verification-access journal and strict helper do not swallow errors. The initial row means authorization was granted, not that bytes were viewed. A successful R2 fetch is updated to `served` before the response can stream; missing/error outcomes are recorded accurately. If the pre-fetch insert or pre-stream served update fails, no bytes are returned and the route responds 503. Processed, upload-incomplete, expired, or deletion-pending documents return 410. Missing records return 404. The journal stores admin user ID, admin IP for 180 days, verification ID, request ID, outcome, and timestamps only—never filename, R2 key, tenant email, unit number, or contents. Scheduled maintenance clears the IP while retaining the remaining access evidence. + +Responses include `Cache-Control: private, no-store, max-age=0`, `Pragma: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive document CSP. Active formats download as attachments; inline images remain no-store. + +## 4. Contribution and authentication continuity + +### Return-target authority + +One server-compatible `src/lib` module is the only constructor/consumer for post-authentication destinations. It accepts only registered review routes: + +- `/review/new` with allowlisted `building` or `placeId` query parameters; +- `/review/edit/{review-id}`. + +It rejects absolute URLs, protocol-relative URLs, backslashes, control characters, fragments, credentials, `/api`, `/admin`, `/auth`, unknown query parameters, malformed encodings, and encoded values over 1,024 characters. Invalid or expired input falls back to `/`. + +A short-lived HttpOnly, SameSite=Lax, production-Secure cookie carries the normalized target through password sign-in/signup. Each Google attempt gets its own opaque attempt ID, per-attempt HttpOnly state cookie, and expiring server-side record containing separate CSRF nonce/hash and normalized navigation target fields. The callback resolves the target from that exact verified attempt, so a second tab cannot overwrite the first tab's destination. Email-verification token records receive an optional normalized `return_to` value so cross-device verification can resume through sign-in without putting a selected property in the emailed URL. + +The verification link verifies email ownership but never creates a session. If another user is signed in, it shows neutral success and requires the verified account to sign in; it never transfers the target into the wrong account. + +### Authorization order + +Both review creation and editing enforce: + +```text +authentication -> email_verified -> ownership (edit) +-> request/Turnstile validation -> review validation +-> integrity enforcement -> database write +``` + +New/edit pages check verified email before Place Details lookup, building creation, or owner data rendering. Both create and edit APIs independently return 403 with an actionable reason, including when an account becomes unverified after changing its email. An unverified user sees a verification/resend state with the retained target. + +Expected status contracts are 401 unauthenticated, 403 email verification required, 409 existing claim, 429 daily limit, 503 integrity/rate-limit infrastructure unavailable, and generic 500 for unknown failures. No SQL or internal field names are returned. + +Signup UI/API both require eight password characters. Public trust copy distinguishes verified email, human moderation, and optional residency verification. + +## 5. Review claims, limits, and moderation signals + +### Claim authority + +A `review_claims` table, not a unique index on live reviews alone, is the authority for one account/building review: + +```text +PRIMARY KEY (user_id, building_id) +review_id UNIQUE NULLABLE -> reviews(id) ON DELETE SET NULL +user_id -> users(id) ON DELETE CASCADE +building_id -> buildings(id) ON DELETE CASCADE +created_at INTEGER NOT NULL DEFAULT (unixepoch()) +``` + +The claim remains if the review is individually deleted, preventing resubmission under the same account; account deletion removes it even though the review remains ownerless. A 409 for a claim with a live `review_id` includes the authorized edit-existing route. A tombstoned claim with `review_id = NULL` returns a distinct consumed-claim explanation and never links to a missing review. Before backfill, a read-only production query identifies every duplicate pair among user-linked reviews; any result blocks migration until the owner explicitly decides which content remains. No duplicate is silently deleted. Backfilled claims copy `reviews.created_at`, never the migration timestamp, so the rolling 24-hour allowance and historical reporting are not distorted. Reviews whose `user_id` is already `NULL` receive no claim and cannot be reclaimed. + +### Zero-gap production cutover + +The claim/scan transition uses two compatible deploys plus brief, explicitly approved submission and approval gates: + +1. Add claim, integrity-job, flag, and feature-gate tables without enabling enforcement. +2. Deploy compatibility code that honors both gates. Transitional submission atomically checks `review_claims` **and** live user-linked reviews, counts both claims and not-yet-backfilled recent reviews for the allowance, and writes the claim/job for every accepted review. Every review-deletion path materializes that claim before removing a legacy review. The approval path immediately blocks approval unless that review has a completed scan; rejection and diagnosis remain available. +3. Backfill all historical user-linked reviews into claims using original review timestamps; leave ownerless reviews unclaimed and enqueue every existing pending review for a content scan. Velocity backfill is bounded to the preceding 24 hours. +4. Close new/edit submissions briefly, wait for in-flight requests, run final idempotent catch-up, and require parity: every user-linked review has the intended claim, every ownerless review has none, no claim maps to multiple reviews, and every pending review has a completed or pending scan job. Approval remains scan-gated throughout. +5. Deploy/enable the final atomic claim writer, drain or account for every legacy pending scan, rerun parity, and reopen submissions only after separate action-time approval. No review approval is reopened separately because the completed-scan invariant has already been continuously enforced. + +If compatibility writing or backfill fails, the gate remains closed only for review submission/editing; public browsing, auth, admin diagnosis/rejection, and receipt-safe deletion remain available. Approval stays blocked for unscanned reviews. Rollback returns to the compatible writer without dropping the expanded tables. + +### Atomic creation and daily limit + +Creation uses one D1 batch and D1's sequential transactional semantics: + +1. conditionally insert the pending review only when no account/building claim exists and the rolling-window claim count is below the bound limit; +2. insert the claim by selecting the just-created review; +3. insert a pending integrity-scan job by selecting that same review. + +If the first statement inserts nothing, later statements also insert nothing; post-batch classification returns 409 for an existing claim or 429 for the allowance. Any unexpected constraint failure rolls the whole batch back. Concurrent batches serialize, so exactly one review/claim wins without a SQL trigger. + +The approved allowance is five new claims per account daily. For deterministic enforcement across time zones and reset boundaries, this design interprets "daily" as a rolling 24-hour window; that window shape is an implementation assumption, not an additional owner-approved product decision. Exported shared constants define the limit/window and are bound into the conditional statement; there is no second literal in migration SQL. A friendly preflight improves UX but is not authoritative. Editing/resubmitting the claimed review consumes no new allowance. The existing hourly endpoint limiter remains a technical flood guard. + +### Moderation signals + +`src/lib/reviewIntegrity.ts` returns typed, versioned signal codes. A dedicated table stores review ID, kind, rule version, safe evidence counts/field names, detection/clear/expiry timestamps, and never copies the matched text. + +Signals are: + +- possible email; +- possible phone number; +- possible external link; +- possible prohibited language; +- fifth-or-later building submission within 24 hours; +- tenth-or-later canonical-landlord submission within 24 hours. + +Velocity counts include all submitted statuses. Landlord velocity uses `buildings.landlord_id`, never renter-entered name text. When an admin links or changes a building's landlord, a bounded recomputation enqueues only affected reviews/submissions from the preceding 24 hours so the common link-after-submission flow cannot miss the signal. Detection is deterministic and local; no free text leaves the platform. + +Each review carries a monotonic content revision, and every scan job/result names that revision. After the core creation batch commits, the request attempts the pending integrity job immediately. A signal write failure does not reject the renter's pending review: the job stays durable, a structured event fires, and the maintenance Worker retries. Approval is a conditional D1 transition that requires a completed scan for the review's **current** content revision in the same operation; no signal result decides approval or rejection. Editing atomically increments the revision, marks prior signals stale, and enqueues the matching rescan while preserving the claim; it does not count as a new velocity event. Because D1 serializes the edit and approval batches, an edit that wins makes stale approval fail, while an approval that wins prevents an ineligible edit. Concurrency tests cover both orders and request retries. Claim/allowance infrastructure still fails closed. Signals sort pending reviews for admins and display as accessible private badges. They never enter public APIs, HTML, metadata, exports, or score calculations. + +## 6. Account export and deletion + +### Recent reauthentication + +Export and deletion require a purpose-bound reauthentication completed within ten minutes: + +- password accounts verify the current password; +- OAuth-only accounts complete a fresh Google authorization round trip bound to the initiating user ID, current session ID/version, purpose, nonce, and CSRF state. The callback requires Google's verified `sub` to equal the provider ID already attached to that account; it never links or switches accounts during reauthentication. + +Single-use reauthentication records are hashed in D1, expire automatically, and are never logged. + +### Versioned JSON export + +`POST /api/user/export` accepts no target user ID. It generates a `schema_version: 1` JSON response directly, uses a non-identifying filename, and sets private/no-store headers. No export artifact is written to D1 or R2. + +The allowlist contains account fields, the owner's reviews including private unit number, saved buildings, notifications, votes, prospectively linked authenticated contact/bug reports, and verification decision metadata. It excludes password hashes, sessions, tokens, rate-limit rows, raw IPs, audit internals, R2 keys, admin notes, unrelated records, and verification bytes. + +Historic email-only contact/bug-report records are handled manually rather than attributed by email matching. Future authenticated submissions link prospectively by user ID. + +### Deletion and retention matrix + +The deletion migration does not rely on today's accidental foreign-key behavior: + +| Data relationship | Own-review deletion | Account deletion | +|---|---|---| +| Review and user-authored review content | Hard-delete selected review | Retain unchanged except set `user_id = NULL`; content, status, private unit number, and score contribution remain; no reclaim or owner edit/export | +| Account/building claim | Preserve with `review_id = NULL` | Cascade with account; the retained ownerless review has no claim | +| Sessions and auth/verification/reset/reauth tokens | Unchanged | Cascade/invalidate all; clear session cookie | +| Votes cast, saved buildings, notifications | Delete review-dependent rows as applicable | Delete account-owned rows; other users' votes on retained reviews remain | +| Verification document bytes and metadata | Copy opaque keys to non-cascading outbox, then delete metadata | Enqueue every pending object before account deletion; retain only non-sensitive review decision/badge state | +| Integrity flags and scan jobs | Cascade with review | Retain with the retained review | +| Prospectively linked authenticated contact/bug reports | Unchanged unless review-linked | Delete; historic email-only submissions remain manual because they are not attributed by email matching | +| Landlord disputes | Retain the independently submitted dispute, set `review_id = NULL`, and retain no copied review text, address, unit, or reviewer identity | Retain linked to the ownerless review; no reviewer identity is available | +| `verified_by`, `reviewed_by`, `resolved_by` moderator references | Unchanged | `ON DELETE SET NULL`; an admin must first lose privileges through the audited admin flow | +| Audit/action history | Retain action/entity/status evidence; no unit number or new user-authored text | Preserve non-identifying evidence; separately inventory and action-time approve one idempotent, itself-audited minimization of legacy target-user IDs/title/free text; clear admin IP after 180 days | + +Current source is incompatible with this matrix: `reviews.user_id` is `NOT NULL REFERENCES users(id) ON DELETE CASCADE`, multiple admin queries inner-join `users`, and several TypeScript interfaces require a string author. Phase 27 first deploys nullable-author-compatible readers, owner checks, notifications, exports, moderation, and admin labels; it then rebuilds `reviews` with `user_id TEXT NULL REFERENCES users(id) ON DELETE SET NULL`, preserving every column, constraint, and index. Row counts, `PRAGMA foreign_key_check`, export/ownership denial, aggregate parity, and public-leak tests cover the rebuild and every line of the matrix. No shared “deleted user” sentinel is created, and any table added later must choose an explicit behavior in its migration and account-rights tests. + +Legacy audit minimization is not implied by this design approval. The implementation first produces a read-only inventory and digest of affected rows/fields, defines the exact idempotent transformation that removes target-user IDs, titles, and free text while preserving action type, non-identifying entity/status evidence, actor accountability, and timestamps, and displays that scope for separate production action approval. The mutation runs only after atomic audit infrastructure is live, records its own required audit event without copying the removed values, verifies post-state against the approved digest, and never rewrites unrelated immutable history. + +### Own-review deletion + +The owner delete path requires ownership and recent reauthentication. Under the approved deletion matrix, it first creates the versioned erasure intent/write quarantine described below, then prepares a review-level receipt, enqueues verification-object cleanup, deletes the frozen review and dependent private rows, and preserves the account/building claim with `review_id = NULL`. Public aggregates recompute from remaining approved reviews. Repeated deletion is idempotent and non-enumerating. The receipt prevents Time Travel from restoring the deleted review or its private unit number; the lifetime claim remains until account deletion. + +### Account deletion + +Account deletion requires recent reauthentication, typed confirmation, and clear pre-confirmation copy that reviews remain public/score-bearing, lose their owner, keep private moderation fields, and cannot later be edited; a user who wants a review removed must delete it first. Admin accounts receive 409 until privileges are revoked through the audited admin path. The account and frozen set of owned reviews are first versioned and write-quarantined. The commit batch revalidates that set, enqueues every verification object into non-cascading work, writes one local account-unlink job, sets every frozen review's `user_id = NULL`, deletes private account-owned rows and claims, then deletes the user. It writes no descendant review-deletion job because those reviews remain live. A D1 failure leaves the quarantined account intact for conclusive abort/completion rather than reopening it implicitly. After commit, every session is invalidated, the cookie is cleared, and immediate R2 cleanup is attempted; pending document cleanup does not resurrect the account or author link. + +### Coverage across deletion paths + +The receipt protocol is a shared prerequisite for every operation that can delete a review or delete an account/remove author links: owner review deletion, account deletion, admin review deletion, admin building deletion, and any future parent cascade. A first D1 batch creates a non-cascading `erasure_intent`, captures the target version and complete descendant digest, and marks the target `erasure_pending` so every mutating route rejects new edits/children for that request. Only then are external receipts prepared. The commit batch requires the same intent, version, quarantine token, and descendant digest; a changed set produces no operation. Review-kind intents authorize review deletion. Account-kind intents authorize only account deletion plus unlinking the frozen reviews, never deleting those reviews. For an authorized replacement set, the old intent must be explicitly aborted and a newly displayed set separately approved rather than inheriting stale authorization. The batch also includes the required admin audit row where applicable and every verification-object deletion intent. + +Receipt instrumentation deploys to all existing deletion paths before the restore guarantee is advertised. The project then waits one full currently configured Time Travel window plus the two-day safety margin—or separately proves complete receipt coverage for every still-restorable legacy deletion—before calling the guarantee active. During that warm-up, a production restore remains a closed, incident-specific manual decision. + +### Crash-consistent erasure ledger + +Every covered review deletion or account-unlink operation uses the same external state protocol with type-specific replay semantics. A separate private R2 bucket stores markers under `erasure/v1/{key-version}/{request-id}/{state}`. Each marker contains only `kind` (`review` or `account`), `HMAC(versioned ERASURE_LEDGER_KEY, kind + internal_id)`, request ID, state, and timestamps—never the raw ID, email, review text, address, or unit number. `review` means delete that review; `account` means delete that account and ensure its frozen reviews have null authors. Marker writes use conditional create (`If-None-Match: *` or the Workers API equivalent), and a prefix-scoped [R2 Bucket Lock](https://developers.cloudflare.com/r2/buckets/bucket-locks/) for the reverified Time Travel window plus two days prevents overwrite or early deletion even if application code is wrong. + +1. In D1, atomically create the erasure intent/quarantine with request ID, target version, and descendant digest. Every relevant mutation path checks the quarantine. If this batch fails, no external marker or type-specific operation runs. +2. Write `prepared` externally. If this fails, no type-specific operation runs; a conclusive absence check lets a separate D1 batch abort the intent and remove quarantine. +3. In the D1 commit batch, require the exact intent/quarantine/version/descendant digest, write a non-cascading local erasure/unlink job with the same request ID/digest, include the required admin audit where applicable, and commit the type-specific operation. Zero-match is unresolved—not permission to unfreeze. +4. After D1 commit, write the immutable `committed` marker containing `operation_committed_at` from the local job. If that write fails, return accepted/pending and let the local job plus scheduled Worker retry until it exists; a later successful marker upload only extends physical lock coverage beyond the required deadline. +5. Write `aborted` and remove quarantine in one D1 batch only after a consistent read proves the type-specific deletion/unlink operation did not commit and the target still matches the frozen version/set. Timeout, conflict, or unverifiable outcome leaves `prepared` plus quarantine in place rather than guessing. +6. Internal user/review IDs are never reused. For a committed operation, `protect_until = operation_committed_at + reverified Time Travel maximum + two days` (currently 32 days); only then does receipt cleanup begin. Terminal-prefix lifecycle rules and daily reconciliation provide asynchronous backstops until absence of deleted data and removed account links is confirmed. There is intentionally no renewable marker state. A `prepared` marker has no expiry rule until resolved: one hour unresolved is critical, 24 hours requires manual incident ownership, and within seven days a resolver must produce `committed` or `aborted`. Forced completion or manual abort requires separate action-time approval and atomically revalidates the frozen target version/descendant set; an admin-origin completion writes the required recovery audit in that batch. A mismatch never inherits the stale authorization: it remains quarantined until a replacement set is shown and separately approved. Missing the seven-day terminal deadline closes affected deletion/recovery operations and pages the maintainer rather than allowing normal writes around an indefinite tombstone. Retain each marker key version and verification secret until cleanup plus bucket inventory confirm no live marker for that version remains; then destroy the retired secret. Rotation creates a new version and never rewrites live receipts. + +Production restore is an ordered maintenance procedure with an external-to-D1 write fence: + +1. Obtain separate action-time approval for the target bookmark and maintenance window. Enable a temporary deny-all maintenance policy at Cloudflare's edge for every custom, production `pages.dev`, and preview hostname that can reach production; pause the maintenance Worker, deploy hooks, and every other scheduled writer. +2. Verify mutation probes fail on every hostname, wait for in-flight requests to drain, and confirm the write watermark is stable. A crash leaves the external fence enabled. +3. Before the destructive restore, write a minimized pre-restore manifest to a separate private R2 prefix. It contains only release/schema/bookmark metadata, purpose-scoped HMAC membership for live accounts/reviews, still-live receipt states, and deletion-job digests—never raw IDs or user data. Conditional create plus a prefix-scoped seven-day lock/lifecycle makes the manifest immutable for the runbook and bounded afterward; a day-five alert requires resolution or a separately approved replacement before expiry. +4. Restore D1, apply the current migration chain, and verify `reviews.user_id` is nullable with `ON DELETE SET NULL` before receipt replay. Then replay before any public traffic: `committed` review receipts remove the review; `committed` account receipts remove any restored account and re-null ownership across the frozen review set without deleting those reviews; `aborted` without `committed` permits the target; and the only nonterminal state, `prepared`, is resolved against the manifest and the same seven-day terminal rule. Anonymous reviews remain in the live-membership set. Any restored account/review absent from that set is handled according to the type-specific matrix, and any unknown state/key shape fails closed. +5. Run migration/schema/FK checks, row/digest reconciliation, deletion-outbox replay, R2 missing-object reconciliation, and read-only production smoke through an emergency maintainer path. +6. Obtain separate approval to reopen, remove the write fence, resume scheduled writers/deploys, run post-open smoke, and confirm the manifest is queued for lifecycle expiry. If evidence conflicts, the manifest expires before resolution, or the pre-restore database could not be captured, traffic stays closed for manual incident resolution—ambiguity never silently reactivates data. + +Crash-injection tests stop execution after marker preparation, immediately before/after D1 commit, during committed/aborted writes, during retry registration, while writing the response, during receipt/manifest expiry, at every maintenance-fence transition, and during type-specific replay—including a pre-change-schema restore upgraded before account replay. Committed logical retention ends at `operation_committed_at + platform window + two days`; unresolved preparation becomes a terminal state within seven days, and terminal asynchronous cleanup remains observable/retriable until deleted bytes/data are absent and removed links are confirmed. Receipts and manifests are never used for analytics or account matching. No indefinite email tombstone or general-purpose identity hash is created. + +## 7. Release, observability, recovery, and admin perimeter + +This foundation ships in Phase 22, before verification retries, integrity scan jobs, or account erasure depend on it. + +### CI and deploy path + +GitHub CI runs `npm ci`, `npm run check`, `npm test`, and `npm run build` with least-privilege permissions and concurrency cancellation. A stable required status check protects `main`; the existing Cloudflare Pages Git integration remains the sole deployer. + +After a production deployment, a workflow verifies the deployed commit and runs the smoke suite against `https://ratemyplace.org`. Phase 22 covers current public pages/APIs, protected-route denial, release, and health. Phase 24 extends it with maintenance freshness; Phase 29 adds healthy/fallback sitemap, social image, robots, noindex, and canonical checks only after those surfaces exist. Turnstile and Maps remain explicit production-only touched-flow checks. + +### Atomic destructive-admin auditing + +Phase 22 inventories every destructive admin route and adds a nullable-legacy, unique `operation_id` to `audit_logs`; every new destructive action must supply it. Shared audit library code builds a parameterized conditional `INSERT ... SELECT` statement and never fires the required audit after the domain change. That audit row is the request-scoped operation guard: it is inserted first from the exact authorization/current-state predicate, and the mutation plus durable external-side-effect intents execute only where that unique operation ID exists. Bound predicate values are captured once and D1 runs the batch sequentially, so a missing/stale target creates neither guard nor downstream effects; a concurrent loser sees the winner's changed state and also creates zero effects. Any statement/constraint failure rolls back the guard and action together. External R2/email work starts only after commit and remains independently retriable. Tests assert exact audit/mutation/outbox counts for winner, stale, missing, replayed-token, and injected audit-failure cases. Once every route has cut over, the same release updates `src/lib/AGENTS.md` and the legacy `createAuditLog` comment so future work is no longer instructed to swallow required destructive-audit failures. + +### Health and structured events + +A public health endpoint initially returns generic status and release identifier; maintenance freshness is added when the scheduled Worker ships. It never exposes table names, counts, provider messages, or secrets. Human operational detail stays behind both Cloudflare Access and Lucia `isAdmin` and shows maintenance heartbeat, oldest deletion job, and oldest pending moderation item. + +GitHub synthetic smoke uses a separate, narrow machine-only ops-health route outside `/api/admin`. Cloudflare Access requires a dedicated service token, and the application independently verifies a timestamped HMAC request signature with a separate `OPS_HEALTH_HMAC_KEY`; neither credential can create a Lucia session or call an admin route. The response contains only release match plus healthy/degraded booleans and coarse ages for heartbeat/alert backlog—no counts, IDs, names, table details, or provider errors. Tests cover valid access, replay/stale signature, either credential missing, secret/token rotation and revocation, and denial to an ordinary browser or authenticated non-admin. + +Structured events use typed names plus subsystem, operation, outcome, request ID, release, opaque job/entity identifier, attempt count, and normalized error code. They never contain unit number, email, reviewer IP, filename, review content, or legacy identifying R2 keys. Threshold-bearing outcomes also increment a bounded `operational_counters` row keyed only by event code and time bucket; actionable one-off failures insert or update a deduplicated `alert_outbox` row with no user content. Counter buckets and delivered/resolved alert rows are retained for 30 days and then physically deleted; pending/dead-letter rows remain only until resolution, after which the same 30-day expiry applies. + +The maintenance Worker evaluates counters, creates deduplicated alerts, and drains `alert_outbox` every five minutes to a provider-neutral HTTPS maintainer webhook stored as a secret. Delivery failure keeps the row pending with bounded backoff; after ten attempts or 24 hours it is marked dead-letter, remains visible, and causes every machine-health check to fail until acknowledged/resolved. A GitHub workflow is scheduled on a five-minute cadence away from the top of the hour and calls the dedicated machine route; its own tested GitHub failure notification is an independent **best-effort** fallback when the Worker, D1, or webhook path is unavailable. GitHub documents that scheduled runs may be delayed or dropped under load, so a hard dead-man SLA would require a separately approved uptime provider: [GitHub scheduled-workflow timing](https://docs.github.com/en/actions/how-tos/troubleshoot-workflows#scheduled-workflows-running-at-unexpected-times). Pages deployment notifications remain a separate deployment channel. Cloudflare traffic error-rate alerts are enabled only if the account plan exposes them; the roadmap does not depend on an Enterprise-only feature. + +The testable objective under normal Worker/webhook availability is: a critical one-off event is durable in the outbox during its request, the Worker attempts delivery within five minutes, and successful webhook delivery occurs within ten minutes. Under normal GitHub scheduler availability, a D1 outage, missed Worker heartbeat, or aged/dead-letter webhook row targets detection within ten minutes; tests verify the route and notification behavior, not GitHub's queue latency. Failure-injection tests cover each source, processor, destination, and fallback independently. + +Immediate alert conditions include required-access-journal failure, destructive-audit transaction failure, aged/exhausted verification deletion, orphan detection, missed maintenance heartbeat, schema mismatch, and post-deploy smoke failure. Provider/auth/Turnstile/email signals alert on abnormal bucketed thresholds rather than expected individual user failures. The exact webhook provider, service token, thresholds, and recipients require configuration-time approval and a delivered test alert. Workers Logs remain diagnostic rather than being mistaken for a custom-field alerting system; current notification availability is rechecked before configuration: [Cloudflare Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) and [Cloudflare available notifications](https://developers.cloudflare.com/notifications/notification-available/). + +### Migration reconciliation and recovery + +Before migration `0029`, inspect live `d1_migrations`, `sqlite_master`, PRAGMA columns/indexes/FKs, and final-state predicates for 0025–0028. Capture a Time Travel bookmark, record file hashes/evidence, and—only under a separate production-mutation approval—insert ledger rows for verified out-of-band migrations using the live ledger schema. Confirm Wrangler reports 0001–0028 applied, update root and migration agent guidance with the reconciled state, and retire the stale sync script. Ledger reconciliation never shares a deploy with a feature migration. + +Each subsystem follows its own expand/deploy/verify/contract cycle. The milestone does not accumulate all additive schemas and then perform one large code deploy. Table rebuilds preserve all rows/indexes/constraints and pass row counts plus `PRAGMA foreign_key_check`. Destructive production migration or restore always has a separate explicit approval. + +D1 restore is rehearsed only on a synthetic remote database, whose creation, restore, and deletion are separate external-action gates. Phase 22 rehearses the external write fence, durable minimized manifest, crash-closed behavior, synthetic receipt fixtures, and ordered reopen steps. Phase 27 reruns the full drill with the implemented receipt writer/replayer and warm-up rules before the restore guarantee activates. Verification documents have no R2 recovery: deleted or lost bytes are never recreated. A D1 restore that resurrects metadata pointing to an absent document is reconciled to missing/deleted, not repopulated. The restored database is migrated to nullable `reviews.user_id ... ON DELETE SET NULL` before review-erasure/account-unlink receipts are replayed and before production traffic resumes. + +These retention assumptions are tied to current platform behavior: Cloudflare documents a 30-day D1 Time Travel window on Workers Paid (7 days on Free), while R2 lifecycle deletion is asynchronous and typically completes within 24 hours of expiry. The implementation plan must re-verify both limits immediately before configuring retention: [D1 Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) and [R2 object lifecycles](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). + +### Admin perimeter + +The activation inventory covers `ratemyplace.org`, any `www` alias, the production `ratemyplace-64y.pages.dev` hostname, branch aliases, and immutable hash-based preview deployments. Cloudflare Access protects `/admin/*` and `/api/admin/*` on every reachable production hostname with an explicit maintainer allowlist, MFA, and a short administrative session; Pages preview Access protects every preview deployment. The production `pages.dev` hostname redirects ordinary traffic to the canonical domain, while direct admin/API requests remain protected rather than relying on the redirect. Preview deployments use isolated non-production bindings and receive no production D1/R2 secrets. Lucia and `isAdmin` checks remain mandatory inside every route. Activation tests unauthorized/authorized browser access, direct API calls, production `pages.dev`, a branch alias, an old immutable preview URL, and emergency recovery. Cloudflare documents that preview Access alone does not protect the production `pages.dev` or custom domain, so each hostname is tested explicitly: [Pages preview deployments](https://developers.cloudflare.com/pages/configuration/preview-deployments/) and [Pages Access known issue](https://developers.cloudflare.com/pages/platform/known-issues/). + +## 8. Accessible contribution + +The public critical path is: + +```text +home address search -> select/add address -> auth -> email verification +-> retained address -> all review steps -> submit -> confirmation/building +``` + +It must be operable without pointer, hover, color perception, or unlabeled placeholders. + +- Home and review address controls implement labeled combobox/listbox semantics, stable options, active descendant, result-count status, Up/Down/Enter/Escape behavior, and named clear buttons. +- Each rating question uses native `fieldset`/`legend` and radio controls for 1–5 and Not rated; selected state is not color-only and targets meet 44×44. +- Step progress is an ordered list with `aria-current="step"`; step headings receive focus and validation failures focus a linked error summary. +- Verification/resend state explains email versus residency verification and announces result without exposing the full email unnecessarily. +- A shared dialog pattern provides name, initial focus, trap, Escape close, and focus restoration. +- Public disclosures and admin expanders use native buttons with expanded/controlled state. +- Global layout gains a skip link, main target, visible focus, and reduced-motion behavior. + +Automated axe and semantic component tests cover regressions. Manual keyboard, NVDA, 200% zoom, contrast, reduced-motion, and 375px checks validate the complete flow with seed data only. + +## 9. Discoverability + +`BaseLayout` receives a narrow SEO contract: title, description, canonical path, optional noindex, image path, and safe JSON-LD nodes. Canonical origin comes from Astro's configured `site = https://ratemyplace.org`, never an untrusted Host header. One shared serializer applies `JSON.stringify` and then escapes `<`, `>`, `&`, U+2028, and U+2029 before any `set:html`; SSR tests use a named-party sentinel containing `