Source: docs-content/05-decisions.mdEdit this page

Nutqi — Decision Log (conversation style)

Product owner instruction: "autoaccept recommended options and choices and document the options you choosed if prompted, in conversation style, in the docs app." Every fork in the road lands here as a short Q&A.


D-01 — Where does the project live?

Q: New repo or inside an existing one? Name? A (chosen): Fresh monorepo repository nutqi, git-initialized on main, with a tracked secret-scan pre-commit hook in .githooks/. Name mirrors the product, kebab-case, no suffixes.

D-02 — Monorepo tooling?

Q: Turborepo + pnpm vs Nx vs plain workspaces? A: pnpm workspaces + Turborepo. Lightest setup that gives task caching and app isolation (apps/web, apps/api, apps/docs, packages/*). Nx rejected as overkill for 3 apps.

D-03 — Frontend stack?

Q: Next.js version/router, styling, components? A: Next.js 15 App Router + TypeScript + Tailwind CSS v4. Tokens from the Figma design system mapped to Tailwind theme (Fayrouz scale primary). Component layer hand-rolled (packages/ui) following the Figma sheets instead of shadcn — the design language (teal outline cards, pill tabs, RTL chips) is specific enough that mapping shadcn primitives would cost more than writing thin components.

D-04 — Backend stack?

Q: NestJS structure, ORM, DB? A: NestJS 11 (monolith, modular) + Prisma + PostgreSQL 16. Modules per domain (auth, users, patients, specialists, centers, bookings, plans, payments, hr, jobs, reviews, notifications). REST with global /api/v1 prefix, Swagger at /api/docs, class-validator DTOs.

D-05 — Auth model?

Q: Sessions vs JWT? Google OAuth in v1? OTP provider? A: JWT access (15m) + rotating refresh (30d) in httpOnly cookies, same-origin via Next rewrite (/api/* → API) per machine-global dev rules. Google OAuth: stubbed behind a feature flag (button renders, disabled tooltip قريباً) — no OAuth keys exist yet; documented. OTP: pluggable OtpChannel interface with a dev console provider (logs the code) — SMS/WhatsApp providers are config, not code, later.

D-06 — Roles?

Q: Design shows 7 experiences — how to model? A: User.role ∈ {ADMIN, PERSONAL_PATIENT, GUARDIAN, SPECIALIST, CENTER_OWNER, CENTER_MANAGER, SECRETARY, CENTER_SPECIALIST}; center staff additionally row in CenterStaff binding user↔center↔branch. Guardian and personal-patient share the patient portal (self-patient = patient where guardianId == null).

D-07 — Fonts?

Q: Figma uses a rounded Arabic display face; what ships? A: Baloo Bhaijaan 2 (Google Fonts, supports Arabic + Latin, matches the rounded headings) for display; IBM Plex Sans Arabic for body/tables (better long-form legibility). Both via next/font with latin + arabic subsets.

D-08 — i18n?

Q: Library, default locale, URL strategy? A: next-intl, locales ar (default, RTL) and en (LTR), URL prefix strategy /{locale}/… with ar as default. <html dir> driven by locale; all spacing via CSS logical properties. Message catalogs per namespace under apps/web/messages/{ar,en}/.

D-09 — Booking slots?

Q: Design's checkbox slot grid — multi-select or single? A: Single slot per booking (radio behavior, chip UI). Multi-slot readings of the checkbox design create pricing/duration ambiguity. Slots generated from specialist work-hours (30-min grid) minus existing bookings.

D-10 — Payments scope?

Q: Real gateway? Wallet math? A: No gateway in v1. Bookings carry a price; payments are recorded (cash/at-center/manual transfer) by secretary/specialist; guardian sees a ledger (fix G01); specialist wallet aggregates earnings minus platform fee (config, default 0) with manual withdrawal requests (fix G19). Gateway (Paymob/Stripe) is an interface stub.

D-11 — Docs app?

Q: Nextra vs custom? A (amended during implementation): Originally Nextra 4 — but the docs/specs markdown contains raw { patientId, ... } DTO braces outside code fences, which MDX parses as JSX expressions → Nextra build would fail and any future content edit could re-break it. Shipped instead: plain Next.js 15 SSG + react-markdown/remark-gfm (never fails on arbitrary content) + client-side mermaid@11 for diagrams, hand-rolled sidebar/prose chrome, dir="auto" per block for mixed ar/en. docs-content/ stays the single source of truth, read from the repo root at build time (no copies).

D-12 — Testing & quality gates?

Q: What proves the build? A: API: Vitest unit + e2e (supertest against Nest app with SQLite-free Postgres testcontainer fallback → plain Postgres in CI/dev). Web: Vitest + Testing Library for components/utils + a Playwright smoke (auth + booking happy path) marked optional if browsers unavailable. Gates: pnpm lint, pnpm typecheck, pnpm test, pnpm build all green at root via turbo.

D-13 — Dev servers?

A: Per machine rules: portless with hostnames nutqi-web, nutqi-api, nutqi-docs; no hardcoded ports in scripts; API_URL wired via portless get nutqi-api in web's dev script; CORS_ORIGIN includes https://nutqi-web.localhost.

D-14 — Status enums?

A: Canonical enums (Arabic labels in UI): Booking PENDING(معلق) / WAITING(انتظار) / POSTPONED(تأجيل) / DONE(تم) / CANCELLED(ملغي); Plan assignment NOT_ANSWERED(لم يتم) / ANSWERED(تم); Job application PENDING(مُعلق) / ACCEPTED(تم القبول) / REJECTED(تم الرفض); Account PENDING_ACTIVATION / ACTIVE / SUSPENDED; HR request PENDING / APPROVED / REJECTED.

D-15 — Question widget set?

A: SHORT_TEXT, LONG_TEXT, SINGLE_CHOICE, MULTI_CHOICE, YES_NO, STARS, LIKERT, MATRIX, PERCENT, DATE, FILE — exactly what the unanswered-plan frame renders; template JSON schema versioned (schemaVersion) for forward-compat.

D-16 — Which flows got frontend-only enhancement beyond design?

A: The E01E15 list in 04-gaps-and-fixes.md; the three that most change feel: questionnaire autosave (E01), optimistic status chips (E04), ⌘K palette (E06).

D-17 — Scope cuts for v1 (explicit):

A: No real video calls (URL field only), no payment gateway, no push notifications (email/console channels wired; WhatsApp/Telegram behind interface), no public center pages (G16 → roadmap), no admin web UI (admin via API/seed; screens roadmap), website page of Figma ignored per instruction.

D-18 — Seed data?

A: Bilingual demo seed: 1 admin, 2 guardians (3 children), 1 personal patient, 2 independent specialists (1 active, 1 pending activation), 1 center (owner, manager, secretary, 2 center specialists, 2 branches), bookings across all statuses, 1 plan template + answered/unanswered assignments, reviews, wallet transactions, job posting + applications, HR records. All demo accounts share a dev-only password documented in the repository README (never used outside local development).

D-19 — Design tokens source of truth?

A: packages/ui/tokens.css (CSS variables) + Tailwind theme mapping. Fayrouz 50–950 and blue 50–950 lifted from the design-system shade kits; semantic aliases (--color-primary, --color-danger, --color-dashboard-bg, --color-text, --color-border) matching Figma style names (Fayrouz, blue, wrong color, dashboard, text color, border color).

D-20 — Package manager pin?

A (amended): pnpm 11 (11.10.0 installed on the machine — original recommendation said 9; newer is fine and pinned via packageManager). Node ≥ 20.19 (machine runs 24). Committed .npmrc with save-exact.

D-24 — Repository hosting & docs polish (client-facing pass)?

Q: Public or private GitHub repo? And what changes for the client-facing docs site? A: Private repo (nutqi on GitHub) — client project, code not for public browsing; machine-default of public overridden deliberately. Docs site gained a client-facing polish pass: dev-internal details (local paths, seed passwords) scrubbed from content; mixed Arabic-inside-English lines get bidi isolation + spacing for readability; per-page section scroll indicator (scrollspy TOC); per-page "Edit on GitHub" links; global quick search (⌘K); placeholder logo mark replaced with a neutral wordmark pending the real brand asset.

D-25 — Specialist/center portal drifts?

Q: What changed while building the two staff portals? A: Specialist sidebar gained a "Patients" list entry (profiles need an index; same precedent as the guardian's directory entry). Nav items are role-filtered (HR self-service → center specialists; staff/hiring → owner+manager; attendance → secretary). The three settings tabs (password/languages/notifications) are shared components reused across all portals. Withdrawal methods enumerated portal-side as bank transfer / e-wallet / InstaPay (spec left them open). Center dashboard stats compute client-side from list endpoints pending a dedicated stats endpoint. Two API follow-ups queued: expose read endpoints for specialist schedule/days-off/booking-settings/certificates/videos, and include templateId in assignment list payloads.

D-22 — API build strategy (implementation drift)?

Q: @nutqi/shared ships raw TypeScript — per-file tsc emit would leave the API's runtime require("@nutqi/shared") pointing at .ts files. A: Nest CLI webpack bundle (webpack-node-externals with an allowlist for @nutqi/*) compiles shared code into dist/main.js; tsconfig paths keeps typecheck honest. Follow-up during phase 2: Nest CLI 11 no longer ships ts-loader (unresolvable under pnpm strict isolation), so the build uses a ~30-line local webpack loader backed by the already-present @swc/core (same transform family as the test pipeline) — zero new dependencies. Also: WorkInfo gained deletedAt (spec 00 lists work-info under soft delete; the spec-01 shorthand omitted it) with restore endpoints per G18. Refresh tokens are opaque rowId.secret pairs (sha256 at rest, rotation revokes); slot uniqueness enforced transactionally (Prisma lacks partial unique indexes); wallet EARNING books net of platform fee; withdrawal debits only when admin marks TRANSFERRED.

D-23 — Frontend implementation drifts?

Q: What changed between spec 03 and the shipped foundation? A: (1) Portals live at real URL prefixes /guardian, /specialist, /center — pure route groups (guardian)… would collide on identical paths (/dashboard ×3 breaks the Next build). (2) Numeric ICU interpolations pass pre-stringified values so Arabic keeps Latin digits per the digits decision. (3) Status labels come straight from @nutqi/shared maps — never duplicated in message files (G21). (4) "Excel" export = CSV with UTF-8 BOM; "PDF" = print stylesheet. (5) Center license document upload happens post-login (register collects the number only). (6) Middleware checks cookie presence; portal layouts do the real role check — fail open if the API is briefly unreachable, closed on wrong role. (7) Guardian sidebar gained a 7th item "Specialists" for specialist discovery (G12).

D-21 — CI/CD (product owner asked mid-build)?

Q: Which CI and which CD targets? A: GitHub Actions single verify job: pnpm + turbo cache, Postgres 16 service, gates lint → typecheck → test → build (.github/workflows/ci.yml), concurrency-cancelled per ref. Turborepo keeps boundaries: each app/package declares its own lint/typecheck/test/build; root runs them via turbo run, cached. CD: web + docs deploy to Vercel connected to the GitHub repo (auto preview per PR, prod on main) — created at first deploy per machine deployment rules; the NestJS api has no provisioned host yet, so api CD is a documented follow-up (options: Railway/Fly/VPS via Docker; Dockerfile shipped in apps/api).

D-26 — Who flattens the data: the API or the portals?

Q: Running the app end-to-end surfaced a whole class of empty columns and one crashing page. The API was returning database rows with their relations still nested (booking.specialist.user.firstName), while every portal table expects a flat row (specialistName). Who adapts? A: The API adapts — one mapper per module, applied on every read path. Each module now converts its rows to the shape spec 02 promises before responding: names resolved to a single string, calendar dates as YYYY-MM-DD (never timestamps), avatars as ready-to-use file URLs, counters (applicationsCount, sessionsCount, bookingsCount) computed server-side, and relations dropped so the database layout never leaks to the client. Applied across bookings, plans & assessments, payments, the specialist directory, staff, patients, jobs, job applications and the five HR tables. Doing it once in the API keeps every portal (and the future mobile app) reading the same payload instead of each screen re-deriving names.

Three related fixes fell out of the same pass: (1) specialty — a specialist types their specialty as free text (تخاطب), but the directory filter offers fixed session types, so the two never matched; both sides now resolve through the shared label list, and the chip simply hides for free text that isn't a known session type. (2) Job titles (دكتور / أخصائي) get proper Arabic/English labels from the shared label file instead of showing the raw code (G21 — labels never live in translation files). (3) The activation ring on the specialist dashboard read a differently-named field than the API sent, so it always showed 0% and kept fully-activated specialists behind the "complete your profile" gate; the names now match and the gate releases as intended.

Guardrails so it cannot silently return: contract tests assert the flat shape on the booking, ledger, assignment, directory and attendance endpoints — including that relations are absent — and the shared avatar component now degrades to initials instead of crashing a page when a name is missing.

D-27 — Light or dark by default?

Q: The Figma is light-only, but every modern product ships a dark mode and the browser already advertises a preference through prefers-color-scheme. Do we follow the operating system, and what happens to the brand teal in the dark? A: Two themes, light and dark, with light as the default and no automatic adoption of the OS preference. A first-time visitor always lands on light, even on a machine that prefers dark; only an explicit user choice switches. The active theme is one attribute on the root element — <html data-theme="light"> / <html data-theme="dark"> — and nothing else (no class="dark", no next-themes). The server always renders data-theme="light", which keeps every route statically renderable (no cookies(), no dynamic opt-in), and a tiny dependency-free blocking script in <head> upgrades the attribute before first paint when localStorage["nutqi-theme"] holds "dark" (absent key ⇒ light). Only the semantic layer flips: the brand ramps (--fayrouz-*, --blue-*) are theme-invariant, :root carries the light values and [data-theme="dark"] overrides the semantics — --surface, --surface-2, --overlay, the --primary-hover/-active/-fg/-soft/-muted/-border family, the G21 status-chip tone pairs and the elevation shadows; radii never change. The consequence is a ban on literal colour utilities in app code: bg-whitebg-(--color-surface), bg-black/40 backdrops → bg-(--color-overlay), text-primary-600text-(--color-primary-fg), enforced by a new apps/web/scripts/check-literal-colors.mjs gate wired into the web lint script, with an allowlist for the deliberately invariant surfaces (the auth split panel and the landing hero sit on a fixed teal gradient in both themes, so their text-white stays — each retained literal carrying a comment that says so). apps/web and apps/docs share the identical key, attribute and mechanism, but docs ships its own local ThemeToggle rather than gaining a dependency on @nutqi/ui. Spec: specs/07-theming.md.

D-28 — One mixed docs site, or two unmixed editions?

Q: docs-content/*.md is English prose with Arabic screen names dropped inline — a phrase like الإحصائيات (specialist home) forces a bidi reflow mid-sentence and reads badly to both audiences. Do we keep the mixed pages and just style the mixing better (as D-24 did), or split the content? A: Split — the docs site ships two complete, unmixed editions. A reader never sees the two scripts interleaved in running prose: the English edition is English prose, English screen names, English headings; the Arabic edition is Arabic throughout. Technical identifiers are explicitly not prose — file paths, route paths, HTTP verbs, enum values, env vars, package names and ID codes stay Latin in both editions, wrapped in backticks so they render as <code dir="ltr"> and the existing lib/rehype-arabic.ts isolation keeps them from breaking the paragraph. One deliberate exception: a new Glossary page carries the Arabic↔English screen-name mapping in two table columns — columns, not sentences — which is exactly why neither edition needs inline translations. Mechanically: locales ["ar", "en"] with ar default (matching D-08), routes move to app/[locale]/page.tsx and app/[locale]/[slug]/page.tsx with the full locale × slug generateStaticParams and dynamicParams = false, so the site stays fully static; app/page.tsx permanently redirects to /ar. English sources keep their paths and Arabic mirrors live in a sibling ar/ folder (docs-content/ar/…, specs/ar/02-api.md, apps/docs/content/ar/…); engineering specs other than the API one stay English-only and out of scope. No new runtime dependency — next-intl is not added to apps/docs; chrome strings live in a typed apps/docs/lib/dictionary.ts where a missing key is a TypeScript error, and a LocaleSwitcher pill navigates to the same slug in the other locale, preserving the hash. Search gets a per-locale index route using the same Arabic folding the web CommandPalette uses, and metadata/sitemap emit alternates.languages for both editions plus x-default/ar. Two gates keep it honest: the smoke test asserts each locale × slug source exists, is non-empty and has the same number of ## headings as its counterpart (that is what catches a half-finished translation), and a new apps/docs/test/no-mixing.test.ts asserts no Arabic-script characters in English running prose and no Latin words of three or more letters in Arabic running prose, outside code spans, links and a small brand allowlist. Spec: specs/08-docs-bilingual.md.

D-29 — When is "bilingual" actually finished?

Q: The web app was declared bilingual back at D-08, yet a handful of user-visible strings never reached the message catalogs — a hard-coded governorate list, an Arabic-only wordmark, an English brand string still carrying Arabic. Do we sweep them by hand every time someone notices, or make the build refuse them? A: Both — one completion pass, then a lint gate so the leaks cannot come back. The pass closes five of them. (1) The governorate filter in apps/web/src/components/guardian/SpecialistsDirectory.tsx moves its hard-coded list into guardian.specialists.filters.governorates, an ordered object keyed by stable ASCII ids (cairo, giza, alexandria, gharbia, dakahlia, assiut) so the value sent to the API stays stable while only the label is localized — and if the API contract expects the Arabic string instead, the Arabic stays the value and only the label is localized; either way the choice is verified against apps/api first, because filter behaviour must not regress. (2) SpecialistProfileSelf.tsx stops joining skills with the Arabic comma unconditionally: it joins with "، " under ar and ", " under en, and keeps splitting on both ، and , so data entered in either locale round-trips. (3) components/common/Logo.tsx renders the Arabic wordmark under ar and Nutqi under en, each with the matching lang attribute. (4) common.meta.brand loses its Arabic half in English — en becomes "Nutqi", ar keeps نُطقي — so the English title template carries no Arabic. (5) The common.theme.* keys introduced by D-27 are added to both locales. The gate is apps/web/scripts/check-i18n.mjs, wired into the web lint script; it fails when the ar and en key sets of any namespace differ, when any value is an empty string, when any en value contains Arabic-script characters (allowlisting common.appName and the brand strings that are deliberately bilingual), or when an ar value is byte-identical to its en counterpart while being longer than two characters — the copy-paste-placeholder trap, allowlisting ASCII product names and numerals. Arabic inside .tsx comments is fine and stays: the gate only inspects message JSON and string literals that reach the DOM. Spec: the completion-pass section of specs/04-i18n.md.

D-30 — What does "production ready" mean for security here?

Q: The product owner asked for every standard security measure to be in place and covered by the pipeline. What was actually missing? A: Three classes of gap, all now closed. (1) Browser-side: good baseline headers existed, but no Content-Security-Policy. The app now ships an enforcing policy with a fresh cryptographic nonce per request, so only scripts the server itself vouched for can run; unsafe-eval is never granted. Added alongside it: HSTS, cross-origin isolation headers, and the framework banner removed. (2) Server-side: the API now validates every request body strictly and rejects unknown fields outright, caps body size, and rate-limits by IP with a much tighter budget on the sign-in and OTP routes (complementing the existing account lockout from G15). The API documentation is off in production unless explicitly enabled. (3) Boot-time: two settings used to fail open — a missing signing secret silently fell back to a publicly known value (the same secret signs password-reset tokens, so a forged token meant account takeover, not just impersonation), and an empty allowed-origins list meant "reflect whichever site is asking, with credentials". Both now refuse to start in production, so a misconfigured deploy stops loudly instead of running insecurely.

A GET /api/v1/health probe was added for the reverse proxy and the container healthcheck: liveness plus a real database check, and deliberately free of version numbers, connection strings and error text.

D-31 — What does the delivery pipeline check now?

Q: One CI job ran the four gates. Is that enough to call it standard? A: No — it proved the code compiled and the tests passed, and nothing else. The pipeline now runs seven jobs in parallel: the four gates against a real PostgreSQL; secret scanning, dependency auditing and a licence policy; static analysis; dependency review on every pull request; a container build that emits a software bill of materials and fails on high or critical vulnerabilities; a database-migration check that catches schema drift before it reaches an environment; and a production deploy that runs only on main after the gates pass. Every job declares the minimum permissions it needs and carries a timeout. Preview deployments come from the Vercel Git integration, so every pull request has a URL a reviewer can click.

D-32 — Where does the API run in production?

Q: The web and documentation sites deploy to Vercel automatically. What about the API? A: A Docker host behind Nginx, not a serverless platform — the API holds long-lived database connections, writes uploaded files to disk, and runs migrations on release, all of which fight the serverless model. The kit ships in deploy/: a Compose stack (API + PostgreSQL, health checks, one-shot migration step), an Nginx TLS template, first-time provisioning, deploy and rollback scripts, and a runbook covering backups, restores and certificate renewal. The API port binds to localhost only; Nginx is the sole public surface.

Two findings from building it are worth recording: the container image was shipping the entire build toolchain and running as root (now a slim runtime image, unprivileged user, 30% smaller), and the Docker build context had no ignore file at the repository root — so every build pulled the developer .env file into the image context. Nothing had been published anywhere, and it is fixed.

D-33 — The documentation site went bilingual. What happens to links already shared?

Q: Doc pages moved from /user-guide to /ar/user-guide and /en/user-guide, and links had already been sent to the client. A: Every old flat URL now issues a permanent redirect to its English page — the language those links served when they were sent, so a shared link never silently changes language under the reader. The site root redirects to Arabic, matching the product's Arabic-first stance, and a language switcher sits on every page.

D-34D-26 flattened the read paths the sweep listed. Why did five endpoints still 500?

Q: Clicking through the app produced 500 pages on the reviews tab, the plan/assessment runner and the specialist profile, plus a guardian profile that span forever. D-26 was supposed to have settled who flattens the data. A: Because D-26 was applied from a hand-written list, and the list was the deep-flow endpoints' blind spot. Worse, the earlier pass had left tolerance behind — an unwrapItems helper plus a dozen X[] | Paginated<X> type unions and Array.isArray(x) ? x : x.items ternaries — so an endpoint could drift from array to envelope and nothing failed: the guard absorbed it and the screen quietly rendered nothing. That tolerance is why the drift survived a whole release. The inventory is now built from code, not memory: every route the API exposes, cross-referenced against every apiGet/apiPost/apiPatch/apiPut/apiDelete call site and the type it asks for, with the live payload diffed against that type. The sweep found 32 mismatched read paths, not five — including two endpoints the portals called that never existed (GET /users/me/languages, GET /specialists/me/work-info, both 404), two me-scoped reads that passed a user id where a SpecialistProfile id was required (reviews and slots, permanently empty), a slot grid that returned bare strings where the wizard reads {time, disabled} (every slot button rendered blank), a notification row that sent titleEn where the bell reads title, and fourteen write paths whose request bodies the API rejected outright under forbidNonWhitelisted.

The API adapts, as D-26 set out: one mapper per module, applied on every read path, emitting exactly the DTO the portal declares — no ORM relations, no foreign keys, no createdAt/updatedAt bookkeeping, dates as YYYY-MM-DD, avatars as ready-to-use URLs, enum codes (never pre-translated strings) so the portals keep resolving labels through @nutqi/shared (G21). Where a screen's vocabulary and the column's differ, the DTO speaks the screen's: WeekBlock is {day,start,end,sessionType}, a certificate is {title,org,from,to}, a case note is one text block. Three columns were missing behind portal fields that had no storage at all and now exist (WorkSchedule.sessionType, DayOff.reason, Session.startTime, Center.about) — a field the UI edits and the server silently drops is the same bug wearing a different coat.

Four payloads deliberately stay non-flat, each typed exactly rather than guarded: GET /specialists and GET /bookings keep {items,total,page,limit} because those two screens are the only ones with a real pager; GET /payments/mine stays {items,totals} because the ledger shows paid/pending sums across the whole ledger, which a page of rows cannot derive; GET /notifications stays {items,unreadCount} for the same reason on the badge; and GET /bookings/upcoming keeps its single-key {booking} wrapper so "no upcoming booking" is a value rather than an empty body. GET /specialists/:id/profile stays composite — one screen with named sections is one payload with named sections — but every section is now flat and fully declared, with no optional-chaining guesswork at the call site.

Guardrails, because the last set was not enough: contract-deep-flow.e2e.spec.ts asserts the exact key set of every endpoint in scope (extra keys fail, not just missing ones) and that no relation rides along; the same run records the real payloads into apps/web/src/test/fixtures/api-payloads.json and every later run re-verifies the file against the live server, so the fixture cannot go stale; and each component that used to crash is mounted in deep-flow-contracts.test.tsx against that recorded payload. unwrapItems is deleted and no array-or-envelope union remains in apps/web. Finally, the error boundary stopped swallowing the evidence: in development it prints the thrown error plus a trail of the recent API calls with their observed shapes, so the endpoint that sent the wrong payload is named on screen instead of guessed. Spec: specs/10-deep-flow-contracts-i18n.md §A.

D-35 — "English support" turned out not to be a translation problem. What was it?

Q: The product owner asked to "add English language support to all flows". The message catalogues were already at full key parity, and a lint gate enforced it. So what was missing? A: The gaps were in code, not in the catalogues. Four kinds, all fixed. (1) Names: every screen showing a person rendered the Arabic spelling regardless of language — the header, greetings, patient lists, booking summaries. Names now resolve per locale, and the specialist directory sends both spellings instead of one the server picked (the server has no locale, so choosing there pinned the English site to Arabic names). (2) Duplicated labels: weekday, role and job-title wording had been copied into the message files next to the shared bilingual maps, and the copies covered only some of the values — a centre owner's role showed as CENTER_OWNER. The copies are deleted; the shared maps are the only source (G21). (3) Punctuation and lists: weekday and skill lists were joined with an Arabic comma even in English. (4) The one that actually blocked people: switching language dropped the query string, so verification, password reset and "continue where you left off" links lost their state mid-flow and could not be completed in English at all.

Guarding it: the lint rules and the test suite now share one rule module, so they cannot disagree. A test fails on any Arabic literal left in a component, another names every key that exists in one language and not the other, and one render test per portal mounts real screens in English and fails if any Arabic reaches the output — including inside aria-label, placeholder and title.

D-36D-35 made names locale-aware. Why did the English dashboard still greet you in Arabic?

Q: After D-35 the specialist directory sent both spellings and the portal resolved them per locale. Yet the guardian dashboard in English still announced the next session under the specialist’s Arabic name, and so did the bookings table, the ledger, the review bylines and every HR row. What was left? A: D-35 fixed the two payloads it was looking at; the rule was never applied to the other twenty-one. Every remaining payload that mentioned a person still sent one pre-composed string — specialistName, patientName, applicantName, guardianName, staffName, authorName, the staff roster's name, the blocklist's name — built server-side as fullNameAr || "firstName lastName". The server has no locale, so that choice pinned the English site to Arabic names everywhere it was made. Every one of them is gone. A payload now sends the spellings it has and the portal picks: personName(row, locale) when the row is the person, refName(row, "specialist", locale) when it merely references one.

The convention, one rule, applied everywhere. Relations stay dropped (D-26), so a referenced person is four flat keys prefixed with the reference name — specialistFirstName, specialistLastName, specialistFullNameAr, specialistFullNameEn — and a row whose subject is a person carries the same four unprefixed, exactly as Patient and the directory card already did. Nothing else changes shape: no nested {specialist: {...}} object, no second key set to learn. The API side is one helper (personRef(prefix, person) plus the personNameSelect column list) used by every mapper; the portal side is refName, a four-line adapter over the existing personName. Changed: bookings (patient + specialist), the payments ledger (both), sessions, plan/assessment cards, reviews, job applications, the centre's patient list (guardian), the centre's staff roster, the blocklist, and all five HR tables. The one place the server still composes a name is the notification row, because it stores a rendered Arabic body and a rendered English one — so each is now composed in its own language.

Fallbacks, because the data is uneven. Some accounts have both spellings, some only Arabic, some only English, some neither. personName prefers the active locale's spelling, falls back to the raw firstName lastName registration input, then to the other spelling — a name never renders empty, and the API never invents one on the server's behalf. All four states are seeded into the contract fixture so the render tests actually exercise them.

Guardrails: contract-deep-flow.e2e.spec.ts asserts the exact key set of every affected payload (extra keys fail too), plus one class assertion that walks every captured payload and fails on any key matching *Name that is not a clinic/branch/file, and on any person carried with fewer than all four keys. Seven payloads that used to come back empty in that harness — the centre's patients, both application lists, HR requests and attendance, the blocklist, patient sessions — are now seeded with a row each, so their shapes are checked rather than assumed. On the web side a test mounts the bookings table against the recorded payload and asserts the English spelling under en and the Arabic one under ar, and the per-portal "no Arabic leaks in English" render tests now carry Arabic spellings in their fixtures, so a component that resolves with the wrong locale fails them. Spec: specs/10-deep-flow-contracts-i18n.md §A2, §B.

D-37 — Was the Vercel auto-deploy broken?

Q: Production looked stuck on an old commit for a while, and deploys were being pushed by hand to catch up. Had the GitHub integration stopped working? A: No — it was a queue, not a fault. Deployment records tell the two apart: a deploy triggered by a push records the moment the repository was pushed, while a deploy sent from a laptop records the tool that sent it. Every push in this session started building within about five seconds. The single exception was one commit that waited twenty-four minutes, and the cause was visible in the same list: ten dependency-update branches had just opened, and each one queued its own preview build on both projects, saturating the build queue. The wrong conclusion ("the integration is dead") was drawn from thirteen minutes of polling, and the hand-sent deploys that followed simply duplicated builds that were already coming.

Two changes so the queue cannot swallow a real deploy again: dependency-update branches no longer produce preview deployments (they are reviewed on their own merits and their pipeline still runs), and the web app skips its build entirely when a push does not touch it or anything it depends on. The documentation site deliberately keeps building on every push — it reads the markdown at the repository root at build time, and that relationship is invisible to the dependency graph the skip check consults, so skipping there could quietly publish stale documentation.

D-38 — The specialist's Patients screen was always empty. Which endpoint should fill it?

Q: The product owner asked for the obvious thing: "specialists should see patients they have bookings with." The screen already existed and was already linked in the sidebar, but it called GET /patients — which returns the caller's own household — so for a specialist it returned an empty array every single time, however full their booking history. Add a role branch inside GET /patients, or give the specialist portal its own route? A: A dedicated GET /specialists/me/patients. A role branch would have made one endpoint mean two different things depending on who asked, and the two screens are not the same screen: the guardian's list is a household register (their children, the self-patient row, the national ID they entered at registration), while the specialist's is a caseload — who the patient is, who brings them, and how the treatment is going. D-26's whole point is that a payload has one shape the portal can declare; a payload whose key set depends on the caller's role is exactly the drift that spec's guardrails exist to catch. GET /patients is therefore untouched and still means "my household", GET /centers/me/patients still means "this centre's patients", and the new route means "the patients I treat". Three sentences, three endpoints.

Scope: a booking, in any status, is the only key. The list is built from the bookings whose specialistId is the caller's own profile — pending, waiting, postponed, done or cancelled. A cancelled booking still means the specialist was handed that patient's details, so hiding the row afterwards would be theatre rather than privacy. Nothing else grants a row: there is no "patients at my centre", no "patients who reviewed me", no free-text search across the patient table.

The row is a treatment view, flat and fully declared (D-26, D-34): id, the four unprefixed name keys, avatarUrl, birthDate, gender, the guardian as the four prefixed keys guardianFirstName / guardianLastName / guardianFullNameAr / guardianFullNameEn (D-36 — the server has no locale, so it sends the spellings it has and the portal picks), then the treating relationship: bookingsCount, lastSessionDate, nextBookingDate and diagnosisCompleted. Sixteen keys, no more. Three things are deliberately not there: nationalId (a stronger identifier than a caseload screen needs), guardianPhone (a therapist needs to know who brings the child, not the family's phone book — the centre's list keeps it because a secretary's job is to call people) and isSelf (a household notion with no meaning in a caseload). Every counter and date is computed from the calling specialist's own bookings and sessions, so two therapists treating the same child see two different rows and neither learns anything about the other's schedule.

Security is the half that got the tests. Access by direct id was already correct — PatientsService.assertAccess grants a specialist read-only access only when a booking links them, and GET /patients/:id, its documents and its sessions all route through it — but "already correct" is a claim, not a guarantee, so it is now pinned by specialist-patients.e2e.spec.ts: three specialists, two of them deliberately booked with the same child. Specialist A gets exactly its own two patients and specialist B's patient in neither the list nor by direct id, documents or sessions (403); B is refused A's cancelled-only patient the same way; a specialist with no bookings at all gets an empty list rather than an error and reaches nothing by id; read access does not become write access (PATCH /patients/:id still 403s); a guardian is refused the route outright; and A's row for the shared child shows A's three bookings, A's next date and A's session date — never B's four, B's sooner date or B's later session.

The patient detail screen was reconciled rather than left to guess. PatientProfileView is shared by the specialist and the centre portals and reads GET /patients/:id, which is correctly gated already, so it keeps that endpoint. What changed is what it reads from it: the National ID row is gone — it is not part of what spec 11 says a specialist sees — and the questionnaire state took its place, which is a fact a therapist acts on. The overview also stopped optional-chaining into existence: patient is narrowed once at the top, so every field below reads a key the contract declares, and a missing patient renders an explicit empty state instead of five em-dashes that look like real data. Guardrails as usual: the exact key set is asserted in contract-deep-flow.e2e.spec.ts, the recorded payload is replayed into the list component under both ar and en, and the English-portal render test mounts the screen against its new route. Spec: specs/11-specialist-patient-scope.md.

D-39 — The previous team's build is still online. What do we take from it, and what do we refuse?

Q: Nutqi was built once before, and both halves of that build are still deployed and public: a Django REST Framework API that publishes its own OpenAPI 3.0.3 schema (40 paths, 87 operations, 103 schemas) and a Next.js client. Reading a competitor's homework is cheap; the question is what to do with it. Do we treat it as a legacy artefact to be replaced wholesale, or do we go through it item by item and adopt what is better than ours? A: Item by item — and four of their items are better than ours. The comparison is written up as a client-facing page (docs-content/07-legacy-comparison.md, slug legacy-comparison, both editions) rather than kept as an internal note, because the client paid for that build too and is entitled to a factual account of what it does well. It does several things well: the auth surface is complete and correctly closed (bearer sign-in with login/refresh/verify/logout, a full OTP recovery set, 401 on personal endpoints, public reference data), professional credentials are modelled as tables rather than typed as text, the account types are explicit schemas, and every paged collection shares one query convention.

Adopted (four). (1) Normalized location reference data with both spellings — country/state/city as tables, each row carrying an Arabic and an English name. We store governorate and city as free text, which is exactly why a city entered in Arabic renders in Arabic on the English site and why directory filtering is string matching; a reference table fixes both at once. (2) A normalized specialty taxonomy — specialty plus sub-specialty, bilingual, as entities a work record points at. Ours is free text bridged to the four session types through the shared label map, which D-26 documented as the flattening mechanism: the bridge works, but it cannot express a sub-specialty and cannot be extended without a code change. It is a workaround, not a model. (3) An email/phone availability check before submit — theirs asks whether an identifier is taken on the step that collected it; ours discovers the duplicate as a 409 after the whole form is filled. In a four-step wizard that is the wrong end of the journey. (4) Generic search and ordering on the paged collections — cheap, composable with the filters already there, and they let a screen add a sort control without an API change. Two more are recorded but not scheduled: a per-specialization first-session price (natural once the specialty table exists), and bearer-token auth alongside the cookie session for a future mobile client — an addition, not a replacement.

Refused (two), because both conflict with decisions that already have tests behind them. Their pagination envelope on every collection is consistent, and consistency is worth something — but D-34 is why we return flat arrays except where a screen genuinely pages: the earlier tolerance for "array or envelope" let 32 read paths drift silently, rendering empty screens instead of failing, and a universal envelope re-introduces exactly that ambiguity. And their pre-composed display names are the thing D-36 deleted from twenty-three payloads: the server has no locale, so a name assembled server-side pins one edition to the other edition's spelling. Payloads send the spellings they have; the portal picks.

One honest debit on our side. Their public schema and hosted Swagger UI are a real strength — it is what let this comparison be exact rather than approximate, and it is what a client developer needs to integrate. Ours is generated but disabled in production by default (ENABLE_API_DOCS, from D-30), which is the right call for an interactive console on a production API and the wrong call for the schema document itself. Follow-up: publish a sanitised, read-only spec for integrators and keep the console off. Also filed as a caution, not a practice: their published schema still carries the generator's placeholder title, description and version — a schema is a client-facing document and should be named like one.