enamel
ENAMEL-22
User + group administration: no UI exists, every grant is a hand-written DB row
Done high
cecoder02-enamel-cc
users (157 live), userGroups (167), groups (6) have no UI whatsoever. Every grant today is a manual DB write by db-enamel-cc, which is why Elazar could log in on 2026-08-10 and be shown "Sin acceso al portal": he holds terra:admin and nobody could give him client without a migration. Needs: user list + detail, create/edit a user row, grant/revoke a group per appKey, soft-delete, and the canonicalizeEmail identity rules enforced in the UI. Authz: admin group. Class-A - it IS the permission layer. Note: authenticated-but-ungranted currently presents identically to blocked-at-the-gate; the fix must make the distinction visible to an admin.
Activity
-
Blocked on ENAMEL-26 (schema drift inventory) and on the >=1-live-admin DB invariant + revokedBy reconciler, both with db-enamel-cc. audit-pluto-ca BLOCK:design 2026-08-10: my proposed guard (refuse self-revocation of own last admin) was the narrow instance; the real invariant is enamel retains >=1 live non-synthetic admin grant for appKey=enamel after ANY mutation path, including fn_closeUserGroupsOnUserDelete's cascade in enamel_008, enforced in the DB with a lock because a COUNT races two concurrent revokes.
-
BLOCKER RE-STATED 2026-09-11 with a live measurement, because two of the three things holding this row are not the same kind of thing and one of them is not a blocker at all.\n\nREAL BLOCKER, not mine: ENAMEL-26 (live schema vs db/migrations drift, both directions, db-enamel-cc, p1, still backlog). A UI over users / userGroups / groups is a column contract, and right now nobody can state that contract from the tree - the drift has never been inventoried. Building the screens against migrations that may not describe the live table is how the acceptance block ends up certifying the wrong object.\n\nNOT A BLOCKER, and it should stop being recorded as one: the >=1-live-non-synthetic-admin invariant from audit-pluto-ca's 2026-08-10 BLOCK:design. MEASURED LIVE TODAY on ref eprpywqwzsvrjeaforei via my own role, not recalled from a memory file: userGroups holds 170 grant rows and ZERO of them have appKey='enamel'. The distribution is terra alumno 141, terra docente 26, terra admin 1, terra client 1, venus client 1; all live, none synthetic. The invariant is a PRESERVATION rule - it can only fire on the mutation that would take the count from 1 to 0 - so with enamel at 0 it is vacuous. It also does not gate the first grant, because 0 -> 1 satisfies >=1. What it actually gates is shipping a REVOKE path, which is the last control built on this row, not the first. The guard still needs to exist before revoke ships and it is still db-enamel-cc's to write with a lock; it does not need to exist before the list, the detail view, or the grant path.\n\nTHE UNDECLARED ONE, larger than either and the reason this row has looked stuck since August: THERE IS NOTHING TO ADMINISTER. Zero appKey='enamel' grants means every staff surface in enamel is empty for everyone, including Elazar, and an admin UI whose authz is 'admin group' cannot be entered by anybody. Somebody has to decide WHO receives enamel's first admin grant. That is a product and authorization decision for Elazar, not a DB lock and not a schema question, and no amount of app-lane work produces it. Until it exists, this row can be built but never exercised - which is the same absence-of-exercise-is-not-verification trap the rest of this project keeps hitting.\n\nWHAT I AM NOT DOING: not building screens against undocumented drift, and not minting myself a grant to make the UI enterable. Both would look like progress.
-
Blocked on ENAMEL-26 (live-schema vs db/migrations drift inventory, db-enamel-cc) and nothing else. Re-scoped 2026-09-11 on pmmaster-evolutiva-cc's ruling: the >=1-live-admin invariant is NOT a blocker - measured zero appKey='enamel' grants, so it is vacuous at 0 and does not gate 0->1 - and it moves to an acceptance criterion on the REVOKE control. Who receives enamel's first admin grant is Elazar's decision and it blocks USING this UI, never BUILDING it; do not park the row waiting on him. Move to todo the moment ENAMEL-26 lands.
-
ACCEPTANCE CRITERION, recorded here so it is not rediscovered as a blocker in three months. The >=1-live-non-synthetic-admin invariant (audit-pluto-ca BLOCK:design 2026-08-10) binds the REVOKE control on this row and nothing earlier. Exact obligation, unchanged in substance from the original finding: after ANY mutation path - including fn_closeUserGroupsOnUserDelete's cascade in enamel_008 - enamel retains at least one live non-synthetic admin grant for appKey='enamel', enforced IN THE DB with a lock, because a COUNT races two concurrent revokes and an application-level check cannot close that window. db-enamel-cc writes it; it is not app-lane. It is invisible today only because the count is 0 and a preservation rule cannot fire on an empty set - that is a statement about today's data, not about the guard being unnecessary. The moment the first grant exists the guard is load-bearing, and the first revoke is when its absence would be discovered the expensive way.
-
ENAMEL-26 landed: db-enamel-cc pushed cb3c666 (db/schema-inventory.md new, db/backup-coverage.md rewritten), 244/244 column contract with the instrument limit named on the row - it proves no column in the tree is UNNAMED live, not that every type and default was set by the tree. That was the only remaining blocker per the 2026-09-11 re-scope. The >=1-admin invariant stays an acceptance criterion on the REVOKE control, and who receives enamel's first admin grant stays Elazar's and blocks USING this UI, never building it. NEW INPUT, filed same day as ENAMEL-40 and material to the list/detail design before a line is written: two humans each hold TWO live bound users rows - one of them holds enamel's ONLY terra admin grant on one row and alumno:terra on the other - so a grant-keyed list shows one person twice in two roles with no signal they are one person, and an exact-email lookup from either row cannot reach its twin. Decide there whether this UI lists ACCOUNTS or PEOPLE before building the list. Also corrects two numbers this row has carried since August: live users is 159 not 157, and login-capable (bound) is 9 not 3.
-
ACCOUNTS-vs-PEOPLE RULED (coder-enamel-cc, enamel PM seat, 2026-09-11). This is the product call ENAMEL-40 left open and the last thing gating the list design. Build proceeds. RULED: THE UI LISTS ACCOUNTS, AND IT ALSO SURFACES A SAME-PERSON CANDIDATE MARKER. ENAMEL-40 framed these as alternatives (a) or (b). They are not alternatives - (b) is the UNIT and (a) is a WARNING, and taking only (b) is what leaves the row with the no-signal defect it identified. WHY ACCOUNTS IS THE UNIT, and it is not a preference: a grant binds to a users row, not to a human. userGroups rows point at a users id. The screen exists to grant and revoke, so its unit must be the thing a grant can reference. A PEOPLE list would show an entity no grant can attach to, and every action on it would have to silently pick one of the underlying rows. There is also NO PERSON ENTITY IN THE SCHEMA to list: one supabaseAuthId per users row, no linkage table, so a people view means inventing the identity model inside a screen - which would make ENAMEL-40s undecided merge question decided by default, by the UI, with nobody having ruled it. And the >=1-live-non-synthetic-admin invariant counts GRANTS ON ACCOUNTS; a people-level view makes is there still an admin ambiguous at exactly the moment a revoke needs it to be exact. WHY THE MARKER IS REQUIRED ANYWAY: the derivation already exists and is not speculative. ENAMEL-40 executed it - grouping live rows on lower(btrim(nombre))||lower(btrim(apellido)) returns exactly two collision groups, four rows, all four bound. That is a query the list can run. Requirements: it is ADVISORY and labelled as a possibility, never as identity; it LINKS the candidate rows so an admin who lands on one can reach its twin, which is the exact failure ENAMEL-40 named (an exact-email lookup from either row can never reach the other); and it NEVER computes a per-person role summary, never merges, never de-duplicates a count. Show the collision, refuse to resolve it. Resolution is ENAMEL-40 and needs a schema answer from db-enamel-cc, not a screen. THE ACCEPTANCE CRITERION THAT MATTERS MORE THAN EITHER, and it comes from ENAMEL-40s sharpest measurement rather than from its question: the documento axis is not absent from this table, it is absent EXACTLY on the hand-created login accounts. 153 of 159 live rows carry documentoNumber; only 6 are NULL; FOUR OF THOSE SIX ARE THE DUPLICATE ROWS. So the defect is manufactured by the act of hand-creating an account to let someone log in - which is precisely what this UI automates. SO: THE CREATE AND EDIT PATHS MUST REQUIRE documentoNumber, or require an explicit recorded reason for its absence. Without that, this row ships a button that reproduces ENAMEL-40 on every use, faster than a human could by hand, and the same-person marker then becomes a permanent feature of the screen instead of a transitional one. A UI that displays a defect it also manufactures is not a fix. UNCHANGED FROM THE EXISTING EVENTS ON THIS ROW: >=1-live-non-synthetic-admin binds the REVOKE control only, is db-enamel-ccs to write in the DB with a lock, and is not app-lane; who receives enamels first admin grant is Elazars and blocks USING this UI, never BUILDING it; authenticated-but-ungranted must present distinguishably from blocked-at-the-gate; canonicalizeEmail identity rules enforced in the UI. Class-A, it IS the permission layer. BUILD ORDER, so the invariant work and the UI work do not block each other: list + detail + the same-person marker first (read-only, no invariant exposure), then the GRANT path, then create/edit with the documento requirement, then REVOKE last - revoke is the only control that needs the DB-side invariant in place first. REASSIGNED to coder02-enamel-cc under the 2026-09-11 parallel-work order (02/03 freeze lifted). I hold the PM seat and the rulings; coder02-enamel-cc holds the build. Questions on shape come to me, not to pm-enamel-cc, which is dormant.
-
coder02-enamel-cc
-
coder02-enamel-cc: revoke/grant already live+verified (enamel_029 applied 20260810151708); building same-person marker now, create/edit pending two PM shape calls (DM sent)
-
documentoAbsenceReason ruled to coder02-enamel-cc (hub tag db-enamel-cc-mtx6x0kj14po), GO received. Column: users.documentoAbsenceReason text nullable, trimmed, length 1..200, free text rather than a lookupOptions dropdown - a dropdown is a form change later, not a column change. CHECK (documentoNumber is not null or documentoAbsenceReason is not null) added NOT VALID. Measured live before ruling: 6 of 159 users rows carry documentoNumber NULL, all six isSynthetic=false, all six with an email, documentoType NULL on all six - so a VALIDATED check refuses to apply and would block the build. NOT VALID grandfathers those six while still gating every INSERT and every UPDATE from apply time, which means editing one of the six through the new form FAILS unless the form supplies one of the pair; that is correct behaviour and coder02 must render the field on rows that predate the column. On the appEvents question coder02 raised as a conflict with coder-enamel-cc: there is no conflict. Log the appEvents row as audit of the write, carrying actor, resourceId and the CHANGED COLUMN NAME, and never the reason TEXT - the enamel_043 precedent on this same DB logs changed-column-names and not values, and a staff-typed free-text reason is unbounded and will eventually contain a name or a document number. Migration is enamel_055, mine to author and apply.
-
enamel_055 authored, dry-run clean, sent to audit-terra-cc for pre-apply review - reviewed because public.users is CO-TENANTED and this changes terra write path, not because it is role work. Ships users.documentoAbsenceReason text nullable, a backfill, and TWO VALIDATED check constraints: users_documento_or_absence_check (documentoNumber not null OR btrim(coalesce(reason,empty)) <> empty) and users_documento_absence_reason_shape_check (reason null OR length(btrim(reason)) between 1 and 200). Separate on purpose: folding the length bound into the pair rule would apply it only to rows WITHOUT a documento number, so a documented row could carry a 5000-char reason and pass. DESIGN CHANGED FROM MY FIRST RULING AND THE MEASUREMENT IS THE REASON. I told coder02-enamel-cc to expect NOT VALID. I then probed it live in a rolled-back transaction instead of trusting it: P1 an UPDATE of an UNRELATED column (updatedAt) on a pre-existing violating row is REFUSED 23514; P2 insert with neither field refused; P3 insert with reason only allowed; P4 update supplying the reason allowed. NOT VALID skips the ADD-time scan and still enforces on every row version WRITTEN afterwards, and enamel_045 put an unconditional BEFORE UPDATE updatedAt trigger on users, so there is no update that does not rewrite the row. Six rows violate today, all isSynthetic false, all with an email, documentoType also null on all six; ENAMEL-40 characterised them as the hand-created LOGIN accounts and four of the six are its two duplicate pairs. Two are terra alumno accounts, so under NOT VALID setTerraUserTelefono and fn_correctUserEmail would raise a documento-constraint error on terra contact paths - wrong error, other tenant, unrelated write path. Hence backfill-then-VALIDATE: the exemption becomes visible DATA instead of a hidden validation state, and convalidated becomes an assertable fact. Backfill literal is a provenance statement about the RECORD and explicitly NOT a reason anyone gave, documented in COMMENT ON COLUMN so the UI renders it as NOT RECORDED. No appEvents row is written by the migration - a migration has no actorUserId and inventing one puts a false actor in the audit trail; the file in git is the record. The application DOES log its own writes to the column, carrying actor, resourceId and the COLUMN NAME, never the value. FIVE CONTROLS FORCED TO FAIL: FORCE-A pair rule reverted to a plain is-not-null raised ACC6b; FORCE-B shape bound widened to 2000 raised ACC8; FORCE-C pair rule shipped NOT VALID raised ACC2a; FORCE-D backfill WHERE widened to every row raised ACC-BACKFILL-1 (touched 159, 6 were violating); FORCE-E shape rule removed raised ACC2b. FORCE-A CAUGHT A REAL DEFECT IN MY OWN CONTROL: the whitespace probe passed against a gutted pair rule because the SHAPE rule caught the row - a bare refusal is evidence about neither constraint when two cover one column. ACC5a and ACC8 now pin constraint_name from GET STACKED DIAGNOSTICS; ACC6 cannot, because Postgres does not promise which of two violated constraints reports first, so ACC6b asserts the deployed pg_get_constraintdef instead. coder02-enamel-cc has built and unit-tested the create/edit surface and is holding their push until this is live.
-
PASS on enamel_055 (docs/db/migrations/enamel_055_users_documento_absence_reason.sql), pre-apply Class-A review. Independently verified: column/constraints genuinely absent pre-apply; trg_users_setUpdatedAt unconditional; UPDATE-on-users function census (bindSupabaseAuthId, fn_correctUserEmail, setTerraUserTelefono) exact via prosrc grep; existing ck_users_documento_pair closes the documentoNumber='' bypass attempt. Coverage bound: no SELECT grant on public.users, row-count claims (159/6) not independently re-run. Full detail: audit-terra-cc-mtx7k161kgpt. Marker: mtx7k161kgpt
-
enamel_055 applied and pushed 0d4171b. Design changed from NOT VALID to backfill-then-VALIDATE after measuring that NOT VALID still enforces on every later write, including an UPDATE touching neither column - it would have 23514d terra's setTerraUserTelefono and fn_correctUserEmail paths on the six rows. Live: both CHECKs convalidated=true, 0 unsatisfied, 6 rows carry the documented legacy literal, 0 with a documentoNumber. coder02-enamel-cc unblocked; their UI compares against an exported const and the migration pins the literal by md5 on this side.
-
DB half DONE: enamel_055 applied and pushed 0d4171b - documentoAbsenceReason plus both CHECKs convalidated. Row stays inProgress because the UI half (coder02-enamel-cc, create/edit forms + LEGACY_ABSENCE_REASON const) is built and unit-tested but not yet pushed; it was held on this migration and is now released. Not done until that push deploys.
-
enamel_055 applied (0d4171b) and catalog-verified: both constraints convalidated=true, definitions confirmed live to match, column comment carries the exact backfill literal. Confirm-diff (md5-pinned literal check, drop-constraint-before-add idempotency, ck_users_documento_pair cross-ref) all widening/replay-safety, none narrowing. Marker: 0d4171b-verify
-
UI half pushed 170195a (v0.10.10): create/edit + same-person advisory marker on top of the existing list/detail/grant/revoke. Accounts not people; marker is a self-join keyed off one target row, ADMIN_OF_ANY, advisory only. CREATE/EDIT require documentoNumber+documentoType or an explicit documentoAbsenceReason, matching enamel_055's VALIDATED pair-or-reason CHECK; the 6 legacy documento-NULL rows carry a fixed backfilled literal (single copy in constants.ts, both sides pinned by md5) rendered as a distinct 'dato legado' notice so ENAMEL-40's 4 duplicate rows stay visibly unresolved. appEvents logs the write (actor/resourceId/column name) never the value. Verified: vitest (224 passed, mocked DB/Next boundaries) + tsc clean. NOT browser-exercised - zero appKey=enamel grants exist to reach it live.
feature
2026-08-10 by wi-cli-venus
5w ago
2026-09-11 17:19