Plan 81 R11 - SekiroKenjii/Quizzivy GitHub Wiki

R11 β€” Landing, leads and v1.0 hardening (v1.0.0)

The last release of Phase R. It puts the deck's Landing on the apex quizzivy.com (D10), makes the apex a second origin of the same app, moves join links and QR codes to it, and adds the consultation form with consent, a privacy notice and an Admin list of requests (D18, DG-06, DG-57). It then runs the hardening Phase 5 planned and never shipped: the rate-limit suite and leak review (T-5.2), the accessibility pass (T-5.3), bundle budgets (T-5.4) and the native Vietnamese review (T-5.8). The query review (T-5.5) shipped in R9. The Landing ships last because its copy markets every module (70 Β§2 rule 8).

Screens are compared with docs/design/deck/Quizzivy Landing.dc.html. The privacy, status and Admin consultation pages are not drawn and are built from deck parts (DG-06, DG-57, DG-100). Branch work/redesign-r11 is cut from develop. External steps (Cloudflare, Google console) are Thuong's, as in T-R0.8.

Deliverable: the Landing at https://quizzivy.com/ in Vietnamese and English, light and dark, with join by code and a consultation form; the apex as a second app origin (credentialed CORS, a session hint cookie, a second Google redirect URI); join links and QR codes on the apex; leads stored with consent for 12 months, emailed to the centre without the phone number, and listed in the Admin console; a privacy notice and a status page; the rate-limit suite, axe in CI, bundle budgets, the reviewed Vietnamese copy; the spec at v1.0.

Exit criteria:

  • The Landing matches the deck at 360, 720, 768, 960, 1024, 1280 and 1440, light and dark, vi and en (T-R11.15).
  • An anonymous visit to https://quizzivy.com/ makes no request to the API. A signed-in visit lands on the visitor's console. Sign-in, Google sign-in and join work on both origins (apex.live.spec.ts).
  • Every open operation is driven past its limit by the suite, and the leak review of every public response is recorded.
  • axe reports no serious or critical violation on the routes of T-R11.11. Lighthouse accessibility is 95 or more on the eight routes named there.
  • Every route's initial JS is within web/bundle-budgets.json, and the Landing's is under 200 KB gzip.
  • The native review is applied, and vi and en have the same keys.
  • The five canaries pass untouched; every CI step of 70 Β§8 is green locally, including pnpm e2e:live and pnpm e2e:live-apex.
  • Released to production as v1.0.0.

Schema changes (numbers assigned at merge):

File Task Notes
NNNNN_create_leads.sql T-R11.6 new table
NNNNN_add_organization_lead_recipients.sql T-R11.6 R5's settings singleton; constant default

Operations:

Operation x-permission Task
POST /public/leads (submitLead, the thirteenth open operation, 70 Β§4.2) none: open (security: []), rate-limited, leak-reviewed T-R11.6
GET /admin/leads, GET, PATCH, DELETE /admin/leads/{id} system.leads T-R11.6
R5's organization settings: gains leadRecipients system.settings.write (unchanged) T-R11.6
R5's admin summary: gains newLeads unchanged T-R11.6
Login, Google sign-in, refresh, logout, R5's set-password completion: a second Set-Cookie unchanged T-R11.1

Order of work. The hint cookie (T-R11.1) and the leads backend (T-R11.6) come first, and the locale split (T-R11.12a) early, because every later landing string lands in its file. The root route (T-R11.3) needs T-R11.1. The landing sections, the form and the pages follow. The apex infrastructure (T-R11.2) is prepared in parallel and switched on at release. The hardening tasks (T-R11.10–T-R11.13) run once the product is feature-complete. At most four agents at a time.

