Skip to content

fix(spec): type object-grid and object-kanban grouping by GroupingConfigSchema - #20856

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20831-component-grouping-typed
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20831-component-grouping-typed

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20831
Clause-②: yes

What this changes

ComponentPropsMap['object-grid'].grouping and ComponentPropsMap['object-kanban'].grouping were z.unknown().optional(). Both now take the list view's own GroupingConfigSchema by reference, imported from ./view.zod beside the six schemas component.zod.ts already takes from there. There is one schema, not a copy of its shape, and each row keeps its describe. A padded grouping field name, a number, a bare string, an empty fields list or an undeclared key inside the config is now refused on these two doors exactly as list-view refuses it.

The prop is typed, not retired. Both renderers are live readers of this exact shape. Readings are at objectui db11afd49, the pin in .objectui-sha:

  • Grid: ObjectGridSchema.grouping is typed as the spec's GroupingConfig (packages/types/src/objectql.ts:1123). ObjectGrid.tsx reads schema.grouping?.fields (:2050, :2839), and useGroupedData.ts reads .field, .order and .collapsed per entry (:281, :443-547).
  • Kanban: effectiveSwimlaneField = schema.swimlaneField || schema.grouping?.fields?.[0]?.field (ObjectKanban.tsx:1212).
  • A read-point grep of every grouping. member across objectui packages/*/src (tests excluded) at the pin finds fields and nothing else.

Files (7, +338 / -7 against origin/main 688ddef3c)

File Why
packages/spec/src/ui/component.zod.ts the import, the two members, and a comment on each
packages/spec/src/ui/component-object-block-grouping.pin.test.ts the pins (below)
packages/spec/src/migrations/entries/semantic/18.ui-object-block-grouping-config-typed.ts + regenerated registry.ts the ADR-0087 disposition (below)
content/docs/references/ui/component.mdx regenerated by gen:docs: grouping now shows its shape on both rows
packages/spec/dropped-refinements.baseline.json outside the claim's file surface; see below
.changeset/20831-object-block-grouping-typed.md minor, BREAKING banner, migration table, Clause-②: yes (narrowing) and the registered ADR-0087 marker

File-surface breach, declared. The spec build refuses the schema change until dropped-refinements.baseline.json records two new sites, grouping.fields.element.field on ui/ObjectGridProps and on ui/ObjectKanbanProps, and moves droppedRefinementSites 610 to 612. The build prints the corrected entry itself. The cause is the non-padded field-name superRefine that GroupingFieldSchema carries. JSON Schema cannot express it, and the ledger already records it at every list-view grouping site (ui/GroupingConfig, ui/GroupingField, ui/ListView, the manifest views). The ledger is hand-edited by design, so a new site is a reviewed line. The alternatives are both out: deleting the refinement is forbidden by the ledger's own rule, and teaching the projection list a new named pattern is a public-contract decision of its own. The ledger's refinementSitesThatDidProject (369) is left alone. This build prints 421, but none of the grouping refinements is one of the five projected patterns, so this diff adds zero to it.

Pins

The pins are in component-object-block-grouping.pin.test.ts: 22 cases, each asserting issue code and path, on both rows.

  • Refused:
    • padded name: custom at grouping.fields.0.field, and the message names the received value and the unpadded spelling;
    • grouping: 42: invalid_type at grouping. There is no fields[0] for a number to fail at, so the triage's grouping.fields[0].field path applies to the padded case only;
    • bare string: invalid_type at grouping;
    • { fields: [] }: too_small at grouping.fields;
    • an undeclared key: unrecognized_keys at grouping.
  • Controls:
    • a fully spelled grouping parses byte-identical (toStrictEqual on the input);
    • the short form { field } parses clean and gains the list view's defaults (order: 'asc', collapsed: false) on the parse output. The component-props gate discards that output and the envelope audit walks raw keys only, so nothing downstream sees them;
    • an absent grouping stays absent.
  • One schema: the member unwraps to GroupingConfigSchema._zod.def by identity, and answers what GroupingConfigSchema answers on the whole corpus, one path level down.

Ablation, run at 69a1be23f (the component.zod.ts blob 39065a62 is unchanged at HEAD): both members were reverted to z.unknown() through scripts/ablation-replace.mjs (anchor 2 to 0, --expect 2, trap restore). Result: 18 failed / 4 passed. The 4 that stay green are the byte-identical and absent controls, which z.unknown() also passes. The restore was proven by blob equals HEAD and an empty git diff HEAD. No dist/ was involved: the test imports ./component.zod from source.

ADR-0087 disposition: registered ui-object-block-grouping-config-typed

A new semantic entry for protocol 18 carries the rewrite table:

  • padded name: write it unpadded;
  • bare string: wrap it as { fields: [{ field }] };
  • empty list, number or other value: delete the key;
  • on object-kanban, swimlaneField still wins over grouping.

It is deliberately not a D2 conversion: a padded name is refused with guidance, never trimmed. The entry states what happens at rest, and it was measured:

  • PageComponentSchema does not parse properties against ComponentPropsMap.
  • The component-props rule is tier: 'advisory', surfaces: CLI_ONLY (packages/lint/src/authoring-rules.ts:1022).
  • So a stored page keeps loading and rendering. The refusal reaches authors as component-props-invalid on os validate / os build / os lint.
  • Measured through validateComponentProps on the rebuilt spec: a padded kanban grouping reports at …properties.grouping.fields.0.field, and 42 on a grid at …properties.grouping. The valid control reports nothing.

node scripts/check-adr-0087-registration.mjs --base origin/main answers exit 0, reporting [BREAKING+clause-②-narrowing] registered ui-object-block-grouping-config-typed (new here …).

Dispatch measurements

  1. Both members read z.unknown() at base 1741c5dcb (:3596, :3993).
  2. No import cycle: view.zod.ts imports nothing from component.zod.ts, and component.zod.ts already takes six lazy schemas from it. The identity pin is green under the lazy proxy.
  3. The grid reader consumes the GroupingConfigSchema shape (readings above), so both rows are typed. There is no needs_decision fork.
  4. The ADR-0087 marker is registered, judged by the gate (above).
  5. Consumer radius, all measured at the merge base 688ddef3c:
    • examples/** has 2 object-grid nodes (app-showcase command-center.page.ts:172, my-work.page.ts:62). Neither authors grouping, and there are 0 object-kanban nodes.
    • No fixture under packages/** authors grouping on either row.
    • The only non-spec package that parses these rows is @objectstack/lint (validate-component-props.ts, page-envelope-audit.ts), downstream of spec (the ...@objectstack/spec direction). Its suite is green (below).
    • Full-repo pin sweep: no test anywhere asserts the old z.unknown() acceptance on these rows, and the padded-name refusal text is unchanged, so no pin flips.

Verification, at HEAD 47c76064d (after merging origin/main through os-regen-merge.sh; no conflicts)

  • pnpm --filter @objectstack/spec build: exit 0; 612 dropped sites across 212 schemas, all declared.
  • pnpm --filter @objectstack/spec check:generated --fix: "All 15 generated artifacts are up to date", measured against a dist built by that run. authorable-surface, authorable-defaults and api-surface are unchanged.
  • pnpm --filter @objectstack/spec typecheck: exit 0. The test layer compiles with debt unchanged (53 files / 251 errors / 138 signatures).
  • Spec suite (vitest run --project local --maxWorkers=2): 579 files, 17088 passed, 1 todo.
  • Consumer @objectstack/lint suite: 117 files, 5436 passed, against a spec dist rebuilt from this tree.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths): 112 commands. --ran reconciles 112 derived, 110 run (all exit 0), 2 NOT MEASURED, 0 UNRUN.
    • Five gates first exited 3 on missing builds: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:lean-entry-closure. Each was rerun green after building lint, client, client-react and objectql.
  • check-changeset-no-major with this body as the event: LEVEL AXIS reads Clause-②: yes, and no package is graded patch.
  • Lint narrowing (the repo-wide pnpm lint is CI's):
    • eslint --no-inline-config --format json over the 4 changed TS files reports 4 files, 0 errors, 0 warnings;
    • eslint.config.mjs puts every packages/**/*.ts in the population, and the other 3 changed files are md, mdx and json, outside its globs;
    • the config never enables type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.

