Skip to content

fix(lint)!: withdraw absolute-colspan-discouraged — its premise was measured false and its recommendation measured worse than the thing it warned about - #17671

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-17328-colspan-rule-reground
Sep 11, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-17328-colspan-rule-reground

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no

Closes #17328

The decision: retire, not re-ground — and the evidence that decided it

Triage's ruling was "withdraw or re-ground the rule, and fix the hint", with the recommendation named as the harmful half. The choice between the two branches is settled by asking what a narrowed rule would still be warning about. Every candidate answer is empty:

Authored shape What #17328 measured Is there anything to warn about?
colSpan: 1 emits zero classes; byte-identical to the control at all three widths No — wholly inert, yet it warned
colSpan: 2 in a 2- or 3-column section renders exactly 2 of 2 / 2 of 3; 0px overflow at every width No — and it is the only spelling that expresses "two columns wide"; neither span: 'full' nor omitting it can
colSpan: 4 in a 3-column section clamps to @2xl:col-span-3; 0px overflow, the case that would overflow if the clamp did not work No
span: 'full' (the recommended alternative) compiles to the identical class as colSpan: 4 and measures byte-identically; at the modal width it is pixel-identical to authoring nothing It is the one that misrenders — see the last section

Overflow is 0px in every configuration at every width in the card's table. With no authored shape that misbehaves, there is nothing left to re-ground, and a narrowed rule could not be given a positive control — the brief's own test for keeping it. A rule that reports 0 and a rule correctly narrowed look identical on a clean tree, so the honest branch is withdrawal.

The corollary matters for anyone tempted to soften the message instead: the rule warned about colSpan: 4 and recommended span: 'full', and they are the same emitted class. There is no wording that makes that coherent.

Re-verification of the ruling's premise, on this branch's base (5ddd5d3)

Re-read first-hand rather than taken from the dispatch. All three hold, plus a fourth the card and triage did not name:

