From 938136c105b1e43ad989c86b4ceb01209d2cb4f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:32:22 +0000 Subject: [PATCH] D607: the Ops canvas graduates, and /account's sections are one menu on a phone (#1165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's newer export of Ops Page changes one thing: on a phone the Account center's section list is one "Section ▾" row instead of a row of chips that scrolled sideways. AccountSectionNav now draws that row below lg, over a transparent native select ("Account section"), so a phone opens its own picker. From lg the column is unchanged. Ops · Boards, Ops Page and ops-data.js move from design/incoming to design/canvases/integrated: all five areas run on main (D567 to D571). integrated/ goes 77 -> 79 in both READMEs; the intake README's row for the four later areas names their routes and decisions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jkNYLq29UXAvcqYxGCSB3 --- design/canvases/README.md | 13 +++- .../integrated}/Ops Page.dc.html | 13 ++-- .../integrated/Ops \302\267 Boards.dc.html" | 0 .../integrated}/ops-data.js | 0 design/incoming/README.md | 17 ++++- documentation/architecture/ROUTE_MAP.md | 2 +- documentation/architecture/decisions/D607.md | 73 +++++++++++++++++++ frontend/src/pages/account/AccountCenter.jsx | 58 ++++++++++----- frontend/test/account_center_d568.test.mjs | 41 ++++++++++- 9 files changed, 184 insertions(+), 33 deletions(-) rename design/{incoming => canvases/integrated}/Ops Page.dc.html (98%) rename "design/incoming/Ops \302\267 Boards.dc.html" => "design/canvases/integrated/Ops \302\267 Boards.dc.html" (100%) rename design/{incoming => canvases/integrated}/ops-data.js (100%) create mode 100644 documentation/architecture/decisions/D607.md diff --git a/design/canvases/README.md b/design/canvases/README.md index b746916e94..8b8cb03b0f 100644 --- a/design/canvases/README.md +++ b/design/canvases/README.md @@ -5,7 +5,7 @@ The corpus the first integration pass worked from. Sorted by one question: | Folder | Count | Meaning | | --- | --- | --- | -| `integrated/` | 77 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | +| `integrated/` | 79 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | | `backlog/` | 28 | Graded `NEW` or `DEFERRED`. No route yet. | | `out-of-scope/` | 26 | Deliberately not being built. The `ROUTE_MAP.md` row says why. | | `shared/` | 6 | The dc-runtime bundle every canvas loads, plus two standalone pitch-deck exports. Generated — both code scanners ignore this tree. Every canvas asks for it as `src="./support.js"`, which resolves next to the canvas and **not** to this folder, so opening one straight from `integrated/` in a browser gets a blank page: copy `shared/support.js` beside it first. Nothing runs these at runtime, so the reference is nominal — it is a convention marking "the shared runtime, not an inlined 69KB copy", and `scripts/read-canvas.mjs` reproduces it when it decodes a new export. | @@ -79,9 +79,18 @@ component, so `Help Ticket Screen.dc.html` now sits next to `Help Center.dc.html (which was replaced in place, its route being live): `integrated/` went **76 → 77**. +**2026-10-06 (D607), two canvases graduated.** `Ops · Boards` and `Ops Page` +moved from `design/incoming/` to `integrated/`, with `ops-data.js`, the script +`Ops Page` loads by that name: `integrated/` went **77 → 79**. Their five +areas run on main: the event organiser (D567), the Account center (D568), +partner earnings (D569), Wellbeing (D570) and the advisor's practice (D571). +They came from the intake queue, not `backlog/`, so `backlog/` did not move. +`Ops Page` is the owner's newer export of the same day, whose one change is +the Account center's section list on a phone. + **The `107` in the title is the first audit's corpus, not today's total.** It is `54 + 26 + 27` — the sum as it stood when `integrated/` read 54. The three -folders hold `77 + 28 + 26 = 131` as of 2026-10-06, counted by listing them, +folders hold `79 + 28 + 26 = 133` as of 2026-10-06, counted by listing them, and the title keeps 107 because the paragraph below uses it for the audit it names. diff --git a/design/incoming/Ops Page.dc.html b/design/canvases/integrated/Ops Page.dc.html similarity index 98% rename from design/incoming/Ops Page.dc.html rename to design/canvases/integrated/Ops Page.dc.html index 901dd8e172..baebf00dee 100644 --- a/design/incoming/Ops Page.dc.html +++ b/design/canvases/integrated/Ops Page.dc.html @@ -7,7 +7,7 @@ - + @@ -349,8 +349,11 @@

-
- {{ n.t }} if enabled +
+
+ {{ n.t }} if enabled +
+
@@ -479,7 +482,7 @@

`: Lab members and admins to `/spinout-lab/83b`, other founders to `/raise/legal-engine/equity` | `legal_83b.ts`, `admin_83b_review.ts`, `captable.ts`, `legal.ts`, `projects.ts` | OUT OF SCOPE | Lab tool. The filing record (method, tracking number, service center, company acknowledgment, tax-return copy; migration 310) is stored and every date is founder-supplied (D361). Operator assist shows the Axal admin assigned to the startup, their last review of the filing record and the next follow-up date, each Not recorded until HQ records it on the Spin-Out Lab's "83(b) review" tab (D580, migration 398; not tax or legal advice, and no graduation gate). Deadline reminders render Not recorded. | | AIRail | chrome | — (right-rail component, no route) | — (component `ui/WorkerRail.jsx`, mounted by the workspace pages) | `ai.ts`: `GET /ai/me/spend` (the caller's own month, today, caps, last run and `by_surface`; with `?surface=`, one page's month, last run and the month's unattributed runs — D552), `GET /ai/pricing`, `POST /ai/workspace/explain`; `services/aiSpend.ts`, `services/aiSurfaces.ts` (the fixed page names), `services/aiRouter.ts`. The org rollup stays admin-only in `monitoring.ts` (`GET /monitoring/ai-usage`) | UPGRADE | Per-page "Worker AI" rail: mode toggle, per-page model picker with $/M pricing, month-to-date spend vs a $40 cap, per-page spend, receipt line, Llama-Guard "Screened" badge. The per-user endpoint exists (Task #176, D401, D404). Since D552 every model call records its page in `ai_usage_logs.surface` and the endpoint answers for one page, and the rail reads it for the page it sits on (#1145 step 3): this page's month, its last run ("No run on this page yet" where there is none) and the month split by page from `by_surface` (`RailPageUsage`). Outside #1145: "Add tokens", per-licence caps (#1138), per-page model menus (D400/D402). The three rail reads — `GET /api/ai/me/spend`, `GET /api/ai/pricing` and `POST /api/ai/workspace/explain` — are mounted; this row does not treat them as missing. -| Account | shared | `/account`, `/account/:section` (was `/settings`; both old paths redirect permanently, query and hash preserved) | same | `settings.ts`, `users.ts`, `kyc.ts`, `billing.ts`, `integrations.ts`, `company.ts`, `trust.ts`, `refer_earn.ts` | UPGRADE | Live has 8 sections. Canvas adds 4 panes: Identity & tax (gov-ID, accreditation evidence, LinkedIn import), Your companies (multi-entity switcher repointing the sidebar), Roles & access (multi-role licences, delegates — no delegate concept exists), Documents & agreements. Notification *presets* replace the 17-row matrix. Sessions, API keys, trusted contacts already ship. UPDATE 2026-08-29 (Wave 2): **Your companies** and **Documents & agreements** shipped into the Account section — SettingsPage made zero company/e-sign calls before, while `/company/memberships` and `/legal/esign` were both live. Your companies is read-only by design (switching stays in `ui/CompanySwitcher.jsx`). Still open: Identity & tax (gov-ID/accreditation evidence beyond the existing Verification tab), and Roles & access — the canvas's delegates concept has no schema, so it is not buildable. Pinned by frontend/test/company_settings_members.test.mjs. **UPDATE 2026-09-19 (D169): the subject is told what was decided.** `users.deletion_requested_at` is the OPEN flag and D168's HQ close clears it, so the amber "Deletion requested " line vanished the moment a decision was made and the page read as if no request had ever been filed — the outcome reached only the Cmd+K recent feed, 20 rows deep. The settings GET now carries `dsr_outcome` (the subject's last closed `dsr_requests` row, via `loadOwnDsrOutcome`) beside the flag it explains, with its own availability state: an unreadable ledger reports itself rather than reading as "nothing was decided" (#204). `components/DsrOutcomeNotice.jsx` renders it where the amber line was, **only while nothing is open** — asking again after a refusal opens a new row and leaves the old outcome in place, so showing both would put a stale denial beside a live re-ask. The reason HQ typed is shown **verbatim**; `withdrawn` is credited to the subject, never to HQ. **Two corrections the build made:** the "duplicate" deletion block was not one block rendered twice — `PrivacySection`'s copy was structurally DEAD (its one call site always passed `hideAccountDelete`, both born in `02c6a12b1`), so the dead branch was deleted rather than the live one collapsed into it; and both copies had been doing a bare `new Date()` on a SQL `YYYY-MM-DD HH:MM:SS` stamp, which V8 reads as the reader's LOCAL time and other engines return NaN for — now routed through `toUtcInstant`. No migration (**273 is next free**), no new `/api/*` method. Pinned by frontend/test/settings_dsr_outcome_d169.test.mjs and cloudflare-worker/test/dsr_outcome_d169.test.ts. | +| Account | shared | `/account`, `/account/:section` (was `/settings`; both old paths redirect permanently, query and hash preserved) | same | `settings.ts`, `users.ts`, `kyc.ts`, `billing.ts`, `integrations.ts`, `company.ts`, `trust.ts`, `refer_earn.ts` | UPGRADE | Live has 8 sections. Canvas adds 4 panes: Identity & tax (gov-ID, accreditation evidence, LinkedIn import), Your companies (multi-entity switcher repointing the sidebar), Roles & access (multi-role licences, delegates — no delegate concept exists), Documents & agreements. Notification *presets* replace the 17-row matrix. Sessions, API keys, trusted contacts already ship. UPDATE 2026-08-29 (Wave 2): **Your companies** and **Documents & agreements** shipped into the Account section — SettingsPage made zero company/e-sign calls before, while `/company/memberships` and `/legal/esign` were both live. Your companies is read-only by design (switching stays in `ui/CompanySwitcher.jsx`). Still open: Identity & tax (gov-ID/accreditation evidence beyond the existing Verification tab), and Roles & access — the canvas's delegates concept has no schema, so it is not buildable. Pinned by frontend/test/company_settings_members.test.mjs. **UPDATE 2026-09-19 (D169): the subject is told what was decided.** `users.deletion_requested_at` is the OPEN flag and D168's HQ close clears it, so the amber "Deletion requested " line vanished the moment a decision was made and the page read as if no request had ever been filed — the outcome reached only the Cmd+K recent feed, 20 rows deep. The settings GET now carries `dsr_outcome` (the subject's last closed `dsr_requests` row, via `loadOwnDsrOutcome`) beside the flag it explains, with its own availability state: an unreadable ledger reports itself rather than reading as "nothing was decided" (#204). `components/DsrOutcomeNotice.jsx` renders it where the amber line was, **only while nothing is open** — asking again after a refusal opens a new row and leaves the old outcome in place, so showing both would put a stale denial beside a live re-ask. The reason HQ typed is shown **verbatim**; `withdrawn` is credited to the subject, never to HQ. **Two corrections the build made:** the "duplicate" deletion block was not one block rendered twice — `PrivacySection`'s copy was structurally DEAD (its one call site always passed `hideAccountDelete`, both born in `02c6a12b1`), so the dead branch was deleted rather than the live one collapsed into it; and both copies had been doing a bare `new Date()` on a SQL `YYYY-MM-DD HH:MM:SS` stamp, which V8 reads as the reader's LOCAL time and other engines return NaN for — now routed through `toUtcInstant`. No migration (**273 is next free**), no new `/api/*` method. Pinned by frontend/test/settings_dsr_outcome_d169.test.mjs and cloudflare-worker/test/dsr_outcome_d169.test.ts. **UPDATE 2026-10-06 (D568, D607, #1165):** the Account center canvas (`ac-*` in `design/canvases/integrated/Ops Page.dc.html`, rows in `ops-data.js`) is built on this page: its six sections first, in its order, and the four it does not draw under "More" (D568). Below `lg` the section list is one "Section ▾" row naming the open section, over a native select, so a phone opens its own picker (D607). That is the owner's newer export of the page; D568 had drawn the export before it, a row of chips that scrolled sideways. Pinned by frontend/test/account_center_d568.test.mjs. | | Admin · Subsidiary | admin | `/admin/subsidiary` (new) | — | — (none) for tenancy; adjacent `admin.ts`, `admin_contracts.ts`, `admin_cohort.ts`, `admin_lp_applications.ts`, `admin_events.ts` | NEW | Territory-licensee console: territory badge filtering every query, seats licensed-vs-used, five approval queues collapsed to one SLA board, HQ-owned read-only template library, anonymised benchmarks. No multi-tenant model exists. **UPDATE 2026-08-29 (Wave 4):** the licence LEDGER now exists (migration 187, `/admin/licences`) — territories, seats, terms and renewals per subsidiary. This row stays gated: it needs the SCOPING half, where an account names the licence it belongs to, and no row carries one. | **UPDATE 2026-09-03 (#416):** the two admin shells are now distinct. A plain admin gets the subsidiary sidebar — the HQ rows are gone from it, "Territory Licences" left the admin group, and the HQ-only pages (`/admin/licences`, `/admin/contracts`, `/admin/accounts`, `/hq`, `/admin/security`) render a stated Super-Admin-only notice instead of a page that 403s. Still gated on the SCOPING half: nothing here filters by territory yet. | **UPDATE 2026-09-15 (#214, D106):** the tenancy wall is now PHYSICAL rather than row-level, which is what unblocks this row — a subsidiary is its own Worker (`studioos-` at `.axal.vc`) over its own D1, so "every count is already filtered" is true by construction and there is no global view underneath to leak. This PR ships the Worker's half of that. `BRANCH_CODE` now changes behaviour, not only cookie names: `hydrateSuperAdmin` answers 0 on a branch and never reads `super_admins`, which closes all 24 HQ routes structurally — the empty table a bootstrapped branch starts with was a data state, not a gate, and one INSERT would have reopened the whole console over branch data, so every deny test seeds that row first. `requireSuperAdmin` refuses with **"HQ only"**, a different sentence from "Super admin required" because there is no elevation to grant on that deployment. `requireHqAuthoring` (= `requireAdmin` + that refusal) covers the three template-store writes and the seventeen assessment authoring writes, leaving every read, `preview` and `rescore` alone: HQ owns the questions, the branch owns the results (S4/S5, D.9). **Migration 256** adds `branch_licence`, `branch_promo_ceiling` and `branch_benchmarks`, each stamped `pushed_at`, and `GET /api/licence/mine` reads the copy on a branch with `source: 'hq_copy'` and `as_of` — without it the route would answer "You do not administer a territory licence" to the one person who does, since `licence_admins` exists on a branch and is empty. `/api/auth/me` gains `branch: {code, name, territories, status, as_of} | null`, which PR 5 renders as the territory badge and the eight-row shell. Also closed here: the `cloudflareSecrets` script-name fallback (a branch admin's integration key would have been written onto **HQ's** Worker), `X-Robots-Tag: noindex` (every branch serves HQ's bundle and its canonicals), seven SPA links built from the `https://axal.vc` literal (a referral link registered the referee in HQ's database), and the four platform-content cron cadences — the cron trim in the generated config does NOT stop them, because a branch keeps `* * * * *` and every block gates on the wall clock. **Still not shipped:** S1–S6 themselves. This PR makes the tier safe to deploy; PR 5 gives it a shell and PRs 12–14 give it pages. **UPDATE 2026-09-15 (#215, D107):** the shell. `shellRoleFor` returns `branch_admin` from `/me.branch` — the deployment fact, not a role — and `SIDEBAR_GROUPS.branch_admin` carries the canvas's eight rows in the canvas's order (Home · Accounts · Approvals · Programs · Community · Contracts · Insights · Settings), on steel `#334155`, with the territory badge in the slot HQ's tenant switcher occupies because both answer the same question. All eight `/branch/*` routes are registered; the ones whose artboards are unbuilt render `BranchZonePending`, which names the artboard, what will be on it and which PR builds it. That reads as a reversal of `sidebarConfig.js`'s rule and is not one — the rule forbids a row with no route, and shipping only the built rows would have meant a ONE-row sidebar, which is not this canvas. Suspension now does something: `requireBranchNotSuspended` answers **423 Locked** (not 403 — a frozen queue is not a permission failure) on the decision write in each of `admin_lp_applications.ts`, `refer_earn.ts`, `admin_cohort.ts` and `spinout_moderation.ts`, always AFTER the admin gate so an anonymous caller cannot learn the licence state; reads stay open, which is what the banner is about. Two defects in PR 4's copy were found by wiring the page to it and are fixed here: the payload emitted its own column name `legal_entity` where the page reads HQ's `legal_entity_name`, and carried no `licence_ref` at all (**migration 257**), so the one screen the copy exists to render showed a blank entity and an unnamed licence. `MyLicencePage` now tells `licence_not_pushed` apart from "you administer nothing", stamps the copy's age, and renders the event trail's `events_available: false` reason instead of "Nothing recorded yet" — an empty array and an unavailable trail are different claims. The canvas moves `backlog/` → `integrated/` (61 → 62, 26 → 25). **Still not shipped:** S1–S6 bodies, PRs 12–14. **UPDATE 2026-09-16 (#238, D123):** a newer export of this canvas landed, replacing the committed copy in place — **S0–S6 becomes S0–S13**. The diff across the whole S0–S6 range is exactly ONE byte and it is in S0: the territory badge reads `BRANCH` where it read `SUBSIDIARY`. That was measured, not taken from the canvas's own CHANGELOG, which claims the same thing; the measurement is what makes the claim checkable, and the rename is its own commit (#248) because every branch PR touches the shell. Appended: **S7** the badge fed by the deployment and the whole shell while a licence is suspended (readable list and locked list, each row with its own reason; assign/release REMOVED rather than greyed) · **S8** the seat ledger and the request-more-seats escalation, where the licensed count is deliberately not a field · **S9** the To-HQ drawer · **S10** the template picker as HQ's read-only library with archived versions visible and unusable · **S11** Insights and Settings as two frames, ownership per row · **S12** the branch AI rail with `decline='true'` · **S13** what the branch sees during an HQ support session. Folder counts unchanged — it was already in `integrated/`. **Two things the canvas draws that the platform does not have**, recorded here so neither is integrated by accident: S9's and S7's escalation answer is drawn as a THREAD with a reply box, and migration 261's header says the opposite in as many words; and S2's Members table renders a bare em-dash for an unassigned seat, which `ui/Honesty.jsx` forbids. **UPDATE 2026-09-17 (#234 first half, D140): S4 ships, and the notice it replaced promised two things neither of which survived measurement.** `/branch/programs` said *"the cohort calendar with **dates you adjust**, and assessment runs whose results are yours."* Measured across the whole worker: there is **no `UPDATE week_windows` at all**, and every `UPDATE cohort_cycles` touches `status`, `app_status`, `force_proceed` or the application window — **never `start_at`/`end_at`**. The four week windows are pure month arithmetic and both rows are written by `INSERT OR IGNORE`, so re-materialising cannot move one either. A date picker would be D134's `still_an_admin` mistake one tier down, so `BranchPrograms` draws no date control and says on the calendar itself that these dates are derived and read rather than set. **What a branch genuinely controls is the outcome, not the calendar** — per-company `grace` (1–168h, reason mandatory) and `override`, both audited through `applyWeekDecision` — and the page LINKS to the console that already makes those two writes (`AdminCohortTiming`, a tab of `/admin/spinout-lab` rather than a route) instead of drawing them twice. **Assessment ships as analytics, not as runs:** 17 of `admin_assessment.ts`'s 23 routes are behind `requireHqAuthoring`, so no authoring control is drawn; and there is **no `GET /sessions` and no `GET /results`**, so the artboard's table of runs has nothing to read — the gap is named on the page and the test reads the absence out of the worker, so shipping a list route fails the assertion rather than leaving the claim stale. **`/branch/community` is an INDEX, not four new screens:** `admin_events` (9 routes), `admin_jobs` (5), `admin_circles` (8) and `admin_network_profiles` (6) carry **zero** `requireHqAuthoring` between them and each already has a live SPA route, so "entirely local" was already true — what was missing is the page the sidebar's Community row points at. Each card states what its console can actually do, because three of the four are narrower than their names: **jobs is moderation only** (no admin create, edit or delete), **events cannot author an event**, and **network profiles is not a member directory** — the only public route over that table is a photo-blob proxy and its only other reader is the Demo Day deck, so what it feeds is that deck's Mentors & Network slide. The index **fetches nothing**: four counts would each be a second read of a console's own list, and a count disagreeing with the table one click away is what D128 ended. `inZone` moved to `lib/zoneTime.js` (the fifth consolidation) and the SPA's `COHORT_TZ` is now pinned equal to the worker's. **No new `/api/*` method, no worker route, no migration — 267 stays free.** **Still not shipped on this canvas:** S5/S6, which wait on `publishTemplate` and `applyBenchmarks` — both specified in F.5 and neither ever built. **UPDATE 2026-09-17 (#245, D142): S7 and S13 ship, and the 423 they rest on was anonymous until now.** `requireBranchNotSuspended` threw a bare `Error`, so a frozen branch's write shipped as `423 {detail}` while `api.js` keys **strictly** on `code` — which is why three places could claim a frozen-branch banner had shipped when nothing could key one. `branchSuspendedBody` is the twin of `adminFrozenBody` now, carrying `code:'branch_suspended'` and HQ's own `suspended_at` / `suspended_note`; both production error paths route through it. **The freeze also grew to cover what S7 draws:** of its four Locked rows only Approvals and admissions were enforced — Community had **zero** gates across `admin_events/jobs/circles`, and Seat assignment locks a control that does not exist (`seat_assignments`: no migration, no route, no api method — D127/D129 count seats from `users.role` instead), so that row is recorded in `lib/branchFreeze.js`'s `NOT_BUILT` rather than drawn. Community's rule, from the canvas's own words: a suspended branch may not publish, approve or feature anything new and may still **take things down** — freezing a takedown would trap a branch with content under its brand it cannot remove. **One list, asserted against the server:** `branchFreeze.js` names the route files enforcing each row and the test checks that set equals the set actually calling the gate, so the screen cannot claim a freeze the worker does not make. S13: the `localStorage.supportSession` payload written since D120 had **zero readers and no remover** — it outlived the 30-minute session AND sign-out; it now expires, self-clears and is purged by `clearSession`, and `HqSupportSessionBar` mounts **above** `PortalSwitcher` so an HQ-driven session can no longer wear the admin's own purple 'Admin Mode' chrome. The badge finally reads the `status` and `as_of` `/me.branch` has shipped since D106. **Still not shipped:** S13's audit-line panel — every reader of `impersonation_sessions` is an HQ route, so the branch has no read of its own rows; filed rather than faked.**UPDATE 2026-09-17 (#234 second half, D147): S5 + S10 ship, and the producer they waited on now exists.** `publishTemplate` was specified in F.5 and had **zero occurrences** in `cloudflare-worker/src`, so `/branch/contracts` rendered a notice for a screen the tier could not produce. **Migration 268** adds `branch_templates` and `branch_templates_sync` — a `branch_*` copy rather than columns on `legal_templates`, because that table is in `schema_baseline.sql` AND carries a runtime `CREATE TABLE IF NOT EXISTS`, so widening it means editing a migration and a bootstrap in lockstep (the `metrics_snapshots` collision, #183/#202), and because HQ's copy does not belong in the table a branch is refused write access to. **Three things the copy deliberately does not carry, each measured:** `body_md` (nothing on a branch renders or instantiates one — `licence_contracts` is HQ's table), `is_active` (`listTemplates` filters `is_active = 1`, so HQ's OWN library shows only active templates and the archived-and-unusable state S10 draws cannot be produced — the page says so and `version` carries the true statement), and a version history. **The write is a reload in one `DB.batch`**, because a library is a set and a push that only inserted could never withdraw: two SQL forms were tried and discarded first — `NOT IN (…)` interpolates its placeholder list into query text (`check-sql-prepare` refused it) and `updated_at < ?` against this push's own stamp is inert when two pushes share a millisecond, which showed up as a FLAKY test rather than a failing one. `branch_templates_sync` exists so *HQ pushed nothing* and *HQ has never pushed* are two sentences, D107's `licence_not_pushed` precedent. **The HQ half ships in the same PR** — `POST /api/admin/contracts/templates/publish` fans out through `services/branches.ts`, reported and never thrown (D111), with a control on `AdminTemplates.jsx`; a producer whose only caller is a test is the defect being fixed one level up. With no branch bound it answers `branches: []` and the SERVER supplies the sentence, so the page cannot drift from it. **Two filed claims did not survive measurement:** `GET /api/legal/templates` reading a hardcoded constant is a MIS-ATTRIBUTION (that is the founder incorporation-kit generator, and its generate path already prefers the D1 store), and `templates_reason`'s *"HQ has authored no master templates yet"* is dead copy rather than a false claim — its route is super-admin-only, so a branch never reads it, and HQ's own seed is 66 rows. **Still not shipped:** S5's Active contracts and Pending signature — `licence_contracts` is HQ's table with every route over it super-admin-only, so the branch has no contracts read of its own and the block states that rather than drawing an empty ledger *(cause corrected by D199: those contracts are the branch's own rows, not `licence_contracts` — see the D199 block below)*; and S6/S11, which wait on `applyBenchmarks`. **UPDATE 2026-09-17 (#252 + #234, D148): S6 ships and `branch_benchmarks` finally has both a writer and a reader.** Migration 256 created that table and NOTHING touched it since. **The threshold is three, and it is arithmetic rather than policy:** at one branch the median IS that branch's figure, at two a branch subtracts its own and reads the other's exactly, and three is the smallest n at which no single branch is recoverable — `MIN_BRANCHES` carries the argument and the threshold is applied PER METRIC, since a median over two of three branches is as recoverable as one over two of two. **Three of S6's four drawn stats cannot be benchmarked and the page says so:** read against `branchOverview`, only `accounts.total`, `seats_used` and `backlog[].count` are measurements — `revenue_mtd_cents` is **null by construction** and every `branchRevenueSummary` stream is `available: false`, so the revenue rate is knowable and the amount is not; activation and throughput have no branch-side read at all. **An unreadable branch is excluded from n, never counted as a zero** — a silence read as zero would drag every median toward the floor and make the platform look worse the flakier its network is; the test seeds four branches with one throwing and asserts 20 rather than 15. The write is a reload, because a metric HQ withheld below k must DISAPPEAR rather than linger at a median HQ no longer stands behind. **The cron is gated on `hqCadences`, the opposite call from D122/D135/D143** — those sweeps act on local rows so their WHERE clause is the tier discriminator, this one fans OUT and a branch has no branches; daily at 04:55 UTC on the existing `* * * * *`, no new expression, and `publishBenchmarks` refuses on a branch itself so the gate and the function agree. **With zero branches it publishes nothing and the cron logs *withheld*, which is the deliverable** — the D129/D131/D140/D147 pattern a fifth time. One defect the tests found rather than review: `median()` with a default sort makes `[9,10,11]` return 11. **No migration — 269 stays free.** **Still not shipped:** S11 Settings, whose owner chips are a different artboard from a benchmark and whose licence summary is already readable at `/admin/my-licence`. **UPDATE 2026-09-17 (#246, D151):** S12 measured before building, and half of it had already shipped — the search sentence (D129) and the cross-branch DECLINE CARD, which D126 refused with a measurement: the rail has no free-text input, `aiRouter.ts` carries no branch awareness and a branch Worker has one D1 binding, so the question cannot be ASKED and a card refusing it is theatre about a wall that is already load-bearing. **What had not shipped was a defect rather than a drawing:** `WorkerRail`'s `canRun = coverage.length > 0`, and THREE of the seven branch zones passed `` no coverage — so `/branch/insights`, `/branch/contracts` and `/branch/community` rendered "Not recorded" and a disabled button under "this page has not loaded a summary", which was FALSE on two of them (Insights had loaded stats, a benchmark and a server-written `unavailable` list; Contracts had loaded HQ's library and its push stamp). **The rule was already in the repo watching the other tier:** `branch_rail_mount.test.mjs` pins three HQ pages with the message "without coverage the rail's only button stays disabled", and D126 is the PR that both fixed those three and mounted the branch rail — it fixed the tier it was auditing and left the tier it was building. **`pushed_at` was read ZERO times in the SPA:** D148 shipped it, `branch_insights.ts` selects it, and the page drew a pushed median with no age — the defect D147 and D149 both landed on, and exactly what S12 rule 3 names ("the rail may cite that, and says when HQ computed it"). Seventh producer with no reader (#252, D142, D149, D150). `/branch/community` is the one DELIBERATE exception and is named rather than filled: it fetches nothing on purpose (D140, for D128's tile-vs-table reason), so its rail explains the absence instead of being given fabricated coverage. **The scope is a SENTENCE naming the branch, not the canvas's caret-less chip** — the territory badge already names it on every branch screen and D150 refused H13's HQ chip, so a chip would contrast with nothing; `branchLabel` lands in `lib/shellRole.js` as the tenth consolidation. **Eight guards were pinned to a spelling rather than a property** — six to the prop-less ``, one to the note as a literal, one comparing two indices across the whole file — and the PR's own new guard made the same mistake before it was run, requiring the HQ pages' `.filter(Boolean)` idiom of a tier that builds coverage four legitimate ways. **No migration — 269 stays free**, no worker change, no new `/api/*` method. **UPDATE 2026-09-18 (#234, D155):** S11 Settings ships, and it was the LAST branch route rendering a placeholder — every row in the branch sidebar now resolves to a real page, so `BranchZonePending` is deleted and the guard that required it to EXIST (`uses.length >= 1`) is re-aimed at the property it was approaching. **The artboard names the wrong owner on two of its five rows and the page corrects it on screen:** the subsidiary name is HQ's twice over (`BRANCH_NAME` is a provisioning var; nothing in the worker writes `branch_licence`), and a role cannot be changed from a branch at all (`admin_promotion_disabled` plus D106's zero super-admins) — what the branch owns is deactivating a non-admin account. Four of five rows are HQ-owned, counted from the rows rather than typed. No field is drawn anywhere; every action is a link to a registered route. `GET /branch/insights` now returns the `by_role` breakdown it computed and dropped (ninth producer with no reader). **No migration, no new `/api/*` method.** **UPDATE 2026-09-22 (D195): the design of record moved — S0–S13 became S0–S19 plus S1b, S1c and S1d.** The export in `design/canvases/integrated/` was replaced with a fresh decode; it is a strict superset, so every id this row already cited is still there. **S14** states this deployment (what `studioos-fr` is, its one binding, what it exports, what it cannot do). **S15** is Insights · Analytics — every figure a query on the branch's own D1 except the anonymised median, drawn as a dashed rule carrying HQ's timestamp. **S16** is Approvals as one age-sorted list, absorbing **eleven** further queues the live console keeps on their own pages (KYC, partner profiles, directory, exploring, jobs, events, circles, partner invitations, best-fit, territory diligence, advisor cohort access); S3 stays a board whose five columns are chosen by age rather than fixed, and wellbeing is deliberately **not** laned until it is known whether the roster is local or an HQ catalog. **S17–S19** put the custom-domain wizard on the branch's **own** Settings, as a section rather than a ninth row: CNAME plus a TXT ownership challenge to one stable target, `cname.os.axal.vc`, so the registrar never sees the per-tenant origin and `{slug}.os.axal.vc` can move without the tenant republishing DNS; Check-now's failure text, the receipt when it passes, a collision, Detach, the mobile DNS pass, and nameserver delegation collapsed behind an acknowledgement that mail breaks until MX is recreated. Until a custom host is Active, members use the fallback the deploy issued — **a licence never waits on DNS**. **Two amendments land on ids that already shipped, and one of them contradicts what is live.** S0's badge reads BRANCH, which D148 already shipped. But the canvas also removes **Home** from every branch sidebar and makes S1 the Studio shell at `/studio` — the territory digest surviving as a strip after the card, and the Worker AI rail removed from that page while `AdminRail` stays on Accounts, Approvals and the rest. The shipped branch shell is eight rows beginning with Home (D107), pinned by `frontend/test/subsidiary_shell_s0.test.mjs`, so **this is a proposal against live chrome rather than a gap to close quietly**, and it is recorded here rather than acted on. No route, worker or migration moves in D195 — it lands the canvases and their ledger rows. **UPDATE 2026-09-22 (#306, D197):** S17–S19's custom host is **built for the tenant and refused on a branch**, and the refusal is the honest half. `licence_domains` (migration 280) is HQ's table because H33's "one host, one licence" rule is a UNIQUE index, and a branch — its own Worker over its own D1 (D.2) — structurally cannot see what another tenant bound, so it can neither enforce uniqueness nor produce the collision refusal that names the other operator. All three tenant writes (`POST /api/licence/mine/domain`, `/domain/check`, `DELETE /domain`) therefore answer **501** on a branch with `domain_hq_only`, and `branchLicencePayload` carries `domain: null, domain_available: false, domain_reason` so `DomainWizard` states the absence rather than drawing a form the server can only refuse. S11's Settings rows gain **Data residency** and **Domain**, both HQ-owned and both rendering `value: null` with a reason — the canvas types `jurisdiction eu` on the residency row and `branch_licence` has no residency column, so printing it would be a claim about this deployment that nothing on this deployment measured. The owner count beside them is now **derived** (`6 of 7 rows HQ-owned`), replacing a typed `4 of 5` that sat under a comment reading "COUNTED, NOT TYPED" since D155. No route on this tier gains a control. | **UPDATE 2026-09-22 (#308, D199):** the Studio card's **Expiring agreements** is a real count, read by `branchHome` from this branch's own `pairwise_ndas.valid_until` and `partner_deals.expires_at` — the two stores in `admin_contracts.ts`'s four-source contract union that record an end date — over (now, now + 60 days], with `datetime()` on both sides and the handler's own `now` bound. It rides `GET /api/branch/home`, so no new route and no new `api.js` method. The task's premise was corrected rather than built: it said the figure was absent because *"the contract ledger is HQ's table"*, but that ledger (`licence_contracts`) is the licence agreement and has no end date, while the Studio's own agreements are local by D.2 — so neither pushing HQ's ledger nor a new branch store was the fix. E-sign envelopes and signed documents record when they were signed and never when they end; the payload names them on `undated` and the card prints that beside every measured answer. An unreadable source makes the total `null`, never smaller. `BranchContracts.jsx` and its README stated the same wrong cause and are corrected; S5's table itself is not built. **UPDATE 2026-09-23 (#311, D206): a branch now knows which kind of licence it runs under, and the To-HQ drawer offers only the kinds that licence allows.** Migration 284 adds a nullable `kind` to `branch_licence`, with no default and no CHECK. HQ's push and pull both send it through one assembler (row 41). `GET /api/branch/escalations` gains `licence_kind`, `licence_kind_known`, `kinds_available`, `kinds_hidden` and `kind_basis`; `kinds` stays the whole vocabulary, because a raise is validated against it. On a white-label, `POST /api/branch/escalations` refuses `content` with a 400 `kind_not_available` before HQ is called. The drawer (`KindPicker` in `BranchApprovals.jsx`) draws that kind as a hidden row carrying the reason, with no control, stepped down by its background and a dashed edge rather than by opacity. A copy that does not name a kind offers all four, because HQ checks its own ledger before it records one. A refusal from either end writes no row: an `undelivered` row would invite a retry that would be refused again. The page header's "not here yet" sentence, stale since D130, is corrected. **UPDATE 2026-09-23 (#312, D208): a content escalation can name the item it concerns, in the branch's own words.** `GET /api/branch/escalations` carries `concerns`: HQ's template library as pushed here (migration 268) and this branch's newest hundred articles, each with a label built by `services/escalationConcerns.ts` — `HQ template · {title} · v{version} · {slug}` or `Article · {title} · {slug}` — plus which sources could be read and whether the article list was cut. A source that cannot be read is its own state with its reason, and neither it nor the lane takes the other down. `POST` takes a pick as `concerns: { type, id }` for `content` only, reads that row again, and sends its label as `subject_ref` to HQ, to the local row and back in the 201 — the same bytes the list showed. A typed `subject_ref` on content is refused (400 `subject_ref_not_accepted`), `concerns` on another kind is refused (400 `concerns_not_for_kind`), and a pick that no longer resolves is a 400, or a 503 when its source cannot be read; none of them calls HQ or writes a row. Every other kind keeps its free-text `subject_ref`. The drawer (`ConcernsPicker`) draws a select only when the list is ready, its option text the worker's label, and a sentence in every other state; each raised row says what it was "About". No migration and no new `api.js` method. **UPDATE 2026-09-23 (#313, D209): S14 — Settings states what this branch Worker is, and what it is not.** `GET /api/branch/deployment` (`requireAdmin` + `requireBranchTier`; not suspension-gated, because reads never are) answers from `services/topology.ts`, the service HQ's Topology page reads too, so the two tiers cannot describe one architecture two ways. Settings gains "This deployment" (`DeploymentZone`): the Worker's identity and its own resources, naming any that are missing; its HQ binding and both RPC sides, with the methods nothing calls drawn dashed; what it writes to the shared Analytics Engine dataset and whether it can read it back; and a cannot-list checked on this deployment rather than recited — a refusal that has stopped holding is drawn in amber with its reason. The canvas's "accounts · queues · statement · audit", "deployed by cloudflare-worker-deploy.yml" and "Vectorize studioos-fr" were each false (D209 carries the table). One new `api.js` method, `branchDeployment()`; no migration. **UPDATE 2026-09-23 (#314, D210): S15 — Insights · Analytics, this territory over time.** `/branch/insights/analytics`, reached from Insights by one literal link and lit under the Insights row, so the branch sidebar stays eight rows; `GET /api/branch/analytics?range=8w|quarter|year` (`requireAdmin` + `requireBranchTier`; not suspension-gated, because reads never are). One line of signed-in accounts per week from this branch's own `activity_logs`, counted under the rule HQ's page counts every branch by (`services/activeAccounts.ts`: only the middleware's rows, Monday-to-Sunday UTC weeks), and the one median HQ pushed, drawn as a dashed rule at its true value and captioned with its week, `n_branches` and when HQ computed it — withheld and unreadable are two sentences, and neither is a line at zero. Seats from the licence copy; median decision age for referrals only (30 days, with n), and HQ publishes no median of it; approval age by queue for referrals and content to HQ (whose clock is HQ's), with LP and cohort applications Not recorded because their stores overwrite the stamps; gates by week from the cohort timeline, where a future deadline reads "not yet due" and a passed one with nobody judged reads "no outcome recorded"; revenue as HQ's share and the complement this branch keeps. Activation is Not recorded. The canvas's line that ends at suspension, its eight-week median and its programme-fee and perk streams were each false against the store (D210 carries the table). Branch Home's rate now reads "owed to HQ · you keep N%", and S4's deadlines are read as UTC. One new `api.js` method, `branchAnalytics()`; no migration. **UPDATE 2026-09-24 (#368, D211): S15 tells a failed read from an empty one.** A read that failed — the request log, the referral and escalation logs, the seat count, the benchmark copy, the revenue summary — is marked `unreadable` on the payload and drawn as Unreadable, with a retry on the chart and the revenue card; D210 drew each as "Not recorded". Each week carries its own gap reason, the legend prints one only beside a missing figure, and the chart's foot says every reason once. The four-week change and this branch's own figure beside HQ's median are refused across a week the log began inside, by the rule the median's input already used (`partialWeekReason`; D210's input let a Monday start through). The median keeps its caption when the log is unreadable; the revenue rate's absence is the server's sentence; a week-gate count nobody could read says so here and on S4, never "0 failed"; an ended cycle says it ended; and a superseded timeline answer can no longer land. No new route and no new `api.js` method; no migration. **UPDATE 2026-09-24 (#316, D214): S4 says what its game list read.** Programs' empty game list stopped saying no assessment game had been authored at HQ — HQ has authored games, and the list read is this branch's own database, which is built with no seed rows and which no call fills — so it names that database and why it is empty. Its runs note and header stop claiming the per-game analytics the page never calls. Community's network-profiles card names the Team & Network slide the roster feeds; no deck has a "Mentors & Network" slide. A branch's roster stays its own (D140); H19's "HQ publishes · a branch nominates" is filed as a decision (#385). No new route and no new `api.js` method; no migration. **UPDATE 2026-09-24 (task 342, D244): a branch with no licence copy fetches one from HQ the first time it is read.** Until now the copy had one writer, HQ's push on a licence transition, and a licence that is simply active has none — so a freshly provisioned branch showed "HQ has not pushed this branch its licence yet" on My Licence and on the analytics revenue card for as long as nothing happened at HQ. `/api/licence/mine` now pulls once through the `HQ` binding, under a 3-second deadline and one attempt per five minutes recorded in `RATE_LIMITS`, writes the answer through the push's own `applyLicenceCopy`, and reads again. Every refusal is its own sentence beside the unchanged `licence_not_pushed` answer, and My Licence draws it with the next attempt in UTC. HQ's `licence()` now requires the branch's `RPC_SECRET`, and the branch arm of `/mine` serves the terms to an admin only. No new route and no new `api.js` method; no migration. **UPDATE 2026-09-26 (task 414, D282): the canvas gains S20–S23, the navigation layer for accounts with no deployed branch.** S20 the eight-row Admin shell on HQ-held accounts, every row to an `/admin` console; S21 the branch-not-deployed strip; S22 every leftover console placed inside the eight rows, sixteen Approvals lanes; S23 the Workspaces launcher from the Admin top bar. A strict append of five hunks, and the S1 Eadwyn mark now points at `frontend/public/eadwyn-ai.png`, which is what the bundled asset turned out to be. Where the new artboards disagree with each other and with the code is listed in D282; nothing retires. No route, no migration, no `frontend/src` change. | | Admin · Super | super-admin | `/admin` | `/hq` · `/admin/licences` · `/admin/accounts` · `/admin/contracts` · `/admin/funds` · `/admin` + `/admin/*` | `admin.ts`, `admin_billing.ts`, `admin_stripe.ts`, `admin_promos.ts`, `admin_contracts.ts`, `admin_articles.ts`, `admin_publications.ts`, `admin_integration_keys.ts`, `monitoring.ts`, `activity.ts` | UPGRADE | Live covers accounts + View-As impersonation with audit, revenue/Stripe/promos, content, integration keys, infra/cron/DLQ. Net-new: the licensing tier (licence ledger keyed on territory, 5-step issue flow whose step 2 hard-blocks on territory overlap, rev-share splits, renewals), tenant switcher + read-only overlay, subsidiary-health cards, HQ escalation queue, token P&L per subsidiary, one content pipeline replacing three. **UPDATE 2026-08-29 (Wave 4): the LEDGER half of the licensing tier shipped** — migration 187 (`territory_licences`, `licence_territories`, `licence_seats`, `licence_events`), `routes/admin_licences.ts` mounted at `/api/admin/licences` before the `/api/admin` catch-all and added to `COOL_OFF_PREFIXES`, and `/admin/licences` → `AdminLicences` with the five-step flow (Entity → Territory → Seats → Terms → Activate). Territory exclusivity is a UNIQUE index on `licence_territories.country_code` **alone** — not `(licence_id, country_code)`, which would only stop a licence duplicating its own row and would let two licences hold France. The picker refuses an overlap rather than flagging it, per the canvas: "a conflict found after signature is an amendment to two contracts, found here it is one click." **A suspended licence still holds its territory**; rows are deleted on terminate and untouched on suspend, so no code path can forget the rule. The fee is integer cents and both rates are integer basis points (3500 = 35%) — `_bps` was added to the money guard's rate-suffix list so a percentage is not asked for cents. **NOT shipped, and this is the important part: the SCOPING half.** No existing query learns a territory from this and no row gains a `licence_id`. Retrofitting that across 151 route files is a programme, and the repo's rule is that tenancy goes through ONE middleware — a half-applied scope reads as enforced and is not, which is worse than none. The visible cost is that seats used, accounts per subsidiary, revenue per subsidiary and the token P&L are reported as **unavailable with the reason**, never as zero: "0 of 325 seats used" is a false statement about a real business. The four licensees in the canvas are placeholders and are not seeded. | **UPDATE 2026-09-03 (#413–#417):** the Super Admin exists as an ELEVATION on `admin`, not a role — a `super_admins` side table (migration 199, rewritten after the first version hit D1's 100-column cap on `users`; #414), one holder by name (207; decision 22), `requireSuperAdmin` on every licence route, `/me` echoing the flag from the side table. The mode shipped in #416: the bar reads Super Admin Mode, View-as leads with Super Admin above Admin (the subsidiary shell, previewable without impersonating), a per-browser `hqView`, the eight-row HQ shell (Home, Licences, Funds, Contracts, Team, Support, Security, Settings), HQ-only notices, the holder console at `/admin/accounts` (grant/revoke behind TOTP → step-up → elevation, audited, never the last holder), and only a holder may impersonate a holder. HQ Home shipped in #417 at `/hq` over `GET /api/admin/hq/overview`: accounts by role, seats licensed, countries held, every licence with renewals and the licence trail; the tenant switcher narrows the page client-side and says so. Contracts at `/admin/contracts` frames the Legal templates panel. **Still not shipped, and said so on screen:** tenant switcher as a server-side overlay, subsidiary-health accounts/MTD/backlog, seat utilisation, token P&L and the HQ escalation queue (all U1 — no row names its licence), the one content pipeline. | **UPDATE 2026-09-10 (Task #152):** the Users-table role picker read as broken and was not. `handleRoleChange`, `api.adminUpdateRole` and `PATCH /api/admin/users/:userId/role` all work; the menu was dead because every new signup lands in `role='exploring'` (`auth.ts:334`) and `AdminPage.jsx` disables every option except `exploring` for such a user, while the handler returns early when the value equals the current one. The disabling is correct — leaving exploring needs a signed binding agreement, re-checked in `admin_exploring.ts` and refused here with 409 `use_exploring_assign_role` — but the only thing saying so was a `title` on a disabled `
  • `. The reason and a link to `/admin/exploring` are rendered text now; the gate is untouched and no Super Admin override was added. Two adjacent defects went with it: the menu was clipped by the card's `overflow-hidden` inside the table's `overflow-x-auto` (both clip an absolutely-positioned child, so for a row low in the table the options were off screen), and once portalled to `` it ran off the bottom of the WINDOW instead — it now opens toward whichever side has room and caps its height to fit. First tests for any of this: `cloudflare-worker/test/admin_role_change.test.ts` (the route's five refusals) and `frontend/test/admin_role_picker.test.mjs`. **UPDATE 2026-09-10 (Task #147, canvas H5):** Revenue shipped as the ninth HQ row at `/admin/revenue` over `GET /api/admin/revenue/summary` (`routes/admin_revenue.ts`, `requireSuperAdmin`, mounted before the `/api/admin` catch-all). Checking each of the artboard's five zones against the database before drawing them is what shaped the page: **two are real** — licence fees, computed per currency from `territory_licences.annual_fee_cents` with suspended licences excluded and fee-less active licences counted separately, and open disputes, read from `/api/admin/billing/disputes` on its own so a Stripe outage costs one zone rather than the page. **One is half** — `ai_usage_logs.est_cost_usd` gives the token COST; nothing records what tokens were billed at, so the margin the canvas draws is refused rather than estimated. **Two have no store at all** — subscription revenue (`account_subscriptions` holds a plan and no amount, and `/ltv` reads Stripe one customer at a time, so a quarter figure would mean walking every customer on page load) and the subsidiary statement ledger. The promo zone reports the codes and redemptions that exist instead of the "budget left" the canvas draws, because no budget or per-subsidiary allocation exists in the product. Token P&L per subsidiary stays U1. **No figure is summed across currencies** and the page says so: licence fees are denominated per licence and the token cost is USD, so the canvas's single platform-revenue headline would be wrong by the exchange rate and stated to the cent. **UPDATE 2026-09-10 (Task #147, canvas H6):** Content and Platform shipped as the tenth and eleventh HQ rows at `/admin/content` and `/admin/platform`, over `admin_content.ts` and `admin_platform.ts` (both `requireSuperAdmin`, both mounted before the `/api/admin` catch-all). **The artboard's subtitle — "one pipeline replacing three systems" — was a third out of date, and checking it changed both pages.** News is NOT a third system: the `/api/admin/news` queue read the same `articles` table behind a `Deprecation: true` header, and **D166 retired it outright** — its publish handler accepted `in_review` as well as `approved`, so the approval gate on `/api/admin/articles` could be walked around by calling the deprecated path. That third of the unification is now done rather than announced. What remains is TWO stores with two meanings of "published" — an article is editorial and goes through review, a publication (`admin_publications`, migration 045) is an audience-and-section digest — and the unified pipeline is **not built here**: merging them is a migration and a product decision, not a read. The pipeline lanes are computed from the statuses `articles` actually uses (draft; submitted/in_review/changes_requested as one review lane; approved as scheduled; published), with `rejected` excluded as not-a-lane and **any status no lane names reported rather than dropped** — `articles.status` carries no CHECK, so a new one can appear with no schema change. **The master template library is a link, not a second copy**: it already lives at `/admin/contracts` over `legal_templates`, and two consoles over one store drift apart. Localisation and brand approval have no store and render Not recorded. On Platform, keys (`integrations`) and jobs (`cron_run_history`) are real — a job that FAILED, one still RUNNING and one that has gone SILENT past a 26-hour window are three states, not one — while **feature flags have no store at all**: what the codebase calls flags is per-user settings, a preference rather than an operator switch. The Platform summary is read-only by construction: no controls, no key material, and the payload says `secrets_included: false` so nobody wires a reveal into it later. **UPDATE 2026-09-10 (Task #147, canvas H7 — the last of H2–H7):** H7 ("Governance, and the 'viewing as' overlay") is the artboard that already had a page: decision A4 folded the Governance row into Security, and `/admin/security` renders canvas Y2. So this landed as a DIFF, not a twelfth row — no sidebar change. **The finding that shaped it: Y2's audit zone read `admin_audit_log` alone, which is a quarter of the trail.** Three of H7's five filters — Impersonations, Licence changes, Suspensions — have no rows in that table at all, so a filter bar over it would have been a working control that returned nothing. `GET /api/admin/security/governance?filter=` now unions the FOUR stores a privileged action actually lands in: `admin_audit_log` (exports, publications, billing, keys, force re-auth), `activity_logs` (role changes, activations, KYC decisions, contract void/resend), `impersonation_sessions` (the support sessions, with the reason #495 made mandatory), and `licence_events` (issue, re-term, renew, suspend, reinstate, terminate, each with its note). Each store returns its own newest 60 and the merge keeps the newest 60 of the union, which is exact rather than approximate. **Two traps are guarded rather than described.** `activity_logs` writes a subject-side TWIN for nearly every admin action — `your_role_changed` beside `role_changed`, `kyc_rejected` beside `kyc_rejected_by_admin` — whose `user_id` is the person it happened TO; joining those as the actor names the subject of an action as the person who took it, so only the admin-side actions are read. And `admin_audit_log.exported_at` holds BOTH `'YYYY-MM-DD HH:MM:SS'` and full ISO with a `T`, which do not sort against each other lexically — the merge sorts on the parsed epoch, and a timestamp that will not parse sorts last rather than to the epoch. **Tenant is real for exactly one row type**: a licence event is about a licence, so it names the subsidiary; nothing else can (U1), and those rows read Not recorded with the reason under the table rather than an em-dash. H7's **Data access** zone shipped — impersonations and exports side by side, in two independently-available halves so an unreadable store cannot make the other half look like the whole answer; a session still open past the 30-minute limit reads **not closed**, because the token expired on time and the closing write is best-effort, where "0m left" would say somebody is still inside. **Deliberately not built:** H7's guardrail-hit panel (no AI-safety counter exists — one constant now serves both the `/overview` zone and this one so they cannot drift), and the "Viewing as: Axal VC France · Return to HQ view" overlay, which is a TENANT-scoped read-only view and therefore U1; the two "view as" modes that do exist are a role switch and a support session against one account. H7's own rule is kept — "no cards, no summary tiles, no chart" — and pinned structurally: no `` may appear between the zone heading and the table. `/overview` stopped selecting the 50 audit rows it no longer renders. **UPDATE 2026-09-15 (#216, D108):** HQ can read a branch. `services/branches.ts` fans out over every `BRANCH_*` service binding with `Promise.allSettled` under a 2s deadline and gives each branch its own state — `ok` with its `as_of`, `unreadable` (explicitly NOT a claim the branch is down), or `not_deployed` for a licence HQ has provisioned but has no binding to yet — so one unreachable territory cannot empty the page or shrink a total. Every aggregate carries `{total, answered, complete}`: "of N branches, M answered" is a sentence H1 has to be able to say. Two `WorkerEntrypoint` classes (`rpc/index.ts`, re-exported from `index.ts` where a binding resolves them) hold NO logic — `cloudflare:workers` does not exist under `node --test`, so the behaviour lives in `rpc/branchOps.ts` and `rpc/hqOps.ts` and a guard asserts the classes stay free of SQL and control flow. **Migration 258** `licence_deployments` (one per licence, `rpc_secret_hash`, the provisioning timeline as `status` and the perishable `last_health_*` kept apart) and **migration 259** `hq_escalations`, which gives the H1 zone its first store: the payload's old sentence "No escalation exists on the platform" was true and is now deleted, not reworded, along with the rail line that said it. An escalation is stamped with the CALLER's code and refused unless that code is a provisioned branch — a binding cannot identify its caller (D.7), so this is attribution, not authentication. SLA is stored as the due date and the band derived on read. **Still not shipped:** `revenueSummary`, `governanceFeed`, the support-session hand-off and H4's grouped-search page; `searchAccounts` exists behind it. No branch is deployed, so `branches` is `[]` in production and PR 7's throwaway branch is the first real fan-out. **UPDATE 2026-09-15 (#218, D110):** H2 and H3 are finished, and Platform gained the console that watches what H3 asks for. **Coverage is a 27-cell EU grid**, built from the member states and joined to the ledger rather than assembled from it — the refusal that stood in `AdminLicences.jsx` ("the same data the territory list already carries") was true of a map of what is HELD and missed the question H2 exists to answer, which is what is still free. Three states, and the middle one is the point: a **suspended licence still holds its territory**, so its cells stay taken; a terminated one holds nothing and is excluded; a country outside the EU is REPORTED rather than dropped, because a grid titled EU coverage that swallowed a Swiss territory would make the ledger look smaller than it is. The **renewal pipeline** derives `days` from a date the caller passes (never stored, the D108 rule) and KEEPS overdue rows with a negative number — hiding the one row that matters because it sorted below zero is how a lapsed licence goes unnoticed. **The issue flow is six steps**: Entity · Territory · Seats · Terms · **Contract** · **Deploy**, with the history moved to its own unnumbered tab (it was the fifth tab called "Activate", while the Activate button has always sat above the tabs). Step 5 instantiates the licence agreement from the master template at its CURRENT version and **stores the rendered text** (**migration 259** `licence_contracts`): a row holding only a slug and a number re-renders from today's values, so a licence signed at a €90,000 fee would display tomorrow's €95,000. A version named by the caller is ignored — an archived version stays binding on contracts that carry it, it is not newly issuable — and a re-issue supersedes rather than overwrites. A merge field the licence cannot fill keeps its placeholder and is listed in `unfilled_fields`; blanking it makes an unfinished contract read as done. Step 6 is the first thing that can ask for a branch: `POST /api/admin/licences/:uid/deploy` writes its `licence_deployments` row **before** dispatching `branch-provision.yml` (a `workflow_dispatch` returns 204 with no body whether the run succeeds, fails or is never scheduled, so a row written afterwards would leave no trace of a failed attempt), and answers **409 `github_not_configured`** — a STATE, not an error — naming the exact secrets and the Actions tab, because `GITHUB_ACCESS_TOKEN` with `actions: write` is task #192 and is unset. `GET /api/admin/deployments` carries `dispatch_available` so the button disables itself with the server's own sentence rather than being pressed into that 409, and a **403 from GitHub is reported as the scope**, not as a missing secret. Everywhere here the provisioning `status` and the live health read are separate fields: a deployment that reached `worker_live` and is unreachable right now has not regressed to `requested`, and the zone's coverage counts only branches HQ asked, reporting `not_deployed` apart. A branch code is **refused, not lower-cased**, on the same charset the provisioning workflow enforces (D105). **The defect found on the way:** `mapError` — which 31 route files call inside their own try/catch, so `app.onError` never sees their throws — carried its own list of auth sentences and did not know 'Super admin required', so this entire console answered a permission refusal with **400 Bad Request**. `AUTH_ERROR_STATUSES` moved to `util/authErrors.ts` and both readers index it. **Still not shipped:** sending a contract for signature (`status` and `envelope_uid` exist and nothing writes them), and the rollout percentage and rollback on Deployments, which need the Cloudflare versions API. **UPDATE 2026-09-15 (#219, D111):** H5's two storeless zones got stores. **Migration 260** adds `subsidiary_usage_reports` (what a BRANCH said about itself, with its own `reported_at`), `subsidiary_statements` (HQ's ledger row) and `licence_promo_ceilings`; `routes/admin_statements.ts` mounts `GET /statements`, `POST /statements/draw`, `PATCH /statements/:uid`, `GET /promo-ceilings` and `PUT /promo-ceilings/:uid` before the `/api/admin` catch-all. **Two tables and not one, because they have different authors and different trust** — folding them together would give 'the branch says it billed EUR 40,000' and 'HQ has decided it owes EUR 14,000' the same authority. Owed is **computed and stored** with the share it was drawn at, the opposite of the SLA band (D108) and for the opposite reason: a band is a view of a date, an owed figure is a claim somebody pays against, and recomputing it under re-termed terms would silently restate it. **The finding that shaped the payload:** a branch cannot total its own revenue from local tables at all — `account_subscriptions` has a plan and no amount, a subsidiary charges no onward licence fee, and `ai_usage_logs` gives a token COST with no price to subtract it from — so `revenueSummary` reports every stream unavailable WITH ITS REASON and carries the measured cost in `estimate_basis`, never as a gross a statement could sum as revenue. A stream nobody reported is COUNTED, not zeroed: `unreported_streams` travels with the row and every surface says the owed figure is a **floor rather than a total**. Nothing is summed across currencies and a stream in the wrong one is reported unusable rather than converted. A re-draw refuses past `draft`; a licence with no agreed share refuses outright rather than drawing at 0%. **`reportUsage` carries a per-deployment secret** verified against `licence_deployments.rpc_secret_hash` (HQ stores the SHA-256), and **a deployment with no hash refuses** — which closed a real gap: `branch-provision.yml` generated the digest, used it once, and never sent it to HQ, so every branch would have had a NULL and every real report would have been refused. The promo **ceiling is not a budget**: `issued_cents` is nullable because 'issued nothing' and 'has not told us' are different, and a zero would report the whole ceiling as available. On the page, Statements and Promotions read their own endpoints with their own retry (the disputes precedent), and the rail's unavailable list lost both lines. **Still absent, and said so:** token margin and the per-subsidiary P&L (U1, and no price is recorded), subscription revenue as a platform figure, and a branch's issued spend until it reports one. **UPDATE 2026-09-15 (#220, D112):** the escalation loop CLOSES. D108 gave a branch a way to push an item up and nobody a way to answer one — `answer`, `answered_by_user_id` and `answered_at` were columns nothing wrote, so H1 was a queue that could only grow. `routes/admin_escalations.ts` adds `GET /api/admin/escalations` (filterable by status and kind, mounted before the `/api/admin` catch-all) and `PATCH /api/admin/escalations/:uid`, and `routes/branch_escalations.ts` gives the branch tier its own prefix `/api/branch`. **A decision is one answer with an author and a time, NOT a thread** — a second answer replaces the first, because two decisions in one column is not a conversation, it is a lost decision — and the branch lane renders it with no reply box, which would otherwise be the most expensive thing on the screen: the person using it would believe they had replied. **The delivery is its own fact**: `answerEscalation` records and does not push; the route pushes and reports `pushed` separately, the D111 promo-ceiling precedent, because an unreachable branch must not make a recorded decision look like one that never happened. **Migration 261** `branch_escalations` is the branch's local mirror (a branch cannot read HQ's database, D.2), keyed on HQ's uid rather than a local id — a push matched on a row number would land on the wrong escalation silently — and a push for a uid this branch never raised is reported, never inserted. A raise HQ cannot take is KEPT as `undelivered` with its reason and is not counted as an escalation HQ has. **THE TRAP, and it is the reason this route is ungated:** D107 freezes every branch write behind 423 and PR 5's frozen banner names an escalation as the appeal path, so gating this one would be a locked door with a sign pointing at it. H6's localisation refusal is **narrowed, not deleted** — brand approval and per-subsidiary attribution exist now, but nothing records that one piece LOCALISES another, so a count would be counting submissions. **The defect found on the way:** `mapError` answered 400 for the new tier refusal because the route threw its own wording — D110's failure reappearing the first time somebody wrote a new one — so the sentence is a shared constant (`BRANCH_ONLY`, the mirror of `HQ_ONLY`) and the test asserts both the status and the table entry. **Still not shipped:** the four local approval queues as one board (PR 13), and a real message thread, which is a store rather than a column. **UPDATE 2026-09-16 (#238, D123):** a newer export replaced the committed copy in place — H1–H7 amended, **H8–H13 appended**. The canvas's own CHANGELOG says "H8–H14"; there is no H14, and the section ids are `changelog, h1…h13`, so six artboards were appended rather than seven. Amendments: the eleven-row HQ nav is now drawn (Accounts reads **Team**, Governance lives under **Security**, and the licence surfaces spell it **Licences**) so the artboard titles and the shipped sidebar agree for the first time; H2's coverage grid gains a sort control and click-to-open on a held cell. New: **H8** issue-flow steps 5–6, Contract then Deploy, where the failure is the 4th of 8 steps and names the credential · **H9** Team grouped by branch with the Branch column · **H10** the statement ledger, gross reported by the branch and owed computed · **H11** the localisation lane, the publish confirmation and Platform → Deployments · **H12** the viewing-as overlay on Home and Team plus guardrail hits by branch, where a stale branch's counts are labelled and excluded from totals that still foot · **H13** the HQ AI rail, whose `scope` is a prop on the SHARED `AdminRail` so the overlay and the rail cannot disagree about which branch is being read. **UPDATE 2026-09-16 (#260/#243, D138):** **H9 shipped**, and with it the first screen whose SUBJECT is the admin accounts. Until now supervising an administrator meant opening one licence at a time — `/admin/licences` → a licence → Administrators → Notices — so "who is frozen right now" could not be asked, only assembled, while the compliance ladder had been live since D135 deployed. `GET /api/admin/hq/admins` (`requireSuperAdmin`, on `admin_hq.ts` beside `/overview`) returns every administrator with the licence they hold and the role they hold it in (`licence_admins ⋈ territory_licences`, read **user→licence** for the first time), their rung on the ladder (`admin_notices GROUP BY user_id, status`, the first read of `idx_admin_notices_user` for a third party), the super-admin badge, `is_active`, and `users.last_active_at` — **written by `middleware/lastActive.ts` and absent from every list payload until now**, so "when was this admin last here" had no answer anywhere. **The defect it closes is functional, not cosmetic:** `SuperAdminHolders`' grant picker read `api.adminListUsers()` with no arguments — the newest 100 accounts — and filtered that PAGE to `role === 'admin'` in the browser; admins are among the OLDEST accounts, so past a hundred rows the `` over it, labelled "Account section". The + phone opens its own picker, and the keyboard and a screen reader get a real + control; the row shows a focus ring when the select has focus. The options + are the same sections in the same order: the canvas's six first, then the + four kept ones in a "More" group. A section id the list does not hold names + the first section rather than a blank row. +- **From `lg` it is unchanged:** the column beside the content, with "More" + over the four kept sections. +- **The chip row is gone**, and with it the effect that scrolled the active + chip into view. + +### Why a native select + +The canvas draws the closed row only. Before D568 this page had a custom +dropdown: a button over a `role="listbox"` list with no arrow-key handling. A +native select needs no focus management or outside-click code, and on a phone +it is the picker the phone already knows. The drawing stays the canvas's, +because the select is transparent and fills the row. + +### Tests + +`frontend/test/account_center_d568.test.mjs`: + +- The section list test checks that the column is the `lg` shape. +- A new test checks the phone row: + - it is hidden from `lg` and is 44px tall; + - it reads "Section Billing and orders ▾" with that section open; + - its options are in the list's order, with the kept ones under "More" and + the open one selected; + - a pick reaches `onSelect`; + - a stray id names the first section; + - the sideways row is gone. diff --git a/frontend/src/pages/account/AccountCenter.jsx b/frontend/src/pages/account/AccountCenter.jsx index 9ea6311ce9..b51cd9a334 100644 --- a/frontend/src/pages/account/AccountCenter.jsx +++ b/frontend/src/pages/account/AccountCenter.jsx @@ -173,25 +173,22 @@ export function Chips({ items, label }) { /** * The section list on the left of the content — the canvas's `acNav`. * - * ONE ELEMENT, TWO SHAPES, which is the canvas's own phone rule - * (`.op-secnav{flex-direction:row;overflow-x:auto}`): a column beside the - * content from `lg`, a row of chips that scrolls sideways below it. The six - * canvas sections come first in the canvas's order; the four the old page had - * and the canvas does not draw follow under "More", kept rather than dropped. + * TWO SHAPES, which is the canvas's own phone rule (D607, the 2026-10-06 + * export: `.op-secnav{display:none}.op-secdrop{display:flex}`). From `lg` it + * is a column beside the content. Below `lg` it is one row, "Section", the + * open section's name and ▾, and the list opens from it. The six canvas + * sections come first in the canvas's order; the four the old page had and + * the canvas does not draw follow under "More", kept rather than dropped. + * + * The phone row is a native select under the canvas's drawing, so the phone + * opens its own picker, and the keyboard and a screen reader get a real + * control. D568 drew a row of chips that scrolled sideways here, after the + * export it was built from; the newer export replaced that row. */ export function AccountSectionNav({ sections, active, onSelect }) { - const rowRef = useRef(null); const main = sections.filter((s) => s.group !== 'more'); const more = sections.filter((s) => s.group === 'more'); - - // On a phone the active chip can sit off the edge of the row. Scroll the - // row itself, never the page. - useEffect(() => { - const row = rowRef.current; - if (!row || row.scrollWidth <= row.clientWidth) return; - const btn = row.querySelector('[aria-current="page"]'); - if (btn) row.scrollLeft = btn.offsetLeft - (row.clientWidth - btn.clientWidth) / 2; - }, [active]); + const current = sections.find((s) => s.id === active) || main[0] || sections[0]; const item = (s) => { const on = s.id === active; @@ -202,7 +199,7 @@ export function AccountSectionNav({ sections, active, onSelect }) { onClick={() => onSelect(s.id)} aria-current={on ? 'page' : undefined} data-testid={`account-nav-${s.id}`} - className={`shrink-0 whitespace-nowrap rounded-axal-sm px-3 py-2 text-left text-[13px] transition-colors lg:whitespace-normal ${ + className={`rounded-axal-sm px-3 py-2 text-left text-[13px] transition-colors ${ on ? 'bg-axal-lavender font-semibold text-axal-violet-deep dark:bg-violet-900/30 dark:text-violet-300' : 'font-medium text-axal-muted hover:bg-axal-ground hover:text-axal-ink dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100' @@ -213,20 +210,41 @@ export function AccountSectionNav({ sections, active, onSelect }) { ); }; + const option = (s) => ; + return ( ); } diff --git a/frontend/test/account_center_d568.test.mjs b/frontend/test/account_center_d568.test.mjs index e1e71d10b2..eaf53cc6e9 100644 --- a/frontend/test/account_center_d568.test.mjs +++ b/frontend/test/account_center_d568.test.mjs @@ -254,8 +254,45 @@ test('the section list draws the canvas sections first, the kept ones under More assert.match(markup, /aria-current="page"[^>]*data-testid="account-nav-billing"/); assert.equal((markup.match(/aria-current="page"/g) || []).length, 1); assert.match(text(h(AccountSectionNav, { sections: SECTIONS, active: 'account', onSelect: noop })), /Following More Onboarding/); - // One element, two shapes: a sideways row on a phone, a column from lg. - assert.match(markup, /flex gap-0\.5 overflow-x-auto lg:flex-col/); + // The column is the lg shape; below lg the Section menu stands in for it. + assert.match(markup, /