NOT MEASURED locally:

  • check:dual-build-cjs-loads and check:type-check-debt, reason: both need every workspace package built (84 have no dist/).
  • The CLI integration tier, reason: declared for CI; this diff touches no CLI file. Its migrate-meta-engine-guidance.test.ts prints every semantic entry. I checked the new entry against its tracker-id regex (no hit), but not its verbatim print.
  • The four CI type-check lanes and the six path-scheduled CI jobs, reason: CI's own runs.

Acceptance notes

These were noted, not filed. Carrier: none.

  • The protocol-18 entry ui-object-grid-page-size-positive-integer-refused says a stored page "is refused on its next authoring-path save". Its sibling object-grid-default-filters-rule-array, and the rule's surfaces: CLI_ONLY, say the refusal is an advisory CLI finding only. This is a read-only reading: I did not exercise a save.
  • The component.zod.ts section header still lists "filter shapes … grouping configs" as the z.unknown() value posture. Both have since been typed. This is historical text, left as it stands.
  • dropped-refinements.baseline.json measured.refinementSitesThatDidProject reads 369 while the build prints 421. The gap predates this diff and is ungated.
  • For the objectui mirror card (the seat files it): the object-kanban registration description (plugin-kanban/src/index.tsx:684) and structuredKeysAreDeclaredAndHonoured-8313.test.ts both describe the spec row as z.unknown(). Its row-4 fixture { fields: [{ field: 'owner' }] } stays valid under the typed row.

