Skip to content

fix(spec): type ApiError.code and the list overlay options bag - #20448

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19920-exported-types-family-close
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19920-exported-types-family-close

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19920
Clause-②: no (narrowing)

This PR takes items 1 and 3 of the remainder that seat 4's release on #19920 (comment 5865019059) names: ApiError.code and the flattened list overlay's legacy options bag. Item 2, ViewFilterRule.operator, is not changed: its input type is a contract choice, so it is analysed as a fork (below) for the seat to take to triage.

What changed

Only types change. No schema's parse, no value, and no export moves; no export is added. The FROM column was read by a compiler-API census at the base 0283cb924 and by probes against the source; the TO column is also probed against the built dist.

item FROM TO
1. ApiError.code (api/error-code-ledger.zod.ts, api/contract.zod.ts) unknown. ErrorCode was cast to z.ZodType naming only its OUTPUT type parameter, and z.ZodType's INPUT parameter defaults to unknown, so the input type of ApiErrorSchema typed code as unknown: { code: 42, message: 'x' } compiled as an ApiError while the schema refuses it at code. The same unknown reached the error.code of every response type built on BaseResponseSchema (58 input aliases, measured) and each ApiError row of a batch result. makeApiErrorSchema repeated the one-parameter cast for a caller-supplied vocabulary. ErrorCode: the cast names both parameters, each spelled with the existing ErrorCode type alias. makeApiErrorSchema: both parameters named, the standard catalogue plus the caller's codes. The …Parsed types do not move: their code was already typed.
3. The list overlay's options bag (ui/view.zod.ts) A string-keyed record of unknown, on the list overlay member and so on ViewMetadata, ViewMetadataParsed, AssembledViewArtifact and AssembledViewArtifactParsed, because listViewKindBlocks() returned a record of string to z.ZodTypeAny. options: { foo: 1, kanban: 42 } type-checked as all four while the member refuses both keys. One optional entry per list kind that names a block (calendar, chart, gallery, gantt, kanban, map, timeline, tree), each the kind's own block with every key optional. The return type is a mapped type derived by the function's own rule (a value of the list shape's type enum that is also a key of the shape), each entry typed by zod's own .partial() answer through a typed helper, never a hand-written copy. The runtime loop is byte-identical; one assertion on its result states what the two derivations share, and the new pin file holds the runtime key set equal to the type's.

A change beyond the order's route, forced by a measurement

The dispatch suggested dropping makeApiErrorSchema's cast. It stays: its vocabulary is caller-supplied and spread into a string[], so the cast is what carries the caller's codes into the type; dropping it would also change the returned schema class (to ZodEnum), a public-type change beyond this item. The defect was the missing input parameter, and that is what changed.

The ErrorCode cast exists because the spread erases the members to string, not to dodge declaration size (mechanism assumption A1). But naming the input parameter DID hit declaration size, measured, and that fixed the spelling:

  • First spelling, inline union in both parameters (74a132da1): the built declarations grew 30,647,033 to 34,006,427 B (+3.36 MB, +11.0%); api/index.d.ts alone +1,262,450 B. Declaration emit prints an inline union literal by literal wherever a schema embeds ApiErrorSchema (78 sites in the api entry), and the input parameter doubled those prints.
  • Landed spelling, the ErrorCode alias (539295c9e): the emitter prints the alias by name, including for the output half the base already printed inline. The declarations SHRINK instead (table below). The bundler emits one new shared chunk, error-code-ledger.zod (31,949 B .d.ts, 31,950 B .d.mts), for the name to be imported from.

Measurements (spec build, base 0283cb924 against head code 539295c9e)

  • TS7056: 0 in every spec build of this round (base, 74a132da1, 539295c9e).
  • Declaration files: 128 at base, 130 at head; the build's own check-dts-references resolves 394/394 relative references across the 130 (382/382 across 128 at base).
declaration file base head delta
api/index.d.ts (.d.mts the same, within 2 B) 2,532,113 1,274,615 -1,257,498
automation-api.zod chunk .d.ts (and .d.mts) 535,440 193,663 -341,777
api-assembled/index.d.ts (and .d.mts) 184,582 86,529 -98,053
contracts/index.d.ts (and .d.mts) 505,045 505,100 +55
view.zod chunk .d.ts (and .d.mts) — item 3 498,393 505,886 +7,493
error-code-ledger.zod chunk, new (.d.ts) 0 31,949 +31,949
all .d.ts / .d.mts files 30,647,033 27,331,377 -3,315,656 (-10.82%)

Reverse verification (from committed state 539295c9e, on disk, through scripts/ablation-replace.mjs)

Each pin file compiled under tsconfig.test.json's options. The pins import ./contract.zod / ./view.zod relatively, so the subject is src and no dist is on the resolution path.

leg reverted to (the base spelling) pin file result
control nothing all three pin files 0 diagnostics
A ErrorCode cast naming the output parameter only api/api-error-code-type.test.ts 2 x TS2322, 3 x TS2578
B makeApiErrorSchema's cast naming the output parameter only api/api-error-code-type.test.ts 1 x TS2322, 2 x TS2578
C listViewKindBlocks() returning a record of string to z.ZodTypeAny ui/view-overlay-options-type.test.ts 2 x TS2322, 9 x TS2578

Every leg: the tool reports the anchor hit once and the mutation landed (blob changed), then the restore proven (blob equals the HEAD blob, git diff HEAD empty); an independent git hash-object check of all three files after the legs matches HEAD, and git status --porcelain is empty.

Tests and gates