Premise Reading
fires on every authored colSpan validate-form-layout.ts:203 — if (colSpan != null) {, no further discrimination; colSpan: 1 included
the message asserts a rendering consequence :210-213 — …so a fixed span only aligns at one width
the hint recommends span: 'full' :214-215 — Prefer span: 'full' (whole row at any column count). The renderer clamps colSpan to the current column count.
the file contradicts itself :13 — "an over-wide colSpan is clamped", one paragraph above the claim the clamp falsifies
a third carrier, more authoritative than either :18-21 — the module docblock repeats the same false premise and the same harmful recommendation

Reverse-read sweep — which existing sentences does this change make false?

Swept tree-wide with git grep. Reported in full, including the zeros.

only aligns at one width (exact): 1 hit — validate-form-layout.ts:212, the retired message itself. Zero elsewhere in the tree.

lines up at the one width: 3 hits — validate-form-layout.ts:20 (the docblock carrier, rewritten here) and two CHANGELOG.md files. Changelogs are an immutable record of what a past release shipped and are not edited.

one width (broad, 14 hits): everything else is an unrelated sense — identifier column widths, SQL storage ceilings, string-family widths. One real hit: skills/objectstack-ui/rules/navigation.md:139. See "carriers left standing" below.

span: 'full' (13 hits): the two carriers in this file (both fixed); two .changeset/CHANGELOG history rows; content/docs/ui/views.mdx ×4; packages/spec/src/ui/view.zod.ts:2494; skills/objectstack-ui/rules/navigation.md:136; and three pure fixtures (examples/app-showcase/.../task.view.ts:368, packages/lint/src/showcase-shape.fixtures.ts:298, the test file) which assert nothing about width and are unaffected.

colSpan tree-wide: ~100 hits, almost all *.form.ts builtin form definitions authoring a colSpan — untouched and still valid, which is the point. The hits that say something about it are the ones tabled above.

FORM_COLSPAN_ABSOLUTE: after this change, zero code references — only tombstone comments in validate-form-layout.ts, index.ts, authoring-rules.ts and the re-judged tests.

Two zeros worth stating as readings rather than silences:

  • FORM_COLSPAN_ABSOLUTE in any .md / .mdx outside CHANGELOG.md: zero hits — no README or doc page advertises the removed export, so nothing else had to move with it.
  • Any importer of the constant outside packages/lint/src: zero hits — the only consumers were this package's own index and tests.

Carriers left standing, deliberately — filed as #17670

The sweep found the same two false sentences in five more places outside this PR's fence: the .describe() on FormField.colSpan and on FormField.span in packages/spec/src/ui/view.zod.ts (which generate content/docs/references/ui/view.mdx), two hand-written docs pages, and skills/objectstack-ui/rules/navigation.md — a governed surface, and the prescriptive one an AI author reads first. Each is outside this card's land point and pulls in a different gate family (spec generated artifacts) or a human-merge surface. Filed unassigned as #17670 rather than fixed here or left unsaid.

content/docs/releases/v12.mdx:73-76 and :82 also name the rule; release notes are release-owned history and are correct as history. Not edited, not filed.

What changed

  • packages/lint/src/validate-form-layout.ts — the (b) emission block and the FORM_COLSPAN_ABSOLUTE constant are gone. The module docblock's colSpan bullet is replaced by a tombstone carrying the measurement, the reason the claim was false, the reason the recommendation was worse, and a standing instruction that reintroducing a colSpan warning takes a browser measurement plus a positive control — not source reading.
  • packages/lint/src/index.ts — FORM_COLSPAN_ABSOLUTE is off the public surface, with the reason recorded at the export site: a rule id on the public surface reads as a check the platform performs (Prime Directive chore: version packages #10), and the compiler is the precise channel for telling a consumer that suppressed it.
  • packages/lint/src/authoring-rules.ts — the registry comment described validateFormLayout as covering "an absolute colSpan under a per-surface derived column count". That becomes false with this change, so it is corrected in place (it now names the section.group rule it always ran and never mentioned).
  • Tests — seven pins re-judged in place. None deleted.

FormField.colSpan in packages/spec is untouched and still parses. Only the diagnostic is gone.

The pins: re-judged in place, never deleted

Each carries a comment saying what it used to assert, what changed and why. Each re-judged pin that would otherwise assert an empty result is paired with a live finding on the same fixture, because expect([]).toEqual([]) would keep passing if the walk stopped reaching the site at all.

Pin Was Is now
validate-form-layout.test.ts "discourages absolute colSpan and steers to span" the wording pin: hint contains span: 'full' asserts the withdrawal, paired with a form-field-unknown on the identical sections[0].fields[1]
"reports both rules for the same field independently" both rules on one entry a colSpan on the entry does not suppress, duplicate or relocate the reference finding it earns
"skips reference-checking when the bound object cannot be resolved" [FORM_COLSPAN_ABSOLUTE] — the colSpan rule was the only thing that could speak on an unresolvable binding [], paired with the identical sections under a resolvable binding reporting both dangling names
"reads the legacy groups bucket too" 2 rows 1 row; the colSpan: 3 stays on the fixture — it is what proves the entry-object shape is read here
"reports every planted defect on that stack" (#6251) 3 rows 2 rows, one per container rung — the traversal assertion is undiminished
"the colSpan rule is unconditional on the object binding" (#16168) the whole subject was the colSpan rule the other half of the same sentence: a well-formed field with a colSpan on a list-bound container is silent, a dangling one is still reported — #16168's fix still holding, now with nothing riding alongside it
authoring-rule-wiring.test.ts "the form-layout rule really runs, and really finds something" ['absolute-colspan-discouraged', 'form-field-unknown'] ['form-field-unknown']; the colSpan: 2 stays on the fixture and the test's whole point — the registry entry's run returns a real finding on all three commands — is carried intact

Verification

  • pnpm --filter '@objectstack/lint^...' build — exit 0.
  • pnpm --filter @objectstack/lint test — 101 files / 3724 passed, 5 skipped, exit 0.
  • pnpm --filter @objectstack/lint typecheck — exit 0 (tsc --noEmit + check:test-typecheck).
  • pnpm lint (eslint . --no-inline-config, the whole repo, not narrowed) — exit 0.
  • Gate families derived with scripts/pm/dispatch-gates.mjs against the real change set and reconciled with --ran: 59 derived, 56 run green, 0 unrun, 3 NOT MEASURED at exit 3 / PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each needs the whole-repo build closure; declared to CI). check:docs-transcript-drift first exited 3 for the same reason and passes after pnpm --filter '@objectstack/lint...' build.
  • Changeset gates green on the committed changeset: check-adr-0087-registration (accepted the no-migration-prescription disposition), check-changeset-no-major, check-empty-changeset, check-changeset-fixed.
  • All readings above taken at 70e35f4beb.

Reverse verification (one-time ablation, not left on the tree)

Prediction before running: red. Re-emitting the withdrawn finding was injected into the committed source; the mutation was proved on disk by marker count and by git hash-object differing from the HEAD blob (8ddaf813… against 0c354a4d…), and the two test files were run against the mutated source.

Result: 7 failed / 48 passed — every one of the seven re-judged pins goes red, and nothing else does. So the pins assert the withdrawal, not nothing.

Restore leg: git checkout HEAD -- packages/lint/src/validate-form-layout.ts under an EXIT INT TERM trap using absolute paths, verified by blob hash equal to HEAD (0c354a4d…) and an empty git diff HEAD — not by an exit code.

AGENTS.md Post-Task Checklist item 4 — does this removal break the pinned sibling checkout?

This PR removes a published export (FORM_COLSPAN_ABSOLUTE from @objectstack/lint), which is exactly the class item 4 names. The Console Pin Gate job does not answer it on this PR, and that is not a pass:

  • The job is gated if: ${{ !cancelled() && needs.filter.outputs.console != 'false' }} (.github/workflows/ci.yml, the console-pin job). The console paths-filter lists exactly seven paths — .objectui-sha, scripts/build-console.sh, scripts/check-console-sha.mjs, scripts/check-console-injection.mjs, scripts/console-spec-probes.mjs, scripts/assert-console-spec-injection.mjs, .github/workflows/ci.yml. No path under packages/ is among them, so a packages/lint diff never triggers it, and a skipped check reads as success in branch protection. On this question CI is NOT MEASURED.
  • ⛔ The filter was deliberately not touched. Manufacturing a trigger is not a measurement.

So it was measured directly instead, on the pinned tree.

The tree that was read

.objectui-sha at this PR's head is 53ded82bf7a494f54e344e19099dbf00854b8694. Fetched exactly as scripts/build-console.sh mode 3 does (git init + fetch --depth 1 origin at that SHA from https://github.com/objectstack-ai/objectui.git), checked out detached. git rev-parse HEAD equals the pin; 6409 tracked files.

Reading 1 — what the pinned sibling imports, zeros included

Identical probe (git grep -F on the symbol), same tree, absent symbols beside their positive controls:

Symbol Hit lines Files
FORM_COLSPAN_ABSOLUTE 0 0
absolute-colspan-discouraged 0 0
validateFormLayout 0 0
FORM_FIELD_UNKNOWN 0 0
FORM_SECTION_GROUP_UNKNOWN 0 0
PAGE_SOURCE_CLASSNAME — positive control 3 1
validatePageSourceStyling — positive control 4 2
validateCapabilityReferences — positive control 6 4
validateSecurityPosture — positive control 5 2

The controls are not arbitrary: the pinned sibling really does depend on @objectstack/lint — apps/console/package.json:94 and packages/app-shell/package.json:85 both declare "@objectstack/lint": "^17.0.0" — so the zeros above are a reading taken on a tree where this exact package is demonstrably present and imported, not on an empty clone.

Every site that reaches into the package, exhaustively — five, and only four symbols between them:

  • apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts:28 — import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from '@objectstack/lint';
  • packages/app-shell/src/preview/capabilityLint.ts:50 — await import('@objectstack/lint'), feature-detecting validateCapabilityReferences and nothing else
  • packages/app-shell/src/preview/securityPostureLint.ts:94 — await import('@objectstack/lint'), feature-detecting validateSecurityPosture and nothing else
  • two comment references in apps/console/vite.config.ts

Namespace / wildcard imports of @objectstack/lint: 0 hits — so there is no import * as lint whose dynamic member read a grep for the symbol could miss. The two dynamic sites read two named functions, by name, and both are still exported here (checked against the export list in packages/lint/src/index.ts, not against a comment that merely mentions the name).

Reading 2 — the gate could not see this change even if the symbol were there

scripts/build-console.sh injects exactly two framework packages into the pinned build. Those two export lines are the only ones in the whole script:

  • :171 export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG"
  • :215 export OBJECTSTACK_SPEC_DIST="$SPEC_PKG"

@objectstack/lint is not injected. The pinned tree is installed with pnpm install --frozen-lockfile --prefer-offline --prod=false against registry.npmjs.org, and objectui's own lockfile resolves '@objectstack/lint@17.2.0' — a published, immutable version. No change to this repo's packages/lint working tree can move what the Console Pin Gate builds against, at PR time or at merge time.

Verdict

Item 4 is satisfied without a sibling fix and without a pin bump: the pinned sibling does not import the removed symbol, and the gate resolves the package from npm rather than from this tree. This ships alone. .objectui-sha is untouched — that pin belongs to #17429, not to this PR.

Declared NOT MEASURED: the Console Pin Gate build itself was not executed locally — it installs objectui's full dependency tree and CI allows it 45 minutes, beyond this session's foreground budget. What is reported above is the two readings that decide item 4's question, not a substitute build. If a reviewer wants the build run anyway, its trigger is a .objectui-sha touch, which is #17429's to make.

Changeset — measured, not defaulted

@objectstack/lint ships files: ["dist", "README.md", "CHANGELOG.md"], and this change moves what dist contains: one export is gone and one class of finding is no longer emitted. A changeset is genuinely owed; skip-changeset is not applicable and no such label is applied. Graded minor with a **BREAKING** banner, per this repo's launch-window convention (major is refused by check-changeset-no-major; breaking-ness is carried by the banner plus the ADR-0087 disposition, not by the level). Disposition: not-required (no-migration-prescription) — nothing an author writes moves, and the one surface that does move has no metadata representation for objectstack migrate meta to reach.

Clause-②

Clause-②: no, as the claiming seat judged on the card. Nothing here widens a contract: a lint warning is withdrawn, packages/spec/src/** is untouched, and the rule's severity was warning throughout. One thing the seat asked to be told: this implementation removes a public export (FORM_COLSPAN_ABSOLUTE from @objectstack/lint). It adds no new export and no new rule constant — the movement is strictly subtractive — but the line is the seat's to re-judge, not mine.

New evidence for the PM: the span: 'full' half

Not filed here, and no objectui change attempted — this session's repository access is objectstack-ai/objectstack only. Two readings from this branch worth having when that half is triaged:

  1. The misrender is recommended by the spec itself, not only by the retired lint hint: packages/spec/src/ui/view.zod.ts:2494 describes span: 'full' as "whole row at any column count" and tells authors to "prefer this over the absolute colSpan". That .describe() is the source the public reference docs are generated from, so the recommendation outlives this PR by a wide margin (carried in [finding] The colSpan / span: 'full' claim #17328 measured false is still on the tree in five more carriers — including the spec .describe() that generates the public reference docs #17670).
  2. The shape of the defect is visible in the emitted class without reading objectui's source: span: 'full' and colSpan: 4 both compile to @2xl:col-span-3 — a hard-coded span of the declared maximum gated at the top breakpoint only. So span: 'full' is not "relative" in the emitted CSS at all; it is an absolute span of 3 with a single container-query gate, which is exactly why it collapses to one column of two at the modal width. Whatever the fix is, "full" needs a class at each breakpoint, not one at the last.

验收备注

  • packages/cli/test/build-warning-truncation-notice.test.ts:65,131 uses the string absolute-colspan-discouraged as synthetic fixture data for a warning-truncation test; it constructs the findings by hand and asserts nothing about this rule. Nothing there becomes false, so it is left alone rather than pulling packages/cli into this diff. Noted, not filed.
  • The (a) / (c) comment labels inside validateFormLayout now have a gap where (b) was. The labels are referenced from test comments, so they are left stable rather than renumbered. Noted, not filed.
  • packages/lint's own module docblock said "the two that matter" while three rule ids existed (form-section-group-unknown was added later and documented only on its constant). Corrected as a side effect of the rewrite, since the sentence had to be rewritten anyway.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU

…asured false and its recommendation measured worse (#17328)

The rule fired on every authored `colSpan`, `colSpan: 1` included, and asserted
that a fixed span "only aligns at one width". Browser measurement at all three
surface widths the message names shows the renderer clamps the span to the
section's column count: overflow is 0px in every configuration, including
`colSpan: 4` in a 3-column section. The hint's recommended alternative,
`span: 'full'`, compiles to the same class as `colSpan: 4` and renders
pixel-identical to authoring nothing at the modal width.

With no authored colSpan shape left that misbehaves there was nothing to
re-ground, so the rule is withdrawn rather than narrowed. `colSpan` itself is
untouched and still parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))

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

  • content/docs/releases/v12.mdx (via validateFormLayout (symbol, a top-level function))

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/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 4 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 7880c184427baec39e5cdd1166f497bf9870a652 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136 — the merge of head 70e35f4beb63f073a950cf201d2222af1f2819cc into base 7880c184427baec39e5cdd1166f497bf9870a652, 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 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136 && git checkout 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7880c184427baec39e5cdd1166f497bf9870a652 70e35f4beb63f073a950cf201d2222af1f2819cc && git checkout -B drift-repro 7880c184427baec39e5cdd1166f497bf9870a652 && git merge --no-ff 70e35f4beb63f073a950cf201d2222af1f2819cc

node scripts/docs-audit/affected-docs.mjs --json 7880c184427baec39e5cdd1166f497bf9870a652

⚠️ 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 7880c184427baec39e5cdd1166f497bf9870a652 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Sep 11, 2026
@claude
claude Bot marked this pull request as ready for review September 11, 2026 10:17
@claude
claude Bot enabled auto-merge September 11, 2026 10:17
@claude
claude Bot added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 0fb6f97 Sep 11, 2026
41 checks passed
@claude
claude Bot deleted the claude/issue-17328-colspan-rule-reground branch September 11, 2026 11:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ured citations, and fold in the two-code sdui-parser port (objectstack-ai#17645) (objectstack-ai#19398)

Fixes objectstack-ai#17429
Part of objectstack-ai#17645 — that card's deliverable (the two-code port) lands here;
the director seat ends card 17645 with the landing record after the
merge (a seat-written record, not a closing reference). The card's
take-over from the offline `domain:devx` seat is the maintainer's
instruction (「18723 os-try-charles 已下班,你也接手」), recorded on the card; the
carrier reader's cross-author row (C9) cannot be cleared without the
offline holder's own `Release:`, so the card is carried as `Part of`,
not as a closing reference.

Clause-②: yes

Corrected from `no` under ruling batch objectstack-ai#201 item 1 (maintainer 「201 A」,
record 5753263180 on objectstack-ai#17645): this PR now also carries objectstack-ai#17645's two-code
sdui-parser port, so the save gate's diagnostic set grows
(`inert-quick-add`, `member-type-mismatch`); the pin bump and the 27
re-measured citations alone moved no accept set. Both landed on this
branch: the fold-in commit `6ef8344645` and the citation-spelling fix
`fc25a5925d` (the head); the lockstep record needed no re-recording (see
the fold-in section).

(Pin-bump half, as originally declared:) Declared from the diff, not
from the card: nothing in the pin bump adds, removes or renames an
authorable key. The two `packages/spec` edits with a published face are
`.describe()` SENTENCES (`Dataset` measure `format`, `FormField.span`);
`check:authorable-surface` and `check:api-surface` both report their
artefacts current with no regeneration, and `pnpm --filter
@objectstack/spec check:generated` reports all 15 artefacts up to date
after `gen:docs` and `gen:migration-registry` ran. The same authored
metadata is accepted and refused as before, so neither arm of the closed
pair applies.

## What moved

`.objectui-sha`: `53ded82bf7a494f54e344e19099dbf00854b8694` →
`87af769e9a3ee28ace099fdd653d3ebd79fe82e2` (objectui `origin/main` tip
read 2026-09-20; `cfcc17d9dd04` — the revision PR objectstack-ai#18723 ported from —
is an ancestor of it, `git merge-base --is-ancestor` exit 0).

Produced by `scripts/bump-objectui.sh --no-commit 87af769e9a3e`, never
by hand. Everything the pin travels with:

| Artefact | How |
|---|---|
| `.changeset/console-87af769e9a3e.md` | the bump script's own digest —
584 releasing changesets of 891 added across 1156 non-merge commits,
`@objectstack/console: minor`, 98 declared-breaking entries listed |
| `sdui.manifest.json` + `scripts/sdui-manifest.record.json` | `node
scripts/gen-sdui-manifest-node.mjs`. The manifest is BYTE-IDENTICAL
(sha256 `49211fee7792…`, 57 components): the pinned commit declares the
same published `@object-ui` `17.6.0` the old pin did, so only the
provenance record moves. `check:sdui-manifest` exit 0 |
| `packages/sdui-parser/objectui-lockstep.json` | `pnpm
gen:sdui-lockstep` against a worktree parked at the new pin (`--update`
refuses off the pin). See the RED below |
| 27 `.objectui-sha` citations under `packages/spec/src/**` | each
re-MEASURED against the new pin's tree — see the table |
| `content/docs/references/ui/{dataset,view}.mdx` | `gen:docs`, from the
two corrected describes |
| `.changeset/17429-pin-bump-describe-corrections.md` |
`@objectstack/spec: patch` for those two sentences |

Lockfile / override state: `pnpm install` in this worktree produced no
`pnpm-lock.yaml` change, and the bump script writes none — the console
SPA is vendored as a built `dist/`, not as a dependency, so this bump
moves no dependency edge in this repo.

## Fold-in: the two-code sdui-parser port (ruling batch objectstack-ai#201 item 1,
maintainer 「201 A」)

Commit `6ef8344645` brings PR objectstack-ai#18723's port onto this branch
**verbatim**. Eight files, each byte-identical to the blob on
`claude/issue-17645-sdui-lockstep-port` (`git hash-object` here vs `git
rev-parse claude/issue-17645-sdui-lockstep-port:PATH` there — all eight
SAME, no hunk rewritten):

| path | blob |
|---|---|
| `packages/sdui-parser/src/kanban-quick-add.ts` | `20f9f74b9f05` |
| `packages/sdui-parser/src/validate.ts` | `156a264073f7` |
| `packages/sdui-parser/src/types.ts` | `567bc1259bea` |
| `packages/sdui-parser/src/index.ts` | `2ea35795b9cf` |
| `packages/sdui-parser/src/codegen.ts` | `76a7d654f1fd` |
| `packages/sdui-parser/src/__tests__/inert-quick-add.test.ts` |
`723cad21333a` |
| `packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts` |
`4b87cadaa067` |
| `.changeset/17645-sdui-parser-lockstep-port.md` | `7c171fddfc43` |

Deliberately NOT brought from that branch: `.objectui-sha`,
`scripts/sdui-manifest.record.json` (both already this branch's, at
`87af769e9a3e`) and `packages/sdui-parser/objectui-lockstep.json`.

### The lockstep record was neither text-merged nor re-recorded — and
needed neither

This branch already carries the record **taken at the live pin** in the
pin-bump round: `objectui.rev` = `recordedAgainstPin` =
`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`, 26 diagnostic codes
including `inert-quick-add` and `member-type-mismatch`. `--update`
re-reads objectui's side only, so a re-record here would have rewritten
the same bytes. Attempted anyway, and it REFUSED, which is the generator
working: `pnpm gen:sdui-lockstep` exit 1, `[head-off-pin] the objectui
checkout is at c2f0f48329de, and .objectui-sha names 87af769e9a3e`;
record file byte-unchanged across the attempt (`e0a6988b1c9a` before and
after). The remedy it prints — parking the sibling checkout on the pin —
was not taken: that is a git write inside a read-only checkout this
container shares with other agents, and the record it would produce is
the one already committed.

**Result — `check:sdui-lockstep` exit 0** on the head: `OK — this copy
is byte-identical to objectui@87af769e9a3e over 214 grammar line(s)
[blob 0131f27cf86d] and agrees on all 26 diagnostic code(s), across 8
non-test source(s)`. `--self-test` exit 0 alongside.
`check:sdui-manifest` exit 0 (manifest intact, sha256 `49211fee7792`,
recorded at the live pin).

### The type-check red PR objectstack-ai#18723 carried is closed by the pin, not by a
code change

The red was `check:objectui-pin-citations` inside the `Type Check ·
source gates` job. It reds exactly on the two heads of objectstack-ai#18723 that
claimed `.objectui-sha` = `cfcc17d9dd04`; on this branch, with the live
pin, the gate is exit 0 (27 asserting citations match, 48 historical
recorded and not checked, 7 anchor content assertions verified).
Reverse-verified here through `scripts/ablation-replace.mjs`: rewriting
`.objectui-sha` to `cfcc17d9dd04` turns the gate RED with five
anchor-content mismatches; restored and proven (blob back to
`035b6937d641`, `git diff HEAD` empty). So the fix is the fold direction
itself: the port lands on the branch whose citations are already
re-measured at the pin. No edit was made inside
`packages/sdui-parser/**` for it, and no `@ts-ignore` or `any` was added
anywhere; `tsc --listFiles` confirms both new test files are inside the
package program.

### Ported from `cfcc17d9`, re-verified against the LIVE pin

objectui DID move `packages/sdui-parser` between `cfcc17d9dd04` and
`87af769e9a3e`: one unrelated feature, objectui#6771's retired `body`
child-list dialect (`body-dialect.ts` +99, `index.ts` +1, `validate.ts`
+31/−7, its own test). It touches neither ported code and stamps only
`not-a-container` and `unknown-prop`, both already in the 26-code set —
which is why `26 = 26` holds at the live pin with it unported. Against
objectui at `87af769e9a3e` the whole non-comment divergence of the five
ported sources is four items, three pre-existing (`ValidationResult` vs
`ManifestValidationResult`; the `code: 'inert-quick-add'` inline literal
forced by `check:dispatcher-error-vocabulary`, pinned equal to the
constant by a test row; `provenance.ts` and `assertFullyLoaded` /
`RegistryConfigLike.lazy` absent here) and one new since `cfcc17d9`
(`body-dialect.ts` absent here — see Acceptance notes). The port
strictly REDUCES divergence in every file it touches: `validate.ts` 146
→ 73, `index.ts` 114 → 92, `codegen.ts` 30 → 3, `types.ts` 96 → 85 diff
lines vs objectui at the live pin.

### Ablations — re-run on this tree

Three, each through `scripts/ablation-replace.mjs` (anchor ×1, mutation
proven on disk by anchor count and blob change, restore proven by `blob
== HEAD` AND an empty `git diff HEAD`): (1) `checkKanbanQuickAdd` stamps
nothing — **5 failed / 8 passed** of 13; (2) the validator's member
check reads no declared arms — **7 failed / 14 passed** of 21; (3) the
codegen narrows no member type — **3 failed / 18 passed** of 21.
Baselines 13 and 21 passed; in each leg the failures are the SUBJECT
rows and every control row stays green. Both suites import
`../index.js`, a relative intra-package specifier, so no rebuild is in
the resolution path.

## Citation spelling fix (`fc25a5925d`)

CI's `Lint & Repo Gates` went red on `check-issue-citations` at
`6ef8344645`: two issue-number citations on docblock lines this branch
rewrote. `packages/spec/src/ui/view.zod.ts` — `objectstack#17328`
respelled bare `objectstack-ai#17328` (the issue exists, closed completed by merged PR
objectstack-ai#17671; the gate probes only unqualified citations, so the
this-repo-qualified spelling was judged against a board never asked for
it). `packages/spec/src/ui/component.zod.ts` — `objectstack-ai#10274` measured
**deleted** by `--probe-cause` (gone from the board and 404 on the web
endpoint, not transferred); no replacement guessed — the sentence now
states in prose that the number no longer resolves and names the live
record (that block plus `check:objectui-pin-citations`). `node
scripts/check-issue-citations.mjs` exit 0 at the head (31 judged, 0
findings); `check:generated` unchanged (TSDoc, not `.describe()`).

## Post-landing smoke evidence (triage's condition, issue comment
5690507317)

```
bash scripts/pm/os-verify-lock.sh -c 'bash scripts/build-console.sh'
  os-verify-lock: VERDICT command-exit 0 · held the lock 653s (10m53s) · waited 0s
  ✓ 8786 modules transformed.
  ✓ Bundle canary 'import/jobs' present — framework client is in the bundle.
  ✓ Console bundle carries THIS tree's @objectstack/spec, and only it.
  ✓ @objectstack/console dist ready (60228 KB) from objectui@87af769e9a3e
```

| Reading | Value |
|---|---|
| exit code | 0 |
| `packages/console/dist` size | 60228 KB — 51,702,779 bytes, 3,285
files |
| content hash | `sha256
259d2dfecaa12507fd3166a3f91c66c52581990e5ccac3899757027fa6ac108d` over
`find … -type f \| sort \| xargs sha256sum \| sha256sum` |
| provenance stamp | `packages/console/dist/.objectui-sha` =
`87af769e9a3ee28ace099fdd653d3ebd79fe82e2` |
| `pnpm check:console-sha` | exit 0 — "Console dist matches the objectui
pin" |

The console build is GREEN at the new pin. 584 commits of objectui
change came with it and none of them broke it.

## The citations — re-MEASURED, never re-pointed

27 asserting citations, all of them stale at the new pin. Each was
re-derived by reading objectui at BOTH shas and comparing content, not
by arithmetic on a line number. `pnpm --filter @objectstack/spec
check:objectui-pin-citations` exit 0; `--verify-anchors` exit 0 with 7
of 7 content assertions verified against objectui at `87af769e9`.

### Corpus-absence records (7 citations + 6 generated copies)

These cite a zero-hit count over the pinned tree, not a file:line.
Re-counted with `git grep -F -o` at both shas; the METHOD was calibrated
first by reproducing the old pin's own numbers exactly (6409 tracked
files, `useState` 2304).

| Record | Cited | Reads at `87af769e9` | Verdict |
|---|---|---|---|
| `18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts:48` |
13 exports of `plugin-lifecycle-advanced.zod.ts` + `debounceDelay` = 0 /
6409 files; controls `objectstack` 10171, `@objectstack/spec` 3479 | all
still 0 / **8228** files; controls **12966**, **4997** | unchanged
verdict, counts re-measured |
| `18.kernel-runtime-config-timeout-unit-in-key.ts:45` |
`resourceLimits.timeout` 0; controls `timeout` 832, `RuntimeConfig` 236,
`resourceLimits` 2 | 0; **1075**, **240**, 2 — and both `resourceLimits`
hits are still prose in `packages/app-shell` | unchanged |
| `18.logging-durations-unit-in-key.ts:48` | 4 names 0; controls
`useState` 2304, `timeout` **702** | 0; **2383**, **1075** | unchanged —
⚠️ but `timeout` 702 was WRONG AT THE PIN IT NAMED: that corpus reads
832. An error since written, found only by re-measuring, corrected here
|
| `18.system-metrics-jsdoc-durations-unit-in-key.ts:70` | 6 def names +
2 keys 0; controls `window` 2710, `timeout` 832, `period` 160,
`interval` 156, `metrics` 301 | all 0; **3464**, **1075**, **170**,
**170**, **324** | unchanged |
| `18.system-tracing-otel-exporter-durations-unit-in-key.ts:66` | 37
exports of `tracing.zod.ts` + 4 key names 0; `Span` 404 / `SpanSchema`
40 unrelated; controls 10171, 3479 | all 0; **486** / **53**; **12966**,
**4997** | unchanged |
| `18.tenant-schema-cache-ttl-unit-in-key.ts:31` | `schemaCacheTTL` 0;
controls `TTL` 112, `tenant` 819 | 0; **156**, **976** | unchanged |
| `migrations/registry.ts` ×6 | the generated concatenation of the six
above | regenerated with `pnpm --filter @objectstack/spec
gen:migration-registry` | follows its entries |

### Read-point records (20 citations + 7 content assertions)

| Record | Anchor, as cited | At `87af769e9` | Verdict |
|---|---|---|---|
| `data/api-methods-batch-conformance.test.ts:64` |
`ObjectGrid.tsx:3538-3553` | `:3940-3955` | MOVED, span byte-identical
(`git hash-object` `6133933199…` both sides) |
| | `hooks/useBulkExecutor.ts:284-289` | `:298-303` | MOVED, span
byte-identical (`0108334833…`); ⚠️ the FILE is no longer byte-identical
(+36/-22), so the record's old identity argument is gone and the span
was re-read |
| `ui/component.zod.ts` tab-item `icon` + `ui/component.test.ts:376` |
`containers.tsx:730-736` | `:853-859` | MOVED, 7 lines byte-identical |
| | `containers.tsx:789` (registration `items` input) | `:912` | MOVED
**and the LINE CHANGED** — it lost `label: 'Tabs'`, gained `of:
'object'` and a longer description; the member list the record cites is
unchanged |
| `ui/component.zod.ts` accordion-item `icon` + `component.test.ts:286`
| `containers.tsx:919-925` | `:1069-1075` | MOVED, byte-identical |
| | `containers.tsx:966` | `:1116` | MOVED **and the LINE CHANGED**,
same way |
| `ui/component.zod.ts` button `icon` + `component.test.ts:3489` |
`form/button.tsx:43`, `:72`, `:74` | unchanged | UNCHANGED |
| | `form/button.tsx:85-102` (inputs), `:103-107` (defaultProps) |
`:85-97`, `:98-102` | MOVED **and SHRANK** — every input dropped its
`label` and `defaultValue`; the four input names and the ABSENCE of an
`icon` input, which is what the record asserts, both hold |
| | `action/resolve-icon.ts:129-132` (`resolveIcon`) | `:322-328` |
MOVED **and REWRITTEN** — the tail no longer indexes `lucide-react`'s
`icons` record; it asks `recordIconName` and hands the pair to
`lazyIconComponent`. Accept/reject behaviour unchanged |
| | `resolve-icon.ts:117-120`, `:100-105`, `:90-92` | `:302-305`,
`:153-158`, `:143-145` | MOVED, byte-identical |
| | `lib/lazy-icon.tsx:66-92` | `:98-124` | MOVED, byte-identical |
| `ui/component.zod.ts` object-metric `icon` |
`plugin-dashboard/src/index.tsx:204` | `:237` (registration now opens at
`:227`) | MOVED **and the LINE CHANGED** — the input's `label: 'Icon
(Lucide name)'` is GONE; the key is still published, now as a bare `{
name: 'icon', type: 'string' }` |
| | `ObjectMetricWidget.tsx:142` / `:474`; `MetricWidget.tsx:312-321` /
`:373-382`; `lazy-icon.tsx:66-80` | `:174` / `:483`; `:351-360` /
`:412-421`; `:98-112` | MOVED, byte-identical |
| `ui/component.zod.ts` kanban `limit` ×2 + `component.test.ts:3360` |
`ObjectKanban.tsx:264` (`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`) |
`:676` (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)`) |
MOVED **and REWRITTEN** — objectui#9925 put a refusal in front of it (a
contract-refused cap is dropped and reported at `:553`). The pinned
fact, that `limit` lowers into the query's top-level `$top`, holds |
| | `ObjectKanban.tsx:71`; `plugin-kanban/src/index.tsx:395-398` |
`:84`; `:447-450` | MOVED, byte-identical |
| | `ObjectKanban.tsx:143` + `plugin-kanban/src/types.ts:134`
(`KanbanSchema.limit?: number`) | ⚠️ **VANISHED** | `KanbanSchema` was
RETIRED at this pin (maintainer ruling 2026-09-09) and `types.ts`
declares the member no more. Re-derived rather than re-pointed: the
published twin is `ObjectKanbanSchema`, imported at
`ObjectKanban.tsx:10`, declaring `limit?: number` at
`packages/types/src/objectql.ts:3735` |
| | `ElementDataSourceGate.tsx:236-241` | `:316-331` | MOVED **and
EXTENDED** — the branch gained objectui#9899's
presence-is-not-authorship test and a `describeDisplacedRowLimit` report
|
| `ui/component.zod.ts` kanban `quickAdd` + `component.test.ts:3427` |
`ObjectKanban.tsx:931`; `plugin-kanban/src/index.tsx:196`;
`KanbanImpl.tsx:355` / `:368` | `:1563`; `:313`; `:621` / `:634` |
MOVED, byte-identical. The absence re-counted at this pin: `quickAdd` /
`onQuickAdd` 0 each in `ObjectKanban.tsx`, against 11 (was 6) for the
sibling `onCardClick` |
| `ui/dataset.zod.ts:207` measure `format` |
`dataset-format.ts:185-198`, `date-display.ts:131-164` / `:117`, `:195`
| `:229-264` (routed at `:370`), `:198-233` / `:152`, `:260-262` | ⚠️
**SUBSTANCE REVERSED** — see below |
| `ui/view.zod.ts:2945` `FormField.span` | the widest-tier-only class
claim at `53ded82bf7` | `spanLadderFor`, `form/form.tsx:204-231` | ⚠️
**SUBSTANCE REVERSED** — see below |
| `ui/view.zod.ts` `ListMapConfig`, 7 content assertions |
`ListView.tsx:113` / `:67`; `ObjectView.tsx:1381`; `objectql.zod.ts:562`
/ `:313`; `ObjectMap.tsx:373` / `:378` | `:146` / `:85`; `:1764`;
`:1574` / `:734`; `:385` / `:390` | all 7 MOVED; ⚠️ one LOST ITS SYMBOL
— `FLAT_MAP_CONFIG_KEYS` became the total `FLAT_MAP_CONFIG_SPELLING` map
(objectui#9950), so `style` now DOES reach the flat product, as
`mapStyle`. The quote was re-read, not re-pointed |

### The two records whose SUBSTANCE the range reverses

Both are claims about what the SHIPPED renderer does, so the pin is what
dates them, and both were re-stated from the new tree. Each also carried
a published `.describe()` saying the same now-false thing, which this
bump is what falsifies — so both sentences are corrected here and
`content/docs/references/ui/{dataset,view}.mdx` regenerated from them.
That is the `@objectstack/spec: patch` changeset in this PR.

- **`Dataset` measure `format`** said "a datetime value ignores it".
objectui#8352 is inside the range: `formatMeasureDate`'s datetime arm
now SELECTS a formatter — `relative` to `formatRelativeDate` (`:260`),
`short` to `formatDateTime(v, { locale, style: 'compact' })` (`:261`),
everything else to the bare `formatDateTime(v, { locale })` (`:262`). A
date PATTERN is still ignored on both arms, which is the half that
survives.
- **`FormField.span`** said only the widest container-query tier's class
is emitted, so a `'full'` field was one cell of two at 720px (objectstack-ai#17328).
objectui#9244 / objectui#9253 (`bd09957380`) are inside the range:
`spanLadderFor` emits one clamped col-span class per multi-column tier.
⭐ The previous revision of this block asked in writing to be re-read at
the bump that absorbed it; this is that re-read. The `'auto'` half —
textarea, markdown, html, richtext and repeater — re-measured UNCHANGED.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (122 commands for this 19-path, 757-line
change set). Run locally, exit codes captured before any pipe:

```
pnpm check:sdui-lockstep                                        EXIT 0   (after the fold-in: 26 = 26 codes, grammar region byte-identical)
pnpm --filter @objectstack/spec check:objectui-pin-citations    EXIT 0   (27 asserting citations match, 7/7 anchors verified)
  … --verify-anchors                                            EXIT 0
node scripts/check-sdui-manifest.mjs                            EXIT 0
pnpm check:objectui-bump                                        EXIT 0
pnpm check:objectui-changeset                                   EXIT 0
pnpm check:console-sha                                          EXIT 0
pnpm --filter @objectstack/spec check:generated                 EXIT 0   (15/15 artefacts current)
MANIFEST=… check:react-declaration-parity                       EXIT 0   (reading unmoved: the manifest is byte-identical)
node scripts/check-adr-0087-registration.mjs --base origin/main EXIT 0
node scripts/check-changeset-no-major.mjs --base origin/main    EXIT 0
node scripts/check-empty-changeset.mjs --base origin/main       EXIT 0
pnpm lint                                                       EXIT 0   (repo-wide, eslint . --no-inline-config)
pnpm --filter @objectstack/spec test                            EXIT 0   (503 files, 14710 tests)
pnpm --filter @objectstack/spec --filter @objectstack/sdui-parser typecheck   EXIT 0
```

plus 27 further derived families, every one exit 0: `check:nul-bytes`,
`check:merge-driver`, `check:watch-hint-literal`,
`check:comment-mask-adoption`, `check:comment-mask-corpus`,
`check:doc-frontmatter`, `check:docs-section-name`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:ci-filter-parity`, `check:keyed-text-bounds`,
`check:undeclared-dep-imports`, `check:section-landing-index`,
`check:liveness`, `check:duration-unit-keys`, `check:llms-txt`,
`check:spec-changes`, `check:upgrade-guide`, `check:strictness-ledger`,
`check:export-origins`, `check:api-surface`, `check:authorable-surface`,
`check:docs`, `check:doc-anchors`, `check:docs-spec-enumerations`,
`check:quick-reference-counts`, `check:corpus-claim-drift`,
`check:changeset-gate-self-tests`, `check:pm-widening-tells`,
`check:published-files`, and both changeset self-tests.

The rest of the 122 are left to CI, which runs the whole farm.
`origin/main` moved 7 commits while this was built and touches NONE of
this diff's 19 paths, so no merge was taken; the queue's rebuilt
generation is what validates the join.

## Acceptance notes

- `check-issue-citations` blind spot (measured on objectstack-ai#17328 at
`6ef8344645`): a citation qualified with this repository's own name
(`objectstack#N` or `owner/repo#N`) is recognised as naming THIS repo
and resolved against the board, but the board is only asked for
unqualified citations and `--probe-cause`'s guard is `!cite.qualifier`
too — a live issue reads `allocated-but-absent` and NOT MEASURED under
`--probe-cause`. Not fixed here (the gate is not this PR's surface);
recorded for the next author of `scripts/check-issue-citations.mjs`.
- objectui's `packages/sdui-parser/body-dialect.ts` (objectui#6771,
landed between `cfcc17d9dd04` and the live pin) has no counterpart in
this copy: for an authored `body` key the two copies stamp different
codes (`not-a-container` under a non-container there, the bare
`unknown-prop` here), invisible to `check:sdui-lockstep`'s code-SET
comparison because both codes are already in the 26-set. Both severities
are `warning`, so `compile().ok` and the save verdict are unchanged. A
third code path the ruling does not name; carrier: the next sdui-parser
port round.
- `provenance.ts` and `assertFullyLoaded` / `RegistryConfigLike.lazy`
remain absent from this copy (zero diagnostic codes; objectstack-ai#18723's acceptance
notes already record `provenance.ts`). `pnpm gen:sdui-lockstep` is
unrunnable in this container because the shared sibling checkout sits
off the pin and the generator's remedy is a git write in someone else's
tree; it cost nothing this round and will bite the next pin bump, which
must park its own checkout.

- `.changeset/18516-span-auto-wide-types.md` (pending, not yet released)
describes the `FormField.span` describe text this PR corrects, including
"At this pin only the widest tier's class is emitted".
`check:empty-changeset` refuses any diff that MODIFIES a changeset
present at the merge base, so it is deliberately left untouched here and
reported instead.
- The `element:button` `icon` `.describe()` still says the renderer
resolves through `lucide-react`'s `icons` map. At the new pin that is
one hop indirect (`recordIconName` + `lazyIconComponent`) but the glyph
set and the accepted spellings are unchanged, so it was left as written
rather than churned.

Original author: the director seat's `os-dev` rounds (pin bump; fold-in
`6ef8344645`; citation fix `fc25a5925d`),
session_012GcsUbuqFGBibkEDMRC1eE; body maintained by the director seat,
`session_012GcsUbuqFGBibkEDMRC1eE`.


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

---------

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 size/m tests tooling

Projects

None yet

2 participants