Concurrent work: #20785 edits component.zod.ts only at PageHeaderProps and one component.mdx line in that section, disjoint from these regions; this diff touches neither authorable-surface/ui.json nor authorable-defaults/ui.json. #20694 is not addressed here; it runs after this lands.


Generated by Claude Code

…figSchema

Both ComponentPropsMap rows declared `grouping: z.unknown()`, so a padded
grouping field name or a wrong-shaped value validated green on these doors
while the same value is refused on list-view. Both renderers read the
GroupingConfigSchema shape, so the rows now take that schema by reference.

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
…grid and object-kanban rows

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
…87 semantic entry

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
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 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/deployment/validating-metadata.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/permissions/index.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/permissions/permissions-matrix.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/permissions/sharing-rules.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/protocol/objectql/security.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/releases/v17/17-0.mdx (via business_unit (literal, a string literal in replacement; a string literal in semantic))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 40d6c5fbd39db70f0e0885c046a11bef37baa0d9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b7f00e27eb04a3016a2c31d06d84e2724ee66d1 — the merge of head 47c76064d73aa96d19046b36f4d1e9cddc63b29b into base 40d6c5fbd39db70f0e0885c046a11bef37baa0d9, 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 8b7f00e27eb04a3016a2c31d06d84e2724ee66d1 && git checkout 8b7f00e27eb04a3016a2c31d06d84e2724ee66d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40d6c5fbd39db70f0e0885c046a11bef37baa0d9 47c76064d73aa96d19046b36f4d1e9cddc63b29b && git checkout -B drift-repro 40d6c5fbd39db70f0e0885c046a11bef37baa0d9 && git merge --no-ff 47c76064d73aa96d19046b36f4d1e9cddc63b29b

node scripts/docs-audit/affected-docs.mjs --json 40d6c5fbd39db70f0e0885c046a11bef37baa0d9

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 40d6c5fbd39db70f0e0885c046a11bef37baa0d9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 47c76064d73aa96d19046b36f4d1e9cddc63b29b
Local-runs: none

Read-only shape held. Inputs: card #20831 (body and all three comments — triage 5908917971, claim 5910072345, os-dev-report 5911558951), PR #20856 (body, the 7-file list, git diff origin/main...refs/review/pr20856 with origin/main at 40d6c5fbd), the head's check-runs, and every factual claim re-read against origin/main and the objectui pin db11afd49 (.objectui-sha on origin/main) with git show / git grep. Nothing was built, run or re-run.

