Skip to content

fix(spec)!: refuse a padded grouping.fields[].field name at the producer instead of handing three renderers a lookup that always misses - #17498

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-17360-grouping-field-non-padded
Sep 10, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-17360-grouping-field-non-padded

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17360

Clause-②: yes — fixed by the ruling, not by the diff's size. This is a spec narrowing, and the objectui#8285 precedent says a spec narrowing declares yes. needs:contract-review rides on both carriers (already on the card; hung on this PR too), the changeset is minor with a **BREAKING** note, and the ADR-0087 disposition is registered. It needs an at-tier verdict on the head that lands — ⛔ this PR is a draft and the at-tier verdict is the domain:spec seat's to commission, so nothing here flips ready, enqueues, or arms auto-merge.

Implemented by an os-dev subagent in session session_01MkQhmuuJAVDjmeWNixwDDH, inheriting the claim and assignee from the domain:spec execution seat's Claim: comment on #17360 (no second claim posted, assignee untouched).

The ruling

Ruling C on objectui#7347, maintainer verbatim 「其他同意」, decision batch #110 item 5: refuse at the producer. GroupingFieldSchema.field gets a non-padded pattern with a refusal naming the field and the offending spelling.

Not .trim(). A trimming schema makes ' a ' and 'a' silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses. objectui's harvester trim stays as defence-in-depth; nothing is removed there. The sibling groupByField axis is explicitly not this card and is untouched.

⭐ The "reuse the vocabulary if one exists" conditional — I measured it and took the FIRST branch, which is not what I inherited

The ruling asks for a non-padded pattern "the same shape the field-name vocabulary already uses elsewhere in packages/spec if one exists — reuse it, do not invent a second".

The seat's hold notes measured that FieldNameSchema was retired under protocol 18, and concluded the conditional resolves to its second branch (define the pattern). I re-verified that against origin/main and half of it is right, but the conclusion is not — and the correction comes from the retired-def's own text:

packages/spec/src/migrations/entries/retired-defs/18.shared__FieldName.ts
  "No schema in either repo ever composed `FieldNameSchema`, so the promised brand
   safety was unobtainable. The real field-name contract is the inline
   `z.string().regex(/^[a-z_][a-z0-9_]*$/)` at `data/field.zod.ts` — untouched by
   this retirement."

So the branded schema is retired (confirmed: the retired-def entry and the semantic entry 18.branded-identifier-schemas-retired are both on origin/main), but a field-name vocabulary is alive and ubiquitous: /^[a-z_][a-z0-9_]*$/, spelled inline at 30+ sites including data/field.zod.ts:932 — the field name itself. The conditional therefore resolves to its first branch, and no protocol-18 retirement has to be re-opened to read it. ⛔ Nothing here revives the retired branded-identifier family — there is no 回翻 in this diff and the card did not have to be returned.

And then I did not adopt that vocabulary — measured, with the evidence:

grouping.fields[].field is authored as a field reference, not a machine name, and the repo proves it: packages/lint/src/validate-list-view-field-refs.test.ts:537 carries grouping: { fields: [{ field: 'owner.name' }] }, a dotted relationship path, in a test that asserts it produces no finding. /^[a-z_][a-z0-9_]*$/ refuses owner.name. Adopting it would have reddened an in-tree grouping fixture — which the acceptance criteria name as a finding to report, not a reason to widen — and would have been a different, larger narrowing than the one ruled.

⇒ The pattern is non-padded only: /^(?:\S|\S[\s\S]*\S)?$/. Exactly what the ruling asked for, nothing wider, nothing narrower. Stated in the source docblock so the next reader does not have to re-derive it.

What changed

packages/spec/src/ui/view.zod.ts — anchored by content on export const GroupingFieldSchema (it happened to still be at :853, but nothing here depends on that):

  • a module-level GROUPING_FIELD_NON_PADDED_PATTERN and a checkGroupingFieldName(raw) refusal builder, following this file's own house idiom for a dynamic per-value message (checkSubmitRedirectUrl + .superRefine), which is what lets the message name the offending spelling as well as the key;
  • field gains the .superRefine, and its .describe() states the rule.

The refusal lands at grouping.fields[N].field — the offending element's own key — and reads, for ' business_unit ':

grouping.fields[].field names the field exactly as it is stored, with no leading or trailing whitespace — received " business_unit ". The group header query is compiled from this string verbatim … Write "business_unit". (ruled 2026-09-10.)

Deliberately left alone: the empty string still parses. A blank name is already refused loudly one layer down by compileListViewGroupQuery's grouping_field_blank (400, path ['grouping','fields',N,'field']); this narrowing exists for the silent case, and refusing the blank here too would be scope the ruling did not ask for.

Also: the ADR-0087 semantic entry 18.ui-list-view-grouping-field-padded-refused, registry.ts regenerated with gen:migration-registry (⛔ no hand edit — the insertion is at :10355, inside the os-generated semantic:18 markers at :5452/:10565), the changeset, and the regenerated content/docs/references/ui/view.mdx.

⭐ Acceptance — the fixture enumeration, which is the item that bites

Harvested with the TypeScript parser, not a grep — the tool's own predicate rather than a re-implementation:

pass population result
1 — literal grouping: { fields: [ … ] } 50 occurrences across 19 files 1 refused
2 — over-approximating: every shape-exact { field, order?, collapsed? } literal in packages/** 906 literals 1 refused

Lit controls: business_unit present in the harvest; priority present (it is the GroupingFieldSchema.parse fixture that pass 1 structurally could not see, which is why pass 2 exists). Dark control: zz_no_such_grouping_field absent.

The one refused spelling is ' ' at packages/spec/src/ui/view-grouping-query.test.ts:507. It is a negative fixture handed straight to compileListViewGroupQuery with no Zod parse anywhere on its path, pinning that same grouping_field_blank refusal — so it is not a fixture that has to parse, and the producer now refuses it one layer earlier for the same reason. Zero in-tree fixtures redden, and the pattern was not widened to fit anything.

Pins added in view.test.ts: the by-name refusal at grouping.fields.1.field (asserted through ListViewSchema so the path is the real one), the six whitespace shapes, a not-a-trim arm, the 20 harvested spellings as an it.each table, and a lit/dark control test that also carries the discriminator (' owner.name' is refused) — because the accepting arms alone would pass just as well against the old bare z.string().

Verification

Reverse verification (ablation). Predicted direction: red. Removed the .superRefine and rebuilt nothing (the test resolves ./view.zod relatively — no dist on the path). On-disk proof before and after: injected-text count 2 to 0, git diff --stat HEAD non-empty. Result: 9 pins red, exactly the ruling's ones (× refuses a padded grouping field name BY NAME at grouping.fields[N].field, the six whitespace shapes, the not-a-trim arm, the lit/dark discriminator); the 20 accepting arms stayed green, which is the expected asymmetry. Restore leg via git checkout HEAD -- PATH under a trap … EXIT INT TERM with an absolute path, proven byte-identical: worktree blob 848f63fa33f3e63da434e59bcc51abcdbdfc8718 equals the HEAD blob.

run result
@objectstack/spec full suite 498 files / 13724 passed
@objectstack/spec typecheck exit 0 (source + scripts + test layer)
@objectstack/lint · @objectstack/rest · @objectstack/platform-objects 3743 · 3146 (+1 skipped) · 561, all passed
repo-wide eslint . --no-inline-config exit 0 over 6569 files, 0 errors 0 warnings
derived gate families (dispatch-gates.mjs) 108 derived, 106 run green, 2 NOT MEASURED, 0 unrun

The downstream three are run because an accept-set narrowing changes the runtime face even though the exported byte shape does not; their first run was the MODULE_NOT_FOUND class (unbuilt @objectstack/formula) and was re-run after building the closures rather than recorded as red.

NOT MEASURED, called out separately from red — neither is a finding:

  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: 33 packages have no dist/. Needs a whole-monorepo build; declared to CI.
  • pnpm check:type-check-debt — exit 3, PREREQUISITE NOT MET: @objectstack/metadata-core unbuilt, and the gate refuses to measure a different world. Its coverage half (check:type-check-coverage) ran green.

check:react-declaration-parity, run exactly as dispatched: exit 0, manifest sdui.manifest.json at the repository root (66910 bytes) — not the packages/spec path a literal $PWD gives you, which the gate itself rejects with a loud "this gate did NOT run". ⚠️ The snapshot's freshness relative to .objectui-sha remains unmeasured (#17405), so this green is about parity against the committed manifest, not about the manifest being current.

Two residuals I am not able to close from here and am therefore declaring rather than papering over: the gate derivation ran on a tree ~9 commits behind origin/main, and one file it derives from (scripts/measure-reserved-identity-name-census.mjs) changed in that range, so CI's re-derivation on the true merge base is the authority on the family set; and the 45 artifact-roster families, the 11 wide-population families and the 6 path-scheduled CI jobs are outside the derived total by construction.

⚠️ One thing the seat should know: the declared file face has a co-tenant

The claim's batch-independence line reads "measured disjoint from … every open PR at claim time". Re-checked at write time: PR #17298 (feat(spec)!: retire the type: 'page' list-view mount, open, mergeable_state: dirty) touches all three of view.zod.ts, view.test.ts and registry.ts.

I did not widen my face and I did not reorder anything. The hunks are disjoint by a wide margin — #17298's earliest view.zod.ts hunk is at :1629 against my :853, its earliest view.test.ts hunk at :3754 against my ~:2050, and registry.ts is generated and regenerates — so this merges cleanly rather than needing the serial hold that #17447 got. Recording it because the claim's reading of it was different from mine, and that is the seat's to arbitrate, not mine.


Generated by Claude Code

…ducer

WIP checkpoint before the heavy verification run.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <[email protected]>
…ducer

`GroupingFieldSchema.field` was a bare `z.string()`, so `'  business_unit  '`
was valid authored metadata. objectui's projection harvester trims the name for
`$select` while three renderers bucket rows by the raw name, so the server
answers under `business_unit`, every per-row lookup reads `undefined`, and the
view collapses into one `(empty)` group / `Uncategorized` lane holding every
record — a silent wrong answer that reads as a true statement about the data.

`field` now carries a non-padded pattern: the refusal lands at
`grouping.fields[N].field` and names the offending spelling verbatim.

Not a `.trim()` — a trimming schema makes `'  a  '` and `'a'` silently
equivalent, the consumer-tolerance direction AGENTS.md #0.1 refuses.

Deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/`:
a grouping level is authored as a field reference and a dotted relationship
path (`owner.name`) is an in-tree spelling of one.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <[email protected]>
…name

Generated projection of the `grouping.fields[].field` describe() change.
`gen:schema` + `gen:docs`; no hand edit.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 135 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 0ee32edef5909be073ed6476b7ae864640afc19fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 844eaeaea7ccb65a6a48859ac87db8960c1a048c — the merge of head db549a42c1cebf2a00eb3410e4d97427f4496d50 into base 0ee32edef5909be073ed6476b7ae864640afc19f, 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 844eaeaea7ccb65a6a48859ac87db8960c1a048c && git checkout 844eaeaea7ccb65a6a48859ac87db8960c1a048c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0ee32edef5909be073ed6476b7ae864640afc19f db549a42c1cebf2a00eb3410e4d97427f4496d50 && git checkout -B drift-repro 0ee32edef5909be073ed6476b7ae864640afc19f && git merge --no-ff db549a42c1cebf2a00eb3410e4d97427f4496d50

node scripts/docs-audit/affected-docs.mjs --json 0ee32edef5909be073ed6476b7ae864640afc19f

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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Seat verdict — clause-② contract review at tier. PASS with advisories, zero must-fix.

Head: db549a42c1cebf2a00eb3410e4d97427f4496d50, confirmed unchanged since review. Readings 2026-09-10T17:53–18:03Z.

Landing pre-checks

# check reading
at-tier verdict, clause ② fires PASS at contract-review tier, on this exact head
check-clause2-carriers --pair 17498 exit 0 — declaration readable, both carriers agree
check-governed-merges --test, final 6-path list exit 0 (not governed); lit control AGENTS.mdexit 3
checks on this head NOT YET — 33 of 34 green, Lint & Repo Gates still in_progress; combined status success

Not enqueued yet, and ④ is why. Lint & Repo Gates is precisely where check-changeset-no-major.mjs and check-adr-0087-registration.mjs actually run — the two gates the review could only answer by READING their sources. A gate job's conclusion is the thing that lets a PR through; an aggregate reading is not. This lands when that job reports completed: success.

What the review settled

  • The narrowing is exactly the one ruled. The shipped pattern is trim-equivalence: 24,000 generated cases, 0 divergences from s === s.trim(); lit control /^\S*$/ on the same harness shows 1152 divergences, so the harness can see a difference when one exists.
  • It is not a .trim(). An accepted value returns byte-identical; a padded one returns success:false rather than a rewritten value. The only .trim() in the diff is inside the message builder computing the remedy.
  • It did not adopt the machine-name grammar. owner.name parses under the shipped pattern and is refused by /^[a-z_][a-z0-9_]*$/ — that string is the one input on which the two candidate grammars disagree, and the shipped side accepts it.
  • Zero in-tree fixtures redden. One padded grouping literal exists (view-grouping-query.test.ts, { field: ' ' }) and it never reaches a Zod parse. Lit control: 4566 non-padded field: literals across 780 files, so the near-zero is a genuine absence.
  • minor is correct and major would be REFUSED. This is the one that resolves opposite to general semver instinct, and it is why the repo's own rule was the thing to read: during the launch window check-changeset-no-major.mjs refuses major outright, and the mandatory breaking-ness carriers are the BREAKING banner plus the ADR-0087 ledger disposition — both present. The registered (not already-registered) disposition is also the correct one for an id this diff adds.
  • registry.ts is a regeneration, not a hand edit. All five fields byte-identical to the entry file (reason 1750 chars), pure +49/-0 insertion, landing at the bisect-exact index in the sorted generated region.

⚠️ Advisory 1 — the PR body under-declares the #17298 co-tenancy by one file, and it is the one that matters

The body says #17298 touches "all three of view.zod.ts, view.test.ts and registry.ts". It touches four of this PR's paths. The fourth is content/docs/references/ui/view.mdx — the only overlapping path routed merge=os-regen (.gitattributes:153).

The body's disjointness argument is a line-distance argument. Its figures are correct (:1629 and :3754 both re-measured) and it is sound for the three code files. It buys nothing on the mdx path, because that driver does not text-merge — and this seat has measured it exiting 0 while silently dropping one side.

Registry adjacency, the one conflict condition .gitattributes documents, does not apply: #17298 adds zero semantic:18 entries.

This is a reason to run the survivor probe after landing, ⛔ not a reason to hold.

Advisory 2 — the accept set is trim-equivalence, including Unicode

NBSP (U+00A0), ideographic space (U+3000) and a BOM (U+FEFF) are all refused as padding. That is correct and arguably the point, but neither the changeset nor the .describe() names the class — both say only "leading or trailing whitespace". No code change requested.

Advisory 3 — one sentence in the body is imprecise in mechanism

The body says the machine-name grammar was rejected because it "would have reddened an in-tree grouping fixture". That fixture would not have reddened: validate-list-view-field-refs.ts never parses through Zod (zero code hits for safeParse / .parse( / GroupingFieldSchema; lit control validateListViewFieldRefs = 1), so it never reaches the schema.

The conclusion is still right and is independently justified — owner.name is a legitimate authored field reference and the ruling said "non-padded". The shipped ledger reason text makes the correct argument and does not repeat the error, so nothing shipped is wrong. ⚠️ Noting it because I made the same claim in my own comment on #17360 and it was imprecise there too.

Post-landing probe — validated as discriminating

Dry-run on today's origin/main (change absent) reads A=0, B=0, C=0 with all three lit controls lit (1, 2, 1). A post-landing reading of 2 / 2 / 1 proves the landing; any zero with its control lit proves a drop.

(A) git grep -c 'GROUPING_FIELD_NON_PADDED_PATTERN' origin/main -- packages/spec/src/ui/view.zod.ts        expect 2
    lit: git grep -c 'export const GroupingFieldSchema' origin/main -- packages/spec/src/ui/view.zod.ts    expect 1

(B) ⭐ THE OS-REGEN SURVIVOR CHECK
    git grep -c -F 'NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim'         origin/main -- content/docs/references/ui/view.mdx                                                 expect 2
    0 = the driver dropped this side; 1 = it half-merged. Either is a silent regression no gate reports.
    lit: git grep -c -F 'Group sort order' origin/main -- content/docs/references/ui/view.mdx              expect 2

(C) git grep -c "id: 'ui-list-view-grouping-field-padded-refused'" origin/main -- packages/spec/src/migrations/registry.ts   expect 1
    lit: git grep -c "id: 'ui-mcp-connect-agent-unknown-keys-refused'" origin/main -- .../registry.ts      expect 1

Once ④ reports green: clear needs:contract-review on both carriers (this PR and card #17360) with the four-step write, flip ready, enqueue.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 10, 2026 18:27
@os-bill
os-bill enabled auto-merge September 10, 2026 18:28
@os-bill
os-bill disabled auto-merge September 10, 2026 18:28
@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit f8e5790 Sep 10, 2026
43 checks passed
@os-bill
os-bill deleted the claude/issue-17360-grouping-field-non-padded branch September 10, 2026 18:55

os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Post-hoc contract review ADOPTED — WOULD HAVE CLEARED WITH NOTES, at tier (exact)

Posted by the domain:spec execution seat, session session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-13T08:20Z. ⚠️ This PR is already merged. Nothing below can be fixed by editing it; this is the at-tier coverage the PR never got, supplied after the fact, and it is here so the record says what was actually checked rather than what was assumed.

Why this review exists

Per #17915: a run of contract-review rounds this shift was dispatched below CONTRACT_REVIEW_TIER. Five merged PRs declared Clause-②: yes and had their needs:contract-review carriers cleared on the strength of a below-tier round. This PR is one of the five, and it is the one carrying a ! breaking change — so it was taken first.

Tier verified by the seat, from the transcript — not from the dispatch parameter

⛔ A model: dispatch parameter is configuration, not a reading. Census of the harness-stamped served-model field on every assistant turn of the round's own transcript (taken 2026-09-13T08:19:28Z):

transcript served-model census
this review round claude-fable-5-1 ×138 — and no other model, zero turns
control: an unrelated os-dev round this session claude-opus-5 ×109

CONTRACT_REVIEW_TIER read at scripts/pm/dispatch-gates.mjs:10176'claude-fable-5-1'; comparison is exact, never a family or prefix floor. ⇒ at tier. The control is there so a census returning one value everywhere cannot be mistaken for an instrument that only ever returns that value.

The answer

No defect on origin/main attributable to this change. The breaking half is exactly the declared narrowing — the round proved trim-equivalence by exhaustion (14,641 four-char strings over an 11-symbol alphabet including NBSP / U+3000 / BOM: 0 divergences from s === s.trim()), with a lit control (/^\S*$/ on the same harness: 150 divergences) proving the harness can tell shapes apart. Migration story present and mechanically registered (check-adr-0087-registration.mjs exit 0, [BREAKING+bang] registered). Not yet in any released tag, so "customers may already depend on it" is not yet true of any installable release.

Two readings worth keeping for whoever next worries about this change:

  • Stored padded views do not break boot. packages/objectql/src/registry.ts:3407-3427 — spec validation on register is "DIAGNOSTIC — deliberately not a gate ([P2] The spec contract stops at authored source — stored metadata is rehydrated unparsed, unconverted, and ungated #3903) … registered anyway"; the refusal lives on the write path (saveMetaItem → 422). That matches the ADR entry's "refused on its next authoring-path save", and it is the difference between a narrowing that bricks an existing tenant and one that merely blocks re-saving.
  • Clause-②: yes was conservative, not forced. All three new identifiers (GROUPING_FIELD_RULING, GROUPING_FIELD_NON_PADDED_PATTERN, checkGroupingFieldName) are module-private; no new payload key; check-widening-tells.mjs exit 0. Per 「收窄仍是语义面,⛔ 不触条款②」 the mechanical limbs do not fire — but 「claim 拿不准 ⇒ 按 yes」 sanctions the declaration, and the spec lane requires the at-tier review for semantic-surface cards regardless. The declaration was right in direction; the round that cleared it was not at tier. Those are two different defects and only the second one was real.

Follow-ups — filed, not left as prose

Per the seat's own rule (⛔ an acceptance note with no named carrier is not a disposition), each got a card or an explicit "承接者:无":

  1. ⚠️ Packed-tarball smoke (release candidate) has been failing on main since 00:30Z today — and the round caught the reading that makes it interesting: sha a9c6477904 succeeded at 23:54Z and failed at 00:30Z on the same sha. An advisory gate red on main is shared damage; filed.
  2. scripts/openapi-self-consistency.test.ts is red in any /tmp-rooted worktree in this container (pnpm's relative symlinks resolve through /tmp; the test symlinks node_modules into an os.tmpdir() sandbox at :174-186). Every agent working out of the scratchpad hits this and must not read it as a main defect. Control that proves it is not this PR's: the identical 8 failures sit in a log dated 2026-09-10T15:13Z, before this PR merged at 18:29Z, and 0 of the failing files mention view or grouping. Filed.
  3. The .describe() and the ADR entry say "whitespace" while the accept set is JS \S — NBSP, BOM and U+3000 are refused and undocumented. Filed to this lane.
  4. A docs tension in the PM rulebook: this PR's body cites "objectui#8285 precedent ⇒ a spec narrowing declares yes", which is a per-card director ruling, while references/lanes/spec.md:19 says narrowing does not trip clause ②. Not a defect in this PR; filed to the skills lane.

The round's NOT MEASURED section is reproduced verbatim below and is not a formality — read it before treating this review as exhaustive.


All readings are in. Final report follows.

Post-hoc contract review — PR #17498 (fix(spec)!: refuse a padded grouping.fields[].field name at the producer)

Reviewer tier self-check (first finding). scripts/pm/dispatch-gates.mjs:10176 read at 2026-09-13T07:53:41Z: export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1'; (docblock: comparison is EXACT, never a family/prefix floor). Harness-stamped served model in this transcript: claude-fable-5-1. Exact match — this review is at tier. (A model dispatch parameter was not used as the reading.)

Method facts. Merged squash commit f8e5790593e (parent 10da5c44e38, 2026-09-10 18:29Z; PR merged 18:55:32Z). PR head db549a42c1c. git merge-base head merge^ = 65ad77d5aea (07:54:43Z). git diff 65ad77d..db549a42 vs git diff f8e5790^..f8e5790: IDENTICAL ignoring index lines — 6 files, +323/−3. Today's origin/main = 225197cdbaf, 230 commits later; 13 of those touched the PR's files (incl. #17298, the co-tenant). Own detached worktree scratchpad/cr17498wt at 225197cdbaf, pnpm install --offline exit 0. Nothing written to GitHub; no branch touched; no stash; no kill.


Q1 — The breaking change, characterised exactly

Reading (tsx script against the worktree's view.zod.ts on origin/main, 07:57:28Z):

input before (bare z.string()) now
"business_unit", "owner.name", "business unit", "Business_Unit", "部门", "" accept ACCEPT, byte-identical (data.field === input true for all)
" business_unit", "business_unit ", " business_unit ", "\tbusiness_unit", "business_unit\n", " " accept REFUSE at path ["field"]; through ListViewSchema the path is grouping.fields.1.field (the offending element)
U+00A0 leading, U+FEFF leading, U+3000 trailing accept REFUSE (JS \S semantics — the accept set is exactly trim-equivalence, Unicode included)

Trim-equivalence proven: 14,641 four-char strings over an 11-symbol alphabet (incl. NBSP/ideographic/BOM): 0 divergences from s === s.trim(); lit control /^\S*$/ on the same harness: 150 divergences. Message names the spelling and the remedy: … received " business_unit " … Write "business_unit". (ruled 2026-09-10.).

Which shipped shapes break: exactly stored list views whose grouping.fields[].field has leading/trailing whitespace. In-tree: git grep -P "field:\s*(['\"])(\s[^'\"]*|[^'\"]*\s)\1" origin/main -- packages apps examples content → 6 hits: 4 are the PR's own pins in view.test.ts, 1 is the negative fixture view-grouping-query.test.ts:507 ({ field: ' ' }, handed to compileListViewGroupQuery, never Zod-parsed), 2 are unrelated message strings (metadata-protocol/protocol.ts:13301, cli/test/metadata-type-schema-gate.test.ts:114). Lit control: line 507 matched by the same regex = 1. JSON "field": " …" = 0.

Migration story present: ADR-0087 semantic entry 18.ui-list-view-grouping-field-padded-refused (replacement = re-author, no .trim(); explicitly no lossless conversion, hand prescription); node scripts/check-adr-0087-registration.mjs --base 65ad77d --head db549a42exit 0 [BREAKING+bang] registered … (new here); changeset minor + **BREAKING** + FROM→TO table; check-changeset-no-major.mjs header (lines 5/47/55) confirms the launch-window minor convention. Boot is not broken by stored padded views: packages/objectql/src/registry.ts:3407-3427 — spec validation on register is "DIAGNOSTIC — deliberately not a gate (#3903) … registered anyway", refusal lives on the write path (saveMetaItem → 422) and reads carry _diagnostics. That matches the entry's "refused on its next authoring-path save". Not yet released: git tag --contains f8e5790593e → none; newest @objectstack/[email protected]; the changeset is still pending among 271. So "customers may already depend" is not yet true of any installable release.

Verdict Q1: the breaking half is exactly the declared narrowing — no more, no less; migration story present and mechanically registered.

Q2 — Clause-② declaration

PR body column-0 line: Clause-②: yes — fixed **by the ruling**, not by the diff's size. (fixed spelling; check-clause2-carriers --pair 17498 exit 0 per the seat's record — not re-run by me).

Mechanical limbs, verbatim from the tree (07:59:09Z): .claude/skills/pm-dispatch/references/lanes/spec.md:19 「放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,不触条款②。」 and references/contract-review.md:13 「机械地板 claim 时可查树:新导出符号或已发布载荷上的新键恒 yes,锁契约复审档复核。」

Diff against them: new identifiers GROUPING_FIELD_RULING, GROUPING_FIELD_NON_PADDED_PATTERN, checkGroupingFieldName are all module-private (no export); api-surface/ui.json, declaration-map, export-origins not in the diff; no new payload key. node scripts/pm/check-widening-tells.mjs --declaration no --diff <pr diff>exit 0 (3 files judged, no tell; 3 NOT MEASURED by construction: changeset, mdx, test). Self-test 269 cases pass.

Verdict Q2: neither limb forces yes; this is a narrowing, so clause ② does not fire mechanically. The yes is the card's conservative routing, which the rulebook sanctions (contract-review.md:6 「只定…保守方向,⛔ 非终审」, :9 「claim 拿不准 ⇒ 按 yes」; widening-tells never blocks yes) and the spec lane requires the at-tier review for semantic-surface cards regardless (core-rules.md:113). Declared correctly in direction. Note: the body and card cite "objectui#8285 precedent says a spec narrowing declares yes" — that is a per-card director ruling (batch #91 on objectui#8285), not the rulebook, and lanes/spec.md:19 says the opposite; a docs-only tension for the PM lane, not a defect here.

Q3 — Scope against the ruling

Ruling, verbatim from #17360: "C — refuse at the producer. GroupingFieldSchema.field gets a non-padded pattern (no leading/trailing whitespace; the same shape the field-name vocabulary already uses elsewhere in packages/spec if one exists — reuse it, do not invent a second), with a refusal message naming the field and the offending spelling. ⛔ Not .trim() … The harvester's trim on the objectui side stays as defence-in-depth … The sibling groupByField axis … is ⛔ not this card."

  • Non-padded pattern, refusal by name + spelling: measured above ✓.
  • "Reuse if one exists": on the merge base, packages/spec/src (non-test, non-migrations) has no non-padded name vocabulary — 6 hits for \S$|^\S|.trim() ===|leading/trailing whitespace, all blank-checks or .describe() text; the only candidate (/^[a-z_][a-z0-9_]*$/) is a different accept set (refuses owner.name, which packages/lint/src/validate-list-view-field-refs.test.ts authors). No second vocabulary invented ✓.
  • Not .trim(): accepted values byte-identical, padded ones refused ✓.
  • groupByField untouched: the only groupByField line in the diff is changeset prose; view.zod.ts:1031/1309/1432 still bare z.string() on main ✓.
  • Changeset names objectui#7347 unblocking on the installable release ✓; minor + BREAKING + ADR-0087 ✓.
  • Nothing beyond the ruling: 6 files; the one file outside the claim's declared face is content/docs/references/ui/view.mdx, the regenerated projection of the .describe() (lanes/spec.md:21 「产物随源走」) — check:docs exit 0 on today's main (222 generated files in sync).

Verdict Q3: in scope, every clause honoured, nothing extra shipped.

Q4 — Defect on main today?

Through os-verify-lock.sh (slot cr17498-posthoc, waited 226 s, held 331 s; the first turn ended while waiting — re-taken, the run had survived): turbo run test on 225197cdbaf:

package result
@objectstack/lint 102 files / 3758 passed, 5 skipped
@objectstack/platform-objects 40 files / 571 passed
@objectstack/spec 473 files passed, 1 failed, 1 skipped; 13500 passed / 8 failed
@objectstack/rest cancelled by turbo on the spec failure (60 files ✓, 0 ×, no summary) → re-run alone: 191 files / 3196 passed, 1 skipped, vitest-exit=0 (08:15:35Z)

The 8 spec failures are all scripts/openapi-self-consistency.test.tsCannot find module '/tmp/node_modules/.pnpm/[email protected]/…/cli.mjs': the test symlinks node_modules into an os.tmpdir() sandbox (lines 174–186) and pnpm's relative symlinks resolve through /tmp — a container artifact. Control: the identical 8 failures sit in this scratchpad's spec-full.log dated 2026-09-10T15:13Z, before the merge (18:29Z); 0 of the failing files mention view/grouping. Light gates on main: check:migration-registry 0 (206 semantic/168 retired-key/178 retired-def, current), check:spec-changes 0, check:authorable-surface 0, check:docs 0. Merge-queue full-suite run on the merge commit itself: run 34514657802, gh-readonly-queue/main/pr-17498-…, head f8e5790, success (lit control: 290 distinct pr- branches across the 300 runs scanned). PR-side: 43 check runs, all success/skipped.

Packed-tarball smoke: publish-smoke.yml, fired by workflow_run after Release on main. Failure streak from 2026-09-13T00:30Z (a9c6477904 succeeded at 23:54Z and failed at 00:30Z on the same sha), 8 fail/cancel through 07:38Z, in_progress on 225197cdba at 07:59Z. Successes on 09-12 21:21–23:54Z on descendants of #17498 (e.g. 9c44eed4cb). So it is not introduced by this PR; whether it is chronic-environmental is suggested by the same-sha flip but its cause is NOT MEASURED (logs not read).

Verdict Q4: no defect on main attributable to this change; the merged block is intact (probes A 2/1, B 2/2 — the os-regen mdx survived #17298 — C 1/1; block diff merged→main: 0 lines; entry and changeset byte-identical; pins #17360 2, BY NAME 1, IN_TREE_… 6).

Q5 — Cross-package blast radius

  • Old describe text sites: exactly 2 files (view.zod.ts, view.mdx), both carrying the new suffix (2/2, 1/1).
  • Snapshot fixtures: 0 __snapshots__/.snap files in the repo at all.
  • authorable-surface/ui.json rows ui/GroupingField:field/order/collapsed unchanged; authorable-surface.base.json untouched; gate exit 0. Liveness ledger row (2026-06-viewschema-property-liveness.md:15) unaffected. Strictness ledger untouched (no new key). No numeric pin on semantic-entry counts found (20[3-7] near "semantic": 0 hits in tests/scripts/audits).
  • GroupingFieldSchema consumers repo-wide: 10 files, all in spec + generated/ledger projections; no other package parses ListViewSchema at runtime (0 non-test parse sites outside spec).
  • objectui (origin/main 69aa9c017, read-only): harvester trims (core/src/utils/__tests__/grouping-fields.test.ts:43-44), usableGroupingFields keeps the raw name (useGroupedData.ts:259-266), gallery reads item[f.field] raw, kanban falls back to schema.grouping.fields[0].field raw — the prose's mechanism holds today.

Verdict Q5: nothing outside packages/spec reddens; lint/rest/platform-objects green on today's main.

Q6 — Published invariants without backing

Backed: compileListViewGroupQuery groupBy column (view-grouping-query.ts:105,123); grouping_field_blank at path ['grouping','fields',index,'field'] (:341-347; status type 501|400 at :305); 「短期不考虑渐进」 present in SKILL.md (1 hit); objectui trim/raw reads (above); load-path diagnostic vs save-path 422 (registry.ts:3407-3427).

Notes (none a defect): (a) .describe()/mdx say "whitespace" while the accept set is JS \S — NBSP/BOM/U+3000 are refused, undocumented (the earlier review's Advisory 2, still unaddressed); (b) the .describe() on main asserts a consumer behaviour ("buckets every row into one empty group") — true of objectui today, stale the day objectui trims; (c) the entry's "50 occurrences / 19 files / 906 literals" is a historical TS-parser count I could not reproduce with the same predicate (grep approximation on the merge base: 43 grouping: { in 16 files) — plausible, not a contract invariant; (d) "decision batch #110 item 5 / 「其他同意」" is chat provenance — not measurable from the tree.


Overall verdict: WOULD HAVE CLEARED WITH NOTES

An at-tier review would have passed this head; the guarantee is now supplied. Follow-ups, none blocking: (1) docs-only — reconcile the "objectui#8285 precedent ⇒ narrowing declares yes" wording with lanes/spec.md:19 (carrier: PM skill seat); (2) docs-only — name the Unicode whitespace class in the .describe()/entry (carrier: domain:spec); (3) tooling — openapi-self-consistency.test.ts is red in any /tmp-rooted worktree in this container (not a main defect; carrier: devx); (4) the publish-smoke streak since 00:30Z today is unrelated to this PR but uninvestigated.

NOT MEASURED, by name: publish-smoke failure cause; the TS-parser harvest counts (50/19, 906) and the prior review's 24,000-case run (I ran my own 14,641); the REST saveMetaItem 422 by actual request; ViewMetadataSchema's union membership of ListViewSchema (not found in my grep window); check:api-surface / export-origins / declaration-map / dual-build-cjs-loads / type-check-debt gates; today's main push-CI conclusion; objectui#7347's current state and its consumer regression test (blocked on release); packages other than the four run.

维护者速读: 这条破坏性收窄(分组字段名带首尾空白即拒)与裁决完全一致,迁移登记与 changeset 齐全,今日 main 上 spec/lint/rest/platform-objects 四个包套件全绿(spec 的 8 个红是本容器 /tmp 工作树的路径伪影,合并前就有);未发现缺陷。Clause-②: yes 属保守申报(收窄本身不触条款②),方向无误。


Generated by Claude Code

os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Served-tier: 138/138 claude-fable-5-1

Verdict record in the ruled shape — post-hoc, WOULD HAVE CLEARED WITH NOTES

Re-posted by the domain:spec execution seat, session session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-13T08:26Z, to carry the Served-tier: line in the fixed spelling. The full review record is comment 5652185399 on this PR; it is unchanged and remains the substance. Per the ruling on #17915 (os-tesla, comment 5651924370, item 1), a verdict without the line is not a verdict — and a gate (item 2) cannot read the table my adoption comment put the reading in.

⚠️ This PR is already merged. This is the post-hoc at-tier coverage required by item 4 of that ruling, not a clearance — there is no carrier left to strip and ⛔ nothing here reopens or reverts anything.

field value
Served-tier 138/138 claude-fable-5-1 — census of the harness-stamped served-model field over every assistant turn of the reviewer's transcript, taken 2026-09-13T08:19:28Z
CONTRACT_REVIEW_TIER 'claude-fable-5-1', read at scripts/pm/dispatch-gates.mjs:10176
comparison EXACT — equal
below-tier turns 0
control a different transcript (an os-dev round this session) read claude-opus-5 ×109 — the probe can return non-fable, so the zero is a reading
verdict WOULD HAVE CLEARED WITH NOTES — no defect on origin/main attributable to this change
follow-ups filed #17960 (publish-smoke red on main) · #17961 (/tmp-rooted false red) · #17962 (Unicode whitespace undocumented) · #17963 (docs tension on narrowing vs clause ②)

⛔ The reading is not the dispatch model parameter, and ⛔ not a bare model-name token grepped from the transcript body — that looser probe has a documented false-positive mode that grows with adoption of this discipline (objectui seat, comment 5651573578 on #17915). This census parses each transcript line as JSON, keeps records whose type is assistant, and reads the structured message.model key.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ict declares (objectstack-ai#17990)

Fixes objectstack-ai#17915

`CONTRACT_REVIEW_TIER`'s own docblock declares the comparison against
the **served** tier EXACT — "never a family or prefix floor" — and
nothing in this tree performed it. The dispatching seat passes a model
as a dispatch parameter, and a passed parameter is configuration rather
than a reading; the docblock's other half said the re-review sub-round's
"opening self-check reads this", but a self-check is prose to the
reviewer, so a round that simply did not run it produced a verdict
indistinguishable from one that did. The census on the card measured 11
rounds served below the declared tier across four days and eleven PRs,
five of them the only clearance a merged `Clause-②: yes` pair ever had.

This lands items 1–3 of the director ruling: the verdict carries the
reading, and the strip is gated on it.

## What changed

**`.claude/skills/pm-dispatch/references/contract-review.md`** (2 lines
of new rule, net 0 at the ratchet):

- 〈复核归属与资格(席内)〉 gains, beside the 同形 definition:
  > 同形含首行 `Served-tier:`,值取复核者转录的 harness `model` 盖章;无此行不成裁决。
- 〈降档保险丝(机读)〉 turns the prose instruction into the mechanism it
described:
> 清标前 `--pair`:裁决 `Served-tier:` ≠ `CONTRACT_REVIEW_TIER` ⇒ exit 4,点名
PR、评论、读数。

**`scripts/pm/check-clause2-carriers.mjs`** — a new finding row, **C7**:

- `readServedTier` reads a `Served-tier:` key line with `Reviewed-by:`'s
own discipline (case-sensitive key, the markdown decoration a seat
writes without meaning it). Three-valued: `read` / `unreadable` /
`missing` — a carrier never started and one started and left unreadable
are different facts.
- ⭐ The value grammar is `[N/M ]tier`, **corrected from a fixture
against the live board** before shipping. Every record the ruling's own
remediation rounds are posting right now spells it control-first —
`Served-tier: 75/75 \`…\`` on PR objectstack-ai#17877, `138/138` on objectstack-ai#17498 — and the
ruling's specimen is written the same way. A reader that demanded the
tier token immediately after the colon would have refused every verdict
produced under the rule it enforces, on day one. The `N/M` is judged
rather than skipped: it is the zero-hit control the discipline already
requires, and a count that is not total is the 「回退证据」 whose own rule
text voids a verdict entire. Absent, it is vacuous — the tier alone
decides, which is the ruling's minimum, so nothing the ruling permits is
refused.
- `reviewOfRecord` carries the reading on the record it already chose,
so C6 and C7 can never disagree about which verdict a clear stands on.
- `c7ServedTierBelow` fires on **C6's population and no other** —
`needsRecordRead`'s completed state, i.e. a clearance judgment of a hung
carrier: declared `yes`, the gate bound and cleared on both carriers,
head unmoved. The refusal names the PR, the verdict comment and the
served value, at exit 4 (a limb not standing), never 3.
- The comparison is EXACT against `CONTRACT_REVIEW_TIER`, **imported**
from `dispatch-gates.mjs` so the model id keeps exactly one value site
across `scripts/pm/**` and `.claude/skills/pm-dispatch/**`. No model
identifier appears anywhere in this diff outside that import.
- 42 self-test cases in a new battery: the ruling's three (at tier
green, below tier red, line missing red), the live value shape and its
bulleted/bolded spelling, the control pins (`0/0` void, `12/133` refused
as fallback evidence, absent vacuous, a perfect control never rescuing a
below-tier value), the exactness pins (a family prefix and an extended
value both refused), and the four populations the row must never reach.

### Deliberately NOT in scope

A `Clause-②: no` pair that never carried the label is never refused for
lacking the line — it is not in the candidate shape, so the row cannot
reach it. A pair still carrying the gate owes nothing yet. An absent or
unsigned record stays C6's row alone. No PASS/FAIL token is read to
reach any of it: what produced a verdict is measurable, what it
concluded stays human.

## One deviation from the dispatched file surface — and why it is inside
it

The dispatch scoped the diff to those two files. It is those two files —
but one edit inside the checker was not foreseen and is worth reading
before approving.

`scripts/pm/check-clause2-carriers.mjs` carried a `dispatch-gates:
no-path-population` marker: "this gate reads no file in the tree at all
… so **no card's file surface can predict it**". The input half is still
exactly true. The other half stopped being true the moment C7's import
landed: a card editing `CONTRACT_REVIEW_TIER` moves the value every
clearance is judged against, so it *does* predict this gate. `pnpm
check:pm-dispatch-gates` catches this directly — its live-half case `no
family both DECLARES no path population and names paths anyway` went red
on `check:pm-clause2-carriers`, measured by ablation (base tree: hints
`[]`; with the import: hints `[".github/workflows"]`).

Keeping a declaration that stopped being true is the exact shape C7
itself exists against, so the marker is **retired**, and the comment
left in its place states the trade rather than hiding it: the import
channel contributes a followed module's *own* literals, so this family
now also inherits a `.github/workflows` lead it never opens, on a gate
whose CI step runs the self-test only. The designed narrowing
(`inherited-population`, declared by the followed module) cannot express
this case — it is per-**module**, and the same module's globs *are* a
real population for `check:pm-widening-tells`, which reads them. Filed
separately as objectstack-ai#17991 rather than worked around here.

⛔ The alternative — restating the tier in this file — is the thing that
let the declared tier and the served one drift apart in the first place,
and is refused.

## Acceptance measurements

All taken at `fed29ced`, against base `9ccc4179`.

| reading | before | after |
|---|---:|---:|
| `Served-tier` in `references/contract-review.md` | 0 | 2 |
| `Served-tier` in `check-clause2-carriers.mjs` | 0 | 26 |
| `Reviewed-by` in `references/contract-review.md` (lit control) | 1 | 1
|
| `references/contract-review.md` lines | 60 | 60 |
| `check-clause2-carriers.mjs` longest line (bytes) | 390 | 390 |

- Every line of `references/contract-review.md` is ≤ 120 bytes
(`LC_ALL=C awk 'length($0)>120'` prints nothing); the four edited lines
measure 105 / 113 / 111 / 108 B. The 120-byte register is the reference
file's; the checker keeps its own line style unchanged (471 → 502 lines
over 120 B, max unmoved at 390).
- `git diff --stat origin/main...HEAD` → exactly
`.claude/skills/pm-dispatch/references/contract-review.md` and
`scripts/pm/check-clause2-carriers.mjs`.
- Line budget, paid by density and not by re-wrap: `+1` the 同形 line,
`+1` the mechanism line replacing the 转录档位核验 prose, `-1` by folding 「⛔
自述档位不是读数」 and 「传参只是配置 ⛔ 不作达档读数」 into one clause (they are one rule: a
tier claim that is not a harness stamp is not a reading), `-1` by
dropping the 转录核验 grep recipe, whose method survives at :49
(「每场前必读服役档,读法见 `platform-readings.md`」) and in the new 同形 line. `node
scripts/pm/check-skill-line-ratchet.mjs` green at 60/60, headroom 0.

## Checks

- `pnpm check:pm-clause2-carriers` — **588 cases pass** (546 before this
PR; the C7 battery is 42 and is registered with its own floor,
`SELF_TEST_BATTERY_FLOOR` 19 → 20, preserving the roster's existing
slack).
- Gate families derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths) on the merged
head: **41 families, 41 run, 41 exit 0**. Reconciled: `--ran` reports
`41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED
zero — all 41 recorded an exit code and none of them is 3)`.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first answered `PREREQUISITE NOT MET` (exit 3, nothing measured). Built
its two declared prerequisites under the shared verify lock (`VERDICT
command-exit 0 · held the lock 141s`) and re-ran it: exit 0.
- **Reverse verification** (both legs on the committed tree, restored
and verified by blob hash): adding the `Served-tier:` requirement turned
the reference record fixture red — `⭐ the objectstack-ai#14155 specimen WITH its
record still reads CLEAN overall` failed with `["C7"]` — before the
fixture gained the line. The row can fail.
- **Live control**, `node scripts/pm/check-clause2-carriers.mjs --pair
17956`: exit **2** on this head and exit **2** on the base script for
the same PR, same sentence (`PR objectstack-ai#17956 is not open, or names no card
this file can derive`). The pair could not be formed, so nothing about
it was judged — the dispatch expected 0 for a `Clause-②: no` pair, and
that PR has since left the open set. ⛔ Not a C7 refusal, and unmoved by
this diff.
- Narrowed lint, with its three readings: universe is `eslint .` over
one `eslint.config.mjs`; `npx eslint --no-inline-config --format json
scripts/pm/check-clause2-carriers.mjs` → 1 file, 0 errors, 0 warnings;
the narrowing excludes nothing because that config "never enables
type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file" (its own line 328), so this
diff cannot move any untouched file's verdict. The `.md` is not an
eslint input.
- `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over both edited files:
no hits; `pnpm check:nul-bytes` green.

## Changeset

`skip-changeset` — `scripts/pm/**` and `.claude/**` publish nothing:
neither path is in any package's `files[]`, and both are on the fast
track (`.claude/**` and PM tooling). Label applied and read back.

## Acceptance notes

- **Filed as objectstack-ai#17991** — the import channel's over-reach, with both
readings: `inherited-population` is keyed on the followed MODULE while
fabrication is a property of the CALLER (the same module's globs are a
real population for `check:pm-widening-tells`, which reads them), and
the `if (entry.selfTest) continue;` guard built to stop exactly this
inheritance never fires for a `pnpm check:*` family, because `selfTest`
is read off the workflow argv while the `--self-test` lives in the
`package.json` script body. Searched first: objectstack-ai#11556 (already resolved,
and left alone here) is the same class by a route its remedy cannot
express; no open card covers it.
- Noted, not filed: nothing else. No other adjacent observation reached
a reproducible defect, a quotable contract violation or an authoring
trap.

## 维护者速读(草稿)

**改了什么** — 契约复核裁决从此必须带一行 `Served-tier:`,值取复核者转录里 harness 逐消息盖的 `model`
字段;`check-clause2-carriers.mjs --pair` 在判定「双载体已清」时读这一行,不等于
`CONTRACT_REVIEW_TIER` 就拒(exit 4),并点名 PR、裁决评论和读到的档位。规则文本同步落在
`references/contract-review.md`,行数 60 → 60。

**为什么改** — 常量自己的 docblock 写着「与服役档的比较是 EXACT」,而树上没有任何东西在比。派进去的 model
是配置不是读数,「开场自检」是写给复核者的散文:一轮不跑它,产出的裁决与跑了的长得一模一样。卡上实测 11 轮在档下产出裁决,其中 5
轮是已合并 `Clause-②: yes` PR 唯一的清标依据。这是本仓在别处一律拒绝的 declared ≠
enforced,落在「一次公共契约加宽到底有没有被复核过」那道门本身。

**风险与代价(含回滚)** —
失败方向是响亮的:清标被拒,不是被静默放行。代价一:规则落地前写的历史裁决没有这一行,再被判定时会红,补救是复核席把自己转录里已经盖好的读数补写成一条新记录(最新的记录优先,不动载体)。代价二:本
PR 让这个门禁第一次有了树内依赖(`CONTRACT_REVIEW_TIER` 所在文件),因此退掉了它「无路径面」的旧声明;派生因此多送一条
`.github/workflows` 的线索,是噪音、已在文件里写明,并已记入验收备注。回滚 = revert 本 PR,一次 revert
即可,门禁回到今天的状态。

**席位意见** — (留空,待席位定稿)

**你要做的** — 受管面(`.claude/**`),本 PR 恒为 draft,⛔ 不由任何 AI 席位合并、入队或挂
auto-merge。请人工确认两件事:① 规则文本那两行的措辞;② 退掉 `no-path-population`
声明这一步是否接受(替代方案是把常量在本文件再写一遍,那正是让档位漂移的那个形状,已拒)。

## Provenance

Authored by the `domain:skills` seat's dispatched executor, session
`session_01DAcomhvR9kKizeYgg89Vo8`
(https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8), on branch
`claude/issue-17915-served-tier-gates-the-strip`. Attribution is stated
here in prose because a REST edit of a PR body appends its own footer
block: the first edit of this body left two, and this revision sends
none so the appended one is the only one.


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

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…e direction is never an error (objectstack-ai#18135)

Fixes objectstack-ai#17963

## Why

`references/lanes/spec.md` answered one question — what **trips** clause
② — and was silent on the other: whether declaring `Clause-②: yes` on a
narrowing anyway is allowed. With the rulebook silent, a PR body became
the authority. The measured cause, verbatim from PR objectstack-ai#17498's body:

> This is a spec narrowing, and the objectui#8285 precedent says a spec
narrowing declares `yes`.

That is a per-card director ruling, not rulebook text, and nothing in
that body says so. Both statements are true at once — the rulebook says
what trips clause ② mechanically, the ruling said what one card should
declare — but nothing said so in one place, so the next author either
argues about which text wins or quietly stops declaring on narrowings
because the rulebook says they do not trip.

The conservative direction was already sanctioned in this same skill,
just never joined to the narrowing rule. `references/contract-review.md`
(unchanged by this PR) carries both:

> `Clause-②: yes | no` 按设计临时:只定是否必过席内契约复核的保守方向,⛔ 非终审。

> claim 拿不准 ⇒ 按 `yes` 挂标走席内契约复核;⛔ 不建全量分类学与 claim 时决策程序。

## The edit

One rule line, inserted directly under the rule it reconciles (`:19` →
new `:20`):

```text
- 收窄不触发条款②,但按 `yes` 申报恒不是错误;⛔ 个案裁决不改本行。
```

93 bytes. It carries all three ruled halves: a narrowing does not TRIP
clause ②, declaring `yes` on one is never an error, and a per-card
ruling does not change this line. No issue number in operational text,
so the provenance above lives in this PR rather than in the rule.

## Line budget — net 0, ceiling untouched

`lanes/spec.md` is pinned at 43 lines
(`scripts/pm/check-skill-line-ratchet.mjs`), zero headroom, every line ≤
120 bytes.

- **Extending `:19` in place was measured first and does not fit.**
`:19` is **108 bytes**; the budget leaves **12 bytes** — four CJK
characters — and the shortest wording carrying the new substance needs
30 or more.
- **The line is therefore paid for by deleting content, not by
re-wrapping.** `- 生成物门禁重生成提交,⛔ 手改。` leaves the same section. Its rule
survives elsewhere, in files that outrank this one:
- `AGENTS.md` → the Documentation Guardrails table (**AUTO-GEN** ❌ Never
hand-edit. Regenerated by …), § *Touched `packages/spec`? Regenerate its
artifacts BEFORE pushing* (match the change to the gate and regenerate
up front; no `check:` script regenerates anything), and Multi-agent
discipline §11 (`pre-commit` refuses a commit that still owes a
regeneration).
- this skill → `references/core-rules.md` 「入队资格是每一个检查全绿 ⛔ 不是必查子集;碰生成物的
PR 入队前先同步再重生成。」 and `references/landing-operations.md` §A.
- Arithmetic: 43 → 43 lines, +1 / −1. `LC_ALL=C awk 'length($0)>120'`
over the file prints nothing, before and after.

## Acceptance greps (both directions)

| assertion | reading |
|:---|:---|
| new clause present | `git grep -n '恒不是错误' --
.claude/skills/pm-dispatch/references/lanes/spec.md` → 0 hits on
`origin/main`, 1 hit (`:20`) here |
| lit control (the grep is not vacuous) | `git grep -n '收窄' --
…/lanes/spec.md` → `:19` still hits, plus the new `:20` |
| file surface | `git diff --stat origin/main` → `1 file changed, 1
insertion(+), 1 deletion(-)` |
| neighbours untouched | `git diff --stat a90a9f2 --
…/contract-review.md …/core-rules.md …/SKILL.md` → empty for each |
| line count | 43 → 43 |
| byte width | every edited line ≤ 120 bytes (`LC_ALL=C awk`) |

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths — the tool takes its own change
set), all run in the foreground with `$?` captured before any pipe, then
reconciled with `--ran`:

> ✓ dispatch-gates --ran: 15 derived famil(ies) accounted for — 15 run,
0 NOT-MEASURED (a DERIVED zero — all 15 recorded an exit code and none
of them is 3).

All 15 exit 0, plus `pnpm check:pm-governed-prose` (exit 0, outside the
derivation, named by the dispatch). The named ones:
`check:pm-skill-ratchet`, `check:pm-skill-id-lint`,
`check:pm-governed-prose`, `check:nul-bytes` — the ratchet's own verdict
line reads `✓ check-skill-line-ratchet: declared cross-file moves: 1,
total ceilings down 9 lines.` with no ceiling raised.

Seven of the sixteen first exited **3 (PREREQUISITE NOT MET)** in the
fresh worktree — not a verdict. Six cleared after `pnpm install`;
`check:doc-formula-expressions` also needed `pnpm exec turbo run build
--filter=@objectstack/formula --filter=@objectstack/lint`, run under
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0 · held the lock
172s · waited 0s`), and then exited 0.

## Local verification scope

The diff touches no package, so there is no dependency-closure build and
no package test or typecheck to owe. The repo-wide lint is CI's run, and
the narrowing here is an empty intersection **measured by eslint
itself**, not asserted: `pnpm exec eslint --no-inline-config --format
json` on the edited path reports one file, 0 errors, and the warning
`File ignored because no matching configuration was supplied` — every
`files:` selector in `eslint.config.mjs` names only
`.ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs`, so a Markdown file is outside
the linted population and cannot move any untouched file's verdict.

## Changeset

`skip-changeset` label, applied through the additive labels endpoint and
read back. `.claude/**` ships in no package's `files[]` — it publishes
nothing.

## Acceptance notes (out of scope, not filed)

- The card's own citations have drifted by line number: it cites
`contract-review.md:9` for 「拿不准 ⇒ 按 `yes`」, which on `a90a9f267` is
`:14` (`:9` is the 保守方向 line), and `core-rules.md:113` for the review
rule, which on `a90a9f267` is `:112` (`:113` is the dispatch-word rule).
Nothing in the tree is wrong; this is why the new line cites by content
and not by number. Successor: whoever reads this card next — no repo
change owed.
- `lanes/spec.md:25` points at 「SKILL.md 模型分档」, which is not a heading
in `SKILL.md`; the rules it means are under `### 派发` and the keyword
does occur there (`:501`), so the pointer resolves by grep, not by
section. Polish, not a defect. Successor: the next edit to either file.

## 维护者速读(草稿)

**改了什么** —— PM 技能包 spec 车道说明加一行规则:收窄不触发条款②,但按 `yes`
申报恒不是错误,个案裁决不改这条。行数配额是零余量,这行由同一节里删掉「生成物门禁重生成提交,⛔ 手改」买单 —— 那条规则在
AGENTS.md 与本技能包的 core-rules / landing-operations 里都还在,不是丢掉。

**为什么改** —— 规则只写了「什么触发条款②」,没写「不触发的能不能照样申报」。空白处被一份 PR
正文顶上去当了权威:它引一张个案裁决说收窄要申报
`yes`。两句话其实都对(一个讲机制,一个讲那张卡),但没有一处把它们放在一起,下一个作者就得在两份文本之间二选一,或者干脆不再申报。一行话把口子合上,保守方向照旧许可,成本是零
—— 语义面卡本来就按契约复审档施工。

**风险与代价(含回滚)** —— 代价是删掉的那条生成物提醒不再出现在 spec 车道页,读者要去 AGENTS.md
看(那份文件本来就要求全文读,且冲突时它为准)。风险低:纯说明文字,无代码、无发布面、无生成物。回滚 = revert 这一个
commit,文件回到 43 行原样。

**席位意见** ——

**你要做的** —— 受管面(`.claude/**`),按 Prime Directive objectstack-ai#14 由维护者人工合并:本 PR 保持
draft,未挂 ready、未入队、未开
auto-merge。确认那一行读起来就是你要的裁决,以及「删这条买那条」的取舍你接受,然后人工合并。

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

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

Co-authored-by: Claude <[email protected]>
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