Code is identical at 539295c9e and 4358d1a33 (4358d1a33 adds the changeset only).

  • Spec: build exit 0 (TS7056 x0); typecheck exit 0, check:test-typecheck: OK — ... 53 file(s) / 251 error(s) / 138 pinned signature(s) held, and --listFilesOnly puts both new pin files in its 540-test-file program; vitest run --project local at 4358d1a33: 565 files passed, 16,604 tests passed, 1 todo; check:generated: all 15 generated artifacts up to date, with no tracked file moved by any build.
  • Consumers (after building spec and the 12-package closure of metadata-protocol): @objectstack/metadata-protocol typecheck exit 0 (192 test files in its program) and tests 189 files passed, 3 skipped, 2,745 tests passed, 19 skipped; @objectstack/types typecheck exit 0 (23 of 23 test files in its program) and tests 22 files, 685 passed; @objectstack/client tsc --noEmit over src exit 0.
  • Probe against the built dist, from a consumer program importing dist/api and dist/ui: 0 diagnostics, where every @ts-expect-error (a numeric code on ApiError, an invented one on BaseResponse, a numeric options.kanban on ViewMetadata, an unknown kind on AssembledViewArtifact) is consumed and a tuple compiles only if ApiError.code is neither unknown nor any.
  • Gates: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4358d1a33 derived 86 commands (the dispatch's 75 plus 11); all 86 run, exit codes written to disk first: 83 exit 0 (check:lean-entry-closure after building objectql), 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:type-check-debt: both need the whole-packages build). --ran: 86 derived, 84 run, 2 NOT-MEASURED, 0 UNRUN. Readings of note: check-adr-0087-registration reads [BREAKING+clause-②-narrowing] not-required (no-migration-prescription); check:api-surface "public API surface + factory signatures unchanged"; check:exported-any "no exported type resolves to any: 2384 types + 1446 schemas across 18 entry points"; check-empty-changeset exit 0.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 5 changed .ts files: 0 errors, 0 warnings; the changeset is outside eslint's configuration. eslint.config.mjs:327 enables no type-aware linting for any file, so this diff cannot move an untouched file's verdict. Repo-wide lint is CI's.
  • NOT MEASURED, left to CI: spec test:repo (it held the verify lock for the whole foreground window, about 595 s, without finishing, twice); the @objectstack/client test-layer typecheck (its 12 dev dependencies include runtime and rest, a 33-package build); the two exit-3 gates above; the objectui and cloud builds.

Consumer census

  • Item 1 in this repo. 116 exported spec aliases carry ApiErrorSchema's shape (58 input names; their …Parsed twins were already typed), found by walking each alias's properties, arrays and union members. Outside spec, code names them in @objectstack/client (return annotations, as unknown as casts and ['data'] reads), @objectstack/metadata-protocol (toRowApiError's cast from any after a safeParse guard, and as BatchUpdateResponse casts) and @objectstack/types (Pick of ApiError's optional fields, not code). All three typechecks are green above; neither named consumer file needed an edit.
  • Item 3 in this repo. Outside spec, ViewMetadataSchema and AssembledViewArtifactSchema are called with safeParse in objectql, rest and metadata-protocol tests and objectql's engine.ts; both schemas' own static types are the erased unions, so no typed options read exists outside spec.
  • objectui at the pin f8a9d0fb. ApiError appears only as Pick of userMessage (two files); none of the four view types is named. Neither narrowing reaches it (from reading, not compiling).
  • cloud: no checkout in this container, NOT MEASURED.

Item 2, ViewFilterRule.operator: not changed, a fork for triage

operator is z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)). zod types a preprocess's input as its function's parameter type, and normalizeFilterOperator takes unknown, so { field: 'status', operator: 42 } compiles as a ViewFilterRule (and as a rule on every carrier: ListView.filter, tab filters, Page.filterBy) while the door refuses it. Who writes the legacy spellings the fold accepts, measured:

  • examples/: 0 legacy spellings on a view-filter carrier, 19 canonical ones in 8 files. (The one legacy-looking hit, operator: 'ne' in app-showcase's invoice.object.ts, is a field's lookupFilters, a separate closed dialect.)
  • In-repo non-test code: 0 (every other hit is another dialect: lookup filters, auth where, skill trigger conditions, analytics).
  • objectui at the pin f8a9d0fb: the filter builder emits camelCase ids (13 of its 22 option values are alias-table keys: notEquals, greaterThan, notIn, isNull, …). Its two producers typed against spec's ViewFilterRule (viewFilterFold.ts, ObjectDataPage.tsx) fold through normalizeFilterOperator before typing, so the canonical id is what reaches the type. objectui at 9f0c84a44 (its current head) emits the 20 canonical ids only.
  • Stored sys_metadata rows: the alias table exists for them; they are read through the runtime parse, whose input is unknown whatever the type says.

The three options, the four axes and the recommendation are in the os-dev-report on #19920 (open_questions). In short: A, canonical enum only (type the preprocess function's parameter; the runtime fold is untouched); B, the enum plus the alias-table spellings (needs the table's keys typed as literals, and still cannot express the case-folded variants the fold also accepts); C, leave unknown with a declared reason. The recommendation is A.

What stays on #19920

A compiler-API census of the 2,337 non-generic exported aliases of packages/spec/src (tests excluded, 11 generic skipped), with an injected control module that must read lit (it did, at base and head):

  • Alias level: 6 aliases resolve to unknown at base and at head, and none belongs to this family: FlowValueSlot, AssignmentValue and their Parsed (value slots), GetPublishedMetaItemResponse and its Parsed (opaque by ruling).
  • Top-level keys: 194 at base, 193 at head; the one that left is ApiError.code, and none entered. The only family site left is ViewFilterRule.operator (item 2). Every other key the census reads is declared z.unknown() / z.any() (the door accepts anything, so the type is honest), a third-party or zod type, a service map or a fixture; GetMetaItemLayeredResponse.code and ViewMetadata.defaults were checked by hand and are both declared z.unknown().
  • Index signatures one level below a top-level key: 391 at base, 387 at head; the four that left are options on ViewMetadata, ViewMetadataParsed, AssembledViewArtifact and AssembledViewArtifactParsed, and none entered. Blind spot, declared: deeper nesting is not walked.

Clause-②

Line 2 and the changeset (b26d6506b) both read Clause-②: no (narrowing). The changeset keeps its BREAKING banner and the ADR-0087 marker not-required (no-migration-prescription), so check-adr-0087-registration still reads the narrowing. The value is no because this diff adds no export and moves no accept set; it only narrows published types. That is the PR #19919 / PR #20260 shape for this defect class.

Acceptance notes

  • makeApiErrorSchema's generic return type still prints the standard catalogue inline: 4 prints in its one declaration (a 4,311 B line in the emitted contract.zod declaration), where the base printed 2. A local generic alias would name it; not done here, being one bounded declaration.
  • A field's lookupFilters is its own closed operator dialect (eq, ne, gt, lt, gte, lte, contains, in, notIn), whose members are spellings ViewFilterRule treats as deprecated aliases. Both are enforced; noted for item 2's triage, not filed.
  • The two new pin files follow the two-program shape of view-overlay-viewkind-type.test.ts: tsc judges the type half, vitest the runtime half; the refusal cases assert the issue code and path, not a bare failure.

