diff --git "a/web/src/settings/permsModel.ts" "b/web/src/settings/permsModel.ts" --- "a/web/src/settings/permsModel.ts" +++ "b/web/src/settings/permsModel.ts" @@ -1,739 +1,1107 @@ -// --------------------------------------------------------------------------- -// settings / permsModel.ts — wave 15, contract C-PERM (ruling R9). -// -// The permission editor's PURE HALF: the wire shapes, the parse, the draft the -// admin is editing, and the body the PUT sends. No React, no fetch, so the -// rules that decide who can see what are testable under node without a browser -// or a server (verify_login's `_test` harness runs them). -// -// ⛔ NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a -// WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the -// server, and it re-validates all of this. An editor that produced a malformed -// record would be refused — the point of the care here is that it never gets -// that far, and that the admin is never shown a state the server does not hold. -// -// THREE RULES THAT LOOK LIKE STYLE AND ARE NOT: -// -// 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and -// saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode -// it prevents: half a filter tree, PUT against a field list nobody could -// read. Fail-closed is not "deny everything" here — it is "never write a -// restriction you could not show the admin". -// 2. **An unknown field TYPE lands on `text`**, it does not disappear and it -// is not passed through. Passing it through would hand the condition -// builder an operator set for a type it does not have; dropping the field -// would take away an admin's ability to HIDE a column just because they -// cannot filter on it. `text` is the house fallback the viz layer already -// uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`). -// 3. **A module the server no longer declares is DROPPED by the whole-record -// replace — and the editor says so out loud** (`orphanModules`). A silent -// drop of somebody's permanent filter is a permission change nobody made. -// --------------------------------------------------------------------------- - -import type { Field, FieldType, FilterTree } from "../customer-grid/types"; - -// --- who may reach the editor at all ---------------------------------------- - -/** The modal's rail entries. Declared HERE, beside the rule that gates them, so - * the component and the gate read one definition of the vocabulary rather than - * two that can drift by one member. `SettingsModal` re-exports it. */ -export type SettingsSection = - | "account" - | "scope" - | "users" - /** - * ⭐ WAVE 33 (owner item 10) — MANAGE AGENT: a bot per Slack channel, walled by - * the same engine that walls a person. The owner placed it explicitly: *"under - * a module called 'Manage agent' which should be under 'Manage user'"* — so it - * is the rail entry immediately after `users`, not a tab inside it and not a - * room somewhere else. Admin-only, and listed in `reachableSection` below for - * the same reason `users` is: every control in it 403s a member. - */ - | "agents" - // Wave 18 (C7): the tenant's credential store and its data-source status board. - | "keychains" - | "connectors" - /* ⛔⛔ `"statements"` LEFT THIS UNION (wave 35 · T38, owner item 14 / ruling R10). - * - * The statement sender is an AGENT now: `Agents -> Monthly statements`, where a scheduled run - * ASSEMBLES a batch and parks it, and a person releases it with a click. It was here since - * EXIT-6, ported off `app.py`. - * - * ⚠ THE SEND DOOR DID NOT MOVE AND DID NOT WEAKEN. `routes_statements` still holds it, still - * behind `admin_gate` + the Royal-Imports tenant gate, still calling - * `collections_send.queue_statement` whose SAFE_MODE allow-list lives in the DATA LAYER. What - * left is one of two front doors onto it, not a capability. - * - * ⛔ AND A STORED PREFERENCE NAMING IT DEGRADES RATHER THAN BREAKING — see `RETIRED_SECTIONS` - * below. Dropping a union member is a TYPE change; the values people have already saved are - * data, and refusing one would strand somebody on a blank pane forever (D-65's lesson: a - * refused stored key locks the door you need in order to fix it). - */ - /** - * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT - * console, visible only to a `platform_admin` account. - * - * ⛔ DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an - * oversight. Every other admin room is gated on `admin`, a boolean the shell - * already holds on the user record. `platform_admin` is a different predicate - * that arrives ASYNCHRONOUSLY on the settings payload — and R3 is explicit - * that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a - * function whose only input is `admin` would either grant the section to every - * tenant admin or deny it to the one account that has it. It is gated where - * the flag actually lives, at the render site in `SettingsModal`. - */ - | "padmin"; - -/** - * ⭐⭐ WAVE 35 · T38 — SECTIONS THAT ONCE EXISTED AND NO LONGER DO. - * - * ⛔ A LIST RATHER THAN A DELETION, because a stored value outlives the type that described it. - * `SettingsSection` is compile-time; the section somebody last had open is DATA, and a person who - * was in Statements when this shipped must land somewhere real. Without this they would restore - * into a section no branch renders — a blank modal, which reads as a broken product rather than as - * a room that moved. - * - * ⚠ THE ENTRY STAYS EVEN AFTER NOBODY COULD PLAUSIBLY HOLD IT. It costs one string and it is the - * only record, in code, that the value was ever legal — deleting it is how the next reader - * "cleans up" a degrade path and re-opens the blank pane. - */ -export const RETIRED_SECTIONS: readonly string[] = ["statements"]; - -/** - * A non-admin never LANDS on the users pane — and therefore never reaches the - * permission editor, which lives inside it. - * - * ⛔ THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*` - * route to a member regardless, and `/perms` is one of them. What this prevents - * is a member deep-linked (or restored) into a room whose every control would - * 403 — an empty frame that reads as a broken product rather than as a closed - * door. It is a named function instead of an inline ternary for one reason: an - * inline ternary cannot have a negative control, and "the pane a non-admin gets - * bounced out of" is exactly the kind of rule that gets refactored away by - * someone who does not know it is load-bearing. - * - * ⭐ WAVE 35 · T38 — it is ALSO where a RETIRED section is degraded, and the two rules live in one - * function on purpose: both answer "this section is not reachable, so where does this person - * actually land", and splitting them would give the caller two chances to forget one. - */ -export function reachableSection( - /* ⚠ WIDER THAN `SettingsSection` ON PURPOSE. The caller passes a value that may have been - STORED under an older build, so typing this parameter to the current union would make the - retired case unrepresentable — and therefore unhandleable — at exactly the callsite whose job - is to handle it. */ - section: SettingsSection | string, - admin: boolean, -): SettingsSection { - // ⛔ FIRST, before the admin rule: a retired section has no admin question to answer, and - // `adminOnly` below would read `false` for it and hand back a section nothing renders. - if (RETIRED_SECTIONS.includes(String(section))) return "account"; - // ⭐⭐ WAVE 32 · R4 — `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason. - // - // Wave 18 (C7) put them here because "every control inside them would 403 a member" — which was - // TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal - // split lands on ALL connections … business-wide is admin-only"*, i.e. a member now genuinely - // owns something in these rooms — their own personal connections. - // - // ⛔ THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain` - // dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` / - // `_may_touch`): a member sees the business-wide entries plus their own, may create only a - // personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had - // this list changed alone, a member would land in a room whose every control 403s — the empty - // frame this function's own header calls "a broken product rather than a closed door". - const adminOnly = - section === "users" || - // ⭐ Wave 33 (owner item 10) — Manage agent is admin-only for exactly the - // reason `users` is: every control in it 403s a member, so a member deep- - // linked there lands in a room whose every button fails. ⛔ It is NOT enough - // that the routes refuse — this list is what stops the EMPTY FRAME, and a new - // admin room added to the rail without a line here is the defect this - // function's header describes. - section === "agents"; - // ⚠ The cast is safe BECAUSE of the retired-section guard at the top: every value reaching here - // is either a current member of the union or has already been degraded to "account". - return adminOnly && !admin ? "account" : (section as SettingsSection); -} - -// --- the wire (C-PERM) ------------------------------------------------------ - -/** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */ -export interface PermsEntry { - /** May this account open the module at all. `false` ⇒ `may_open` denies. */ - access: boolean; - /** The PERMANENT filter, AND-ed under everything the user does. `null` = none. - * ⚠ The whole tree — `{conj?, nodes}` — never a bare node list: `[A, B]` under - * `or` means something entirely different from `[A, B]` under `and`, and the - * loss is invisible in every payload (C-PERM amendment 2). */ - filter: FilterTree | null; - /** Field keys this account never receives. Server-stripped from every wire. */ - hiddenFields: string[]; - /** - * ⭐⭐ W38-T19 — MAY THIS ACCOUNT BUILD AND RECEIVE **METRIC** COLUMNS on this database? - * `false` empties the measure offer at every grid door, which takes the Metric kind off the - * field picker AND fail-closes a create with `measure_not_offered`. - * - * ⛔ OPTIONAL, AND THE CHOICE WAS **FORCED** RATHER THAN PREFERRED. Required is the loud - * option and it is the one this file would otherwise take: `tsc` would then name every - * `PermsEntry`-shaped literal that forgot the key. But `web/verify_login.py` compiles - * `src/shell/_test/shell.test.ts` in the same `npx tsc` call as this file's own suite, and - * that file passes a bare `{access, filter, hiddenFields}` literal straight into - * `moduleSummary(entry: PermsEntry | undefined)`. A required key reds `web_login` at COMPILE - * time ("tsc emitted nothing") in a file W38-T19's fence does not contain, with no legal - * repair. Optional is what the fence permits. - * - * ⚠ SO THE DEFAULT IS CENTRALISED INSTEAD, IN EXACTLY TWO PLACES, and both spell it the same - * way: `parseEntry` reads the wire (`metrics: r.metrics !== false`) and `toPutBody` writes it - * (`metrics: e.metrics !== false`). Absence GRANTS at both ends, matching - * `perm_scope.may_metrics` and `_clean_perms` on the server, so a record written before this - * key existed reads as unrestricted rather than as a silent mass revocation. Every literal - * that omits the key therefore means the one thing it could safely mean. - */ - metrics?: boolean; -} - -export type PermsRecord = Record; - -/** A module the editor can draw a full rule for: it came with a field list. */ -export interface PermsModule { - key: string; - label: string; - /** Empty ⇒ no readable schema ⇒ access toggle only (rule 1 above). */ - fields: Field[]; - /** An app surface (Assistant, Agents): access is the whole rule, by construction and - * not by a missing schema, so the row can say so instead of apologising. */ - surface?: boolean; - /* - * ⭐⭐ WAVE 36 (W36-T22 / CONTRACT C2 / OWNER RULING R6) — `enforced` IS DELETED - * FROM THIS SHAPE, NOT DEFAULTED TO `true`. - * - * It answered "does a rule stored here actually get applied?", and until wave 36 - * the answer genuinely differed per row: `perm_scope` walled the registry topics - * and `routes_tables` gated a `ut_*` database on `user_tables.may_open` alone, so - * a wall saved against one would have been INERT — this editor saying DENY while - * the table routes kept serving, with nothing anywhere saying so. - * - * W36-T21 armed it. `perm_scope.scoped_table` is the ONE door to any database's - * rows; every `ut_*` read applies the row filter and the hidden-field closure, - * and a route that cannot apply them REFUSES rather than serving the lot. So the - * flag is now constantly true — and a flag that is always true is a lie with a - * green gate behind it, which is why C2 says delete rather than default. - * - * ⚠ THE APOLOGY GOES WITH IT. `ModulePermsList`'s "Not set here" branch, this - * model's unenforced filtering in `toPutBody` / `copyBlocked` / `accessSummary`, - * and `routes_admin`'s `unenforced_module` refusal were four spellings of one - * fact. Owner item 11: *"EVERY database should be able to be toggleable by admin. - * I'm only seeing 'Not set here'."* - */ -} - -export interface PermsPayload { - /** In server order — the order the sections render in. */ - modules: PermsModule[]; - entries: PermsRecord; - /** Keys in `perms` that the server no longer declares. A whole-record replace - * drops them; the editor states that before the admin saves. */ - orphanModules: string[]; - /** C-PERM amendment 4's migration marker. ABSENT means this record still runs - * under the LEGACY wall (`bus`/`agent` query scope + the `modules` grant), so - * the editor must not present empty perms as "no access" — that would be a - * confident lie about an account that can currently see everything. */ - migrated: boolean; - /** This account is an ADMIN, and admins bypass `perms` entirely. Every rule - * the editor can draw for them is inert — which it has to say out loud. */ - isAdmin: boolean; -} - -// --- parsing ---------------------------------------------------------------- - -/** Every `FieldType`, as a runtime set. - * - * ⚠ The `Record` is the point, not the Set: it is a - * COMPILE-TIME exhaustiveness check. Add a type to the union in - * `customer-grid/types.ts` and this file stops compiling until it is listed - * here — which is how a whitelist stays honest across a tree boundary S3 does - * not own. A hand-maintained array would silently narrow instead. */ -const FIELD_TYPE_TABLE: Record = { - text: true, status: true, currency: true, int: true, date: true, pct: true, - select: true, user: true, multiselect: true, checkbox: true, phone: true, - email: true, url: true, rating: true, created_time: true, formula: true, - automation: true, - // Wave-22 C7 (added by C, the same one-key edit the alarm demands). - metric: true, - // Wave-19 R7 (added by session A — see the dated amendment in the split doc). This ONE key is - // the whole edit: the exhaustiveness alarm above did exactly what it promises, and the fix it - // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of - // this file. - image: true, - // Wave-23 C7 (added by session D — `settings/**` is frozen this wave and this ONE key is the - // exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union - // and this listing cannot land in two different changes. Posted in D's mailbox for C.) - json: true, - // 2026-08-07 — the relational pair, listed for the reason the alarm above states and for no - // other: the exhaustiveness check is a COMPILE error, so the union and this listing cannot - // land in two separate changes. Nothing about the permissions wall treats either kind - // specially — a link/rollup column is granted and hidden like any other column. - link: true, rollup: true, - // ⭐ Wave-27 item 13 (R13) — same one-key edit, same reason, and `settings/**` is D's fence - // this wave so it is not even an exception: the alarm above is a COMPILE error, so the union - // and this listing cannot land in two changes. Nothing about the permissions wall treats a - // code column specially — it is granted and hidden like any other column. - code: true, - // ⭐⭐ Wave-34 (owner ruling R13) — `ai_enrich`, the same one-key edit for the same reason the - // four notes above give: the exhaustiveness alarm is a COMPILE error, so the union and this - // listing cannot land in two changes. `settings/**` is in NO lane's fence this wave, which - // makes it less of an exception than `json` and `code` were, not more. - // ⚠ Nothing about the permissions wall treats an enrichment column specially: it is granted - // and hidden like any other column. The thing that IS special about it (a human-edited cell is - // never overwritten by the agent) is a WRITE law in `core.user_tables`, not a grant. - ai_enrich: true, -}; - -export const KNOWN_FIELD_TYPES: ReadonlySet = new Set(Object.keys(FIELD_TYPE_TABLE)); - -/** Rule 2: whitelist, never pass through, never drop. */ -export function fieldType(raw: unknown): FieldType { - return typeof raw === "string" && KNOWN_FIELD_TYPES.has(raw) ? (raw as FieldType) : "text"; -} - -function asStringArray(raw: unknown): string[] { - return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : []; -} - -/** One field of a module's schema, from the nav/schema payload shape. - * `key` and `label` are the minimum that makes a row renderable — a field - * without them cannot be shown OR named in a rule, so it is skipped rather - * than rendered as a blank the admin might tick. */ -export function parseField(raw: unknown): Field | null { - if (!raw || typeof raw !== "object") return null; - const r = raw as Record; - if (typeof r.key !== "string" || r.key === "") return null; - const label = typeof r.label === "string" && r.label !== "" ? r.label : r.key; - const options = asStringArray(r.options); - return { - key: r.key, - label, - type: fieldType(r.type), - // Anything not explicitly the overlay stratum is treated as source data. - // Consequence in this editor: nothing here offers to EDIT a field, so the - // only thing `source` drives is the "Pre-set" chip in the hide list. - source: r.source === "overlay" ? "overlay" : "odoo", - // Carried because `identityKey` reads it — see the rule there. - ...(r.pinned === true ? { pinned: true } : {}), - ...(options.length ? { options } : {}), - ...(typeof r.note === "string" ? { note: r.note } : {}), - ...(typeof r.description === "string" && r.description !== "" - ? { note: r.description } - : {}), - }; -} - -/** `fields_by_module[key]` is a BARE ARRAY of fields — S1's canonical answer - * (routes_admin `get_perms`), not the nav/schema envelope the contract line - * implied. The earlier tolerant branch that also read `{fields: […]}` is gone: - * one shape, asserted here, beats two readings of a sentence. - * - * The LABEL does not come from here — it rides the payload's `modules` list, - * which is also what fixes the ORDER. Anything unreadable yields an EMPTY field - * list, which R9 already has a defined rendering for (access toggle only). */ -export function parseModule(key: string, label: string, raw: unknown, - surface = false): PermsModule { - const fields: Field[] = []; - for (const f of Array.isArray(raw) ? raw : []) { - const parsed = parseField(f); - if (parsed) fields.push(parsed); - } - return { key, label: label || key, fields, ...(surface ? { surface: true } : {}) }; -} - -/** A filter is taken WHOLE or not at all. A tree whose `nodes` is not a list is - * not a narrower filter, it is an unreadable one — and this editor's job is to - * never show a rule it could not also save back. */ -export function parseFilter(raw: unknown): FilterTree | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const r = raw as Record; - if (!Array.isArray(r.nodes)) return null; - const conj = r.conj === "or" ? "or" : r.conj === "and" ? "and" : undefined; - return { - ...(conj ? { conj } : {}), - nodes: r.nodes as FilterTree["nodes"], - }; -} - -/** ⚠ ABSENT ENTRY ⇒ `access: false`, matching what the server will ENFORCE for a - * migrated record (C-PERM amendment 4: `perms_v == 1` + no entry ⇒ DENY). The - * editor showing "access on" for a module the wall denies would send an admin - * to debug a permission that was never granted. - * - * For an UN-migrated record the same absence means the opposite — the legacy - * wall still applies and may grant everything — which is why `PermsPayload` - * carries `migrated` and the editor states it rather than letting this default - * speak for a case it does not describe. */ -export function parseEntry(raw: unknown): PermsEntry { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return { access: false, filter: null, hiddenFields: [] }; - } - const r = raw as Record; - return { - access: r.access === true, - filter: parseFilter(r.filter), - hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)), - // ⭐⭐ W38-T19 — `!== false`, NOT `=== true`, AND THE TWO ARE OPPOSITE HERE. `access` reads - // absence as DENY because a migrated record declares every governed database, so a missing - // `access` is a decision. No `metrics` key was STORABLE before this ticket, so EVERY record - // in every tenant is missing one: reading absence as deny would paint the box unticked for - // every account on the day this shipped, and the next save of any unrelated change would - // write that revocation for real. The server's `may_metrics` and `_clean_perms` default the - // same direction, so the editor shows what the wall will actually do. - metrics: r.metrics !== false, - }; -} - -/** Sorted + de-duplicated, always. Dirty-detection compares payloads, so a list - * whose ORDER can drift would make an untouched module read as edited. */ -export function normalizeHidden(keys: readonly string[]): string[] { - return [...new Set(keys)].sort(); -} - -/** - * The IDENTITY column — the one field a module may never hide. - * - * ⛔ WHY THIS EXISTS AT ALL. `FieldsHidePanel` takes an optional `lockedKey` and - * documents that "absent = nothing is locked". Absent, a single click on - * "Hide all" hides EVERY field including the row's own name — and with - * amendment 5's server-side transitive closure behind it, that writes a record - * whose faithful enforcement is a table of blank rows. The user could open the - * module and see nothing in it, the wall doing exactly what the record said. - * Nothing in C-PERM's PUT validation refuses that: it refuses UNKNOWN field - * keys, and the identity column is perfectly known. - * - * ⚠ MIRRORED, NOT INVENTED: this is `useGridColumns.ts:201-204`'s rule verbatim - * — the pinned field, else the first. The grid computes the same key to decide - * which column its own Hide-fields panel locks, so an editor using a different - * rule would lock a different column from the one the table protects. - */ -export function identityKey(fields: readonly Field[]): string { - return fields.find((f) => f.pinned)?.key ?? fields[0]?.key ?? ""; -} - -/** Every field a module MAY hide — the whole list minus its identity column. */ -export function hideableKeys(fields: readonly Field[]): string[] { - const locked = identityKey(fields); - return fields.filter((f) => f.key !== locked).map((f) => f.key); -} - -/** `{perms, fields_by_module}` → everything the editor renders. Returns `null` - * only for a body that is not an object at all; a body missing either half - * parses to an editor with nothing to offer, which is the honest rendering of - * "the server told us nothing". */ -export function parsePermsPayload(body: unknown): PermsPayload | null { - if (!body || typeof body !== "object") return null; - const b = body as Record; - const byModule = - b.fields_by_module && typeof b.fields_by_module === "object" - ? (b.fields_by_module as Record) - : {}; - const rawPerms = - b.perms && typeof b.perms === "object" && !Array.isArray(b.perms) - ? (b.perms as Record) - : {}; - - // ⚠ `modules` CARRIES THE LABELS AND THE ORDER, and `fields_by_module` carries - // neither. Deriving sections from the field map alone renders every heading as - // a registry KEY — "customer_data" where the product says "Customer" — and in - // whatever order the JSON happens to enumerate. The field map is still the - // outer bound: a module named in `modules` with no field list is the - // schema-less case, not an error. - // - // ⭐⭐ WAVE 36 (W36-T22 / C2) — no `enforced` is read, and an OLDER SERVER STILL - // PARSES: a payload that carries the key is simply ignored, which is the one - // direction this can be wrong in safely. Every row the server declares is a row - // whose wall `routes_tables` applies (W36-T21), so there is nothing left for a - // per-row flag to distinguish. - const declared: Array<{ key: string; label: string; surface?: boolean }> = Array.isArray(b.modules) - ? (b.modules as unknown[]).flatMap((m) => { - if (!m || typeof m !== "object") return []; - const r = m as Record; - return typeof r.key === "string" && r.key - ? [{ key: r.key, label: typeof r.label === "string" ? r.label : r.key, - surface: r.surface === true }] - : []; - }) - : Object.keys(byModule).map((k) => ({ key: k, label: k })); - - const modules = declared.map((d) => parseModule(d.key, d.label, byModule[d.key], d.surface)); - const entries: PermsRecord = {}; - for (const m of modules) entries[m.key] = parseEntry(rawPerms[m.key]); - - return { - modules, - entries, - orphanModules: Object.keys(rawPerms).filter((k) => !(k in entries)).sort(), - migrated: typeof b.perms_v === "number" && b.perms_v >= 1, - // `role == 'admin'` bypasses `perms` entirely (C-PERM amendment 4). Sent by - // the route so the editor can SAY so instead of rendering stored rules that - // do not apply — the one misreading of that clause that could hurt. - isAdmin: b.is_admin === true, - }; -} - -// --- the draft the admin is editing ----------------------------------------- - -/** Immutable edits: every setter returns a NEW record, so React sees the change - * and `isDirty` compares against a snapshot that no setter has mutated under - * it. In-place edits are how a Save button ends up permanently greyed. */ -function withEntry(rec: PermsRecord, key: string, patch: Partial): PermsRecord { - const cur = rec[key] ?? { access: false, filter: null, hiddenFields: [] }; - return { ...rec, [key]: { ...cur, ...patch } }; -} - -export function setAccess(rec: PermsRecord, key: string, access: boolean): PermsRecord { - return withEntry(rec, key, { access }); -} - -export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | null): PermsRecord { - // An empty tree is NO filter, not an empty one. `{nodes: []}` matches every - // row, so persisting it would mean "restricted, to everything" — a rule that - // reads as a restriction in the record and is not one on screen. - const empty = !filter || filter.nodes.length === 0; - return withEntry(rec, key, { filter: empty ? null : filter }); -} - -/** ⭐⭐ W38-T19 — the Metrics capability toggle. A plain `withEntry` patch like every other - * setter, so the draft stays immutable and `isDirty` sees the change through `toPutBody`. */ -export function setMetrics(rec: PermsRecord, key: string, metrics: boolean): PermsRecord { - return withEntry(rec, key, { metrics }); -} - -export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord { - return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) }); -} - -export function toggleHidden(rec: PermsRecord, key: string, fieldKey: string): PermsRecord { - const cur = rec[key]?.hiddenFields ?? []; - const next = cur.includes(fieldKey) - ? cur.filter((k) => k !== fieldKey) - : [...cur, fieldKey]; - return setHidden(rec, key, next); -} - -export function hiddenSet(rec: PermsRecord, key: string): ReadonlySet { - return new Set(rec[key]?.hiddenFields ?? []); -} - -export function filterOf(rec: PermsRecord, key: string): FilterTree { - // The panel takes a tree, never null — an absent filter is an EMPTY tree to - // edit, which is what "add your first condition" has to render against. - return rec[key]?.filter ?? { nodes: [] }; -} - -// --- what gets sent --------------------------------------------------------- - -/** - * The PUT body. Whole-record replace, so this emits an entry for EVERY module - * the server declared — including the ones the admin never touched, because a - * missing key in a replace is a deletion, not a no-op. - * - * ⚠ A module with no readable schema is emitted `{access, filter: null, - * hiddenFields: []}` (R9). Its access toggle is real and is honoured; what is - * refused is inventing a restriction against a field list nobody could read. - * - * ⭐⭐ WAVE 36 (W36-T22 / C2) — EVERY DECLARED MODULE IS EMITTED. The wave-33 rule - * here was "an unenforced module is not emitted at all", because - * `routes_admin::_clean_perms` refused the key with `unenforced_module` and - * emitting it would have 400'd the whole save. That refusal is deleted: W36-T21 - * armed the wall over every database, so a rule stored against a `ut_*` one is - * applied by the same code that applies `customer_data`'s. Withholding it now - * would be the opposite defect — an admin edits a database, presses Save, and the - * editor silently drops it. - */ -export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: PermsRecord } { - const perms: PermsRecord = {}; - for (const m of payload.modules) { - const e = rec[m.key] ?? { access: false, filter: null, hiddenFields: [] }; - // ⚠ The identity column is stripped at the BOUNDARY as well as withheld - // from the UI. The panel never offers it, but a record written before this - // rule — or by anything else — could still name it, and this editor is the - // last place that record passes through before the wall enforces it - // faithfully. Two layers for the same reason C-PERM validates at PUT time - // AND `permits()` re-checks: records go stale in ways forms cannot. - const locked = identityKey(m.fields); - perms[m.key] = m.fields.length - ? { - access: e.access, - filter: e.filter, - hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)), - // ⭐⭐ W38-T19 — ALWAYS EMITTED, never left to the server's default. A PUT is a - // whole-record replace and this editor is the last thing the record passes through, - // so a key it withholds is a key whose meaning is decided somewhere else. Emitting it - // is also what keeps `isDirty` honest: that function compares the SENT SHAPE, and a - // capability the payload never carries could be toggled all day without lighting Save. - metrics: e.metrics !== false, - } - // ⭐⭐ W38-T19 — THE SCHEMA-LESS ARM EMITS **NO** `metrics` KEY, and the omission is the - // rule rather than an oversight. R9: access is the WHOLE rule for a module with no - // readable schema. An Assistant or Agents row has no grid and therefore no measure door, - // this editor renders no Metrics box for it, and a whole-record COPY is the one path that - // could otherwise carry a revocation onto it — a restriction the admin was never shown. - // ⚠ ABSENT IS NOT UNDECIDED HERE, IT IS GRANTED, and it resolves in exactly one place: - // `_clean_perms` stores `bool(raw.get("metrics", True))`, so an omitted key persists as - // `true`. Sending `true` explicitly would be the same value by a longer road, and it - // would break `shell/_test/shell.test.ts`'s "a schema-less module saves access only" — - // a leg whose CLAIM is right and which this file must not make false. - : { access: e.access, filter: null, hiddenFields: [] }; - } - return { perms }; -} - -/** Which slice of one account's access a copy carries onto another. */ -export type CopyScope = { kind: "all" } | { kind: "module"; key: string }; - -/** - * C-PERMCOPY (wave 17, item 16) — the record to PUT onto ONE target, composed - * from that target's OWN payload and this account's SAVED entries. - * - * ⛔ WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE COMPONENT. It is the - * SECOND producer of a PUT body in the product, and the first one aimed at - * somebody else's record. A PUT is a whole-record replace: a key that does not - * come back is DELETED. `toPutBody` already carries a negative control named - * `replace-omits-untouched-modules` for exactly that failure — and every way of - * getting it wrong from here reaches the same place by a different road. Inline - * in a `useCallback`, no gate could see any of it. - * - * THE RULE, in one line: **the target's tree is the base; the copy overwrites - * a slice of it.** Never the source's tree with the target's bits merged in — - * that composes a record out of modules the TARGET may not declare. - * - * · `{kind:'all'}` — the whole record is replaced by the source's. - * · `{kind:'module'}` — that one module is replaced; every other module keeps - * the value the target's own GET returned, byte for byte. - * - * ⚠ TWO EDGES, both currently unreachable because `_PERM_MODULES` is server-wide - * (every payload declares the same modules), and both named rather than left to - * be discovered if that ever stops being true: - * 1. The source has a module the TARGET does not declare. `toPutBody` iterates - * the TARGET's modules, so the copy is dropped — silently, under a message - * that says it was copied. `copyDropped()` below is what lets the caller - * tell the truth about that. - * 2. The target declares a module the SOURCE has no entry for. It lands on - * `NO_ACCESS`, which is the honest reading of "apply this account's access": - * if the source does not grant it, the target must not keep it. Deliberate, - * and it is why a whole-record copy is offered as "replace", not "merge". - */ -export function copyTargetRecord( - target: PermsPayload, - source: PermsRecord, - scope: CopyScope -): PermsRecord { - if (scope.kind === "all") { - const out: PermsRecord = {}; - for (const m of target.modules) { - out[m.key] = source[m.key] ?? { access: false, filter: null, hiddenFields: [] }; - } - return out; - } - return { - ...target.entries, - [scope.key]: source[scope.key] ?? { access: false, filter: null, hiddenFields: [] }, - }; -} - -/** The modules a copy CANNOT carry, because the target does not declare them. - * Empty in every deployment where the module list is server-wide; a caller that - * reports "copied" without consulting it would be guessing. */ -export function copyDropped( - target: PermsPayload, - source: PermsRecord, - scope: CopyScope -): string[] { - // ⚠ IT MUST MATCH `toPutBody`'s FILTER EXACTLY — this function's whole job is to - // name what the copy will NOT carry. W36-T22 deleted the `enforced` filter from - // both, so both read the module list whole; if one of them ever grows a filter - // again, the other has to grow it in the same commit. - const declared = new Set(target.modules.map((m) => m.key)); - const wanted = scope.kind === "all" ? Object.keys(source) : [scope.key]; - return wanted.filter((k) => !declared.has(k)).sort(); -} - -/** Compares the SENT SHAPE, not the draft, so a change the payload cannot carry - * (a hidden-field list on a schema-less module) never lights up Save. */ -export function isDirty(payload: PermsPayload, saved: PermsRecord, draft: PermsRecord): boolean { - return ( - JSON.stringify(toPutBody(payload, saved)) !== JSON.stringify(toPutBody(payload, draft)) - ); -} - -// --- how a rule reads ------------------------------------------------------- - -/** Counts a tree's leaves, groups included. The editor states the size of a - * restriction rather than showing "Filtered" for one condition and for twenty. */ -export function countLeaves(tree: FilterTree | null): number { - if (!tree) return 0; - let n = 0; - const walk = (nodes: readonly unknown[]) => { - for (const node of nodes) { - if (node && typeof node === "object" && Array.isArray((node as { children?: unknown[] }).children)) { - walk((node as { children: unknown[] }).children); - } else { - n += 1; - } - } - }; - walk(tree.nodes); - return n; -} - -/** - * One sentence per module for the accounts list and the section head. - * - * ⚠ NO EMOJI, no icon vocabulary — this string is read aloud by a screen reader - * and printed in the gate's output. It also never says "restricted" without - * saying to WHAT: "Filtered" alone is the kind of summary that makes an admin - * open every section to find the one that is set. - */ -export function moduleSummary(entry: PermsEntry | undefined, schemaless = false): string { - if (!entry || !entry.access) return "No access"; - if (schemaless) return "Full access"; - const conds = countLeaves(entry.filter); - const hidden = entry.hiddenFields.length; - // ⭐⭐ W38-T19 — A REVOKED CAPABILITY IS A RESTRICTION AND THE COLLAPSED ROW HAS TO SAY SO. - // The whole point of this sentence is that an admin can read a database's rule without - // opening it; a row reading "Full access" over an account that cannot build a Metric column - // is the summary lying, which is the failure mode this function's own header is about. - const noMetrics = entry.metrics === false; - if (!conds && !hidden && !noMetrics) return "Full access"; - const parts: string[] = []; - if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`); - if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`); - if (noMetrics) parts.push("Metrics off"); - return parts.join(", "); -} - -/** The account-list cell: what this user may open, across all modules. - * - * ⭐ W36-T22 — COUNTS EVERY DECLARED MODULE. Wave 33 counted enforced ones only, - * because this sentence must describe what the payload will SAVE and a `ut_*` - * database's access was not decided here at all. It is now (W36-T21 / R6), so - * omitting those five would under-report an account's real access — the same - * rule, with the fact underneath it changed. */ -export function accessSummary(payload: PermsPayload | null, rec: PermsRecord): string { - const governed = payload?.modules ?? []; - if (!payload || governed.length === 0) return ""; - const open = governed.filter((m) => rec[m.key]?.access); - if (open.length === 0) return "No access"; - // ⚠ A SCHEMA-LESS MODULE CANNOT BE RESTRICTED, so it is never counted as one - // — the same guard `moduleSummary` takes. Without it a draft filter on a - // module whose rule `toPutBody` strips would be summarised as "1 restricted", - // describing a restriction that is not going to be saved. The header and the - // Save confirmation both read this sentence, so it must describe the PAYLOAD. - const restricted = open.filter( - (m) => - m.fields.length > 0 && - ((rec[m.key]?.filter?.nodes.length ?? 0) > 0 || - (rec[m.key]?.hiddenFields.length ?? 0) > 0) - ).length; - const all = open.length === governed.length; - const head = all - ? `All ${open.length} module${open.length === 1 ? "" : "s"}` - : `${open.length} of ${governed.length} modules`; - return restricted ? `${head}, ${restricted} restricted` : head; -} +// --------------------------------------------------------------------------- +// settings / permsModel.ts — wave 15, contract C-PERM (ruling R9). +// +// The permission editor's PURE HALF: the wire shapes, the parse, the draft the +// admin is editing, and the body the PUT sends. No React, no fetch, so the +// rules that decide who can see what are testable under node without a browser +// or a server (verify_login's `_test` harness runs them). +// +// ⛔ NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a +// WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the +// server, and it re-validates all of this. An editor that produced a malformed +// record would be refused — the point of the care here is that it never gets +// that far, and that the admin is never shown a state the server does not hold. +// +// THREE RULES THAT LOOK LIKE STYLE AND ARE NOT: +// +// 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and +// saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode +// it prevents: half a filter tree, PUT against a field list nobody could +// read. Fail-closed is not "deny everything" here — it is "never write a +// restriction you could not show the admin". +// 2. **An unknown field TYPE lands on `text`**, it does not disappear and it +// is not passed through. Passing it through would hand the condition +// builder an operator set for a type it does not have; dropping the field +// would take away an admin's ability to HIDE a column just because they +// cannot filter on it. `text` is the house fallback the viz layer already +// uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`). +// 3. **A module the server no longer declares is DROPPED by the whole-record +// replace — and the editor says so out loud** (`orphanModules`). A silent +// drop of somebody's permanent filter is a permission change nobody made. +// --------------------------------------------------------------------------- + +import type { Field, FieldType, FilterTree } from "../customer-grid/types"; + +// --- who may reach the editor at all ---------------------------------------- + +/** The modal's rail entries. Declared HERE, beside the rule that gates them, so + * the component and the gate read one definition of the vocabulary rather than + * two that can drift by one member. `SettingsModal` re-exports it. */ +export type SettingsSection = + | "account" + | "scope" + | "users" + /** + * ⭐ WAVE 33 (owner item 10) — MANAGE AGENT: a bot per Slack channel, walled by + * the same engine that walls a person. The owner placed it explicitly: *"under + * a module called 'Manage agent' which should be under 'Manage user'"* — so it + * is the rail entry immediately after `users`, not a tab inside it and not a + * room somewhere else. Admin-only, and listed in `reachableSection` below for + * the same reason `users` is: every control in it 403s a member. + */ + | "agents" + // Wave 18 (C7): the tenant's credential store and its data-source status board. + | "keychains" + | "connectors" + /* ⛔⛔ `"statements"` LEFT THIS UNION (wave 35 · T38, owner item 14 / ruling R10). + * + * The statement sender is an AGENT now: `Agents -> Monthly statements`, where a scheduled run + * ASSEMBLES a batch and parks it, and a person releases it with a click. It was here since + * EXIT-6, ported off `app.py`. + * + * ⚠ THE SEND DOOR DID NOT MOVE AND DID NOT WEAKEN. `routes_statements` still holds it, still + * behind `admin_gate` + the Royal-Imports tenant gate, still calling + * `collections_send.queue_statement` whose SAFE_MODE allow-list lives in the DATA LAYER. What + * left is one of two front doors onto it, not a capability. + * + * ⛔ AND A STORED PREFERENCE NAMING IT DEGRADES RATHER THAN BREAKING — see `RETIRED_SECTIONS` + * below. Dropping a union member is a TYPE change; the values people have already saved are + * data, and refusing one would strand somebody on a blank pane forever (D-65's lesson: a + * refused stored key locks the door you need in order to fix it). + */ + /** + * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT + * console, visible only to a `platform_admin` account. + * + * ⛔ DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an + * oversight. Every other admin room is gated on `admin`, a boolean the shell + * already holds on the user record. `platform_admin` is a different predicate + * that arrives ASYNCHRONOUSLY on the settings payload — and R3 is explicit + * that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a + * function whose only input is `admin` would either grant the section to every + * tenant admin or deny it to the one account that has it. It is gated where + * the flag actually lives, at the render site in `SettingsModal`. + */ + | "padmin"; + +/** + * ⭐⭐ WAVE 35 · T38 — SECTIONS THAT ONCE EXISTED AND NO LONGER DO. + * + * ⛔ A LIST RATHER THAN A DELETION, because a stored value outlives the type that described it. + * `SettingsSection` is compile-time; the section somebody last had open is DATA, and a person who + * was in Statements when this shipped must land somewhere real. Without this they would restore + * into a section no branch renders — a blank modal, which reads as a broken product rather than as + * a room that moved. + * + * ⚠ THE ENTRY STAYS EVEN AFTER NOBODY COULD PLAUSIBLY HOLD IT. It costs one string and it is the + * only record, in code, that the value was ever legal — deleting it is how the next reader + * "cleans up" a degrade path and re-opens the blank pane. + */ +export const RETIRED_SECTIONS: readonly string[] = ["statements"]; + +/** + * A non-admin never LANDS on the users pane — and therefore never reaches the + * permission editor, which lives inside it. + * + * ⛔ THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*` + * route to a member regardless, and `/perms` is one of them. What this prevents + * is a member deep-linked (or restored) into a room whose every control would + * 403 — an empty frame that reads as a broken product rather than as a closed + * door. It is a named function instead of an inline ternary for one reason: an + * inline ternary cannot have a negative control, and "the pane a non-admin gets + * bounced out of" is exactly the kind of rule that gets refactored away by + * someone who does not know it is load-bearing. + * + * ⭐ WAVE 35 · T38 — it is ALSO where a RETIRED section is degraded, and the two rules live in one + * function on purpose: both answer "this section is not reachable, so where does this person + * actually land", and splitting them would give the caller two chances to forget one. + */ +export function reachableSection( + /* ⚠ WIDER THAN `SettingsSection` ON PURPOSE. The caller passes a value that may have been + STORED under an older build, so typing this parameter to the current union would make the + retired case unrepresentable — and therefore unhandleable — at exactly the callsite whose job + is to handle it. */ + section: SettingsSection | string, + admin: boolean, +): SettingsSection { + // ⛔ FIRST, before the admin rule: a retired section has no admin question to answer, and + // `adminOnly` below would read `false` for it and hand back a section nothing renders. + if (RETIRED_SECTIONS.includes(String(section))) return "account"; + // ⭐⭐ WAVE 32 · R4 — `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason. + // + // Wave 18 (C7) put them here because "every control inside them would 403 a member" — which was + // TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal + // split lands on ALL connections … business-wide is admin-only"*, i.e. a member now genuinely + // owns something in these rooms — their own personal connections. + // + // ⛔ THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain` + // dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` / + // `_may_touch`): a member sees the business-wide entries plus their own, may create only a + // personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had + // this list changed alone, a member would land in a room whose every control 403s — the empty + // frame this function's own header calls "a broken product rather than a closed door". + const adminOnly = + section === "users" || + // ⭐ Wave 33 (owner item 10) — Manage agent is admin-only for exactly the + // reason `users` is: every control in it 403s a member, so a member deep- + // linked there lands in a room whose every button fails. ⛔ It is NOT enough + // that the routes refuse — this list is what stops the EMPTY FRAME, and a new + // admin room added to the rail without a line here is the defect this + // function's header describes. + section === "agents"; + // ⚠ The cast is safe BECAUSE of the retired-section guard at the top: every value reaching here + // is either a current member of the union or has already been degraded to "account". + return adminOnly && !admin ? "account" : (section as SettingsSection); +} + +// --- the wire (C-PERM) ------------------------------------------------------ + +/** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */ +export interface PermsEntry { + /** May this account open the module at all. `false` ⇒ `may_open` denies. */ + access: boolean; + /** The PERMANENT filter, AND-ed under everything the user does. `null` = none. + * ⚠ The whole tree — `{conj?, nodes}` — never a bare node list: `[A, B]` under + * `or` means something entirely different from `[A, B]` under `and`, and the + * loss is invisible in every payload (C-PERM amendment 2). */ + filter: FilterTree | null; + /** Field keys this account never receives. Server-stripped from every wire. */ + hiddenFields: string[]; + /** + * ⭐⭐ W38-T19 — MAY THIS ACCOUNT BUILD AND RECEIVE **METRIC** COLUMNS on this database? + * `false` empties the measure offer at every grid door, which takes the Metric kind off the + * field picker AND fail-closes a create with `measure_not_offered`. + * + * ⛔ OPTIONAL, AND THE CHOICE WAS **FORCED** RATHER THAN PREFERRED. Required is the loud + * option and it is the one this file would otherwise take: `tsc` would then name every + * `PermsEntry`-shaped literal that forgot the key. But `web/verify_login.py` compiles + * `src/shell/_test/shell.test.ts` in the same `npx tsc` call as this file's own suite, and + * that file passes a bare `{access, filter, hiddenFields}` literal straight into + * `moduleSummary(entry: PermsEntry | undefined)`. A required key reds `web_login` at COMPILE + * time ("tsc emitted nothing") in a file W38-T19's fence does not contain, with no legal + * repair. Optional is what the fence permits. + * + * ⚠ SO THE DEFAULT IS CENTRALISED INSTEAD, IN EXACTLY TWO PLACES, and both spell it the same + * way: `parseEntry` reads the wire (`metrics: r.metrics !== false`) and `toPutBody` writes it + * (`metrics: e.metrics !== false`). Absence GRANTS at both ends, matching + * `perm_scope.may_metrics` and `_clean_perms` on the server, so a record written before this + * key existed reads as unrestricted rather than as a silent mass revocation. Every literal + * that omits the key therefore means the one thing it could safely mean. + */ + metrics?: boolean; +} + +export type PermsRecord = Record; + +/** + * ⭐⭐ W40-T16 (owner instruction 13 / contract C3) — ONE FIELD OF THE PERMISSIONING LIST: a + * `Field` plus the one thing only this list knows, which is whether the row stands for a bound + * MEASURE rather than for a stored column. + * + * ⛔ THE FLAG CANNOT BE CALLED `metric`, AND THAT IS A COLLISION RATHER THAN A PREFERENCE. + * `customer-grid/types.ts::Field` already declares `metric?: {source?, measure, window, agg?}` — + * an OBJECT, the definition of a Metric COLUMN. C3's wire flag is a BOOLEAN meaning "this + * pseudo-field stands for a measure". Two types under one name on one interface do not compile, + * and `types.ts` belongs to another fence, so the translation happens HERE at `parseField` — + * the boundary whose entire job is translating the wire into what this editor renders. + * + * ⚠ OPTIONAL, WHICH IS LOAD-BEARING RATHER THAN CAUTIOUS. A plain `Field[]` stays assignable to + * `PermsField[]` only while the added key is optional, and the grid toolbar's own hide panel + * passes exactly that. A required flag would red the build in a file this ticket may not repair. + */ +export interface PermsField extends Field { + /** C3's `metric: true`. `=== true`, so ABSENCE MEANS NO — the exact opposite of `filterable`, + * where absence means yes. That asymmetry is why the two are parsed in different tickets. */ + isMetric?: boolean; +} + +/** + * ⭐⭐ W40-T16 — HOW A METRIC ROW READS. Owner instruction 13, verbatim: *"one row per metric, + * named 'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the + * permissioning is limited to"*. + * + * ⚠ THE SEPARATOR IS AN ASCII HYPHEN-MINUS AND THAT IS DELIBERATE. CLAUDE.md rule 2 bans the EM + * dash (U+2014) and the EN dash (U+2013) from anything that reaches a screen. `-` is neither, + * and it is the character the instruction itself is written with. + * + * ⛔ THE GUARD IS THE WHOLE REASON THIS IS A FUNCTION. `_module_fields` is free to label its + * `measure_`-namespaced pseudo-fields fully, so the word may already be on the wire; prefixing + * one that carries it ships "Metric - Metric - Revenue". A label that already begins with the + * word is therefore taken AS IT STANDS. + * + * ⚠ IT TAKES THE RESOLVED NAME, NOT THE FIELD, and that is forced rather than tidy. The ONE name + * resolver is `customer-grid/types.ts::fieldLabel` (wave 30 R5); `types.ts` imports `./windows` + * at RUNTIME, and `verify_login.py` runs THIS module's compiled artifact under bare node. A value + * import from there would drag that graph into the harness for the sake of one string, so the + * caller resolves the name with the house resolver and this function decides only the prefix. + */ +export function metricLabel(name: string): string { + return name.toLowerCase().startsWith("metric") ? name : `Metric - ${name}`; +} + +/** + * ⭐⭐ W40-T17 (owner instruction 15 / contract C3, amendment AM-2) — WHICH FIELDS THE + * PERMISSION EDITOR'S HIDE LIST IS ABOUT. Owner, verbatim: *"stop displaying 'Shared with me' / + * 'Shared with everyone' fields under Hide Fields - permission on pre-set Fields only."* + * + * ⛔ AM-2 IS WHY THIS IS A SEPARATE VOCABULARY RATHER THAN A NARROWING OF THE PANEL'S SECTIONS. + * One payload carries two memberships because instructions 15 and 16 pull `_module_fields` in + * opposite directions: *"THE HIDE PANEL TAKES `metric || !custom`, the Filter builder takes + * everything with `filterable`."* Two consumers, two readings, one wire. A single list narrowed + * to satisfy both would satisfy neither. + * + * ⛔ THE `metric ||` HALF IS LOAD-BEARING AND IT IS NOT SYMMETRY. A measure column created on an + * Odoo grid is stored `source: "odoo", custom: true, derived: true` (`core/grid_events.py:1374`), + * so `!custom` alone would delete every per-metric checkbox W40-T16 shipped — the control owner + * instruction 13 asked for, gone, with every gate on it still green. A row that stands for a + * measure is in this list BECAUSE it is a measure, whatever stratum it was minted in. + * + * ⚠ IT DOES NOT READ `shared`, AND THAT IS DELIBERATE. `shared` decides which SECTION the grid's + * panel files a row under; `custom` decides whether the column is the database's own or somebody's + * addition to it, which is the question instruction 15 asks. A shared PRE-SET column (a route + * order is one: `shared: true` with no `custom`) is still the database's own column and stays. + */ +export function presetHideFields(fields: readonly PermsField[]): PermsField[] { + return fields.filter((f) => f.isMetric || !f.custom); +} + +/** + * ⭐⭐ W40-T18 (owner instruction 16) — THE STORED CONDITION WHOSE COLUMN IS GONE. + * + * Owner, verbatim: *"If the field is deleted, its permission filter goes with it."* The half + * that lives on the client is what the admin SEES in the meantime, and today it is a lie of + * omission: `filter-kit/ops.ts::withCurrentField` appends the unresolved key as its own picker + * row with `label: current` and no type mark, so a rule left over from a deleted column renders + * as `custom_1723489` sitting in the sentence looking like a field name. + * + * ⛔ THE CONDITION IS NOT DROPPED, AND THAT IS THE RULE RATHER THAN A CHOICE. This panel edits + * LIVE permission records at `settings/ModulePermsList`, and a rule that vanishes on render is a + * rule the admin never decided to delete — the same law `orphanModules` states one layer up + * (rule 3 in this file's header). It is MARKED and left removable instead. + * + * ⚠ THREE EXCLUSIONS, EACH LOAD-BEARING: + * - a key the module still declares but no longer OFFERS (`filterable: false`) is NOT this. It + * resolves through `fieldByKey`, keeps its own label, and `withCurrentField` appends it on + * purpose so narrowing the picker never orphans history. + * - `COHORT_FIELD` / `VIEW_FIELD` are leaves, not columns; they are passed IN rather than + * imported, because reading them from `customer-grid/types` would be a VALUE import that + * drags `./windows` into the bare-node harness `verify_login.py` runs this module under. + * - AN EMPTY FIELD MAP MEANS "not answered yet", NEVER "all deleted". A schema-less module and + * a payload still in flight both present as zero fields, and without `size` this would accuse + * every condition in the record at once. + */ +export function isDeletedFieldRef( + colId: string, + fields: { has(key: string): boolean; size: number }, + measures: { has(key: string): boolean }, + leafKeys: readonly string[] +): boolean { + if (colId === "" || fields.size === 0) return false; + if (leafKeys.includes(colId)) return false; + return !fields.has(colId) && !measures.has(colId); +} + +/** + * What that row READS as at rest. One word, and the width is why. + * + * ⚠ THE ARITHMETIC, so the choice is arguable rather than a preference: `.cg-cond-field` is + * `flex: 0 1 136px`, and `96px` once the condition sits inside a group; index.css derives the + * readable text width as `clientWidth - padding - border - caret - mark`, leaving roughly 82px + * at the wide end, where "Days since order" (91.0px at 12.5px Inter) already runs over. + * + * ⚠ IT WOULD NOT CLIP, THOUGH, AND THE DISTINCTION MATTERS. `ops.ts`'s "about 15 characters" + * note was written about a NATIVE `