From 2e34b4126668facc8707af7a76744dbc5e3e81d1 Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Thu, 27 Aug 2026 00:20:28 -0400 Subject: [PATCH 01/18] docs: plan v1.6 trust and density --- .planning/PROJECT.md | 36 +- .planning/ROADMAP.md | 31 +- .planning/milestones/v1.6.0-REQUIREMENTS.md | 146 +++++ .planning/milestones/v1.6.0-ROADMAP.md | 244 +++++++++ .../specs/2026-08-26-trust-density-design.md | 518 ++++++++++++++++++ 5 files changed, 962 insertions(+), 13 deletions(-) create mode 100644 .planning/milestones/v1.6.0-REQUIREMENTS.md create mode 100644 .planning/milestones/v1.6.0-ROADMAP.md create mode 100644 docs/superpowers/specs/2026-08-26-trust-density-design.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index a090e91..ec0dec2 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 +- [ ] Review and approve the v1.6.0 "Trust + Density" design. +- [ ] 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"** — Proposed 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 and visible to authorized admins, never on public surfaces; owner edit/export redisplay is a pending design decision. + +- Roadmap: `.planning/milestones/v1.6.0-ROADMAP.md` +- Requirements: `.planning/milestones/v1.6.0-REQUIREMENTS.md` +- Design for review: `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-26 for v1.6.0 "Trust + Density" design review* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2158d0c..b556abf 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 (planned; design review pending) ## 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-26 — v1.6.0 "Trust + Density" proposed for design review* diff --git a/.planning/milestones/v1.6.0-REQUIREMENTS.md b/.planning/milestones/v1.6.0-REQUIREMENTS.md new file mode 100644 index 0000000..0bf4a78 --- /dev/null +++ b/.planning/milestones/v1.6.0-REQUIREMENTS.md @@ -0,0 +1,146 @@ +# Requirements: RateMyPlace Boston — v1.6.0 "Trust + Density" + +**Defined:** 2026-08-26 +**Status:** PLANNED — design review pending +**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. Authorized admins may read it. Whether it is redisplayed to the submitting reviewer in edit/export flows is a decision gate; 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. +- 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. +- 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 and authorized-admin moderation flows. Owner edit/export redisplay is implemented only if the access-matrix decision is approved. +- [ ] **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. Preserve `audit_logs.admin_ip` only as a separately disclosed, access-controlled security record; make it nullable and have scheduled maintenance clear it after the approved retention period (proposed: 180 days). +- [ ] **PRIV-06:** If approved at the design gate, remove Google OAuth `profile` scope and stop collecting/storing reviewer names and avatars while preserving email-only authentication. +- [ ] **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 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:** If the retention decision is approved, 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. +- [ ] **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 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/account deletion, 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:** If the lifetime-claim default is approved, 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:** After the numeric default is approved, put the new-claim limit and window in exported constants and enforce five new review claims per account per rolling 24 hours through one atomic conditional insert; do not duplicate the literals in a SQL trigger. +- [ ] **INT-05:** After the numeric defaults are approved, 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 legacy claim with the source review's original `created_at`, 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, and verification decision metadata. Include private `unit_number` only if owner redisplay/export is approved. +- [ ] **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:** If the proposed hard-deletion matrix is approved, permit an ordinary user to hard-delete the account and associated reviews after reauthentication and typed confirmation. +- [ ] **RIGHT-07:** Enqueue every associated R2 object before D1 cascades, invalidate every session, clear the cookie, and keep retry jobs alive after the account row is gone. +- [ ] **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 erasure receipts in a separate private store for every direct or cascading review/account deletion, including owner deletion, admin review deletion, account deletion, and parent-building cascade. Before writing `prepared`, atomically create a D1 erasure intent that quarantines the target from new writes and records its version plus complete descendant digest; the deletion 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 `deletion_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 every boundary; no ambiguous receipt silently reactivates data and no receipt becomes an indefinite tombstone. +- [ ] **RIGHT-11:** Implement and test an explicit deletion/retention matrix for every user/review-linked table, including 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. Before implementation, the owner must choose either an audited, separately approved one-time minimization of identified legacy audit payloads (preserving non-identifying action evidence) or an explicit bounded-retention exception; do not silently override the immutable-audit contract. +- [ ] **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; replay receipts and manifest differences; run schema/data/R2/smoke checks; then reopen only under separate approval. The restore guarantee is not declared active until every deletion 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..a46776b --- /dev/null +++ b/.planning/milestones/v1.6.0-ROADMAP.md @@ -0,0 +1,244 @@ +# Roadmap: v1.6.0 "Trust + Density" + +**Created:** 2026-08-26 +**Status:** PLANNED — design review pending +**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/deletion, explicit cross-table retention, and restore-safe review/account erasure 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, and admin-visible, but recursive tests find no `unit_number` key or value on any public surface. Owner redisplay/export follows the approved access-matrix decision. +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. If approved, Google OAuth authenticates with verified email without collecting or persisting profile name/avatar data. +5. `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. If 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. If the lifetime-claim default is approved, deleting an individual review does not permit a second review for the same account/building; deleting the account removes its claim. +4. After the numeric default is approved, five new claims per account per rolling 24 hours is enforced by an atomic conditional insert using exported constants, with no duplicated trigger literal. +5. Building, landlord, 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 review owner/building pair, 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 and erase their data safely across D1 and R2, and a later D1 restore cannot silently revive a deleted review or account. + +**Requirements:** RIGHT-01–RIGHT-13 + +**Success criteria:** + +1. A recently reauthenticated user can download a direct JSON export containing only the documented allowlist; private unit number follows the approved owner-access decision. +2. Under the approved deletion matrix, an ordinary user can delete an owned review or account; every session is invalidated and every associated R2 deletion is complete or durably pending. +3. A D1 failure leaves the account intact; an R2 failure after D1 commit never resurrects the account and remains retriable. +4. A table-by-table contract proves what cascades, is retained, is nulled, or follows the approved audit-payload decision for disputes, prospectively linked contact/bug reports, moderator references, verification data, claims, and audit history; admin self-deletion is rejected. +5. Every direct and cascading review/account deletion—including admin review and building deletion—uses a versioned D1 erasure intent plus external conditional-create receipt. The intent quarantines writes and freezes the target version/descendant digest before `prepared`; protection runs through deletion commit plus the reverified Time Travel window and margin. 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. +6. 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 and replays, runs integrity/smoke checks, and reopens only under separate approval; a crash leaves traffic closed and the manifest durable. +7. The restore guarantee activates only after every deletion 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. +8. Crash injection at every R2/D1/response boundary plus HMAC-key rotation, retention, manifest-expiry, and replay tests prove receipt confirmation and recovery remain durable without indefinite pseudonymous tombstones. + +**Planned plan documents/sub-releases:** export and reauthentication; table-by-table deletion contract; review/account erasure 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. +- 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 status changes only after source verification. Each phase receives a dedicated implementation plan after the v1.6 design is approved.* 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..2451fc8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-trust-density-design.md @@ -0,0 +1,518 @@ +# Trust + Density Design + +**Date:** 2026-08-26 +**Milestone:** v1.6.0 "Trust + Density" +**Status:** Proposed for owner review +**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 and permanent deletion paths rather than unqualified promises for unbuilt capabilities. +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. + +### Proposed defaults requiring owner approval + +- the owner may see/edit/export their own unit number after submission, while authorized admins may view it for moderation; +- Google OAuth drops `profile` scope and existing reviewer name/avatar data is removed; +- undecided verification documents enter deletion after 30 days; +- admin audit IP is cleared after 180 days; +- five new claims per account per 24 hours, with human-review velocity flags at five building or ten landlord submissions; +- an account/building claim survives individual review deletion but is removed with the account; +- account deletion hard-deletes its reviews, with short-lived external erasure receipts protecting both review and account deletion from Time Travel resurrection. +- identified legacy audit JSON is either minimized through a separately approved, itself-audited operation or retained under an explicit bounded exception; the owner must choose which contract outranks the current immutable-audit rule. + +## 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 | Proposed | Proposed | Decision gate; ownership is checked before any response/update | +| Owner's authenticated data export | Proposed | No | Decision gate; included only if owner redisplay is approved | +| 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, if approved, 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/account lifecycle. The value is deleted with the review or account and 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. Under the proposed retention default, `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 + +If approved, Google OAuth requests `openid email`, verifies Google's `email_verified` claim, and stores only the provider ID and normalized email required for authentication. Name/avatar writes and display-name editing are removed. Existing name/avatar values are purged only after a deployed release has no remaining readers or writers. + +## 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/account deletion, 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). + +Parent deletion 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, and account deletion include both their required audit row and every associated deletion key in the same D1 batch before cascades can remove verification lookup rows. The independent registry/upload tombstone survives those cascades 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. + +If the default is approved, 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 (until its retention expiry), verification ID, request ID, outcome, and timestamps only—never filename, R2 key, tenant email, unit number, or contents. + +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()) +``` + +If the default is approved, the claim remains if the review is individually deleted, preventing resubmission under the same account; account deletion removes it. 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; 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. + +### 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 reviews, counts both claims and not-yet-backfilled recent reviews for the allowance, and writes the claim/job for every accepted review. If the lifetime-claim default is approved, 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 reviews into claims using original review timestamps; 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 review has the intended claim, 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 proposed allowance is five new claims per account in a rolling 24-hour window. 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, saved buildings, notifications, votes, prospectively linked authenticated contact/bug reports, and verification decision metadata. Private unit number is included only if owner redisplay/export is approved. 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 | Hard-delete all owned reviews | +| Account/building claim | Preserve with `review_id = NULL` if the proposed lifetime-claim rule is approved | Cascade with account | +| 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 | Cascade all account-owned rows | +| Verification document bytes and metadata | Copy opaque keys to non-cascading outbox, then delete metadata | Same for every owned review before user cascade | +| Integrity flags and scan jobs | Cascade with review | Cascade through reviews/account | +| 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 | Same | +| `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 | Decision gate: either separately approve and audit minimization of identified legacy target-user IDs/title/free text while preserving non-identifying evidence, or document a bounded retention exception; clear admin IP after the approved period | + +Row-count, foreign-key, export, and public-leak tests cover every line of the matrix. Any table added later must choose one of these behaviors in its migration and account-rights tests. + +### 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—if approved—preserves the account/building claim. Public aggregates recompute from remaining approved reviews. Repeated deletion is idempotent and non-enumerating. The receipt prevents Time Travel from restoring the deleted review, its private unit number, or its live claim link. + +### Account deletion + +Account deletion requires recent reauthentication and typed confirmation. Admin accounts receive 409 until privileges are revoked through the audited admin path. Under the approved deletion matrix, the account and its descendant set are first versioned and write-quarantined. The deletion batch revalidates that frozen set, enqueues every verification object into non-cascading work, writes local erasure jobs for the account and each descendant review, and deletes the user according to the matrix. 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. + +### Coverage across deletion paths + +The erasure protocol is a shared prerequisite for every operation that can delete a review or account: 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 deletion batch requires the same intent, version, quarantine token, and descendant digest; a changed set produces no deletion. 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 and every verification-object deletion intent where applicable. + +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 deletion uses the same external protocol. 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. 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 deletion runs. +2. Write `prepared` externally. If this fails, no deletion runs; a conclusive absence check lets a separate D1 batch abort the intent and remove quarantine. +3. In the D1 deletion batch, require the exact intent/quarantine/version/descendant digest, write a non-cascading local erasure job with the same request ID/digest, include the required admin audit where applicable, and commit the deletion. Zero-match is unresolved—not permission to unfreeze. +4. After D1 commit, write the immutable `committed` marker containing `deletion_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 deletion 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 deletion, `protect_until = deletion_committed_at + reverified Time Travel maximum + two days` (currently 32 days); only then does application cleanup begin. Terminal-prefix lifecycle rules and daily reconciliation provide asynchronous backstops until absence 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, then replay before any public traffic: `committed` wins; `aborted` without `committed` permits the target; the only nonterminal state, `prepared`, is resolved against the manifest and the same seven-day terminal rule; and any restored account/review absent from the frozen live-membership set is removed or quarantined according to the deletion matrix. 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 replay. Committed logical retention ends at `deletion_committed_at + platform window + two days`; unresolved preparation becomes a terminal state within seven days, and terminal asynchronous deletion remains observable/retriable until bucket absence is 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; review/account erasure receipts are replayed before production traffic can resume. + +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 `