pluto general archive-reason validator: the candidate regex rejects the .gitpush-pre.sh L204 mandated 'system:migration:<slug>' form
Split out of PLUTO-437, where it was latent and is now moot for that function. coder-pluto-cc read pluto's own pluto/.gitpush-pre.sh (CR 67). PREFIX SET: exact match to the proposed regex, no delta - ALLOWED = (admin: public: system: demo: bulk: user:), nothing carried from a sibling app. SLUG SHAPE: real delta. The hook constrains the PREFIX only and imposes no slug shape, while the SAME hook at line 204 REQUIRES migrations to carry SET LOCAL app.archive_reason = 'system:migration:<WI-slug>' - a SECOND colon. The candidate pattern ^(admin|public|system|demo|bulk|user):[a-zA-Z0-9-]+$ REJECTS pluto's own mandated migration form, because ':' is not in the character class. The pre-existing literal admin:primaryRoleChanged:route:/admin/usuarios:lookupOptionId:... in src/lib/admin-actions.ts fails it too, for colons AND slashes. WHY THIS IS ITS OWN ROW: PLUTO-437 rev5 removed p_reason from fn_confirmPaciente entirely - it now takes (p_paciente_id, p_actor_id) and hard-codes c_reason CONSTANT text := 'admin:pacienteConfirmed', so there is no caller-supplied reason left to validate on that path and the regex question does not arise there. The validator question is real but general, and it must not ride a single function's migration. THE TRAP TO AVOID, which is the reason this is p2 and not p3: a reason validator that rejects the hook's own mandated form is worse than no validator. It passes review (it looks strict), it passes every test anyone writes for the happy path it was built for, and it fails the first migration that complies with line 204 - at apply time, inside a transaction, on an unrelated change. Nothing in the function's own tests can surface it. SCOPE: (1) decide pluto's general archive-reason shape; coder-pluto-cc's candidate ^(admin|public|system|demo|bulk|user):[a-zA-Z0-9-]+(:[a-zA-Z0-9-]+)*$ admits colon-separated segments while still barring free prose and spaces, and is the starting point rather than the answer. (2) Reconcile it with .gitpush-pre.sh L95 and L204 so the gate and the validator cannot disagree - whichever is authoritative, there must be ONE source for the shape, and per commons the per-app file wins. (3) The admin-actions.ts route literal with its colons and SLASHES is a pre-existing question this row inherits: either the shape admits a route segment or that literal changes. Do not silently widen the pattern to admit slashes just to make an existing string pass - decide whether a route belongs in an archive reason at all. HARD CONSTRAINT: no DDL enum of literal reasons. Ruled on PLUTO-437 - an archive-reason string is not portable across apps, the allowlist lives in each app's own .gitpush-pre.sh, and freezing one app's action vocabulary into DDL pushes the next legitimate action toward inventing a PREFIX, which is exactly what the per-app rule exists to prevent. New actions are encoded in the SUFFIX.
Activity
-
MEASURED CORPUS, which resizes this row: the candidate pattern ^(prefix):[a-zA-Z0-9-]+$ rejects 48 of the 50 archive-reason literals in src/, not just the system:migration: form. Enumerated via grep over every setArchiveContext( call site. Only 'admin:requestMissingInfo' and 'public:requesterComplete' have a single colon; everything else is prefix + colon-separated segments (admin:comision:create, admin:practica:reviewStateChange, user:practica:edited, ...). So the real grammar already in use is prefix:segment(:segment)*, and 'widen it to admit a second colon' understates it. THE HARDER QUESTION, bigger than the route literal pmmaster flagged: the properties the regex was supposed to buy - no free prose, no PII - are ALREADY violated in the live corpus, by interpolation rather than by shape. - User-entered free text: admin:alumnoAssignment:remove:${reason.trim()}, admin:docenteMembership:remove:${rowSubtype}:${reason.trim()}, admin:support-admin-access-reject:${trimmed.slice(0,170)}, admin:support-admin-user-roles-set:remove:${trimmed.slice(0,170)}, admin:user:revokeRole:${trimmed.slice(0,200)}, admin:user:cancelPendingRestore:...:${trimmed.slice(0,200)}. - Raw UUIDs: ${reviewer.id}, ${newUserId}, ${requestId}, ${targetUserId}, ${batchId}. evolutiva-coder-commons bans raw IDs/UUIDs in change logs, and an archive reason is a change-log surface. - A route: admin:user:reactivate:route:/admin/usuarios. So this row is not 'pick a regex'. It is: (1) ratify prefix:segment(:segment)* as the grammar, since the corpus already is that; (2) rule on whether a segment may carry user free text, a uuid, or a route - today all three do; (3) only then decide what a validator can enforce without rejecting the corpus or silently truncating evidence. A validator that rejects the gate's own mandated form is worse than none, and so is one that passes a reason carrying PII. Per pmmaster: no DDL enum of literal reasons, the per-app .gitpush-pre.sh stays authoritative, new actions encode in the SUFFIX never as a new prefix, and do not widen the pattern to admit slashes merely to make the route literal pass.
-
Six of NINE free-text sites cleared: 284723a, v2.23.131, live-version MATCH vs remote main package.json (Class-S PTD). Changed: administrar-actions.ts:875 (alumnoAssignment:remove), :530 (docenteMembership:remove, rowSubtype kept), admin-actions.ts:760 (user:revokeRole), :858 (user:bulkDeactivate:batch), support-admin-core.ts:136, admin-batch.ts:900 (ar/user uuids kept - join keys). Route segment ':route:/admin/usuarios' dropped at admin-actions.ts:1237: a hardcoded constant with one call site, not per-row data, so nothing to move to a column; also the only slash in the corpus. CORRECTION TO MY OWN DESTINATION READ: the corpus is NINE free-text sites, not six. My earlier grep was line-scoped and missed three multi-line calls whose reason literal sits on a continuation line - admin-actions.ts:861 bulkDeactivate (deleteReason written untruncated, so cleared in this push), :1010 reactivateInactive, :2479 rejectAccessRequest. THREE DDL legs, not one: support-admin-access-core.ts:151 and admin-actions.ts:2479 both need the SAME accessRequests rejection-motivo column already assigned to db-pluto-cc; admin-actions.ts:1010 is a THIRD leg nobody owns - its UPDATE sets only isActive/updatedAt on users and deleteReason is wrong there (reactivate NULLs it), so it needs its own column or store. All three HELD per the ordering constraint. Validator not written - lands only after the full corpus passes it, asserted in the pre-push hook not by hand.
-
FOUR MORE SITES CLEARED at 66e29b7, v2.23.133, live-version MATCH vs remote main (Class-S). Fourth correction to this corpus read, found by MEASURING the candidate grammar rather than reasoning about it. (1) My earlier claim that admin-actions.ts:1237 held the only slash in the corpus was WRONG: there were FIVE route segments, and the other four sat on continuation lines of multi-line calls - the same line-scoped blindness that made the free-text count six instead of nine. Now cleared: admin-actions.ts:1313, admin-batch.ts:120 and :840, personas-actions.ts:295. (2) THREE of those four interpolated an EMAIL ADDRESS into the reason (from/to on an email change, newEmail on the two approval paths) - a category the ruling does not name: not a uuid join key, not operator free text. All three sit on an UPDATE that sets users.email, and users is trigger-archived, so rowBefore holds the old address and rowAfter the new one untruncated. The reason copy was a strict lossy duplicate of the audited record, so dropping it improves the record - the same ground as the five motivo sites. The ar and user uuids stay as join keys. (3) THE LOAD-BEARING FINDING: the grammar now passes 65 of 65 literals INCLUDING the three free-text sites still held on DDL, because their free text lives inside a template interpolation and the grammar can only see SHAPE, never CONTENT. Shape and content are two separate rules, so LANDING THE VALIDATOR DOES NOT CLOSE THIS ROW - a green corpus is not evidence the no-free-text ruling holds. The validator is now unblocked on its own terms; PLUTO-769 and the accessRequests column remain the actual remaining work.
-
Shipped 6d3276b (v2.24.2), live-verified at v2.24.3 (ef51a7a is the PLUTO-582 handoff on top). (1) SHAPE DECIDED: ^(admin|public|system|demo|bulk|user):SEG(:SEG)*$ with SEG = [A-Za-z0-9-]+, and in SOURCE a ${...} interpolation counts as ONE opaque segment. Bars prose, spaces and slashes; admits every literal in the tree and all 50 migration sentinels. (2) ONE SOURCE: new src/lib/archive-reason.ts holds the prefix list + segment char class. scripts/archive-reason-lint.py PARSES both out of it (cannot import TS) and exits non-zero if it cannot - no prefix list means no lint, never an empty-allowlist pass. The migration-sentinel gate no longer spells 'system:migration:' itself: it greps a pattern printed by --migration-reason-regex, which refuses to emit if 'system' is no longer an allowed prefix. format-archive-reason.ts PREFIX_LABEL is now Record<ArchiveReasonPrefix,string>, so a prefix without a Spanish label fails the typecheck - this exposed a third divergence nobody had filed: demo: was allowed by the push lint and UNKNOWN to the UI formatter, rendering as a humanised blob. Fixed in the same change. (3) ROUTE: dropped, not admitted. admin-actions.ts setPrimaryRole's reason loses route:/admin/usuarios; it was the only reason literal in the tree with a slash, appEvents already carries the route in its own column, and one call site is not a convention. CORPUS, the reason this row existed: the lint's shape rule is defined by the GRAMMAR (any literal starting with an allowed prefix) over src/**, not by setArchiveContext call sites. 65 literals sit in call sites, 152 prefix-shaped literals exist in the tree, and the one real violator was in the 82 outside - passed to fn_setPrimaryRole. A call-site-scoped shape check would have reported green. The prefix rule stays call-site-scoped on purpose (a corpus selected by 'starts with a prefix' cannot test that property). Shape rule skips *.test.ts: roster-import-savepoint.test.ts:38 holds a deliberately malformed slice of source as a grep needle. LEXER: full state machine (code/string/template/comment/regex) with ${...} recursing into code state and a per-literal interpolation depth. Both previously-measured traps produced FALSE violations, not misses, and both are regression-tested: a template nested in an interpolation, and non-reason string fragments ('aprobar'/'approved') inside a reason template - the old greedy-backtick regex hid those three only by accident. FORCED RED (src/lib/archive-reason-lint.test.ts, 23 cases, drives the real script as a subprocess): admin:a/b, oops:bad, admin:has space, admin::double, a slashed reason OUTSIDE any setArchiveContext call (the miss this row is about), an unparseable SSOT, and the migration derivation refusing with 'system' removed from the prefix list. Measured separately: the OLD migration grep passed 'system:migration:' with an empty slug and 'system:migration:foo bar'; the derived pattern rejects both. COVERAGE: hook run end-to-end green (exit 0, 1138 tests). NOT exercised - no runtime behaviour was tested, since the only runtime change is one reason string losing a segment; no deploy path reaches the lint; SHAPE ONLY, so a reason built from operator free text inside a ${...} still passes (PLUTO-771 owns that); and no Vercel deploy-status read was possible from this session (MCP list_deployments 403, get_deployment 404, no vercel CLI on venus) - the live check is a cache-busted /api/health version-match against the REMOTE package.json on main, which per prior measurement is not by itself a deploy-READY oracle.
-
completed