Check-runs on 47c76064d, as read by this act (2026-09-30T12:57Z): 30 success — among them the required Lint & Repo Gates (which runs Migration registry matches its entry files), TypeScript Type Check (the rollup; its four lanes Type Check · source gates with check:authorable-surface, check:docs, check:spec-changes, check:upgrade-guide, check:export-origins; Type Check · consumer gates with check:api-surface, check:exported-any; Type Check · workspace; Type Check · debt ledger — all success), Build Core, Dogfood Regression Gate and its three shards, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; plus Check Changeset (the job that runs both check-adr-0087-registration and check-changeset-no-major), Spec property liveness, Build Docs, Test Core (1/6) to (4/6); 2 skipped by design (Console Pin Gate, Packed-tarball smoke (opt-in)); 2 still in_progress at the read: Test Core (5/6) and (6/6). No check reads failure. The required Test Core rollup is therefore not final yet; this record judges the diff, and the landing waits on that conclusion as the queue rule already requires.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named and judged:

  1. ComponentPropsMap['object-grid'].grouping: z.unknown() to GroupingConfigSchema.optional() by reference — RIGHT. On origin/main the row reads grouping: z.unknown().optional().describe('Row grouping config') (component.zod.ts:3596). GroupingConfigSchema (view.zod.ts:1399) is strictObject({ fields: z.array(GroupingFieldSchema).min(1) }); GroupingFieldSchema (:1314) carries the non-padded field superRefine (checkGroupingFieldName, :1205), order as an enum defaulting asc, collapsed as a boolean defaulting false. The narrowing is exactly the accept set list-view already applies, in the same GroupingConfigSchema.optional().describe(...) spelling (view.zod.ts:2710). The describe is kept. The renderer reads the whole shape: at the pin, packages/types/src/objectql.ts:1123 types grouping?: GroupingConfig ("Aligned with @objectstack/spec GroupingConfigSchema"), ObjectGrid.tsx:2050 and :2839 read schema.grouping?.fields, and useGroupedData.ts reads .field / .order / .collapsed per entry (:431-457, :490-517). Triage's direction — type both rows, one schema, not a copy — is met.
  2. ComponentPropsMap['object-kanban'].grouping: the same change — RIGHT. origin/main component.zod.ts:3993 read z.unknown(). At the pin, ObjectKanban.tsx:1211-1212 reads schema.swimlaneField || (schema.grouping?.fields?.[0]?.field) and nothing else of grouping. A git grep over objectui packages/*/src (tests excluded) at the pin for any grouping.MEMBER read other than fields returns 0 hits, so no renderer reads a member the typed shape would refuse. Typed, not retired, as triage ruled.
  3. Five refusal classes now reach both doors — RIGHT, and pinned with code AND path on both rows (component-object-block-grouping.pin.test.ts §1: padded name custom at grouping.fields.0.field; non-object invalid_type at grouping; bare string invalid_type at grouping; fields: [] too_small at grouping.fields; undeclared key inside the config unrecognized_keys at grouping). The message assertions (the received " business_unit ", then Write "business_unit".) match checkGroupingFieldName's text on origin/main (view.zod.ts:1209-1219). Triage's second pin (a valid grouping parses byte-identical) is §2's toStrictEqual control; absence stays absence; §3 pins identity (member.unwrap()._zod.def is GroupingConfigSchema._zod.def) and corpus parity one path level down — the pin that reds a later copy of the shape, which triage forbade.
  4. Parse OUTPUT of a short-form entry { field } now gains order: 'asc' and collapsed: false — RIGHT, and bounded. It is the list view's existing behaviour, and nothing downstream consumes the output of these two rows: PageComponentSchema.properties is z.record(z.string(), z.unknown()) on origin/main (page.zod.ts:324), so page parsing never applies the props schema; the only non-spec consumers of ComponentPropsMap are @objectstack/lint (validate-component-props.ts calls safeParse and reads issues only; page-envelope-audit.ts and validate-page-field-bindings.ts never call .parse nor read .data) and sdui-parser's kanban-quick-add.ts (the retired quickAdd key, unrelated). Pinned as the short-form control.
  5. The published JSON Schemas for ui/ObjectGridProps and ui/ObjectKanbanProps now describe grouping's shape — RIGHT, with no artifact left stale. json-schema.manifest/ui.json lists schema NAMES only (both already listed); authorable-surface/ui.json records Schema:key rows only (ui/ObjectGridProps:grouping and ui/ObjectKanbanProps:grouping are already present on origin/main); api-surface/ records export existence, and no export moved. check:authorable-surface (source gates) and check:api-surface (consumer gates) read success.
  6. dropped-refinements.baseline.json: two new sites (grouping.fields.element.field on both rows), droppedRefinementSites 610 to 612 — RIGHT. The refinement is GroupingFieldSchema's superRefine, which JSON Schema cannot express; origin/main already records the identical site on every list-view door (ui/ListView, ui/ObjectListView, ui/GroupingConfig as fields.element.field, and the manifest / listViews projections). Two sites, two lines, the count moved by exactly two. refinementSitesThatDidProject (369) is rightly untouched: the diff adds no projected site.
  7. content/docs/references/ui/component.mdx regenerated by gen:docs (both rows now { fields: object[] } plus one Nested Shape table each) — RIGHT (an AUTO-GEN surface, not hand-edited; check:docs and Build Docs green).
  8. ADR-0087 disposition registered ui-object-block-grouping-config-typed, a protocol-18 semantic entry and no D2 conversion — RIGHT. Triage ruled "refused with guidance naming the field, never silently trimmed"; a trimming conversion would be the consumer tolerance Prime Directive Add comprehensive test suite for Zod schema validation #12 refuses. The entry's rewrite table (padded to unpadded; bare string wrapped; number / empty list / other deleted; swimlaneField wins on kanban) matches the changeset's table. The at-rest claim is verified (item 4), and the component-props rule is tier: 'advisory', surfaces: CLI_ONLY on origin/main (authoring-rules.ts:1022-1030), so "a stored page keeps loading; the refusal reaches authors on os validate / os build / os lint" is a true statement. registry.ts: the emitted block is byte-identical to the entry file and sits in derived order (ids sorted within major 18 — after ui-mcp-connect-agent-unknown-keys-refused, before ui-object-grid-page-size-positive-integer-refused); the registry gate ran green inside Lint & Repo Gates. docs/protocol-upgrade-guide.md is rightly unmoved: it renders majors up to PROTOCOL_MAJOR = 17 (PROTOCOL_VERSION is 17.0.0), and step 18 is the unreleased next major (check:upgrade-guide green).
  9. component.zod.ts gains one import (GroupingConfigSchema from ./view.zod) — RIGHT, the only way to reference rather than copy; view.zod.ts imports nothing from component.zod.ts, and that import list already carried six lazy schemas, so no cycle is introduced.
  10. Consumer radius — RIGHT. examples/** has 0 object-kanban nodes on origin/main, no spec UI test asserts grouping on these rows, and @objectstack/lint is the consumer that parses them (Type Check · consumer gates green).
  11. Unchanged, and rightly so: swimlaneField semantics on kanban; ListView.grouping; GroupingConfigSchema itself and its exports.

Residual, not a defect of this diff: the section header at component.zod.ts:3315-3316 still describes "filter shapes … grouping configs" as living in objectui — historical text, now stale for both; the dev noted it; a later tidy, not this narrowing.

② Semver level

  • Changeset .changeset/20831-object-block-grouping-typed.md: '@objectstack/spec': minor, a BREAKING banner, the FROM-to-TO migration table with a one-line fix per refused shape, Clause-②: yes (narrowing), and exactly one marker adr-0087: registered ui-object-block-grouping-config-typed. This matches what the diff publishes: an accept-set narrowing on two published props schemas of a released package. Under the declaration grammar, (narrowing) is BREAKING and takes minor with its migration (check-changeset-no-major admits no major); it is the grade triage gave ("a BREAKING minor line"). Check Changeset (both gates) reads success on this head. Neither skip-changeset nor patch — right.
  • Clause-②: line. The claim (5910072345) and the PR body carry Clause-②: yes; the changeset carries Clause-②: yes (narrowing). Value and level agree across all three carriers and the level axis is green. Precision reading against the criterion 「本卡放宽接受集或扩大公开面吗」: this diff widens no accept set and adds no export or key, so the literal spelling is no (narrowing); yes over-declares the widening half (the reader's own docblock labels yes (narrowing) a diff that widens AND narrows). It under-declares nothing: the breaking fact rides the arm in the changeset, where check-adr-0087-registration reads it. Ruled under ③.
  • The PR body's line carries no arm. Nothing reads the body's arm (the level gate reads the value; the ADR-0087 gate reads the changeset), so this is harmless; one spelling across the carriers is preferable if the seat touches the line.

③ Boundary flags

Dev-declared deviations (6):

  1. File-surface breach, dropped-refinements.baseline.json — ACCEPTED. The spec build refuses the schema change until the hand-edited ledger records the site; dropping the refinement is forbidden by the ledger's own rule, and teaching the projection a named pattern is a separate contract decision. Judged right in ①.6; declared, minimal (two parity lines), proportionate.
  2. One import line outside "the two grouping members only" — ACCEPTED (①.9).
  3. Triage's grouping.fields[0].field path for grouping: 42 — ACCEPTED as measured. A number has no fields[0] to fail at; invalid_type at grouping is the correct issue, and the padded case carries triage's path on both rows.
  4. Changeset yes (narrowing) beside the body's bare yes — ACCEPTED; see the open question.
  5. origin/main merged through os-regen-merge.sh before the PR opened, zero regeneration diff — ACCEPTED (the prescribed sequence).
  6. Model-free commit trailer pair per AGENTS.md rather than the harness reminder's — ACCEPTED; AGENTS.md outranks the reminder.

Open question — Clause-② value (A: keep yes / yes (narrowing) as graded; B: respell no (narrowing)): answered, A stands for this head. The value was graded by triage and executed by the claim; both gates read the same level (minor) and the same breaking arm under either spelling, and a narrowing declared yes is never under-declared. The precise literal spelling is B, and the line is the seat's (the claim's), not the dev's: if the seat respells, it respells all three carriers — claim, PR body, changeset — in one act before enqueue, so the carriers never disagree. No push is required by this record.

Out-of-scope findings (4, dev-noted, carrier none or the mirror card):

  • ui-object-grid-page-size-positive-integer-refused says a stored page "is refused on its next authoring-path save", while its sibling entry and the rule registration (tier: 'advisory', surfaces: CLI_ONLY) say advisory CLI only — ESCALATED to the seat. This is a sentence in shipped os migrate meta guidance; if the save path does not refuse, a machine-readable surface lies (Prime Directive chore: version packages #10). The seat measures one save and files a card if the reading holds. Not this PR's text.
  • refinementSitesThatDidProject 369 recorded against 421 printed — pre-existing and ungated; noted, no action on this PR.
  • The component.zod.ts section-header text — noted (① residual).
  • objectui plugin-kanban/src/index.tsx:684 and the structuredKeysAreDeclaredAndHonoured-8313 test header describe the spec row as z.unknown() — routed to the objectui mirror card the seat files at ACCEPT (Blocked-by: this card), as triage directed; its row-4 fixture { fields: [{ field: 'owner' }] } stays valid under the typed row.

No governed surface is touched (7 files under .changeset/, content/docs/references/, packages/spec/); the PR is draft, no auto-merge, no reviewer requested — the landing is the seat's after the required rollups conclude.

Implemented-by: claude/issue-20831-component-grouping-typed
Reviewed-by: session_018fxqvRJW12TaHC7DUQ89Y6

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 13:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit f750119 Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20831-component-grouping-typed branch September 30, 2026 13:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…that decided it (objectstack-ai#20866)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the seventeenth stage of the `domain:services` lane of the
dead-citation sweep. It covers `packages/services/service-job/src/**`
and nothing else. By the seat's claim (`5911377566`), it is the next of
the two single-site packages left in the lane, taken one package per
stage. Later stages cover the other packages, so this PR says `Part of`
and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 16 (the latest is PR
objectstack-ai#20850, landed as `40d6c5fbd`, the base here). That is **2 sites on 2
lines in 2 files, covering 1 number**:

- `src/never-fires.fixture.ts:30`, the census's one site in this package
at the base (a docblock);
- `src/db-job-adapter.timeout.test.ts:55`, a test-comment site the
census defers, found by the supplementary reading below and rewritten
too, as stages 1 to 15 did.

No test string, source string or gate-invisible spelling in this package
carries a dead number, so these two sites are the whole population.

Both rewritten lines now cite the commit in this repository that decided
what they describe: **1 commit sha**. No ADR or ruling record records
that decision (see the per-number table), so ruling C's commit rung
applies. No number was dropped.

Only comments changed. Each file keeps its line count (2 lines out, 2
in), so no line citation into either file moves. No code token moves
(see the guard below). **No citation number is added**: the only tracker
number on the added lines is the live `objectstack-ai#8628`, which already stood on
its line.

**No changeset.** Neither rewritten line reaches `dist`, and `dist` is
byte-identical with and without the rewrite (see Changeset below), so
this PR takes `skip-changeset`.

## Census: `service-job`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-job/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-job sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `40d6c5fbd`, run 2026-09-30T12:36:32Z to 12:40:56Z |
enumerated, 187 pages, frontier objectstack-ai#20855 (newest objectstack-ai#20854 before; objectstack-ai#20855
after, an issue opened 12:40:06Z inside the run, so the frontier equals
the newest at the run's end) | 745 | **1** | 1 | 1 | 1 |
| after | head `6ac866f86`, run 12:46:10Z to 12:49:55Z | enumerated, 187
pages, frontier objectstack-ai#20856 (newest objectstack-ai#20856 before and after) | 744 | **0** |
0 | 0 | 0 |

The whole-repo drop is 1, and the finding sets of the two runs differ by
exactly one row, `never-fires.fixture.ts:30 objectstack-ai#8748`, removed; none was
added. The `resolves` tally is 33,221 in both runs, and
`resolves-as-pull-request` (1,985) and `cross-repo-unjudged` (1,018) did
not move either. Neither run was truncated or discarded: both
enumerations read 187 pages at the newest frontier. The seat's census
counted 1 here at `6bff748b`, and the base agrees: `6bff748b` is an
ancestor of the base, and no commit between them touches this package's
`src`.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings. So a second reading runs the gate's own exported
`extractCitations` (whole-file and comment-prose projections) and
`namesThisRepository` over every `.ts` file under `service-job/src` (18
files). It takes its verdicts from the before census's own board reading
rather than from a second enumeration: a number is dead when that census
reported it `allocated-but-absent`, and alive when the gate's own
census-scope extraction (`collectCitations`) judged it and the census
did not report it. 4 numbers are covered by neither, because they stand
only in test files. Each was read on its own through the read-only
tools, and each answers as an issue: `objectstack-ai#8628`, `objectstack-ai#9611`, `objectstack-ai#9631` and
`objectstack-ai#9633`. The probe's control: `objectstack-ai#8748` answers 404 on the issues endpoint
and on the pull-request endpoint.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `40d6c5fbd` | 95 | **2** | 1 | 1 | 0 | 0 |
| after, `6ac866f86` | 93 | **0** | 0 | 0 | 0 | 0 |

Its src-comment column equals the census's 1, which is the control on
the second instrument. The 93 live citations are the same in both
readings (81 by the census's judgement, 12 by the single reads), none is
cross-repo, none is unjudged, and the drop of 2 citations is exactly the
two rewritten sites. A third, raw reading (every `#` followed by 2 to 6
digits, whatever surrounds it) finds 95 occurrences before and 93 after,
the same drop of 2, and no token beyond the gate's grammar.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#8748` | 2/2 | 2/0 | `226cb71d5` (PR objectstack-ai#8774): every exact-count case
in this package moves onto the inert `NEVER_FIRES` cron fixture (`'0 0
30 2 *'`, February 30th, which never occurs), and the fixture moves out
of the timeout suite into the shared `never-fires.fixture.ts`. Its
message records the measurement `:30` describes: only `Date` faked, the
registration placed 2/5/10/15 ms before the expression's instant, one
extra handler run per case under the firing spellings, and two of the
three no longer reproducing at a 2 ms lead. That is what `:30` says, and
what `:55`'s 「now shared with the sibling job suites rather than spelled
a second time here」 says. Its subject names `objectstack-ai#8748`, and its own diff
names it on 2 added lines; `git blame` puts both rewritten lines in this
commit |

The sha matches exactly one commit (`git rev-parse --disambiguate`,
count 1) and is an ancestor of the base (`merge-base --is-ancestor`,
exit 0; reverse leg, base against the anchor, exit 1; control legs exit
0: `7246d631d`, 5,451 commits behind the base, and the repository's root
commit `1598cabe4`, 15,209 behind, against the anchor's 5,436; the
history is complete, `--is-shallow-repository` false, 15,210 commits at
the base). `objectstack-ai#8748` answers 404 on the issues endpoint and on the
pull-request endpoint, read one by one.

No ADR, `scripts/adr-anchors/` file or ruling record names `objectstack-ai#8748`, PR
objectstack-ai#8774, `NEVER_FIRES`, `never-fires`, February 30th or an inert cron
(`git grep` over `docs/adr` and `scripts/adr-anchors`: 0 hits), so
ruling C's first rung is empty. The repository's other `objectstack-ai#8748` tokens
are `objectui#8748`, a cross-repo reference the gate never judges.

## Wordings to check

- **`never-fires.fixture.ts:30`, tag swap in place.** 「Measured (objectstack-ai#8748)
by faking ONLY `Date`」 became 「Measured (commit 226cb71) by faking
ONLY `Date`」. The form is the one
`packages/lint/src/lint-flow-patterns.ts:1468` already carries on `main`
(「Measured (commit 8ed9c54)」).
- **`db-job-adapter.timeout.test.ts:55`, tag swap in place.** 「(objectstack-ai#8628,
objectstack-ai#8748)」 became 「(objectstack-ai#8628, commit 226cb71)」. The live `objectstack-ai#8628` stays: it
is the report of the wall-clock flake, whose own change, `7246d631d` (PR
objectstack-ai#8750), first made this suite's cron unable to self-fire. The form is
the one stages 7 and 9 landed (for example `(objectstack-ai#5928, commit 69787f0)`
in `plugin-audit`).
- Neither line was reflowed, so each is 11 characters longer than before
(70 to 81, and 72 to 83), a little past its block's wrap.
`eslint.config.mjs` declares no line-length rule, and reflowing would
have moved neighbouring lines.

## Sites left

- **In `src`: none.** No test title, assertion message, operator log
string, runtime refusal, quoted maintainer ruling or generated file in
this package carries a dead number.
- **Outside `src`, listed and left, not edited in this stage:**
- The release-owned `CHANGELOG.md` names the dead `objectstack-ai#13112` on 1 line
(`:705`; 404 on the issue and pull-request endpoints). Its other 27
numbers are live: 19 by the census's judgement, and by single reads
`objectstack-ai#18715`, `objectstack-ai#14619`, `objectstack-ai#9517`, `objectstack-ai#1893`, `objectstack-ai#9611`, `objectstack-ai#9631` and `objectstack-ai#9633` as
issues and `objectstack-ai#9546` as a pull request.
  - `vitest.config.ts` names the live `objectstack-ai#10374`.
  - `README.md`, `package.json` and `tsconfig.json` name none.

## Mechanical guard: no code token moves

The guard compares, base `40d6c5fbd` against head, over both touched
`.ts` files:

- **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited). String
and template literals are therefore read in full.
- **Reading 2**, the full token stream in parser context (a
`getChildren` walk, so punctuation and keywords are included; JSDoc
nodes skipped).

Results:

- Real run at the head `6ac866f86`: 755 base leaf tokens, **0 files with
a token change** on either reading (exit 0).
- Comment control (「croner resolves Feb 29 forward」 to 「croner rolls Feb
29 forward」): 0 files changed, as expected (exit 0).
- Positive control, a code identifier renamed (`expression: NEVER_FIRES
}` to `expression: NEVER_FIRESX }`): DIFFER on the identifier in both
readings (exit 1).
- Positive control, a string literal changed (`'0 0 30 2 *'` to `'0 0 31
2 *'`): DIFFER on the string literal in both readings (exit 1).
- Positive control in the second file, a numeric literal changed
(`TIMEOUT_MS = 20` to `21`): DIFFER on the literal in both readings
(exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blobs (`1fd5e1b3f612`, `ce37b1050c78`), with `git diff HEAD`
empty and a clean tree afterwards.

## Changeset: none (`skip-changeset`)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. `never-fires.fixture.ts` is a test-only support module
(nothing in `src/index.ts` re-exports it, and the tsup entry is
`src/index.ts` alone), and the other touched file is a test. Measured on
the built package (A3), after `pnpm --filter
'@objectstack/service-job...' build` at `6ac866f86`, where the package's
own `tsup` ran and cleaned its output folder:

- Neither rewritten line appears in any `dist` file (0 in all 6, source
maps included). Nor do the old number, `objectstack-ai#8628`, the anchor sha, the
neighbouring lines `:28`, `:31`, `:54` and `:56`, `NEVER_FIRES`, the `'0
0 30 2 *'` expression, or a never-written negative phrase.
- Positive controls, text that does ship: `CronJobAdapter` (45 hits),
`DbJobAdapter` (73) and `sys_job_run` (52) across the entries, and two
docblock lines of the `DbJobAdapter` class JSDoc in `db-job-adapter.ts`
(「`{@link DbJobAdapterOptions.recordRuns}` is `false`, the on/off switch
for」 and the `recordRuns` gating line below it), each in `index.d.ts`
and `index.d.cts`.
- **Byte identity.** The package was rebuilt with the base text put back
in both files (through nested `scripts/ablation-replace.mjs` legs, with
each file proven equal to its base blob, `919a2e444f23` and
`eb00f6eb1978`, during the leg), and again after the proven restore. The
sha256 of all 6 `dist` files is identical in all three builds: JS
entries, declaration files and source maps.

So nothing this PR rewrites is published, and a changeset would announce
a change no consumer can observe. No `.changeset/*` file is added or
touched.

## Gates (head `6ac866f86`)

- **Citation judging, as CI runs it:** `node
scripts/check-issue-citations.mjs` exits 0 (「no issue citations added
against 40d6c5f」). `pnpm check:issue-citations` exits 0 (self-test,
114 cases, 8 batteries).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the
sibling-package prose-id baseline holds, no growth).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `6ac866f86` (change set derived
from git: 2 paths against the merge base `40d6c5fbd`) derived 54
commands, all among the dispatch list's 63.
- All 63 of the dispatch list ran, each with its exit code captured
before any pipe, and all 63 exit 0; none exited 3.
- `--ran`, fed each command with its exit code, reports 54 derived, 54
run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. Of the other
9, it classifies 7 as pending-changeset families and 2 as outside this
derivation.
- A full `turbo run build` over `./packages/*` and `./packages/*/*` ran
first under the shared verify lock (71 of 71 tasks), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in a directory this diff touches): `pnpm
check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm
check:filter-alias-parity`, plus `node
scripts/check-changeset-fixed.mjs`, which the dispatch named. Each exits
0.
- **Tests and typecheck, under the verify lock, at `6ac866f86`:**
- `pnpm --filter @objectstack/service-job test`: 11 files pass and 117
tests pass, which is every tracked test file under `src/`.
- `pnpm --filter @objectstack/service-job typecheck` exits 0, and `tsc
--noEmit --listFiles` puts both touched files in the program (the config
includes `src` and excludes no test file).
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 2 touched `.ts` files, gives 2 files, 0 errors and 0 warnings
(its `--format json` output). Both files are in eslint's own population
(not reported ignored; `dist/index.js`, the control, reads ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636,
including the `clause #N` position). At the base and at the head,
`#N-word`, `#A/#B`, `option #N`, `clause #N` and URL-spelled links are
each on 0 lines, so the claim's 0 / 0 / 0 / 0 / 0 hold. The one
prefix-joined token, `pre-objectstack-ai#6617` in a test title of
`db-job-adapter.degraded-outcome.test.ts`, is inside the gate's grammar
and names a live number.
- **Wording only:** 「the card」 / 「this card」 stands on 4 comment lines
in 3 files under `service-job/src`. It carries no number, neither
instrument sees it, and this diff removes no antecedent.
- **Outside-`src` residue.** `CHANGELOG.md:705` names the dead `objectstack-ai#13112`;
the file is release-owned and is left.
- **The census instrument did not truncate in this stage.** Both
enumerations read 187 pages at the newest frontier.
- **Base.** The branch is on `main` at `40d6c5fbd`. `main` has since
moved two commits (to `660a9b247`). Neither touches
`packages/services/service-job`, `scripts/check-issue-citations.mjs`,
`scripts/pm/dispatch-gates.mjs` or `.changeset/config.json`, so no merge
was taken; the merge queue rebuilds on the merged generation. The one
derivation input that moved is
`scripts/doc-authoring-prose-id.baseline.json`, where the second commit
removed only `packages/objectql` entries; this package has no entry
there before or after, so the merged tree cannot move that gate's
verdict on this diff.

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

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