Line 1 was changed from the partial-landing marker to this closing keyword by the domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW): item 2 (ViewFilterRule.operator's input type) now has its own card, #20450, for triage, which is the dispatch order's A4 condition for closing #19920 with this PR.

Line 2 and the ## Clause-② section were amended by the same seat after the at-tier record 5871015216 found the value yes wrong for this diff; the changeset line moved with them in b26d6506b, and the claim on #19920 was amended in place. The stale Part of paragraph was removed.

ErrorCode and makeApiErrorSchema's code cast named only z.ZodType's output
parameter, so ApiError.code (an input type) was unknown. Both now name
Output and Input.

listViewKindBlocks() returned Record<string, z.ZodTypeAny>, so the flattened
list overlay's legacy options bag was a string-keyed record of unknown. Its
return type is now a mapped type over the kinds that name a block, each entry
zod's own partial type of the kind block. The runtime loop is unchanged.

Two type-pin files hold both.

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
Naming Input as the inline union doubled the union's prints at every
schema embedding ApiErrorSchema (+3.36 MB of declarations). The alias is
printed by name, so the built declarations shrink by 3.32 MB instead.

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/api/error-code-ledger.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/api/error-code-ledger.zod.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7fa3e3e07cc67877ae2f5d49eac42665c0515fda → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f24453daaa3950c4074a4c55ad586f4a46e49acd — the merge of head b26d6506bfd199bbc04421aae8a9b38081ef05bc into base 7fa3e3e07cc67877ae2f5d49eac42665c0515fda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f24453daaa3950c4074a4c55ad586f4a46e49acd && git checkout f24453daaa3950c4074a4c55ad586f4a46e49acd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7fa3e3e07cc67877ae2f5d49eac42665c0515fda b26d6506bfd199bbc04421aae8a9b38081ef05bc && git checkout -B drift-repro 7fa3e3e07cc67877ae2f5d49eac42665c0515fda && git merge --no-ff b26d6506bfd199bbc04421aae8a9b38081ef05bc

node scripts/docs-audit/affected-docs.mjs --json 7fa3e3e07cc67877ae2f5d49eac42665c0515fda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4358d1a334193101953aff832b6f9abd84c82482
Local-runs: none

Inputs: card #19920 (body and all 18 comments), card #20450 (body), PR #20448 (body, the 6-file list and the per-file patches, which are the whole net diff: 6 files, +281/−4, three commits), the at-tier record 5864698088 on PR #20369 (its ③5 is what routed the three sites to this round), and the 53 check-runs on the head, re-polled until none was in progress. Context for unchanged code came from GitHub's patch context and from this repository's checkout in the reviewer's container (not a git checkout, and its view.zod.ts predates PR #20369, so it was read only for regions no PR since moved: the list shape's kind blocks, ApiErrorSchema, BaseResponseSchema, makeApiErrorSchema, ModificationResultSchema, the pm scripts). Nothing built, run or re-run.

① Derived judgments

Accept set: none moves — RIGHT. All three source hunks change types only and leave every value expression as it was:

  • api/error-code-ledger.zod.ts: the ErrorCode const's cast goes from z.ZodType naming the output parameter alone to z.ZodType naming both, each spelled with the ErrorCode alias; the z.enum(...) call, its member spread and its error map are untouched.
  • api/contract.zod.ts: makeApiErrorSchema's code cast gains the same input parameter (StandardErrorCode union TExtra[number], written twice); the z.enum(vocabulary, ...) call and its .describe() are untouched.
  • ui/view.zod.ts: listViewKindBlocks() keeps its loop byte for byte (overlayTypeValues, unwrap().partial().optional()); only the declared return type and a blocks as ListViewKindBlocks assertion on the return are added. partialListViewKindBlock is declared and never called (only its return type is read), and the three new type aliases are module-local.
  • Runtime evidence on the head: Build Core, Dogfood Regression Gate (three shards and the summary), Temporal Conformance, Spec property liveness and Test Core are success; check:generated inside the required lanes is green, consistent with no schema, default, refinement or served JSON Schema moving.

Public surface, each change named:

  1. z.input of the exported const ErrorCode: FROM unknown TO ErrorCode — narrowing, RIGHT. z.ZodType's second parameter is the input type and defaults to unknown; naming it with the existing alias is the smallest correct spelling. z.output and the ErrorCode type alias itself do not move.
  2. ApiError.code (ApiError is z.input of ApiErrorSchema, whose code is ErrorCode.describe(...) at contract.zod.ts:29): FROM unknown TO ErrorCode — narrowing, RIGHT; ApiErrorParsed.code does not move, its output was already the union. Pinned by api-error-code-type.test.ts: an IsUnknown triple, five refused-body directives, and a runtime half tying each refused body to invalid_value at code.
  3. Every schema that embeds ApiErrorSchema narrows with it — RIGHT, and the embedding sites were checked: BaseResponseSchema.error (contract.zod.ts:312, hence every response input type built on it, which is the banner's list), ModificationResultSchema.errors (contract.zod.ts:461, the rows BulkResponse carries), batch.zod.ts:208 (the batch result rows), and makeApiErrorSchema's extend. EnhancedApiErrorSchema declares code: StandardErrorCode and is untouched, so the banner is right not to name it; contracts/approval-service.ts:630 uses the ErrorCode type alias on an interface and does not move.
  4. makeApiErrorSchema(codes): the returned schema's INPUT code FROM unknown TO StandardErrorCode union codes — narrowing, RIGHT; its OUTPUT and its class do not move. Pinned by the Acme block of the same file. Dropping the cast, the route the dev declined, would have changed the returned schema's class and, because vocabulary is a string[], WIDENED the parsed code to string; keeping it is the only spelling that leaves the output where it was. RIGHT.
  5. listViewKindBlocks()'s return type: FROM a record of string to z.ZodTypeAny TO a mapped type over the list shape's type enum values that are also keys of the shape, each entry the instantiated return type of partialListViewKindBlock (zod's own ZodOptional of the .partial() object) — RIGHT, and it is the runtime rule at the type level: ListViewShapeSchema is a strictObject, so .shape is typed; its type key carries .default('grid') (view.zod.ts:2502), so the output enum has no undefined; the eight block keys kanban, calendar, gantt, gallery, timeline, chart, map, tree (view.zod.ts:2619–2626) are each XConfigSchema.optional() with or without .describe(), which keeps the ZodOptional type; grid names no key. The never arm is a deliberate tripwire and the pin's NeverEntries check holds it.
  6. The options bag on VIEW_METADATA_MEMBERS.listOverlay, hence the list-overlay member of ViewMetadata, ViewMetadataParsed, AssembledViewArtifact and AssembledViewArtifactParsed: FROM a string-keyed record of unknown TO one optional entry per kind block with every key optional — narrowing, RIGHT, named in the banner. ListViewOverlayOptionsSchema is strictObject(..., listViewKindBlocks()), so the typed shape flows into the member with no other edit, and the runtime bag is the same strict object over the same per-kind schemas. Pinned by view-overlay-options-type.test.ts: key-set equality on both sides of the parse, the never-check, six refused-body directives on the bag, four on the union types, and a runtime half holding the runtime key set equal to the type's and refusing the same bodies at all three doors.
  7. Exports: none added, removed or renamed — RIGHT. The helper and the three aliases are module-local; the new bundler chunk error-code-ledger.zod (.d.ts / .d.mts) is shipped bytes not addressable through exports, which the review rulebook says is not a published surface. check:api-surface unchanged is gate-verified (TypeScript Type Check success).
  8. The declaration-size claims, as far as the diff and the check-runs carry them: the MECHANISM is in the diff (the base printed the union inline at every embedding site; the head names an exported alias, which declaration emit prints by name), so the sign of the overall change is credible; the figures themselves (−3,315,656 B overall, +7,493 B on the view.zod chunk, 128 to 130 declaration files) are the dev's local readings — no check-run measures declaration bytes. What the gates prove: the build emits (Build Core success, so no TS7056), the declaration closure resolves inside that build, and the typed pins compile (Type Check · workspace success). Not gate-verified, not contradicted.
  9. Consumers: toRowApiError (metadata-protocol) casts from any to ApiError['code'] and compiles against the narrowed type; response-envelope.ts (types) reads a Pick of optional fields — Type Check · consumer gates and · workspace success. objectui at the pin names ApiError only through a Pick of userMessage (the dev's reading, not compiled); Console Pin Gate was skipped by the console path filter, so it is NOT MEASURED here, as on every PR of this family. cloud is not measured by anything in this repository's CI.

Nothing derived is wrong or missing.

② Semver level

③ Boundary flags

From the dev report 5870687948 (three open questions, nine deviations, two out-of-scope findings), the PR body's own flags, and the seat's ACCEPT 5870763898.

  1. Item 2, ViewFilterRule.operator, moved to spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450 — RIGHT. The seat filed it before the ACCEPT with the dev's three options, measured authors and recommendation carried verbatim and marked as triage's choice; the diff does not touch ViewFilterRule (the only view.zod.ts hunk is the listViewKindBlocks region). The input type is a contract choice, so a card is the right carrier and a rider would have been wrong. ANSWERED.
  2. Fixes #19920 — RIGHT. The card's four named sites and the fifth are typed on main (PR fix(spec): InlineAction, ViewMetadataParsed and AssembledViewArtifact(Parsed) name their shapes, not unknown (#19920) #20260, PR fix(spec): JoinedReportBlock, a ViewItem config and the overlay viewKind carry the shapes their doors accept (#19920) #20369); seat 4's release 5865019059 named exactly three remaining sites; items 1 and 3 land here and item 2 is carried by spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450, which itself says [finding] four more exported spec types resolve to unknown while their TSDoc promises a shape — ViewMetadataParsed, InlineAction, AssembledViewArtifact, JoinedReportBlock (the #19871 class, other sites) #19920 closes with this PR. The two guards, "Part-of PR must not also close its card" and "The card this PR closes must claim this branch", are success on the head, and the newest Claim: on the card (5868438648) names this branch. The objectui tripwire is carried by the seat, not the card. Nit, non-blocking: the body still carries the earlier "Why Part of" paragraph beside the closing-keyword note at its end; a body edit by the seat, not a code change.
  3. The Clause-② value (open question 2; the seat kept yes) — ESCALATED as the one blocking item, in ②.
  4. A2's size reading (open question 3: +7,493 B on the view.zod chunk against a +2,729 B reference) — ANSWERED on the merits, accepted: the cost is bounded to one chunk (about 1.5% of it), 0 TS7056, the type is exact rather than an any escape, and the whole PR is a net shrink of the built declarations. There is no gate on declaration size, so this stays the dev's reading (①8). Not blocking.
  5. Deviation: keeping makeApiErrorSchema's cast — ANSWERED, accepted, with the stronger reason in ①4: dropping it would have widened the parsed code to string and changed the returned class. The defect was the missing input parameter, and that is what moved.
  6. Deviation: two spellings on the branch (the inline union, then the alias) — process only; the net diff carries the alias spelling, which is the right one (①8).
  7. The new error-code-ledger.zod chunk — not a published surface (①7); Build Core success covers the declaration closure. Non-blocking.
  8. NOT MEASURED locally by the dev (spec test:repo, the @objectstack/client test-layer typecheck, check:dual-build-cjs-loads, check:type-check-debt, the objectui and cloud builds) — answered by CI where CI reaches: Test Core, Type Check · workspace, · consumer gates and · debt ledger, and Build Core are success; Console Pin Gate skipped by the path filter (not measured here); cloud not measured anywhere in this repository's CI. The same state as the family's prior records.
  9. Out-of-scope findings (lookupFilters is its own closed operator dialect; makeApiErrorSchema's return type prints the catalogue inline four times in one bounded declaration) — Acceptance notes, RIGHT; neither is a defect card.
  10. Concurrency and fence: PR feat(spec)!: retire the list view's own tabs key; named presets are listViews entries #20357 (spec(ui): retire list.tabs and the view container's body name (2 keys); listViews + ViewTabBar and the row name already deliver both #20301) edits view.zod.ts at ListViewShapeSchema, disjoint from the listViewKindBlocks hunk; no open PR touches the two api/ files (the claim's reading; nothing in the diff contradicts it). GitHub reads the PR as blocked (draft plus checks), not dirty, so the base moving since the dev's measurements produced no conflict.
  11. Local-runs: none. The inputs were the PR, the card, spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450 and the head's check-runs; nothing was built, run or re-run.

Implemented-by: claude/issue-19920-exported-types-family-close
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: FAIL


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b26d6506bfd199bbc04421aae8a9b38081ef05bc
Local-runs: none

Inputs: card #19920 (body and all 18 comments, among them the claim 5868438648 as amended in place and the three dev reports), card #20450 (body; no comments), PR #20448 (its body as edited, the 6-file list, the net diff against main fetched as a diff, and its two comments, the prior at-tier record 5871015216 among them as an input rather than a verdict), the check-runs on this head re-polled until none was in progress, and origin/main context read with git show in this container: scripts/pm/clause2-line.mjs, the two changeset gates and the workflows that run them, api/contract.zod.ts, api/error-code-ledger.zod.ts, api/errors.zod.ts, api/batch.zod.ts, ui/view.zod.ts, ui/assembled-views.zod.ts, shared/lazy-schema.ts, shared/strict-object.ts and recursive-schema-input-assertions.ts. The head's four commits exist as objects here, so the GitHub diff was compared with the local three-dot diff against origin/main: identical apart from index lines. Nothing was built, run or re-run.

This is a patch round on the prior head 4358d1a33. The three source blobs and the two pin files at this head are byte-identical to 539295c9e (blob hashes compared), and 4358d1a33..b26d6506b is one line of one file: changeset line 7. ①, ② and ③ below are re-derived against this head's net diff (6 files, +281/−4), not against the prior record.

① Derived judgments

A. Accept set: none moves — RIGHT. Every value expression in the three source hunks is as it was:

  • api/error-code-ledger.zod.ts: the z.enum(...) call, its member spread and its error map are untouched; only the trailing cast and the TSDoc move.
  • api/contract.zod.ts: makeApiErrorSchema's z.enum(vocabulary, ...) and its .describe() are untouched; only the cast and a comment move.
  • ui/view.zod.ts: listViewKindBlocks()'s three-statement loop is byte-identical (overlayTypeValues, unwrap().partial().optional()); the declared return type and a blocks as ListViewKindBlocks assertion on the return are the only changes. partialListViewKindBlock is declared and never called (only its instantiated return type is read), and ListViewShapeFields, ListViewKindBlockName and ListViewKindBlocks are module-local.
  • Runtime evidence on this head: Test Core (six shards and the rollup), Dogfood Regression Gate (three shards, the rollup and Verify CLI), Temporal Conformance and Build Core are all success, consistent with no schema, default, refinement or served artifact moving.

B. Public surface, each change named and judged:

  1. The input type of the exported const ErrorCode: FROM unknown TO ErrorCode — narrowing, RIGHT. The repository's own rule text (recursive-schema-input-assertions.ts header) states that z.ZodType takes two type parameters, Output and Input, and that Input defaults to unknown; an enum's input is its output, so naming both with the same union is the enum's true shape. The output half does not move: the alias ErrorCode is by definition StandardErrorCode | RegisteredErrorCode, the union the base cast wrote out. Spelling the cast with the alias rather than the inline union is right for the emitter (item 8).
  2. ApiError.code: FROM unknown TO ErrorCode — narrowing, RIGHT. ApiErrorSchema.code is ErrorCode.describe(...) (contract.zod.ts:29) and ApiError is that schema's input type (:580); ApiErrorParsed (:582) does not move, its output was already the union. Pinned by api-error-code-type.test.ts: an IsUnknown triple, five refused-body directives, and a runtime half tying each refused body to invalid_value at code.
  3. Every schema that embeds ApiErrorSchema narrows with it on the input side — RIGHT, and the embedding sites were checked on origin/main: BaseResponseSchema.error (contract.zod.ts:312, hence every response input type built on it; the "58 input aliases" is the dev's count, measured by no gate), ModificationResultSchema.errors (:461, hence ModificationResult and BulkResponse), BatchOperationResultSchema.errors (batch.zod.ts:14, hence BatchOperationResult and BatchUpdateResponse), and makeApiErrorSchema's extend. EnhancedApiErrorSchema (errors.zod.ts:383) declares its own code: StandardErrorCode and is untouched, so the banner is right not to name it; contracts/approval-service.ts:28 imports the ErrorCode type alias, which does not move.
  4. makeApiErrorSchema(codes): the returned schema's input code FROM unknown TO StandardErrorCode union codes — narrowing, RIGHT; its output and its class do not move. Pinned by the Acme block of the same pin file. Keeping the cast is RIGHT, not merely tolerable: vocabulary is a string[], so without the cast the enum's own type would be string on both sides — the OUTPUT would widen from the union to string, a published-type widening the base did not have, and the returned class would change. The defect was the missing input parameter, and that is what moved.
  5. listViewKindBlocks()'s return type: FROM a record of string to z.ZodTypeAny TO a mapped type over the kinds that name a block — RIGHT, and it is the runtime rule at the type level. ListViewShapeSchema is a lazySchema wrapping a strictObject(...); lazySchema returns its factory's type, strictObject returns closedObject(z.object(shape).strict()), and closedObject returns its argument's type, so .shape is typed. The shape's type key is a nine-value enum with .default('grid') (view.zod.ts about :2490), so its output carries no undefined; the eight block keys kanban, calendar, gantt, gallery, timeline, chart, map, tree (about :2619 to :2626) are each a lazySchema wrapping a strictObject(...), made .optional(), some .describe()d, which keeps the optional wrapper, so each matches the pattern the mapped type asks for and each entry is zod's own .partial().optional() type read through the helper; grid names no key and drops out. The never arm is a tripwire and the pin's NeverEntries check holds it at never.
  6. The options bag on VIEW_METADATA_MEMBERS.listOverlay, hence the list-overlay member of ViewMetadata (view.zod.ts:6813), ViewMetadataParsed (:6835), AssembledViewArtifact (assembled-views.zod.ts:117) and AssembledViewArtifactParsed (:129): FROM a string-keyed record of unknown TO one optional entry per kind block with every key optional, on input and on output — narrowing, RIGHT, and named in the banner. ListViewOverlayOptionsSchema is strictObject(..., listViewKindBlocks()), so the typed shape flows into the member with no other edit, and the member's .strip(), two .superRefine() and .overwrite() keep the object's own type. Pinned by view-overlay-options-type.test.ts: key-set equality on both sides of the parse, the never-check, six refused-body directives on the bag and four on the union types, and a runtime half holding the runtime key set equal to the type's and refusing the same bodies at all three doors.
  7. Exports: none added, removed or renamed — RIGHT. No source hunk introduces an export; the helper and the three aliases are module-local; the two new files are tests, not entry points; packages/spec/api-surface/*.json is untouched, and check:api-surface (Type Check · consumer gates, lint.yml:6510) is success on this head. The new bundler chunk error-code-ledger.zod (.d.ts and .d.mts) is shipped bytes reached through relative references from the api entry, not an exports path.
  8. Declaration size, as far as the diff and the check-runs carry it: the mechanism is in the diff (the base cast wrote the union out, and declaration emit repeats an inline union at every embedding site; the head names an exported alias, which emit prints by name), so the sign of the change is credible. The figures (−3,315,656 B overall, +7,493 B on the view.zod chunk, 128 to 130 declaration files) are the dev's local readings; no check-run measures declaration bytes. Gate-proven: Build Core success (the declarations emit, which a TS7056 would fail) and all four Type Check lanes success (the typed pins compile). Not contradicted.
  9. Consumers: toRowApiError (metadata-protocol) casts from any to ApiError['code'], and response-envelope.ts (types) reads a Pick of optional fields; both compile inside Type Check · workspace and · consumer gates, success. objectui at pin f8a9d0fb names ApiError only through a Pick of userMessage and none of the four view types: the dev's reading, not compiled here, and Console Pin Gate was skipped by the console path filter on this head, as on every PR of this family. cloud is measured by nothing in this repository's CI.

Nothing derived is wrong or missing.

② Semver level

  • Level minor on @objectstack/spec — RIGHT. A published-type narrowing ships as minor in the launch window (the PR fix(spec): ViewMetadata names a view body (the union of its members’ input types), not unknown #19919 / fix(spec): InlineAction, ViewMetadataParsed and AssembledViewArtifact(Parsed) name their shapes, not unknown (#19920) #20260 / fix(spec): JoinedReportBlock, a ViewItem config and the overlay viewKind carry the shapes their doors accept (#19920) #20369 shape); not patch, not skip-changeset, no major anywhere.
  • The BREAKING banner — RIGHT. It names the narrowed carriers FROM → TO (ApiError, every response type built on BaseResponseSchema, ViewMetadata, ViewMetadataParsed, AssembledViewArtifact, AssembledViewArtifactParsed, and the input type of a makeApiErrorSchema schema) with the "if your code stops compiling" instruction, and its own sentence "no export changes, and no export is added" matches ①7. Nit, non-blocking: the bold list does not name the two row aliases ModificationResult and BatchOperationResult (①3); the bullet's "each ApiError row of a batch result" covers them in substance.
  • The (narrowing) arm — RIGHT. ADR-0087 not-required (no-migration-prescription) — RIGHT: nothing an author writes moves, no stored row changes, no export changes, nothing for objectstack migrate meta to reach; the narrowing's channel is the consumer's compiler. check-adr-0087-registration reads breaking-ness from the **BREAKING banner and from the arm through readClause2Line, so with no (narrowing) it still reads [BREAKING+clause-②-narrowing] and demands the disposition it finds.
  • The Clause-② value — RIGHT, in all three carriers. clause2-line.mjs defines the value as the answer to whether the diff widens a published accept set or expands the public surface, no (narrowing) as "NOT a widening, but breaking", and yes (narrowing) as a diff that widens one surface and narrows another. This diff widens nothing (①A, ①7) and narrows published types, so the truthful spelling is no (narrowing), the same as PR fix(spec): ViewMetadata names a view body (the union of its members’ input types), not unknown #19919 and PR fix(spec): InlineAction, ViewMetadataParsed and AssembledViewArtifact(Parsed) name their shapes, not unknown (#19920) #20260 for this defect class (PR fix(spec): JoinedReportBlock, a ViewItem config and the overlay viewKind carry the shapes their doors accept (#19920) #20369's yes (narrowing) had three added exports behind its yes). Applied to each carrier: changeset line 7 reads Clause-②: no (narrowing) (key at line start, token no, the parenthetical opens with narrowing: declared, no, arm narrowing); PR body line 2 reads the same and is the first key-initial line of the body (the later ## Clause-② heading has no colon and is not a declaration, and the inline mentions in the closing note do not outrank a declaration); the claim 5868438648, amended in place, reads Clause-②: no with no arm, the shape every claim of this family carried, and its amendment sentence names the key without a colon so it makes no second reading. Check Changeset is the machine reading of the body and the changeset: success on both runs of this head, the push-triggered run 36430942777 and the body-edit-triggered run 36431255102, the latter being the one that read the final body. The level axis of check-changeset-no-major is satisfied by minor under no.
  • Other reds: NONE. 42 check-runs on this head, re-polled until none was in progress: 37 success, 5 skipped, none failed. Every required context (the main ruleset's seven) is success: Lint & Repo Gates and TypeScript Type Check (run 36430942670, with all four Type Check lanes), Test Core (six shards and the rollup), Dogfood Regression Gate (three shards, the rollup and Verify CLI), Build Core and Temporal Conformance (run 36430942668), and Governed Surface Queue Guard (run 36430942812). Also success: both Check Changeset runs, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, and both runs of each of the four claim and single-writer guards. The five skips are the expected-skips roster's own: Build Docs and Console Pin Gate (the filter job's path outputs), Packed-tarball smoke (opt-in), and Auto Label and Check PR Size on the body-edit run (their if: excludes the edited event; both succeeded on the push run).

③ Boundary flags

From the dev report 5870687948 (three open questions, nine deviations, two out-of-scope findings), the seat's ACCEPT 5870763898, the prior record 5871015216, and what this patch round introduced.

  1. Item 2, ViewFilterRule.operator, moved to spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450 — RIGHT, ANSWERED. spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450 exists (open, filed bare with no labels, as its body says) and carries the dev's three options, the measured authors and the recommendation verbatim as triage's choice; it states that [finding] four more exported spec types resolve to unknown while their TSDoc promises a shape — ViewMetadataParsed, InlineAction, AssembledViewArtifact, JoinedReportBlock (the #19871 class, other sites) #19920 closes with this PR. The diff does not touch ViewFilterRule or normalizeFilterOperator (the only view.zod.ts hunk is the listViewKindBlocks region, about :5665). The input type is a contract choice, so a card is the right carrier and a rider in this PR would have been wrong.
  2. Fixes #19920 — RIGHT. The card's four named sites and the fifth are typed on main (PR fix(spec): InlineAction, ViewMetadataParsed and AssembledViewArtifact(Parsed) name their shapes, not unknown (#19920) #20260 as 17bd3187, PR fix(spec): JoinedReportBlock, a ViewItem config and the overlay viewKind carry the shapes their doors accept (#19920) #20369 as 681868ca); seat 4's release 5865019059 named exactly three remaining sites; items 1 and 3 land here and item 2 is carried by spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450. The two guards, "Part-of PR must not also close its card" and "The card this PR closes must claim this branch", are success on both runs of this head, and the newest Claim: on the card (5868438648) names this branch. Card-hygiene nit for the seat, non-blocking and not this PR's: the card body still ends with Blocked-by: #20230, a card the claim 5861479551 reads as closed completed; the closing merge makes it moot.
  3. The Clause-② value (open question 2; the prior record's one blocking item) — RESOLVED. All three carriers now read no (②). The patch round moved nothing else: one changeset line, the seat's body edits, and the claim amended in place.
  4. A2's size reading (open question 3: +7,493 B on the view.zod chunk against a +2,729 B reference) — ANSWERED on the merits, accepted: the cost is bounded to one chunk (about 1.5% of it), 0 TS7056, the type is exact rather than an any escape, and the PR as a whole shrinks the built declarations. No gate measures declaration size, so this stays the dev's reading (①8). If the seat holds A2 to a tighter reading, the dev's option B (revert the view.zod.ts hunk, its pin file and the changeset's second bullet) is the seat's call, not this record's. Not blocking.
  5. Deviation: keeping makeApiErrorSchema's cast — ANSWERED, accepted, with the stronger reason in ①4: dropping it would have widened the parsed code to string and changed the returned class.
  6. Deviation: two spellings on the branch (74a132da1 inline union, 539295c9e alias) — process only; the net diff carries the alias spelling, which is the right one (①8).
  7. The prior record's nit, the stale "Why Part of" paragraph — REMOVED. Verified against the body at this head: no Part of remains; the closing note explains the line-1 change and cites spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450. Wording nit, non-blocking: the section heading ## What stays on #19920 and its sentence naming ViewFilterRule.operator as "the only family site left" now describe a site that lives on spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450; the closing note resolves it, so no reader is misled.
  8. The new error-code-ledger.zod chunk — not a published surface (①7); Build Core success covers the declaration closure. Non-blocking.
  9. NOT MEASURED locally by the dev (spec test:repo, the @objectstack/client test-layer typecheck, check:dual-build-cjs-loads, check:type-check-debt, the objectui and cloud builds) — answered by CI where CI reaches: Test Core, Type Check · workspace, · consumer gates and · debt ledger, and Build Core are success; Console Pin Gate skipped by the path filter (not measured here); cloud not measured anywhere in this repository's CI. The same state as the family's prior records.
  10. Out-of-scope findings (lookupFilters is its own closed operator dialect; makeApiErrorSchema's return type prints the catalogue inline four times in one bounded declaration) — Acceptance notes, RIGHT; neither is a defect card.
  11. Concurrency and fence: PR feat(spec)!: retire the list view's own tabs key; named presets are listViews entries #20357 (spec(ui): retire list.tabs and the view container's body name (2 keys); listViews + ViewTabBar and the row name already deliver both #20301) edits view.zod.ts at ListViewShapeSchema (about :2769), disjoint from the listViewKindBlocks hunk; no open PR touches the two api/ files (the claim's reading; nothing in the diff contradicts it). GitHub reads the PR as blocked (draft plus required checks), not dirty.
  12. Introduced by this patch round: the one-line changeset commit b26d6506b; the seat's body edits (line 2, the removed paragraph, the rewritten ## Clause-② section, the closing amendment note); the claim amended in place. The body edit re-triggered CI on the same head, so this head carries two runs of the small workflows; both are read above and neither is red. No code moved (blob hashes equal 539295c9e).
  13. Local-runs: none. The inputs were the PR, the card, spec(ui): ViewFilterRule.operator's input type is unknown (a z.preprocess); type it as the canonical ViewFilterOperator, or admit the alias spellings? (the last site of #19920's family) #20450, the head's check-runs and origin/main read-only; nothing was built, run or re-run.

Implemented-by: claude/issue-19920-exported-types-family-close
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36434919545 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the tw
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit cf55914 Sep 28, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19920-exported-types-family-close branch September 28, 2026 14:58
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ewFilterOperator (objectstack-ai#20450) (objectstack-ai#20503)

Fixes objectstack-ai#20450
Clause-②: no (narrowing)

## What

`ViewFilterRuleSchema.operator` is a `z.preprocess` over the alias fold,
and zod 4.6.1 types a preprocess's INPUT from its function's parameter
(`preprocess(fn: (arg: B, ctx) => A, schema): ZodPreprocess(U, B)`, with
`B = unknown` by default). The function was `normalizeFilterOperator(op:
unknown)`, so `ViewFilterRule['operator']` was `unknown`, and `{ field:
'status', operator: 42 }` compiled as a rule on every carrier.

This PR lands triage's direction A: the typed input of `operator` is the
canonical `ViewFilterOperator`. The runtime fold is untouched, so stored
`sys_metadata` rows and plain-JS producers that carry an alias still
parse and fold.

- `packages/spec/src/ui/view.zod.ts`: the preprocess now runs through a
module-private wrapper, `foldAuthoredViewFilterOperator(op:
ViewFilterOperator): string`, which returns
`normalizeFilterOperator(op)`. The wrapper is not exported, so no export
is added and `api-surface/` is byte-identical.
- `packages/spec/src/ui/view-filter-operator-input-typed.test.ts`: the
two-half pin (see the verification record below).
-
`packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts`
and the regenerated registry region: the ADR-0087 registration (see
Acceptance notes, item 1).
- `.changeset/20450-view-filter-operator-input-typed.md`:
`@objectstack/spec` `minor`, a BREAKING banner, a FROM → TO table and
the one-line fix, `Clause-②: no (narrowing)` (corrected in round 2, the
seat's answer `5877491493`), and the `registered` disposition.

### Which site, and why not `normalizeFilterOperator`'s own parameter

Triage's execution line named `normalizeFilterOperator`'s parameter. I
measured that site first. It is exported (`api-surface/ui.json`), and
its callers pass raw strings or `unknown` by design:

- `packages/lint/src/validate-preset-comparands.ts:634` passes a stored
rule's `rule.operator`.
- `packages/rest/src/view-filter-rule-lowering.ts:72` passes a stored
rule's `operator`.
- `packages/spec/src/conversions/registry.ts` passes `op.slice(1)`
(:10984), `op` (:11167) and the literal `'eq'` (:11060, :11187).
- `packages/spec/src/ui/filter-rule-array.ts:152` passes the literal
`'eq'`.
- objectui at its pin calls it in `ObjectView.tsx`, `ListView.tsx`,
`UserFilters.tsx`, `filter-converter.ts`, `filter-builder.tsx` and
`viewFilterFold.ts`, each with a string or `unknown`.

Narrowing that parameter would break every one of these callers. A
canonical-in, canonical-out fold would also have nothing left to fold.
The smallest correct site is the preprocess's own input at :923. The
wrapper reaches the same result (the typed input is canonical) and
leaves the exported fold's signature as it was.

## Verification record

Final gate union on HEAD `bed8cabb44` (after merging `origin/main` at
`fc0db22bcf` through `scripts/pm/os-regen-merge.sh`). My delta against
the merged main commit is the five files above: +288 / -5.

**Premise, measured at base `8e02859185` against the built
`dist/*.d.ts`.** I ran a downstream probe that resolves
`@objectstack/spec/ui` through the package `exports`. `tsc` gave EXIT 0
on all of these: `operator: 42`, `operator: 'eq'` and `operator:
Symbol('x')`, on `ViewFilterRule` and on three carriers
(`ListView['filter']`, `ViewTab['filter']`,
`InterfacePageConfig['filterBy']`).

**Reverse verification.** I rebuilt spec with the change and ran the
same probe. `tsc` gave EXIT 2 with 9 errors, one on each of the 9
non-canonical lines. The canonical line still compiles. The runtime is
byte-identical to base: a number is refused with `code:
'invalid_value'`, `path: ['operator']` and the canonical `values`;
`'eq'` folds to `equals`; `'NotIn'` folds to `not_in`.

**Compile-half ablation** (at `60102b7b42`, through
`scripts/ablation-replace.mjs`). I widened the wrapper's parameter back
to `unknown`. The anchor went 1 → 0 and the blob went `4403a5bb` →
`d8324f31`. `check:test-typecheck` then reported one problem:
`src/ui/view-filter-operator-input-typed.test.ts: 6 type error(s)`,
which are the six `@ts-expect-error` directives (TS2578). Restored: the
blob equals HEAD `4403a5bb` and `git diff HEAD` is empty.

**Runtime-half ablation** (at `bed8cabb44`). I replaced the wrapper body
`return normalizeFilterOperator(op);` with `return op;`, which still
compiles. The pin went `3 failed | 2 passed`: the three fold tests
failed; the canonical and refusal tests held. Restored: the blob equals
HEAD `07956823` and `git diff HEAD` is empty.

**Spec package** (post-merge, `bed8cabb44`):
- `pnpm --filter @objectstack/spec typecheck` exit 0 (src, scripts and
the test layer). `check:test-typecheck: OK — 53 file(s) / 251 error(s) /
138 pinned signature(s) held`, so no test file moved.
- `vitest run --project local`: `Test Files 573 passed (573)`, `Tests
16794 passed | 1 todo`.
- `check:generated`: all 15 generated artifacts up to date. Nothing
records the input type: `api-surface/`, `export-origins/`,
`declaration-map/`, `authorable-surface/` and the JSON schemas are
unchanged. `spec-changes.json` and the upgrade guide hold because
in-progress major-18 entries reach them at release (control: the sibling
`view-filter-rule-scalar-operator-array-refused` is in neither).

**Consumer typechecks.** These ran against spec `dist` built from
`60102b7b42`; the narrowing is identical after the merge. They are a
selection of `@objectstack/spec`'s downstream consumers (the
`...@objectstack/spec` direction), not the whole 73-package closure.
Every one exited 0, and each test-layer ledger held exactly:
- `@objectstack/lint` (2 files / 6 errors)
- `@objectstack/metadata-protocol` (plain `tsc`, tests included)
- `@objectstack/rest` (0 / 0)
- `@objectstack/objectql` (40 / 234)
- `@objectstack/platform-objects` (1 / 3)
- `@objectstack/plugin-sharing` (2 / 3)
- `@objectstack/plugin-security` (0 / 0)
- `@objectstack/plugin-audit` (0 / 0)
- `@objectstack/plugin-approvals` (8 / 324)
- all five examples: `app-showcase`, `app-todo`, `app-crm`,
`app-multi-package`, `embed-objectql`

The rest of the closure is CI's.

**Census of typed producers.** Non-test `.ts`/`.tsx` under `packages/`,
`examples/` and `apps/`:
- 3 `operator:` literals carry an alias spelling. None is on a
view-filter carrier: an app-showcase `lookupFilters` entry, and two doc
comments in other dialects. As a control, the same grep for canonical
spellings hits 113 times.
- 2 non-literal `operator:` writes. Neither is a view-filter rule: a
cross-field refusal record in `formula`, and a text-operator door
verdict in `objectql`.
- 0 non-test typed producers write an alias through `ViewFilterRule`.

No test fixture needed an escape: every alias-carrying fixture already
reaches the fold through `.parse` / `.safeParse`, which take `unknown`.

**objectui at its pin `dd3f7e1be3`** (read-only). I exported its sources
with `git archive` into a scratch directory, so nothing was written to
the objectui repo. I compiled `packages/*/src` and `apps/console/src`
against this branch's spec `dist`, with its root compiler options,
`@object-ui/*` mapped to source, and React 19.2.8 / `@types/react`
19.2.18. There were 0 errors naming the operator union or
`ViewFilterOperator`.
- Positive control: a scratch-only file wrote a `string`, an alias, and
a `string` through objectui's own
`RecordRelatedListComponentProps.filter`. Exactly those 3 lines errored,
and every other error was identical to the run without the control.
- The typed producers read the same way. `viewFilterFold.ts:211` casts
to `ViewFilterRule['operator']`. `ObjectDataPage.tsx` builds a record
and pushes the parse OUTPUT.
- objectui resolves published `@objectstack/spec` (`^17.x`), so it meets
this at its next spec bump, not through the Console Pin Gate.

**Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 88 families on
`bed8cabb44`. I ran 87 and each exited 0. `--ran` reconciliation: `88
derived famil(ies) accounted for — 87 run, 1 NOT-MEASURED`.
- `check-adr-0087-registration`: `[BREAKING+bang+clause-②-narrowing]
registered view-filter-rule-operator-input-canonical (new here: …)`.
- `check-changeset-no-major`: no `major`. Its level axis is PR-scoped
and reads NOT APPLICABLE locally, so that half is CI's.

NOT MEASURED: `check:type-check-debt`. Reason: its `--re-measure` runs a
full-repo `turbo run build`, which does not fit the foreground cap. I
took targeted readings instead:
- The root program (`tsc -p tsconfig.json`) has 26 errors, equal to its
DEBT ledger's 26, and 0 of them name the operator union.
- The other three DEBT packages (`cloud-connection`, `hono`,
`observability`) contain no `operator` or `ViewFilter` text.

## Acceptance notes

1. **Surface extension: one hand-written file beyond the claim's file
surface.**
`packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts`
sits outside the claimed surface; its generated registry region is
inside it. Why:
- The changeset carries the FROM → TO the dispatch asked for, and
`check-adr-0087-registration` refuses `no-migration-prescription` on a
body with a prescription.
- `type-surface-only` is closed to any diff that touches `packages/spec`
or moves a `*.zod.ts` (ADR-0087 D8, predicates 2 and 3). The ADR's
reason is that for such a diff the ledger *is* a channel that can carry
the change.
- So the honest disposition is `registered`. The precedent is
`spec-type-alias-input-suffix-retired`, a type-only spec change
registered the same way.
- The entry records that nothing at rest is rewritten (no D2
conversion). The family predecessor, objectstack-ai#20448, took
`no-migration-prescription` without a concrete rewrite. The reviewer may
prefer that route; it means dropping the FROM → TO table.
2. **`Clause-②` value.** Round 1 carried the claim's `yes`. By
`scripts/pm/clause2-line.mjs`'s own definitions this diff reads `no
(narrowing)`, "NOT a widening, but breaking": it adds no export and
widens no accept set, as the family precedents (objectstack-ai#19514, objectstack-ai#20448)
declared. The seat answered `5877491493` (Q1: A), and round 2 corrected
the changeset line and this body's declaration line to `Clause-②: no
(narrowing)`. The level (`minor`), the BREAKING banner and the ADR-0087
marker are unchanged.
3. **Prose that now names the old spelling (noted, not filed).** These
still describe the operator as `z.preprocess(normalizeFilterOperator,
…)`: `packages/metadata-protocol/src/protocol.ts:1187`,
`protocol.graft-normalized-operators.test.ts:6`,
`packages/spec/src/ui/view-filter-rule-wire-id.test.ts:185`, and
objectui's `packages/types/src/zod/complex.zod.ts:283`. The preprocess
now runs a wrapper that delegates to that fold, so each statement is
still true about behaviour. Only the literal spelling drifted.
4. **Observation (not filed).** The fold's case-folded branch reaches
only aliases whose lower-cased form is itself a table key.
`GREATERTHANOREQUAL` does not fold: the table holds `greaterorequal` and
`greaterThanOrEqual`, not `greaterthanorequal`. This is deliberate per
the table's docblock ("the folds exist per measured legacy spelling").
The pin tests the case-fold branch on `GT` and `NotIn` only, for that
reason.
5. **The objectui measurement has a stated blind spot.** Third-party
modules (radix, lucide and others) were not installed, so the types that
flow through them collapse to `any` and cannot error either way. The
positive control shows the channel is live for every spec-typed
position.

---

_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

2 participants