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,1107 +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; - -/** - * ⭐⭐ 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 ``, which truncates mid-glyph. This control is a + * button whose `.cg-fsel-label` carries `text-overflow: ellipsis`, so a longer label degrades to + * "Deleted colu…" rather than breaking. One word is therefore a craft call, not a constraint: + * the slot reads COMPLETE at every nesting depth, which is what `ops.ts` means by "the label + * gives way, never the control". + * + * ⛔ SO THE RESTING ROW SAYS ONE WORD, IN AMBER, AND NOTHING MORE. The sentence below and the + * raw key both live on the picker's LIST, which the admin sees on opening the control to act. + * At rest the key is in the button's accessible name only. State it that way; do not claim the + * row explains itself standing still. + * + * ⚠ NO EM DASH AND NO EN DASH (CLAUDE.md rule 2) — both of these reach a screen. + */ +export const DELETED_FIELD_LABEL = "Deleted"; + +/** The sentence across the top of the picker list (`FieldSelectButton`'s `title`, which paints + * `.cg-pop-title`). One line, per DESIGN.md "never over-explain". */ +export const DELETED_FIELD_NOTE = "This column no longer exists."; + +/** The three members of `customer-grid/FieldSelect.tsx::FieldSelectItem` this rewrite touches. + * RESTATED rather than imported: that file is a `.tsx`, and `verify_login.py` compiles this + * module with no `--jsx`, so even a type-only import from it would put a JSX file in the + * harness's compilation. Every other member of the real interface rides through untouched + * because the rewrite spreads the row it replaces. */ +export interface DeletableFieldRow { + key: string; + label: string; + hint?: string; +} + +/** + * Re-label the one picker row `withCurrentField` synthesised for a key nothing resolves. + * + * ⚠ THE RAW KEY IS NOT THROWN AWAY, it MOVES: out of the label, where it impersonated a field + * name, and into `hint` — the muted second line `FieldSelect` already paints under a row's + * label. Support can still read it, and it reads as evidence rather than as a column. + */ +export function withDeletedFieldLabel( + rows: readonly T[], + colId: string +): T[] { + return rows.map((row) => + row.key === colId ? { ...row, label: DELETED_FIELD_LABEL, hint: colId } : row + ); +} + +/** 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). + * ⭐ W40-T16 — `PermsField[]`, so C3's per-metric rows survive the parse into what the hide + * panel renders. Every consumer that wanted `Field[]` still compiles: the added key is + * optional, so the two array types are assignable in both directions. */ + fields: PermsField[]; + /** 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): PermsField | 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), + // ⭐⭐ W40-T16 (owner instruction 13 / C3) — THE METRIC FLAG. + // ⚠ `=== true`, so an absent key means NO. `filterable` is the opposite (absent means yes), + // which is exactly why the two cannot be added in one careless sweep. + ...(r.metric === true ? { isMetric: true } : {}), + // ⭐⭐ W40-T17 (owner instruction 15 / C3) — THE TWO MEMBERSHIP FLAGS, AND THEY COULD NOT + // LAND ON THEIR OWN. `FieldsHidePanel` files a field under "Shared with me" or "Shared with + // everyone" from `custom || shared`, and `customer-grid/types.ts::fieldEditMode` answers + // "collaborative" whenever a definition carries no `permissions` bag — which every field on + // THIS wire does, because `_module_fields` sends none. So the instant these two parse, every + // custom or shared column in the manage-user room lands under "Shared with everyone": the + // exact list owner instruction 15 asks us to stop drawing. The gate that stops it is + // `sharedSections={false}` on the manage-user mount, and it ships in this same change. Land + // either half alone and the product holds the defect between them. + // ⚠ `=== true` FOR BOTH, matching the metric flag one line up and OPPOSING `filterable` + // (W40-T18), where absence means yes. That asymmetry lives inside this one function on + // purpose: it is the shape a careless sweep gets wrong. + ...(r.custom === true ? { custom: true } : {}), + ...(r.shared === true ? { shared: true } : {}), + // ⛔⛔ W40-T18 (owner instruction 16 / C3) — THE FILTER FLAG, AND IT IS READ BACKWARDS FROM + // THE THREE ABOVE ON PURPOSE. `customer-grid/types.ts::filterableFields` is + // `fields.filter((f) => f.filterable !== false)`: ABSENCE MEANS FILTERABLE. So the property + // is emitted ONLY when the wire says `false` outright, and a payload that never mentions it + // parses WITHOUT the key — which is what leaves every such column offered. + // ⛔ `filterable: r.filterable === true` is the shape this ships broken in: every field on + // today's server lacks the key, so all of them would parse `false` and the permission filter + // builder would offer NOTHING AT ALL, with the parse looking symmetrical and correct. + // Three flags one way, one flag the other, inside one function. Read the predicate, not the + // neighbours. + ...(r.filterable === false ? { filterable: false } : {}), + // 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: PermsField[] = []; + 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); +} + +/** + * ⭐⭐ W40-T17 — THE KEYS THE PERMISSION EDITOR'S HIDE LIST ACTUALLY GOVERNS: what it LISTS + * (`presetHideFields`, AM-2's `metric || !custom`) minus the one row it can never hide. + * + * ⛔⛔ THE LOCKED KEY IS RESOLVED FROM THE **FULL** LIST, AND SWAPPING THAT IS A SILENT + * MIS-LOCK. `identityKey` is "the pinned field, else THE FIRST", so + * `identityKey(presetHideFields(fields))` would answer with whatever survives the narrowing + * first — on any module whose identity column were ever filtered out, a perfectly ordinary + * column would be treated as the one that cannot be hidden, while the real identity column + * became hideable. The panel's own `lockedKey={identityKey(module.fields)}` reads the full list + * for exactly this reason, and these two must agree or the row that is disabled on screen is not + * the row the bulk action protects. + */ +export function governedFieldKeys(fields: readonly PermsField[]): string[] { + const locked = identityKey(fields); + return presetHideFields(fields).map((f) => f.key).filter((k) => k !== locked); +} + +/** + * ⭐⭐ W40-T17 — "Hide all", SCOPED TO WHAT THE ROOM SHOWS. + * + * ⛔ WHY THIS IS NOT `hideableKeys(fields)` ANY MORE, AND WHY W40-T17 IS THE TICKET THAT OWES + * THE CHANGE. Before the narrowing, the permission editor LISTED every field a module had, so a + * whole-list replace and the list on screen named the same set. This ticket narrows the list to + * the database's own pre-set columns plus its bound measures, and a bulk action that kept + * replacing the WHOLE list would then write hidden keys for columns the admin cannot see in the + * room they clicked in — a restriction nobody was shown, which is the one thing this file's + * header forbids. + * + * ⚠ SO IT REPLACES A SLICE, NOT THE RECORD: the stored list minus the governed keys, plus the + * governed keys. Anything the record already hid that this room does not govern is carried + * through untouched, exactly as `copyTargetRecord`'s module scope carries the target's other + * modules. + * + * ⛔ AND THE IDENTITY KEY IS STRIPPED ON THE WAY OUT even though `governedFieldKeys` already + * excludes it. A record written elsewhere could name it, and a "carry the ungoverned keys + * through" rule would faithfully carry THAT one through as well — re-creating the table of blank + * rows this file's `identityKey` header exists to prevent, through the very control that used to + * be immune to it because it replaced the list wholesale. `toPutBody` strips it again at the PUT + * boundary; two layers, for the reason stated there. + */ +export function hideAllKeys( + rec: PermsRecord, + key: string, + fields: readonly PermsField[], +): string[] { + const governed = governedFieldKeys(fields); + const governedSet = new Set(governed); + const locked = identityKey(fields); + const stored = rec[key]?.hiddenFields ?? []; + return normalizeHidden( + [...stored.filter((k) => !governedSet.has(k)), ...governed].filter((k) => k !== locked)); +} + +/** + * ⭐⭐ W40-T17 — "Show all", SCOPED THE SAME WAY, AND THIS IS THE HALF THAT MATTERS MOST. + * + * ⛔⛔ IT SUBTRACTS, IT DOES NOT CLEAR. The old spelling was `onSetHidden([])`: an empty list, + * i.e. nothing is hidden on this database at all. Under a narrowed list that control REVEALS + * columns the admin was never shown — it WIDENS a permission through a button whose own list did + * not contain the fields it just un-hid. "Hide all" over-reaching is dishonest and fails safe; + * "Show all" over-reaching fails OPEN, and a permissions screen is not allowed to get that + * direction wrong. + * + * ⚠ IT READS THE **STORED** `hiddenFields`, NEVER `hiddenSetFor`. That union is a READ-SIDE view + * that paints every metric row hidden while a legacy blanket `metrics: false` stands; feeding it + * into a write would persist the blanket as explicit keys from a control that is supposed to be + * clearing them. The blanket's conversion has exactly one door — `normalizeMetrics`, which + * `PermsEditor` already runs in front of every hidden-field write — and adding a second here is + * how two normalisers come to disagree. + */ +export function showAllKeys( + rec: PermsRecord, + key: string, + fields: readonly PermsField[], +): string[] { + const governedSet = new Set(governedFieldKeys(fields)); + const locked = identityKey(fields); + const stored = rec[key]?.hiddenFields ?? []; + return normalizeHidden(stored.filter((k) => !governedSet.has(k) && k !== locked)); +} + +/** `{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 ?? []); +} + +/** + * ⭐⭐ W40-T16 — WHAT THE HIDE PANEL ACTUALLY DRAWS AS HIDDEN: the stored list, UNION every + * metric row when a legacy blanket revocation stands. + * + * ⛔ THE CASE IT EXISTS FOR IS THE TICKET'S OWN NEGATIVE CONTROL: *"a user whose saved record + * predates this still has their old blanket metrics setting honoured"*. `PermsEntry.metrics` is + * one boolean over the whole database (W38-T19), and this ticket replaces the control that wrote + * it with one checkbox per metric. A record already carrying `metrics: false` therefore has a + * revocation with nothing on screen expressing it — the panel would paint every metric row as + * granted while `perm_scope.may_metrics` refuses the lot. + * + * ⛔ A READ-SIDE UNION, NEVER A REWRITE ON LOAD. Folding the blanket into `hiddenFields` when the + * record parses would edit somebody's stored rule as a side effect of an admin OPENING the page, + * and the first unrelated Save would persist it. `metrics` stays on the wire, in `parseEntry` and + * in `toPutBody`, exactly as W38-T19 left it; this function only decides how it READS. + * + * ⛔ A NEW EXPORT RATHER THAN A WIDER `hiddenSet`. `verify_login.py` compiles + * `shell/_test/shell.test.ts` in the same tsc call as this file's own suite, and re-signing an + * existing export reds a gate in a file this ticket may not repair. `hiddenSet` is still the base + * and is called below, so there is ONE spelling of "the keys the record hides". + */ +export function hiddenSetFor( + rec: PermsRecord, + key: string, + fields: readonly PermsField[], +): ReadonlySet { + const keys = new Set(hiddenSet(rec, key)); + if (rec[key]?.metrics === false) { + for (const f of fields) if (f.isMetric) keys.add(f.key); + } + return keys; +} + +/** + * ⭐⭐ W40-T16 — THE WRITE-SIDE HALF OF `hiddenSetFor`, AND WITHOUT IT THE READ-SIDE HALF IS A + * TRAP. Called at the top of every hidden-field write: a blanket `metrics: false` becomes the + * EXPLICIT per-metric state instruction 13 asks for, exactly once, at the moment an admin edits. + * + * ⛔⛔ THE DEFECT IT CLOSES, STATED AS THE STATE IT WOULD OTHERWISE LEAVE. `hiddenSetFor` unions + * every metric key while `metrics === false`, so on such a record a metric row reads hidden and + * CANNOT BE MADE VISIBLE AGAIN: unticking it writes the key into `hiddenFields`, the union + * re-hides it, "Show all" is inert for it, and — because W40-T16 retires the blanket box and + * nothing else in the UI writes `PermsEntry.metrics` — the revocation is permanent and has no + * exit. That makes this ticket's own `done-when` ("checking one hides exactly that metric") + * FALSE for those records. A read-side union alone is half a feature. + * + * ⛔ ON EDIT, NEVER ON LOAD, AND THE TWO ARE DIFFERENT IN KIND. Rewriting when the record parses + * would edit somebody's stored rule as a side effect of an admin OPENING a page, and the first + * unrelated Save would persist a decision nobody made. An edit is a decision: the admin is + * already changing this database's hidden fields. + * + * ⭐ THE UNION IS WHAT MAKES IT LOSSLESS. What the blanket MEANT (every metric hidden) becomes + * what the record SAYS, so effective permissions do not move by a single field at the moment of + * normalisation — only their spelling does. A rewrite that dropped the metrics would silently + * GRANT them; one that kept `metrics: false` would change nothing. + * + * ⚠ A NO-OP OTHERWISE, RETURNING `rec` ITSELF rather than a copy. Every setter here is immutable + * so that `isDirty` compares against an unmutated snapshot; a fresh object on every keystroke of + * an ordinary edit would be churn with no meaning, and reference identity is what lets a caller + * compose this in front of any write without paying for it. + */ +export function normalizeMetrics( + rec: PermsRecord, + key: string, + fields: readonly PermsField[], +): PermsRecord { + const cur = rec[key]; + if (!cur || cur.metrics !== false) return rec; + const metricKeys = fields.filter((f) => f.isMetric).map((f) => f.key); + // ⛔⛔ THE EMPTY CASE IS THE ONE THE PARAGRAPH ABOVE RULES OUT, AND IT IS REACHABLE TODAY. + // "THE UNION IS WHAT MAKES IT LOSSLESS ... a rewrite that DROPPED the metrics would silently + // GRANT them" is exactly what happens when `metricKeys` is empty: `metrics` flips to `true`, + // nothing is added to `hiddenFields`, and an explicit revocation an admin once made is gone. + // + // Not hypothetical. `customer_data` binds NO measures at all: `model/topics/odoo_customers.yml` + // declares no `measures:` key, so `routes_admin::_metric_fields('customer_data')` returns `[]` + // and every field arrives `isMetric: false`. Any legacy `customer_data` record carrying + // `metrics: false` therefore had its revocation erased by the admin's next unrelated + // hidden-field edit, with `isDirty` lighting up Save on a change nobody made. + // + // ⭐ AND RETURNING EARLY REINTRODUCES NOTHING. The trap this function exists to close is + // `hiddenSetFor` unioning metric keys into the hidden set so a metric ROW cannot be un-hidden. + // With no metric fields there is no such row, so there is nothing to be trapped by and nothing + // to normalise. Found by wave 40 QA; the mechanism itself is correct wherever metrics exist. + if (!metricKeys.length) return rec; + return withEntry(rec, key, { + metrics: true, + hiddenFields: normalizeHidden([...cur.hiddenFields, ...metricKeys]), + }); +} + +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; +}