Decisions taken in this file, worth a second look.

  • The apex serves the same SPA as app.quizzivy.com, and differs only at /. On the host named by VITE_LANDING_HOST (quizzivy.com), / is the Landing. On the app host, / keeps today's redirect to the console or to /login. Every other route is identical on both. The Landing's links (/login, /join/:code, /privacy) therefore stay on the apex, and a student who opens a join QR finishes signing in and enrolling on the origin they started on.
  • A signed-in visitor on the apex stays on the apex. Bouncing them to app.quizzivy.com would drop the in-memory access token mid-flow and split the join journey across two origins. localStorage is per origin, so theme, language and local answer drafts are kept separately on each; drafts are backed by server autosave, so nothing is lost. Console pages carry X-Robots-Tag: noindex on both hosts.
  • "Signed in?" comes from a non-secret hint cookie, never from a refresh probe. The API sets quizzivy_signed_in=1; Domain=quizzivy.com; Path=/; Secure; SameSite=Lax (not HttpOnly, holding no identity), with the refresh cookie's lifetime, whenever it sets the refresh cookie. It clears the hint on logout and on every refused refresh. The Landing calls /auth/refresh only when the hint is present. Probing on every visit would wake Neon for every anonymous visitor and crawler, and rotate tokens against open app tabs. A localStorage flag cannot work, because it is per origin. The hint is a performance hint, never an authorisation signal: the refresh cookie still decides.
  • The cross-origin refresh race is the residual R-06 already accepts. Two tabs on different origins refreshing at the same moment can trip reuse detection, exactly as two tabs on one origin can today. The hint keeps anonymous visits off /auth/refresh, so the apex adds no new race for people who are not signed in. A server grace window would be a Β§5 change and is not made here.
  • An anonymous Landing makes no API call at all. The contact block (phone, email, address) reads build-time VITE_ORG_* values, not R5's organization settings: R1's VITE_ORG_NAME and VITE_ORG_FRONT_DESK_PHONE, and VITE_ORG_EMAIL and VITE_ORG_ADDRESS, which T-R11.4b adds. R5 keeps these build values as the Landing's source and the front door's fallback. A public read of those settings would reach the database on every visit, and a crawler would keep Neon awake. The cost is that editing the contact details in Admin settings reaches the Landing at the next build (O-R11.4). The consultation submit and the /status page are the only calls, each on an explicit action.
  • The app host keeps probing the session without a hint. Users signed in before v1.0 hold a refresh cookie but no hint until their next refresh. Gating the app host on the hint would sign them all out. Only the Landing route is gated.
  • Join links and QR codes move to the apex; nothing else does. VITE_PUBLIC_ORIGIN (https://quizzivy.com) builds the class link and the QR payload. Old app.quizzivy.com/join/… links and printed QR codes keep working, because the route exists on both hosts. Email links (R5's set-password link, R7's notifications) stay on the app host, where the consoles have always lived.
  • Testimonials render only with recorded consent, and never as placeholders. DG-100's interim is "placeholder testimonials until consent". On a production page, a quote with a name that nobody said is a fabricated review. The section reads a static list whose entries each carry a consentRecordedAt, and it is hidden while the list is empty (O-R11.2).
  • Marketing copy states what shipped. The deck's copy predates several releases. Features says "Five question types", where R6 shipped more. FAQ 1 says students "create an account", but only Google sign-in creates one. FAQ 5 describes a reset flow that R5 changed. The product mock's address pill reads quizzivy.com/admin for a teacher dashboard. Each line is rewritten to match the product (vi first), and the PR lists every sentence that departs from the deck.
  • A lead submission always answers 202 {received: true}. Nothing is echoed. Validation names only the field. Per-IP limits answer 429. Two cases are dropped silently and still answer 202: a honeypot field that was filled, and a phone number with three requests in the last 24 hours. A 429 there would tell anyone that a number had been used. There is no captcha: it would add a third party and bot detection to a form with a few requests a day.
  • Consent is part of the contract. The request must carry consent: true and a privacyNoticeVersion from an enum (the first is 2026-10), so the schema refuses anything else. Both are stored with the lead. Changing the notice adds an enum value, and the stored version says what each person agreed to.
  • The notification email carries no phone number. It gives the first name, the interests, who the course is for, the best time to call, and a link to /admin/leads/{id}, which needs an admin session. The person agreed to a call back, not to their number sitting in several inboxes (the same rule as R5's data-export link and D15).
  • A lead stores no IP address or user agent. The rate limiter already holds the IP, in memory only. The lead holds what the form asked for, the consent, and its handling. The retention sweep deletes leads after 12 months (D18), and an admin can delete one on request.
  • Consultation requests sit under the Admin console's People group, as "Consultation requests", behind the hidden system.leads key (70 Β§4.1), with a count badge of new requests like the Users badge. DG-57 names no place for them. People is where the Admin console lists the other humans it tracks.
  • Locale files split by surface, keys unchanged. Today both locales (about 260 KB of JSON) sit in the entry chunk. That alone would break any honest Landing budget. The files split by top-level key into common, auth, landing, student, teacher and admin. common stays in the entry, and each tree loads its surface for the active language before rendering, merged into the one translation namespace. No t() call site changes, and the copy-pinned tests load every surface in setup. The split is its own mechanical PR.
  • Budgets are measured, then pinned; the Landing's has a ceiling. Each route's budget is its R11 measurement plus 10%, in web/bundle-budgets.json. Raising a budget is a reviewed change to that file. The Landing may not exceed 200 KB gzip whatever the measurement, because it is the page a stranger opens cold on a phone.
  • The rate-limit suite is a Go end-to-end test over the contract, not a Playwright spec. Limits are server behaviour. The suite reads api/openapi.yaml, so a new open operation without a case fails the build. The web's 429 states are unit-tested with the screens that show them.
  • /status is a real page. The deck's footer links "Status" to the maintenance frame (DG-100). /status reads GET /public/status (R1) when visited, and says "All systems normal", shows the scheduled window, or shows R1's maintenance content.

T-R11.1 β€” The session hint cookie

Depends on: β€” Touches: server/internal/modules/identity/http/{cookie.go,auth.go,google.go}, R5's set-password completion handler, server/internal/platform/config/config.go, server/internal/platform/config/tests/, server/internal/modules/identity/http/tests/cookie_test.go, api/openapi.yaml (the Set-Cookie descriptions on login, Google sign-in, refresh, logout and set-password), generated code, .env.example, fly.toml Size: S Done when:

  • Config SESSION_HINT_COOKIE_DOMAIN: a bare domain (no scheme, port or path; startup refuses anything else). Empty means a host-only cookie, which is what local development on localhost needs. .env.example documents it, and fly.toml sets quizzivy.com.
  • sessionHintCookie builds quizzivy_signed_in=1 with Path=/, the configured Domain, Secure from REFRESH_COOKIE_SECURE, SameSite=Lax, no HttpOnly, and Max-Age equal to the refresh TTL. clearSessionHintCookie sets the same attributes with Max-Age=-1.
  • Every response that sets the refresh cookie also sets the hint: login, Google sign-in, refresh, and R5's set-password completion when it signs the user in. Logout clears both. Every refused refresh (401) clears the hint.
  • Two cookies need two Set-Cookie headers, which the generated single-header response types cannot write. Those responses use visitors that add both, as clearedSession does for logout today.
  • Tests: the attributes; set on each path; cleared on logout and on each refusal outcome (unknown, expired, reused, revoked, disabled); host-only when the domain is empty; config refusal.
  • client.refresh.test.ts is untouched. No web code reads the hint before T-R11.3.

T-R11.2 β€” The apex as a second origin

Depends on: T-R11.1 Touches: fly.toml (CORS_ALLOWED_ORIGINS, GOOGLE_REDIRECT_URI, SESSION_HINT_COOKIE_DOMAIN), server/internal/platform/httpx/tests/cors_test.go, web/public/{_headers,robots.txt (new),sitemap.xml (new)}, .github/workflows/deploy.yml (VITE_LANDING_HOST, VITE_PUBLIC_ORIGIN), .env.example, scripts/{verify-deployment.mjs,verify-google.sh}, web/playwright.config.ts (the live project ignores the apex spec), web/tests/support/apex/playwright.config.ts (new, the live-apex project), web/vite.config.ts (preview.allowedHosts), web/package.json (e2e:live-apex), .gitignore (dist-apex/), .github/workflows/ci.yml (the e2e-live job's second API and apex step), web/tests/e2e/apex.live.spec.ts (new), docs/setup/dns.md, docs/plan/00-overview.md Β§4.1, docs/plan/30-risks.md (R-07) Size: M Done when:

  • CORS_ALLOWED_ORIGINS is exactly https://app.quizzivy.com,https://quizzivy.com: credentialed and exact-origin, as httpx.CORS already does. cors_test.go covers both origins, preflights for PATCH and DELETE, and refuses https://www.quizzivy.com and http://quizzivy.com.
  • GOOGLE_REDIRECT_URI lists https://app.quizzivy.com/auth/google/callback and https://quizzivy.com/auth/google/callback. loadGoogle already splits the list, and exchange-time matching stays exact. verify-google.sh checks both against T-R0.8's Google console entry.
  • Cloudflare (Thuong), documented in docs/setup/dns.md: - quizzivy.com added as a custom domain of the existing Pages project - www.quizzivy.com 301-redirected to the apex by a Redirect Rule - both proxied
  • _headers: - https://app.quizzivy.com/* sends X-Robots-Tag: noindex - the console, auth and join paths (/app/*, /teacher/*, /admin/*, /login, /auth/*, /join/*, /set-password, /change-password) send it on every host - the CSP is unchanged: connect-src already names https://api.quizzivy.com
  • robots.txt allows / and /privacy and disallows the console trees. sitemap.xml lists https://quizzivy.com/ and https://quizzivy.com/privacy.
  • verify-deployment.mjs smokes both origins: the SPA shell at /, /login, and a credentialed preflight to the API from each origin.
  • apex.live.spec.ts runs in its own Playwright project, live-apex, never in live. The existing build bakes in VITE_API_BASE_URL=http://localhost:8080 and the existing API allows only http://localhost:4173, so the apex cannot share them without breaking the localhost live specs. Like e2e:content, it has its own config, web/tests/support/apex/playwright.config.ts: - its own webServer: a separate build (--outDir dist-apex) with VITE_API_BASE_URL=http://api.quizzivy.test:8081, VITE_LANDING_HOST=quizzivy.test and VITE_PUBLIC_ORIGIN=http://quizzivy.test:4174, previewed on port 4174 - Chromium with --host-resolver-rules="MAP quizzivy.test 127.0.0.1, MAP *.quizzivy.test 127.0.0.1" (.test names, as AGENTS allows); the preview accepts quizzivy.test and app.quizzivy.test (Vite preview.allowedHosts) - web/playwright.config.ts's live project gains testIgnore for apex.live.spec.ts - pnpm e2e:live-apex runs it
  • A second API process serves the apex on :8081, from the same binary and database, with API_PORT=8081, CORS_ALLOWED_ORIGINS=http://quizzivy.test:4174,http://app.quizzivy.test:4174, SESSION_HINT_COOKIE_DOMAIN=quizzivy.test and REFRESH_COOKIE_SECURE=false. In ci.yml's e2e-live job, a step starts it after the first API, waits for its /healthz, and runs pnpm e2e:live-apex after pnpm e2e:live; the failure log prints both APIs' output. The :8080 API and its env are unchanged.
  • The spec needs a reason under AGENTS' live rule, and has one: the claim is that the two origins and the API meet. Cases: - an anonymous apex / shows the Landing with no API request - sign in on the app host, then open the apex /: the console opens on the apex - sign out on the apex, then open the apex /: the Landing, with the hint gone - the Landing's join box leads to /join/:code on the apex, where sign-in and enrolment finish
  • 00-overview.md Β§4.1 describes both origins, the hint cookie and why the refresh cookie stays host-only. R-07's checklist is re-run on both origins, with the result in the PR.

T-R11.3 β€” The root route: Landing for visitors, console for the signed-in

Depends on: T-R11.1, T-R11.12a Touches: web/src/app/RootRoute.tsx (new), web/src/app/router.tsx, web/src/app/guards/HomeRedirect.tsx, web/src/app/boot/ (R1), web/src/lib/session/hint.ts (new), web/src/features/landing/pages/LandingPage.tsx (new, frame only), web/tests/units/app/root-route.test.tsx (new), web/tests/e2e/landing.spec.ts (new), web/playwright.config.ts (the stubbed build's VITE_LANDING_HOST and the chromium project's host-resolver rule), web/vite.config.ts (preview.allowedHosts), web/tests/integration/router-chunks.test.ts Size: S Done when:

  • hint.ts reads quizzivy_signed_in from document.cookie, inside try/catch.

  • RootRoute at /, when location.hostname === import.meta.env.VITE_LANDING_HOST: - hint absent: the lazy Landing, and R1's boot skips its session probe for this route - hint present: the boot's single-flight refresh (the same client promise); on success, navigate(homePathFor(principal), { replace: true }); on 401, the Landing (the API has cleared the hint)

    On any other host, `/` behaves as `HomeRedirect` does today.
    
  • router-chunks.test.ts gains LANDING = /\/features\/landing\//: - landing modules are out of the entry chunk - the landing chunk graph holds no teacher, admin, student or focus-layout module, and no @tiptap, prosemirror, parse5 or @dnd-kit module - every existing assertion is kept

  • landing.spec.ts (stubbed, chromium): the one stubbed build (web/playwright.config.ts's webServer env) sets VITE_LANDING_HOST=quizzivy.test, the chromium project maps quizzivy.test to 127.0.0.1 with --host-resolver-rules, and the preview accepts that host. An anonymous visit to http://quizzivy.test:4173/ issues no request to the API origin (page.on("request")), and with a stubbed hint and refresh it lands on /teacher. Other specs keep localhost, where / is unchanged, and the build's VITE_API_BASE_URL is unchanged.

  • Unit tests: each branch of RootRoute, and the boot skipping its probe only on the Landing.


T-R11.4a β€” Landing: frame, header, hero and join, features, roles

Depends on: T-R11.3 Touches: web/src/features/landing/ (pages/LandingPage.tsx, components/{LandingHeader,MobileMenu,Hero,JoinBlock,ProductMocks,Features,Roles}.tsx), web/src/index.css (landing type tokens), web/tests/units/styles/tokens.test.ts, web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/ (new) Reuses: R1's segmented control, theme preference, sheet, Brand; features/join/code.ts Size: M Done when:

  • Frame: base type 15/1.55, a 1200px container, breakpoints under 720 (mobile) and from 960 (wide), and the h1 step at 1100. The Landing sizes R1 deferred join @theme as tokens (h1 38/48/58, h2 28/38), pinned by the token test. Smooth scrolling applies only without reduced motion.
  • Header, sticky and 64px: - the logo (colour or on-dark by theme) linking to #top - from 960, the anchors Features, For you, Fair tests, FAQ - EN/VI through R1's setLocale, with Vietnamese the default (DG-100) - the theme toggle on R1's persisted preference - "Sign in" (outline, from 720) to /login - "Book a consultation" (primary) to #consult
  • Below 960, a menu button opens a sheet with the anchors and Sign in: focus trapped, Esc and any link close it (DG-100, extrapolated and listed).
  • Hero: - the eyebrow pill with a static lime dot, the h1 and the subtitle - the join block: label; a 48px monospace upper-case input normalised by code.ts to the Β§6 alphabet, capped at 8 and dashed after 4; the placeholder K7QM-2PXA (DG-02) - at 8 characters, the success border and the enabled "Join class", which opens /join/K7QM-2PXA (the deck drops the code; we pass it) - an incomplete submit shows "Enter all 8 characters." (DG-02's invalid state, extrapolated) - the helper line with Sign in and "book a free consultation"
  • Product mocks: aria-hidden, drawn from tokens, keeping a light paper surface in dark mode (DG-35); the address pill reads quizzivy.com/teacher.
  • Features: six cards on the sidebar band, with copy true to the product (this file's decision).
  • Roles: tabs Teachers, Students and Centre admins, following the WAI-ARIA tabs pattern (arrow keys, aria-selected), each panel as the deck draws it.
  • Unit tests: the join normalisation and route, the menu's focus and Esc, the tabs' keyboard, the language switch, the theme toggle persisting.
  • Keys are vi first, with en from the deck where the copy is still true, all in landing.json.

T-R11.4b β€” Landing: integrity, testimonials, FAQ, footer and meta

Depends on: T-R11.4a Touches: web/src/features/landing/components/{Integrity,Testimonials,Faq,LandingFooter}.tsx, web/src/features/landing/content/testimonials.ts (new), web/src/features/landing/useLandingMeta.ts (new), web/index.html, web/public/og/quizzivy-og.png (new, 1200Γ—630 from docs/design/brand/png/quizzivy-logo-horizontal-color-2400.png), web/src/lib/config.ts (VITE_ORG_EMAIL, VITE_ORG_ADDRESS), .env.example, .github/workflows/deploy.yml (repository vars), web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/ Size: M Done when:

  • lib/config.ts gains orgEmail (VITE_ORG_EMAIL) and orgAddress (VITE_ORG_ADDRESS) beside R1's orgName and frontDesk. The Landing's phone is R1's front-desk phone (VITE_ORG_FRONT_DESK_PHONE). .env.example documents the two new values as public, deploy.yml passes them from repository vars, and an unset value hides its line (DG-41).
  • Integrity: the inverted band (--primary background, --primary-fg text), the eyebrow in lime, the h2, the subtitle, and the four cards (fullscreen and focus; no copy and paste; "Flags, not accusations"; a full timeline), worded per D8.
  • Testimonials: rendered from testimonials.ts, whose entries each carry quote {vi, en}, name, role {vi, en} and consentRecordedAt. The section is hidden while no entry has consent (this file's decision). Initials follow DG-22.
  • FAQ: an 820px column and a single-open accordion with the first item open. Each question is a button with aria-expanded and aria-controls. Answers 1, 4 and 5 are rewritten to match the product (Google creates an account; the centre controls Google sign-in; R5's reset flow).
  • Footer: - the logo and the about text - Product (anchors), Get started (/login, /join, #consult), and Help (/status, #faq, the mailto: from VITE_ORG_EMAIL) - a "Privacy" link to /privacy, which the deck lacks (D18, listed for DG-06) - the bottom bar with "Β© {year} Quizzivy" and the address from VITE_ORG_ADDRESS - every line whose value is unset is hidden
  • Meta: - index.html keeps product-level <title> and description for both hosts, and gains static Open Graph tags (og:title, og:description, og:image, og:url https://quizzivy.com/), since most scrapers do not run JavaScript - useLandingMeta sets document.title, the description, <link rel="canonical"> and html[lang] per language while the Landing is mounted, and removes the canonical link on unmount
  • Unit tests: testimonials hidden without consent and shown with it; FAQ keyboard and single-open; footer lines hidden when unset; canonical link lifecycle.

T-R11.5 β€” Join links and QR codes on the apex

Depends on: T-R11.2 Touches: web/src/features/join/code.ts, R4's class detail join-code card and QR dialog (web/src/features/classes/), R5's Admin classes join-code column and dialog, .env.example, .github/workflows/deploy.yml, web/tests/units/join/code.test.ts, web/tests/units/classes/ Size: S Done when:

  • publicJoinUrl(code) returns ${VITE_PUBLIC_ORIGIN}/join/${formatted}, falling back to window.location.origin when the variable is unset (development). It is the only builder of join links. A unit test greps web/src for another "/join/" concatenation.
  • Copy link, the QR payload and the QR download (R4's "for slides or Zalo") use it, in the Teacher and Admin consoles.
  • Existing app.quizzivy.com/join/… links keep resolving. join.spec.ts covers an old-host link.
  • Release notes say that new links use quizzivy.com and that printed codes and old links still work.

T-R11.6 β€” Leads: storage, the public operation, email, retention and the Admin API

Depends on: R5's email outbox, organization settings, retention sweep and data export Touches: migrations/NNNNN_create_leads.sql, migrations/NNNNN_add_organization_lead_recipients.sql (both new), server/internal/modules/leads/ (new: domain/{lead,commands,errors,repository}.go, application/{app.go,command/{submit,update,delete}.go,query/{list,get}.go}, repositories/leads.go, http/{public,admin}.go), server/internal/core/wiring/leads.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go}, api/openapi.yaml (tags public, admin-leads), generated code, server/internal/platform/email/templates/{vi,en}/lead_received.tmpl (new), R5's retention sweep (server/internal/core/maintenance/{retention.go,tests/retention_test.go}), R5's organization settings and admin summary, R5's data export, server/internal/modules/audit/domain/{actions,categories}.go, web/src/lib/i18n/locales/{vi,en}/admin.json (audit.action.lead.*; {vi,en}.json if T-R11.12a has not landed, whose split puts them in admin.json), server/internal/platform/db/tests/{constraints,privileges}_test.go, docs/plan/20-data-model.md Size: L Done when:

  • create_leads, app.leads: - id uuid PK DEFAULT uuidv7() - full_name text (1–120, trimmed), phone_digits text CHECK (phone_digits ~ '^[0-9]{9,15}$') - interests text[] NOT NULL DEFAULT '{}' with a CHECK that it is contained in {ielts, toeic, communication, kids, business} - course_for text CHECK IN ('self','child','company'), call_time text CHECK IN ('any','morning','afternoon','evening'), locale text CHECK IN ('vi','en') - privacy_notice_version text NOT NULL, consented_at timestamptz NOT NULL - status text NOT NULL DEFAULT 'new' CHECK IN ('new','contacted','closed'), note text (≀ 1,000), handled_by FK users SET NULL, handled_at - created_at, updated_at (trigger) - indexes (created_at DESC), (status, created_at DESC) and (phone_digits, created_at DESC)

    The app role gets `SELECT, INSERT, UPDATE, DELETE`. The maintenance role gets `SELECT,
    DELETE`: the sweep's `DELETE … WHERE created_at < $1` reads the rows it deletes, so
    `DELETE` alone fails with 42501. This is R11's grant for the widened sweep (70 D16), and
    `privileges_test.go` pins both roles' grants on `leads`. Down drops the table.
    
  • add_organization_lead_recipients adds lead_recipients text[] NOT NULL DEFAULT '{}' with CHECK (cardinality(lead_recipients) <= 5) to R5's settings singleton. The constant default needs no fill trigger. Down drops the column.

  • POST /public/leads (submitLead, tag public, security: [], no x-permission, x-max-body-bytes: 4096): - request: fullName (1–120), phone (^[0-9 +().-]{9,20}$), interests (unique enum items, ≀ 5), courseFor, callTime, locale, consent (enum: [true]), privacyNoticeVersion (enum: ["2026-10"]), and website (≀ 200, the honeypot) - response: 202 {received: true}; validation errors name only fields - the handler normalises the phone to digits: 9–15 digits, else a field error; this rule is one the schema cannot express - a filled honeypot, or a third lead for the same digits within 24 hours, answers 202 without storing, and logs lead_dropped with the reason and no personal data - otherwise, one transaction inserts the lead and enqueues one lead_received email per configured recipient (R5's outbox template CHECK allows only [a-z_])

  • RateLimits() adds POST /public/leads at 3 a minute and 10 an hour per IP. AssertPublicRoutesLimited passes. The open-operation pin in auth_middleware_test.go gains submitLead (POST /public/leads), the thirteenth (70 Β§4.2: six at v0.6.0, seven after R1, eleven after R5, twelve after R7), with its one-line justification (the list's name follows its count).

  • Leak review in the PR: the response is constant; the dropped paths run the same phone lookup and differ only by the skipped insert; no error reveals a known number, a recipient or the honeypot.

  • Email lead_received (templates/{vi,en}/lead_received.tmpl), vi and en (the recipient's locale, else the organisation default): - subject "New consultation request Β· {first name}" - the interests, who the course is for, the best time to call and the received time - a link to https://app.quizzivy.com/admin/leads/{id} - never the phone number or the full name; a test asserts the outbox payload holds neither - no recipients means no email

  • Admin operations (system.leads): - GET /admin/leads?status=&q=&page=&limit= β†’ {items: [{id, fullName, phone, interests, courseFor, callTime, locale, status, note, createdAt, handledBy: {id, name} | null, handledAt}], page, total}; q matches the name (accent-insensitive through app.immutable_unaccent(lower(…))) or the phone digits - GET /admin/leads/{id} - PATCH /admin/leads/{id} {status?, note?}, which sets handled_by and handled_at on a status change - DELETE /admin/leads/{id} - updates and deletes are audited through the data-modifying CTE pattern (lead.updated, lead.deleted). The update's diff is {status: {old, new}, noteChanged: true} only, and the delete's holds the lead id only: never the note, the name or the phone, because an append-only row outlives the lead's 12 months (D16, D18) - add audit.action.lead.updated and audit.action.lead.deleted keys (vi first) in admin.json, list both in audit/domain/actions.go, and map the entity lead in audit/domain/categories.go as an explicit All-only entry, like the organization and retention entries (leads are not accounts)

  • R5's organization settings accept leadRecipients (≀ 5, format: email). R5's admin summary gains newLeads, null without system.leads.

  • Retention: R5's retention-sweep gains a step that deletes leads created more than 12 months ago, as quizzivy_maintenance, and counts them in its System audit row and its dry run. The 12 months is D18's, not a setting. Scheduled runs apply only while RETENTION_SWEEP_ENABLED is true (70 D16), so the release checklist verifies it (O-R11.5). R5's data export includes leads, formula-escaped.

  • Tests: - unit: phone normalisation, honeypot, the repeat cap - integration: insert and lead_received outbox rows; no phone in the payload; the sweep, run as quizzivy_maintenance, deletes at 12 months and keeps younger leads; PATCH handling fields; DELETE audited; no audit row holds the note, name or phone - contract: consent: false and an unknown notice version refused - access: a teacher gets 403 on every admin operation, and a custom role cannot be granted system.leads (R2's hidden-key rule)

  • 20-data-model.md gains leads and a deviation row for personal data held outside users, with its consent and retention.


T-R11.7 β€” Consultation form

Depends on: T-R11.6, T-R11.4a Touches: web/src/features/landing/components/{Consultation,ConsultationForm,InterestChips,ConsultationSent}.tsx, web/src/features/landing/{api.ts,input.ts}, web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/consultation.test.tsx (new) Size: M Done when:

  • The #consult band has two columns from 960. - Left: the eyebrow, h2 and subtitle; the contacts: phone (VITE_ORG_FRONT_DESK_PHONE), email (VITE_ORG_EMAIL) and address (VITE_ORG_ADDRESS), each hidden when unset. - Right: the form card.
  • Fields, as the deck draws them: - Full name and Phone, side by side when there is room - Interested in: chips as toggle buttons with aria-pressed - The course is for: Myself, My child or My company (segmented, a radiogroup) - Best time to call: R1's select
  • A consent checkbox, unchecked, above the submit (DG-06, extrapolated and listed): "I agree that {org} may use my name and phone number to call me back about courses, as described in the privacy notice." The notice link opens /privacy in a new tab (rel="noopener"). The deck's privacy line stays under the button.
  • The honeypot website field is off-screen, tabindex="-1", autocomplete="off" and aria-hidden.
  • Validation as the deck: nothing until the first submit, then live. One error at a time, in a role="alert" line: the name, then the phone (9 digits or more), then consent ("Please agree to be contacted before sending."). The invalid inputs get the danger border.
  • States (DG-101, extrapolated and listed): - submitting: the button disabled, reading "Sending…" - sent: "Thanks, we got it", "An adviser will call {name} within one working day.", and "Send another request", which resets everything - 429: "Too many requests from this connection. Try again in {n} minutes." - network or server error: "We couldn't send your request. Please try again, or call {phone}."
  • The zod input carries Expect<Equal<…>> against the generated request type, and privacyNoticeVersion is taken from the generated enum.
  • Unit tests: the validation order; consent required; the payload, including a trimmed name and the chips; each state; the honeypot never rendered to assistive technology.

T-R11.8 β€” Privacy notice and status pages

Depends on: T-R11.4b, T-R11.12a Touches: web/src/features/landing/pages/PrivacyPage.tsx (new), web/src/features/landing/components/LandingLayout.tsx (new, the Landing's header and footer as a layout), web/src/app/pages/StatusPage.tsx (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}/{landing,common}.json, web/tests/units/landing/privacy.test.tsx, web/tests/units/errors/status.test.tsx (new) Size: S Done when:

  • /privacy: public, lazy (the landing chunk), on both hosts, inside LandingLayout, a 720px column, printable. Version 2026-10 and its date are shown. Vi is written first; Thuong reviews the text (O-R11.1). It covers: - who is responsible (the organisation's name, address and email from VITE_ORG_NAME, VITE_ORG_ADDRESS and VITE_ORG_EMAIL) - what the form collects, and why: a call back about courses and a placement test - the legal basis: consent, under Decree 13/2023/ND-CP - who handles it: the centre's advisers and admins, and the processors (Fly.io, Neon, Cloudflare, Resend), with where they store it - retention: 12 months, then automatic deletion - rights: to access, correct or delete, and to withdraw consent, through the contact email - that nothing is collected about a child

  • /status: public, on SystemFrame, reading GET /public/status on mount and on "Check again": - maintenance: null shows "All systems normal" (circle-check, success) - a scheduled window shows its times and "Tests that close during the update are extended." - an active window shows R1's maintenance content - an error shows "We couldn't check the status." with retry

    It is the only page reachable from the Landing that calls the API, and only when visited.
    
  • Unit tests: the version shown; the org lines hidden when unset; each status branch.


T-R11.9 β€” Admin: consultation requests

Depends on: T-R11.6, R5's AdminLayout Touches: web/src/features/leads/ (new: pages/admin/LeadsPage.tsx, components/{LeadSheet,LeadStatusBadge}.tsx, api.ts), R5's admin nav definition, web/src/app/router.tsx, R5's Organization settings form, web/src/lib/i18n/locales/{vi,en}/admin.json, web/tests/units/leads/ (new) Reuses: R5's Users table, card list, user sheet and badges; DataTable, Pager, SearchInput, ConfirmDialog Size: M Done when:

  • Route /admin/leads, lazy. The People group gains "Consultation requests" (phone-incoming) with system.leads, and a warning-soft badge counting new requests. The nav test passes.
  • "Consultation requests" / "People who asked us to call them back." Tabs New, Contacted, Closed and All, and a search.
  • Columns: Name, Phone (a tel: link), Interested in (chips), For, Best time, Received (relative, exact on hover), Status. Below 560 it becomes a card list.
  • A row opens a sheet with every field, the consent version and time, Status (New | Contacted | Closed), Note, and Save. Delete confirms "Delete this request? Use this when the person asks us to remove their details."
  • Organization settings gains "New consultation requests go to" (up to five addresses), with the hint "They get an email without the phone number, and a link to this page."
  • States (DG-83): skeleton; "No consultation requests yet."; error with retry.
  • Built from deck parts and listed for DG-57 sign-off. Compared with the Admin Users patterns at the five widths, light and dark. Keys vi first and listed.
  • Unit tests: tabs and search in the URL, the badge, the sheet's save, the delete confirm, the recipients field's cap.

T-R11.10 β€” Rate-limit suite and the public leak review (T-5.2)

Depends on: T-R11.6 Touches: server/tests/ratelimit_flow_test.go (new, build tag e2e), server/tests/harness_test.go, server/internal/platform/ratelimit/tests/limiter_test.go, server/internal/core/router/tests/public_routes_test.go, docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md Size: M Done when:

  • The suite reads api/openapi.yaml and walks every open operation and every operation in RateLimits(): the credential-minting ones (resets, bulk reset, user creation and import, the docs session) and the heavy reads (R9's gradebook export). From one client IP (sent in CLIENT_IP_HEADER, as production does), it drives each past each window and asserts 429, with Retry-After between 1 and the window, and admission again after the window (a fake clock).
  • Keyed limits: - per join code across several IPs (/join/preview, Google join, the in-app join) - per email on login - R2's NAT budget: 30 students joining one class from one IP all succeed, while probing other codes from that IP is limited
  • An open operation missing from the registry fails the suite, beside the startup assertion.
  • The limiter's memory is bounded: 50,000 distinct IPs leave the LRU at its capacity (unit test).
  • The leak review of every public response body is recorded in the PR: - /join/preview: class name, teacher display name, schedule and room, never counts (R8) - the join failure modes cannot be told apart - login has one failure shape - refresh has its documented codes - /public/status holds only the maintenance window - R5's set-password: a used, expired or unknown token look the same - /public/leads answers a constant 202
  • NFR-S05 and NFR-S06 flip to βœ…. 15-phase-5.md marks T-5.2 as done in R11.

T-R11.11 β€” Accessibility pass (T-5.3)

Depends on: every screen task of R1–R11 Touches: web/package.json (dev dependency @axe-core/playwright; reason: an automated WCAG scan of built routes in CI; nothing ships to users), web/tests/e2e/{a11y,keyboard}.spec.ts (new), web/src/** (fixes), docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md Size: L Done when:

  • a11y.spec.ts (stubbed, chromium) runs axe with the WCAG 2.2 A and AA rules and fails on serious or critical findings. Routes: - the Landing (vi and en, light and dark, 360 and 1280), /privacy, /status - /login, /join/:code, /change-password, and the 404, 403, error and maintenance pages - /app, /app/classes, /app/learn, the flashcards session, /app/grades, the intro, the engine, the result - /teacher, the wizard, /teacher/grading, the builder, /teacher/gradebook, a course and a word list - /admin, /admin/users, /admin/roles, /admin/leads
  • keyboard.spec.ts covers: - a full attempt by keyboard: answers, the audio player, fill-in blanks, flag, review, submit - a flashcards session (Space, then 1–4) - the Landing's menu, tabs, FAQ and form - moving through the gradebook's cells with their tooltips on focus
  • A visible focus ring on every interactive element (R1's --focus, DG-30); icon-only buttons labelled; decorative icons aria-hidden; colour never the only signal (bands, status chips, flags, bars).
  • Reduced motion is honoured by the marquee, the live dot, the flip card, smooth scrolling and the drawer.
  • Lighthouse accessibility of 95 or more, recorded in the PR (a manual run), on /, /login, /join/:code, /app, the engine, the result, /teacher and /admin.
  • Small fixes land in this task. Larger ones become issues, and any serious or critical finding blocks the release.
  • 15-phase-5.md marks T-5.3 as done in R11, and the NFR accessibility entries are updated.

T-R11.12a β€” Locale files per surface

Depends on: β€” Touches: web/src/lib/i18n/locales/{vi,en}.json β†’ web/src/lib/i18n/locales/{vi,en}/{common,auth,landing,student,teacher,admin}.json, web/src/lib/i18n/{index.ts,loadSurface.ts (new)}, web/src/app/router.tsx (each tree's loader), web/tests/support/setup.ts, web/tests/support/locales.ts (new), web/tests/units/i18n/parity.test.ts, web/tests/units/i18n/surfaces.test.ts (new), web/tests/units/imports/i18n.test.ts, web/tests/units/question-bank/structure-validation.test.ts, server/internal/modules/audit/domain/tests/catalog_test.go Size: M Done when:

  • Two commits. First the file split, by top-level key, mechanical and nothing else. Then the loader.
  • Every direct reader of locales/{vi,en}.json moves in the split commit, so pnpm typecheck, pnpm test:unit and the Go unit tests stay green: - web/tests/support/locales.ts merges one language's surface files into one object - imports/i18n.test.ts and question-bank/structure-validation.test.ts import that helper instead of @/lib/i18n/locales/{vi,en}.json - R5's catalog_test.go reads audit.action.* from locales/{vi,en}/admin.json; the audit tree belongs to the admin surface - a grep in the PR finds no other import or path naming locales/vi.json or en.json
  • common is imported by index.ts. loadSurface(surface) fetches the active language's file and merges it with addResourceBundle(lng, "translation", data, true, true). Each tree's lazy awaits its surface before rendering: auth, landing, student (with FocusLayout), teacher and admin. Switching language loads the other language's already-loaded surfaces first, then switches.
  • surfaces.test.ts maps every literal t("…") key under src/features/**, src/layouts/** and src/app/** to the surface of the tree that renders it, and fails when the key is in neither that surface nor common. A feature shared by two trees uses common.
  • The parity test runs per surface. The test setup loads every surface, so copy-pinned tests are untouched.
  • Measured before and after: the entry chunk's size, in the PR.

T-R11.12b β€” Bundle budgets (T-5.4)

Depends on: T-R11.12a, T-R11.4b Touches: web/tests/integration/bundle-budget.test.ts (new), web/bundle-budgets.json (new), web/tests/integration/router-chunks.test.ts, web/vite.config.ts (only if a split is needed), docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md Size: M Done when:

  • The test builds once, as router-chunks.test.ts does, and sums the gzip size of the entry and the statically reachable chunks for each route: / (the Landing), /login, /join/:code, /app, the engine, the flashcards session, /teacher, the builder and /admin.
  • Budgets are the R11 measurement plus 10%, in bundle-budgets.json. The Landing is capped at 200 KB gzip whatever the measurement. A regression fails the build. Raising a budget is a reviewed diff of the file, with its reason.
  • The Landing preloads at most two font files (Be Vietnam Pro 400, latin and vietnamese subsets). R1's "no Google Fonts URL in dist/" test stays.
  • router-chunks.test.ts keeps its assertions: the anonymous graph has no console, rich-editor or @dnd-kit module; the student graph has no teacher or admin module; @dnd-kit is only in the builder and course-outline graphs.
  • The engine is measured once under a throttled mobile profile (Playwright over CDP: 4Γ— CPU, "Fast 3G"). Time to interactive is recorded in the PR; it does not gate.
  • NFR-E05 and NFR-E06 flip to βœ…. 15-phase-5.md marks T-5.4 as done in R11.

T-R11.13 β€” Native Vietnamese review (T-5.8, language half)

Depends on: T-R11.12a, every copy-bearing task of R11 Touches: web/scripts/i18n-review-sheet.mjs (new), web/src/lib/i18n/locales/vi/*.json, R5's and R7's vi email templates, the tests that pin changed strings, docs/plan/15-phase-5.md Size: M Done when:

  • The script exports every key as CSV (surface, key, vi, en, and the files that use it, from a scan). Cells are formula-escaped with the same rule as the product's exports.
  • A native speaker (Thuong, or someone he names) reviews the sheet. Priority: - join, the integrity dialogs (D8's tone), the test intro and the result - the Landing, the consent text and the privacy notice - the email templates - flashcards and courses - the Admin console's permission labels
  • The changes land in one copy-only PR, with no behaviour. Tests that pin a changed vi string change in the same PR.
  • The glossary terms (DG-20) are used consistently. The priority screens are checked at 360 and 768 with the reviewed strings: nothing clips, and no label has a fixed width (DG-21).
  • vi and en have the same keys, and no user-facing string is English-only (the lint rule and the parity test).
  • 15-phase-5.md marks T-5.8's language half as done in R11.

T-R11.14 β€” Docs and the v1.0 reconciliation

Depends on: every other R11 task Touches: docs/quizzivy-spec-v0.3.md (Β§2, Β§4, Β§5, Β§6.5, Β§9, Β§12, Β§13, Β§15, Β§16; version bumped to 1.0 per Β§18), AGENTS.md, README.md, docs/plan/{00-overview,15-phase-5,20-data-model,30-risks,40-open-items,50-nfr-registry,70-redesign-overview}.md, docs/setup/{dns,email}.md Size: M Done when:

  • Spec: - Β§2 and Β§4: the two origins, VITE_LANDING_HOST, VITE_PUBLIC_ORIGIN and the hint cookie - Β§5: the hint is not an authentication signal - Β§6.5: /public/leads and its limits - Β§9: the Landing, /privacy and /status - Β§12: the Landing's type scale and breakpoints - Β§13 and Β§15: leads and its operations - Β§16: the phases as shipped - the version reads 1.0, and every "Availability: R" line is resolved
  • AGENTS.md: - the "Redesign in progress" note is removed - the open-operation count becomes thirteen, with submitLead named (70 Β§4.2) - the live E2E section names the live-apex suite, its second API on :8081 and its .test hosts (T-R11.2) - the apex facts: the Landing makes no API call; the hint cookie never authorises; join links go through publicJoinUrl - the repository map gains modules/leads and features/{landing,leads} - the locale surfaces are described
  • 15-phase-5.md is marked superseded, with its disposition: - T-5.1 lives in R3's engine - T-5.2 β†’ T-R11.10; T-5.3 β†’ T-R11.11; T-5.4 β†’ T-R11.12b - T-5.5 β†’ R9 (T-R9.10, T-R9.11) - T-5.6 and T-5.7 were covered by every release's five-width comparison and DG-83 states, and by T-R11.15's sweep - T-5.8 β†’ T-R11.13 and this task
  • 30-risks.md: R-06's residual names the two origins; R-07 records the re-run; PR-1…PR-10 get their final state.
  • 40-open-items.md is reconciled: every item is answered, deferred with a reason, or on the post-1.0 list.
  • 50-nfr-registry.md is reconciled. NFR-P04 now says personal data stays minimal and that leads hold a phone number, with consent and 12-month retention.
  • 70-redesign-overview.md is checked against what R11 built, and any disagreement is corrected there: - Β§4.2's open-operation list names submitLead as the thirteenth - Β§8.1's step list gains pnpm e2e:live-apex beside pnpm e2e:live - class staff (D14): if DG-04 is still open, v1.0 ships with Assistant hidden from role pickers and class staff on the post-1.0 list (Β§2 rule 9, Β§10), and 40-open-items.md says so - the train is marked complete
  • README.md names both origins.

T-R11.15 β€” Browser verification and the v1.0 sweep

Depends on: T-R11.4a … T-R11.9 Touches: seed/NN-dev-leads.sql (new, development only, numbered at merge) Size: M Done when:

  • The Landing is compared with design-deck on 5175 at 360, 720, 768, 960, 1024, 1280 and 1440, in light and dark and in vi and en: the header at both breakpoints, the hero's two-column switch, the mocks, the bands, the FAQ and the footer grid. Sizes are measured with getBoundingClientRect.
  • /privacy, /status and /admin/leads are checked at the five widths, light and dark, against the deck parts they are built from.
  • The v1.0 sweep: every route of every console at 360 and 1280, in light, with seeded data. It checks for horizontal scroll, clipping, and the loading, empty and error states. Findings are fixed in their task's branch or filed as issues, and a serious one blocks the release.
  • The dev server is confirmed fresh. Vite and the API are stopped afterwards.

Release checklist

  • Every task is ticked on work/redesign-r11. develop is merged into it, and verification is re-run after that merge.
  • Every CI step runs locally, in order, judged by exit code (70 Β§8.1): - make lint, make gen-check - make test-api (after docker compose up -d db minio, including ratelimit_flow_test.go), and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration (with bundle-budget.test.ts), pnpm build - pnpm e2e (with landing.spec.ts, a11y.spec.ts and keyboard.spec.ts), pnpm e2e:content - pnpm e2e:live, required because auth and join changed, then pnpm e2e:live-apex (apex.live.spec.ts) with the second API on :8081 (T-R11.2) - node scripts/check-design-deck.mjs
  • The perf job is green on the release commit.
  • The five canaries pass, and their files are untouched against develop.
  • Security: - the isolation suite and the escalation tests pass - E2E 9 passes with its key list unchanged - the leak review of /public/leads and the full public review are in their PRs - the rate-limit suite passes, and the open-operation pin names the new operation
  • Both migrations are rehearsed on a Neon branch of production.
  • Before the deploy (Thuong): - SESSION_HINT_COOKIE_DOMAIN, the second GOOGLE_REDIRECT_URI and the new CORS origin are in fly.toml - the Google console lists both redirect URIs (verify-google.sh) - VITE_LANDING_HOST and VITE_PUBLIC_ORIGIN are set for the Pages build - the repository variables VITE_ORG_EMAIL and VITE_ORG_ADDRESS are set, and R1's VITE_ORG_NAME and VITE_ORG_FRONT_DESK_PHONE still are (the Landing's contacts and the privacy notice's controller details; an unset line is hidden) - the repository variable RETENTION_SWEEP_ENABLED is true, and the last scheduled retention-sweep run applied: without it, D18's 12-month lead deletion never runs - the Resend domain still verifies
  • Deploy and smoke, in this order: - Thuong's go, then release/1.0.0 β†’ main - deploy the API (the release command migrates), then Pages - attach quizzivy.com to the Pages project and add the www redirect (T-R11.2), then wait for the certificate - smoke /healthz, /livez, and sign-in as each role on both origins - an anonymous https://quizzivy.com/ makes no API request (DevTools) - submit one consultation request, and check the email and the Admin list - open an old app.quizzivy.com/join/… link and a new apex link - set the lead recipients in Organization settings - back-merge to develop, and tag v1.0.0
  • Release notes, vi first: - Quizzivy 1.0 - the Landing at quizzivy.com - new join links and QR codes use quizzivy.com, and old links and printed codes still work - the consultation form, the privacy notice, and how long requests are kept - accessibility and speed work - where to set who receives consultation emails
  • T-R11.14's docs are in the release. Neon compute hours are measured for the week after (PR-7), to confirm that the Landing adds none.

Open items

  • O-R11.1 β€” Legal review of the privacy notice. The notice is written against Decree 13/2023/ND-CP, and the data is stored by processors outside Vietnam. Whether the centre needs a cross-border transfer dossier, and the final wording, are Thuong's call with whoever advises the centre. Default: ship the notice as drafted, versioned 2026-10, and revise it through a new version.
  • O-R11.2 β€” Testimonials. No quote ships without the person's recorded consent. Default: the section is hidden until Thuong supplies consented quotes.
  • O-R11.3 β€” A privacy notice for learner accounts. D18 covers leads. Accounts, attempts and integrity events have their own story (NFR-P04, P05). Default: /privacy covers the site and the form; a notice for accounts is on the post-1.0 list.
  • O-R11.4 β€” Contact details on the Landing come from the build. Editing them in Organization settings does not change the Landing until the next build. Default: accept; a static file regenerated on save is a post-1.0 option that still keeps the Landing off the API. The Landing's phone is R1's front-desk phone (VITE_ORG_FRONT_DESK_PHONE); a separate sales number would be one more VITE_ORG_* value. Default: the front-desk phone.
  • O-R11.5 β€” Lead deletion depends on the sweep's switch. D18's 12-month deletion runs in R5's retention-sweep, whose scheduled runs apply only while RETENTION_SWEEP_ENABLED is true (D16). Pausing the sweep also pauses lead deletion. Default: keep D16's single switch, and the release checklist verifies it is true. Running the lead step regardless of the switch would change D16 and is Thuong's call.
  • DG-100. The mobile menu, the vi default, the SEO text and the Status target are built from the interim rules and listed for sign-off. Testimonials depart from the interim, as decided above.
  • DG-101. The form's submitting, error and 429 states are extrapolated.
  • DG-06. The consent checkbox, its wording and the privacy page are drawn by us from deck parts.
  • DG-57. The Admin consultation list and the recipients field are built from the Users page's parts.
  • DG-02. The Landing's invalid-code hint is extrapolated. The placeholder uses the Β§6 alphabet.

Generated from docs/plan/81-r11.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️