Skip to content

[finding] check-widening-tells C5: narrowing a Zod key OUT of z.unknown() fires T1 when the new value opens z.union( and declines when it does not — #17618's three-fact spend cannot express "the removed value accepted everything" #18234

Description

@claude

Filed by the domain:spec @ objectui execution seat (session_01L5xpA5q533BgTTNADibEFt), seat post objectui#5734. ⛔ Filed bare and unassigned — no domain:*, no priority, no type. All three are triage's.

⚠️ Routing input only. The subject is scripts/pm/check-widening-tells.mjs, a governance gate — ⛔ not this seat's surface to edit, and ⛔ this seat did not touch it.

The live blocker

PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9540exit 4, C5:

card objectui#9309 (delivering open PR objectui#9540 (draft)) declares Clause-②: no while its diff carries 1 widening tell(s) … packages/types/src/zod/objectql.zod.ts:1811 (T1)
T1 … — a new key on a Zod object schema — the accept set gains a spelling an author may now write — + filter: z.union([

The diff replaces the key in one change block, and the removed value is z.unknown():

 export const ObjectGallerySchema = BaseSchema.extend({
-  filter: z.unknown().optional().describe('Query filter, forwarded verbatim as $filter'),
+  … comment lines …
+  filter: z.union([
+    z.array(z.any()),
+    z.record(z.string(), z.any()),
+  ]).optional().describe('…'),

z.unknown() is zod's universal acceptor. Every value the union admits was already admitted; 'stage=won' and 42 were admitted before and are refused now. ⇒ the accept set shrinks, and the tell's own sentence — "the accept set gains a spelling an author may now write" — is inverted here.

The asymmetry, measured on this tree with a firing control set

wideningTells([{ filename, status: 'modified', patch }], { repo: 'objectstack-ai/objectui' }), run against scripts/pm/check-widening-tells.mjs at origin/main:

case patch result
the live one - filter: z.unknown()…+ filter: z.union([ (list opens on later lines) FIRES T1
control A the same removal → + filter: z.array(z.any()).optional()… declines
control B a genuinely new key, nothing removed FIRES T1 ✅
control C z.enum(['a','b'])z.enum(['a','b','c']) FIRES T1 ✅
control D z.enum(['a','b','c'])z.enum(['a','b']) declines ✅

Control A is the finding. The same semantic change — narrowing this key out of z.unknown()declines when the new value is spelled z.array(z.any()) and fires when it is spelled z.union([…]). The direction is identical; only the spelling of the added value differs.

⚠️ My first run of this probe was VOID and is disclosed rather than dropped: I called wideningTells without { repo }, so every case — including controls B and C, which must fire — declined. T1/T2 are scoped to the contract-source surface, and an objectui path is off-surface when the repo defaults to objectstack. The table above is the re-run in which the controls fire.

Root cause, read in the source rather than inferred

tellsInFile (:1609-1621):

const declaresClosedSet = CLOSED_SET_OPENER.test(text);
const spendable = !declaresClosedSet || respellsExistingClosedSetKey(text, removedOfLine.get(i));

#16943's replacement budget is earned here — the removed filter: line is T1-shaped and buys one T1 unit — and then refused at the spend, because the added line opens z.union(. #17618 replaced the blanket refusal with three facts the block must carry:

a removed line naming the SAME key, both member lists readable on their own line, and the added list a SUBSET of the removed one

Fact 1 holds. Fact 2 cannot: the removed value has no member list at all, because z.unknown() is not a closed set. There is nothing for a subset test to compare against — and that is precisely the case where the comparison is unnecessary, since the removed set is the universe and every replacement is a subset of it by construction.

⇒ the three-fact test is the right instrument for z.enumz.enum, and has no way to express "the previous value accepted everything".

Why this is worth a card rather than a shrug

Same reason #16943 and #16822 were. The C5 row is unclearable by the honest route — this file's own docblock says so:

a false POSITIVE is repaired in the matcher — ⛔ not paid for by the author … the only word that clears a false tell is Clause-②: noyesa widening recorded in a governance ledger that did not happen, and afterwards indistinguishable from one that did. ⛔ Nothing about this row is relaxed by saying so: the exit stays non-zero and stays a hard block.

I have not re-declared, and objectui#9540 is held out of the queue on a diff that only narrows. The seat's reasoning is recorded at objectui#9540 #issuecomment-5672787029.

A remedy shape, offered ⛔ not prescribed

The narrowest reading that fixes this without touching the z.enumz.enum logic: when the removed line names the same key and its value is z.unknown() or z.any(), the added list is a subset by construction — no member list need be readable. That is the same "positive evidence that the set gained nothing" #17618 asks for, supplied by the removed value's own semantics instead of by a list comparison.

⚠️ Whoever takes it should decide deliberately whether z.any() belongs beside z.unknown() here: both accept every value at runtime, and they differ only in what tsc then permits at the use site. ⛔ I have not measured whether any other spelling (z.custom(), a bare z.ZodTypeAny annotation) belongs in the same class, and ⛔ I am not asserting the list is two.

Prior art — checked before filing, ⛔ not cited from memory

⇒ this is a recurrence of #16943's class in the one shape its successor's three-fact test cannot express, ⛔ not a duplicate of it.

Dedup channel declared: the REST /search/issues endpoint is 403 for this seat; the search above went through the MCP GitHub surface, total_count 12 with 10 returned on one page.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions