Skip to content

fix(spec)!: a dashboard that declares two global filters under one name is refused - #29162

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-3p7kpx
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-3p7kpx

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Requested by Steve · project thread
Fixes #29023

Clause-②: no (narrowing)
Metadata-protocol: narrowed

维护者速读

  • 改了什么:仪表盘的 globalFilters 里两个筛选器同名(无 name 时按 field 算)现在在所有解析入口被拒收,保存门答 422 INVALID_METADATA,报错落在第二个的 name 上并点名两个下标和改法。
  • 为什么改:同名时看板只画一个控件,输入的值却去筛另一个定义的字段,卡片数字悄悄错了;卡里复现:值 north 落到 status 上,卡片读 0。
  • 风险与回滚:收窄,带重复名的仪表盘再保存会被拒;仓内作者写的仪表盘重复数为 0。已存的行照常加载,_diagnostics 标 valid: false。回滚即还原本 PR。
  • 先看:packages/spec/src/ui/duplicate-global-filter-names.ts(判定与报错文字)、packages/spec/src/ui/dashboard.zod.ts(挂在数组上的 superRefine)、迁移条目 18.ui-dashboard-global-filter-duplicate-name-refused.ts。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/ui/dashboard-global-filter-duplicate-name.test.ts,预期 11 项全绿,其中第三项断言报错位置为 globalFilters[1].name。
  • 需要你做的:无,席位落地(落地前走一次契约复核)。

What changes

  • DashboardSchema.globalFilters refuses two or more entries under one filter key. The key is the entry's name, or its field when it has no name (the same derivation as globalFilterKey in system/i18n-resolver.ts, which the dashboard schema cannot import without a cycle through ui/component.zod.ts; a test holds the two equal). One custom issue per repeated key, at the SECOND declaration's name (or its field when that entry is keyed by field), naming the key, every index and the remedy: rename all but one (and every widget filterBindings key that meant a renamed one) or delete the copy.
  • The predicate and message live in packages/spec/src/ui/duplicate-global-filter-names.ts, the shape of data/duplicate-action-names.ts. Neither the predicate nor the refinement is exported, so the public surface does not move.
  • The refinement is attached to the ARRAY, so DashboardSchema.shape.globalFilters carries it into any mirror that spreads the shape (pinned).
  • Semantic migration entry ui-dashboard-global-filter-duplicate-name-refused (step 18), modelled on ui-dashboard-filter-binding-unknown-refused: refused with a prescription, no conversion. Which definition keeps the name is the author's call, and an automatic rename would silently unbind every widget that meant the renamed one.
  • Ledger: dropped-refinements.baseline.json gains the five sites the array refinement adds (ui/Dashboard globalFilters and the four installed-package manifest projections; droppedRefinementSites 983 to 988). JSON Schema cannot state unique-by-key, so the rule reaches the runtime and not the file, as the ledger records.
  • Regenerated: content/docs/references/ui/dashboard.mdx (the globalFilters describe).
  • Changeset .changeset/29023-dashboard-global-filter-duplicate-name.md, @objectstack/spec minor, adr-0087: registered ui-dashboard-global-filter-duplicate-name-refused.
  • No forced test edits: no existing test changed.

Tests

  • New packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts, 11 cases: two CONTROL accepts (distinct names; distinct names over the same field), the card's reproduction refused at ['globalFilters', 1, 'name'] with the message's first sentence pinned, unnamed pair located at .field, name colliding with another entry's unnamed field, empty name keyed by field, three declarations (one issue at the second, every index listed), two repeated keys (one issue each), the rule travelling with .shape.globalFilters, parity with globalFilterKey, and the predicate leaving malformed input to the schema.
  • Ablation (spec tests import src, so no build leg): deleting the .superRefine(refuseDuplicateGlobalFilterNames) line through scripts/ablation-replace.mjs --delete (anchor 1 to 0) turns 7 of 11 red, the seven refusal cases; the four that stay green are the two controls and the two predicate-only cases. Restore verified: blob equals HEAD, git diff HEAD empty. A first attempt with an empty --replacement was refused by the tool (no mutation landed) and is not counted.
  • Save door, measured with a scratch saveMetaItem test over the stub engine of protocol.draft-publish-verdict.test.ts (not committed): the card's body answers 422 INVALID_METADATA with one issue at globalFilters.1.name in default, publish and draft mode, and stores nothing; the distinct-name control saves. A stored row that repeats a key still loads through getMetaItem, with _diagnostics valid: false carrying the refusal.

Round 1 (seat): Metadata-protocol amended from semantic to narrowed on the claim and in the changeset (a refused value is narrowed); origin/main merged at 0d6d4061 through scripts/pm/os-regen-merge.sh (no conflict; the branch's regenerated dashboard.mdx kept, step 4 rebuilt and check:generated reads 14/14 up to date). Readings below are on the merged head 6172f51e (local, shared box; counts only):

Package suites (contract face: spec plus the two consumers the delta names, plus dogfood for the narrowing arm)
  • pnpm --filter @objectstack/spec test: Test Files 791 passed (791), Tests 23378 passed, 1 todo. Lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/lint test (widget bindings consumer): Test Files 189 passed (189), Tests 7830 passed. VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol test (the draft-publish verdict test that names the binding refusal): Test Files 298 passed, 4 skipped (302), Tests 22069 passed, 26 skipped. VERDICT command-exit 0.
  • pnpm --filter @objectstack/dogfood test (closure built first; no shard, no path): Test Files 387 passed, 3 skipped (390), Tests 3204 passed, 28 skipped. VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec typecheck (on 8331c756, before the merge, which moved no file of this diff): exit 0; the test layer (tsconfig.test.json) compiles the new test file with no new debt.

Acceptance notes

  • Blast radius, measured on this tree: 95 globalFilters arrays across 136 files (examples, packages, skills, docs, ui), 14 with two or more entries, zero repeat a key in authored metadata. One hit is a ui renderer unit test (ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts, "lets duplicate names win last") that feeds resolveDashboardFilterDefs directly and never parses through the spec; it stays green and is outside this card (ui/** not touched). HotCRM: NOT MEASURED (outside this session's repository scope).
  • packages/lint validate-widget-bindings.ts keys filters by name with later-wins semantics; once the schema refuses duplicates it needs no change, and no gate asked for one.
  • The reserved name dateRange: a globalFilters entry named dateRange on a dashboard that also declares dateRange is not refused by this rule (the lint resolver lets the later definition win). Out of scope here; reported to the seat.
  • os migrate meta --stored does not list stored dashboards this refuses: the v18 stored-row listing (conversions/refused-shapes.ts) carries a detector for the action-name sibling but none was added here (outside the claim's file surface). Reported to the seat as an option.

Verification

Derived gates: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 6172f51e (7 paths vs merge base 0d6d4061): 106 commands, every one exit 0 on the first pass.
  • (On the pre-merge head 8331c756, three first exited 3 on prerequisites, a shallow clone and missing built output, and were re-run green; this round built those packages up front.)
  • dispatch-gates --ran: 106 derived famil(ies) accounted for, 106 run, 0 NOT-MEASURED.
  • Changeset level axis, offline (check-changeset-no-major.mjs --event with this body's two lines, base 0d6d4061): exit 0, Clause-②: no (narrowing) with Metadata-protocol: narrowed accepted.
  • CI families: per the PR's check-runs.

Generated by Claude Code

DashboardSchema.globalFilters now declares each filter key (name, else
field) once. One custom issue at the second declaration's name (or field)
names the key, every index and the fix. Semantic migration entry
ui-dashboard-global-filter-duplicate-name-refused carries the prescription;
no conversion renames on the author's behalf.

Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr
Co-authored-by: Claude <noreply@anthropic.com>
…ate global filter name refusal

dropped-refinements.baseline.json gains the five sites the array refinement
adds (ui/Dashboard and the four installed-package manifests); the dashboard
reference page carries the new describe.

Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr
Co-authored-by: Claude <noreply@anthropic.com>
…ed row does

Measured at the save door: a stored dashboard that repeats a filter key
still loads with _diagnostics valid:false carrying the refusal; its next
save, draft or publish, answers 422 INVALID_METADATA.

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

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/quick-reference.mdx (via DashboardSchema (symbol, a top-level const))

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

  • content/docs/releases/v15.mdx (via DashboardSchema (symbol, a top-level const))

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

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

Coarse fallback — 168 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 0d6d4061f8083f158f3beade5928340b47e129a2 → packageMentionDocs.

Which tree this was computed on

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

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

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

…etadata-protocol narrowed

A refused value is narrowed in the AGENTS.md taxonomy; the claim is
amended to match. Level, BREAKING banner and ADR-0087 marker unchanged.

Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Head-sha: 6172f51e5673a0b43faeb76af11d53317ab173e3 · read 2026-11-02T09:37Z

维护者速读

  • 改了什么:一个仪表盘的 globalFilters 里,同一个筛选名(没写 name 时按 field)只能出现一次;重复的会在保存、校验、构建时被拒,报错指出名字和所有位置,并说怎么改。
  • 为什么改:重复时看板只画一个筛选控件,输入的值却去筛另一条定义的字段,卡片数字悄悄出错(dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023)。
  • 风险与回滚:这是收窄(BREAKING):以前能存的重复写法现在被拒。仓内 95 个 globalFilters 里重复的是 0 个。已存的重复行仍能加载,诊断里带拒绝原因。回滚即撤销本 PR。
  • 先看:packages/spec/src/ui/duplicate-global-filter-names.ts 与 dashboard.zod.ts 里 globalFilters 的 superRefine。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/ui/dashboard-global-filter-duplicate-name.test.ts(11 个测试)。
  • 需要你做的:无,席位落地。

席位意见:可落地,等契约复审。

席位核对 — ACCEPT · domain:spec#1 · head 6172f51e

  1. Shape: draft, base main, Fixes #29023, Clause-②: no (narrowing) / Metadata-protocol: narrowed at line start in the body and the changeset (spec minor, !, BREAKING banner, migration, one ADR-0087 registered marker). Within claim 6571453894's surface as amended (the claim's semantic corrected to narrowed by the seat; AGENTS.md checklist 3: "a value refused … is narrowed"). Not governed.
  2. Diff, read on the head: one predicate module, ui/duplicate-global-filter-names.ts: key = name, else field, the same derivation as globalFilterKey (held equal by a test). It is attached to the globalFilters array, so DashboardSchema.shape.globalFilters carries it into mirrors. There is one located custom issue per repeated key, at the second declaration's name (or field), naming the key, every index and the fix. Nothing new is exported. D3 semantic entry 18.ui-dashboard-global-filter-duplicate-name-refused (refused with a prescription, no conversion). dropped-refinements.baseline.json gains the new refinement's mirror sites. The reference dashboard.mdx describe is regenerated.
  3. Door measured (dev): PUT /api/v1/meta/dashboard answers 422 INVALID_METADATA at globalFilters.1.name in default, publish and draft mode, and stores nothing. A stored row that repeats a key still loads, with _diagnostics valid:false carrying the refusal. Blast radius: 0 of 95 authored globalFilters arrays repeat a key.
  4. Round 1 → 2: Metadata-protocol: narrowed in the changeset and the body. Main merged at 0d6d4061 via os-regen-merge.sh with no conflict, and check:generated is 14/14. The dev patched the body to this head via issue_patch: the signature is line 1, the bytes read back identical, and the offline --event and partof checks exit 0.
  5. Readings (dev, on 6172f51e): spec 791/791 files; lint 189/189; metadata-protocol 298 passed + 4 skipped; dogfood 387 passed + 3 skipped (VERDICT 0). dispatch-gates: 106 derived, 106 run, all exit 0, 0 NOT-MEASURED. adr-0087-registration exit 0. Ablation: deleting the refinement turns 7/11 red, and the 4 controls stay green. Check-runs on 6172f51e at this reading: 59 success, 10 skipped, 0 failed.
  6. Open questions, seat's calls: Q1 = B (narrowed, done). Q2 = A and Q3 = A: the stored-row detector in REFUSED_STORED_SHAPES and the metadata-protocol door pin go to follow-up finding spec(dashboard): the duplicate globalFilters key refusal (#29023) has no stored-row detector and no save-door pin #29212. The out-of-scope dateRange collision is finding spec(dashboard): a globalFilters entry named dateRange parses and silently replaces the built-in date range's binding #29213. The ui renderer test that pins "duplicate names win last" is noted, not filed: it is now unreachable through spec and harmless.
  7. Landing: one CONTRACT_REVIEW_TIER review (a narrowing), handed to triage (line below). Then the seat readies and queues.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (maintainer standing word 2026-10-28T15:04Z: the Fable quota is short, so contract reviews wait for triage) · head 6172f51

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 6172f51e5673a0b43faeb76af11d53317ab173e3 · 2026-11-02T09:39Z

Taken on the lane's Request: contract-review doorbell (#28646, 6573874032). A narrowing (DashboardSchema.globalFilters refuses a duplicate filter key) with a semantic D3 entry, at CONTRACT_REVIEW_TIER.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6172f51e5673a0b43faeb76af11d53317ab173e3
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts, packages/spec/src/ui/dashboard.zod.ts, packages/spec/src/ui/duplicate-global-filter-names.ts + packages/spec/src/system/i18n-resolver.ts (globalFilterKey), packages/lint/src/validate-widget-bindings.ts (dashboardFilterDefs, dashboard-filter-binding-unknown), ui/packages/core/src/utils/dashboard-filters.ts (resolveDashboardFilterDefs), packages/spec/src/shared/strict-object.ts (aliases), packages/spec/src/conversions/refused-shapes.ts + refused-shapes.test.ts (REFUSED_STORED_SHAPES), packages/metadata-protocol/src/protocol.ts (loadMetaFromDb, [metadata_spec_invalid]), packages/spec/src/migrations/types.ts + entries/README.md + registry.ts.template, packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts and 18.object-duplicate-action-name-refused.ts (the two siblings), docs/adr/0087-metadata-protocol-upgrade-contract.md (the stored-metadata addendum, #3903), AGENTS.md post-task checklist 3, scripts/check-changeset-no-major.mjs

Inputs read: card #29023 body and all four comments (claim 6571453894 as amended, triage, dev reports 6572629846 and 6573734445); PR #29162 body, file list and net diff vs main; the three PR comments; check-runs on the head re-fetched during this review: 74 runs, 60 success, 14 skipped, 0 failed, 0 pending (Check Changeset completed green after the triage fetch). The lane's ACCEPT (6573856745) and the dev reports were read as claims and checked against the tree, never adopted.

① Derived judgments

  • Accept-set narrowing of DashboardSchema.globalFilters — right. z.array(GlobalFilterSchema).superRefine(refuseDuplicateGlobalFilterNames); one custom issue per repeated key at [indexes[1], secondKey] relative to the array, i.e. globalFilters[j].name (or .field when the second entry is keyed by field). The refusal reaches every door that parses a dashboard (defineStack, os validate, os build, the save door) because it is the schema, not a lint rule — the correct placement for a defect the card measured at PUT /api/v1/meta/dashboard (the sibling dashboard-filter-binding-unknown is lint-only and the card's "same class" is answered at the stronger door).
  • Key derivation is the one the consumers use — right, verified in the live tree. Spec filterKeyOf: non-empty string name, else non-empty string field. globalFilterKey (system/i18n-resolver.ts): identical, held equal by the new parity test. Lint dashboardFilterDefs (validate-widget-bindings.ts): typeof f.name === 'string' && f.name ? f.name : f.field. Renderer resolveDashboardFilterDefs (ui/packages/core): f.name || f.field, byName.set later-wins, which is exactly the card's observed defect (second definition's field). An empty name falls back to field in all four. So the rule refuses exactly the boards the renderer collapses into one control, and no distinct-key board is refused.
  • No alias bypass — right. strictObject aliases (key, variable → name) are rename guidance raised from the unrecognized_keys path, not a lift into the output, so { key: 'r' } is refused as an unknown key and never reaches the predicate as an unnamed entry.
  • No public-surface move — right. duplicateGlobalFilterNames is exported from its module but ui/index.ts does not re-export the module (only ./dashboard.zod); refuseDuplicateGlobalFilterNames is module-private. api-surface unchanged in the diff, consistent.
  • Array-level attachment — right and pinned (.shape.globalFilters test). On main nothing spreads DashboardSchema.shape for globalFilters (only ui/packages/core reads .shape.dateRange), so this is defensive rather than load-bearing today; it costs nothing and the baseline ledger records it.
  • dropped-refinements.baseline.json +5 sites (983 → 988) — right: the new array refinement at ui/Dashboard.globalFilters plus the four installed-package manifest projections of the same slot; JSON Schema cannot state unique-by-key. Generated; check:generated is in the green check-runs. Not reviewed under ④.
  • D3 entry registered under step 18 — right. registry.ts is generated by readdirSync over entries/semantic/ (build-migration-registry.ts), so the new file self-registers; MIGRATIONS_BY_MAJOR[18].semantic carries it, which is what check-adr-0087-registration registered resolves against. No STEP18_RATIONALE fragment is owed: neither step-18 sibling (binding-unknown, duplicate-action-name) added one; the template fragments belong to retirements with conversions.
  • Consumers left unchanged — right. packages/lint later-wins keying and the ui renderer's later-wins are now unreachable through a parsed dashboard; both still apply to a stored invalid row (see ④), which is the ADR-0087 "availability over purity" posture, not a gap in this diff.
  • Regenerated dashboard.mdx describe — right; the lane's TIER_DEFAULT remainder, consistent with the new .describe().

② Semver level

.changeset/29023-dashboard-global-filter-duplicate-name.md: '@objectstack/spec': minor, title fix(spec)!:, Clause-②: no (narrowing), Metadata-protocol: narrowed, a **BREAKING** banner, Before/Now/Not changed/Fix/Stored rows migration prose, and exactly one marker adr-0087: registered ui-dashboard-global-filter-duplicate-name-refused. AGENTS.md checklist 3: "a value refused or a requirement tightened is narrowed"; a kind takes at least minor; (narrowing) is BREAKING and must carry its migration and one ADR-0087 disposition — all present. check-changeset-no-major.mjs reads narrowed beside no (narrowing) as the breaking signal shipping minor in the launch window (its own self-test names narrowed beside a line without the (narrowing) arm a contradiction, so the pairing here is the only consistent one). The PR body as fetched reads Clause-②: no (narrowing) / Metadata-protocol: narrowed at line start, matching the changeset (the dev report's "body still reads semantic" is superseded; the body on the head reads narrowed). Check Changeset on the head: success. Level, arm and kind: right.

③ Boundary flags

  • Dev Q1 (semantic vs narrowed): answered — narrowed in the changeset and the body; the seat amended the claim. Closed.
  • Dev Q2 (no os migrate meta --stored detector): answered as follow-up — spec(dashboard): the duplicate globalFilters key refusal (#29023) has no stored-row detector and no save-door pin #29212 is open and names it. refused-shapes.test.ts pins REFUSED_STORED_SHAPES as a fixed seven-id list and requires only that each listed id be a D3 entry of its step; it does not require every refused D3 entry to have a detector (the closer sibling, binding-unknown, has none). Not a gate on this PR. Escalation recorded under ④ as should-fix prose in the entry.
  • Dev Q3 (no metadata-protocol door pin): follow-up spec(dashboard): the duplicate globalFilters key refusal (#29023) has no stored-row detector and no save-door pin #29212. The spec parse is the door's rule; protocol.draft-publish-verdict already pins the dashboard save door's parse path. Acceptable.
  • Out-of-scope dateRange collision: spec(dashboard): a globalFilters entry named dateRange parses and silently replaces the built-in date range's binding #29213 open. The predicate's header states the exclusion explicitly. Pre-existing, not widened by this diff.
  • Deviation "main not merged before pr_create": resolved in round 1 (merge 6172f51e on 0d6d4061, no conflict, 14/14 generated artifacts current per check:generated in CI).
  • Deviation "spec typecheck not re-run on the merged head": the merge moved none of the seven files, and the head's typecheck check-run is green. Accepted.
  • HotCRM blast radius: NOT MEASURED, stated by the dev. The remedy is documented in the changeset and the D3 entry, and a HotCRM dashboard repeating a key is refused with a located prescription at its next build, not silently changed. Recorded, not blocking; the HotCRM lane should grep globalFilters on its pin move.
  • ui renderer test "lets duplicate names win last": noted, not filed by the seat; it still describes the renderer's behaviour for stored invalid rows, so it is correct to keep.

④ Design judgment

packages/spec/src/ui/duplicate-global-filter-names.ts — holds. Tried to break it: (a) { name: '', field: 'region' } keys by field, as globalFilterKey does — pinned. (b) A name equal to another entry's unnamed field is one key — pinned, and it is one key at the renderer too. (c) Non-array, non-object and keyless entries return [] and are left to the schema's own issues — right; it must not duplicate the element parse's complaints. (d) Three declarations give one issue at the second with every index listed — right, and the located index is the first point an author can act on. (e) field: z.string() admits '', so [{ name: 'r', field: '' }, { name: 'r', field: 'status' }] is refused by the spec while the renderer would have skipped the first (if (!raw?.field) continue) and drawn one control: the divergence is in the refusing direction only — a colliding board never passes — and the first entry is broken anyway. (f) The message names the key, every index, why it matters, the field fallback when relevant, and the remedy including the filterBindings rename; it carries no tracker number (pinned, check:doc-authoring). Should-fix, not blocking: the predicate also publishes page.NAME, so the message could name the page.NAME variable as a third thing a rename moves — the D3 entry's reason does, the runtime message does not.

packages/spec/src/ui/dashboard.zod.ts — holds against the card's reproduction and its first consumer. The refinement sits on the array before .optional(), so undefined parses and the rule travels with .shape.globalFilters (pinned). Path is array-relative, so the issue lands at ['globalFilters', j, 'name'] on a dashboard parse and at globalFilters.1.name dotted in _diagnostics and the 422 body, which is where Studio's inline error reads it. The .describe() and the JSDoc on globalFilters and the private refinement name the key derivation and the consumer (filterBindings). The filterBindings describe (lint-only refusal) and GlobalFilterSchema.name's "defaults to field" prose are unchanged and remain true. No must-fix.

packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts — holds against SemanticMigration, the entries README and ADR-0087's stored-row rules, with should-fix prose. id, surface (dotted, dashboard.globalFilters, with an example that spells a shape, not a call), replacement, reason, acceptanceCriteria present; no conversionIds (right: no D2 conversion, and the entry says why none is lossless — which definition keeps the name and which widgets meant it are the author's facts); no relevantWhen (right: omitting it keeps the entry always listed, and the enumeration test pins only entries that carry one). The stored-row posture it states is the tree's: loadMetaFromDb counts the row invalid, warns [metadata_spec_invalid] and registers it anyway (ADR-0087 stored-metadata addendum, #3903: "refusal at boot would unhook live tables and make the row unfixable in Studio"); every read carries _diagnostics valid: false with this located issue (the schema is the parse computeMetadataDiagnostics runs, unlike the lint-only sibling); the next save, draft or publish, is refused 422. So: not refused at boot, refused at the next save — the entry says exactly this.
Should-fix (follow-up prose, not blocking): (1) the entry says the stored board "still loads" but not that it still renders as before — one control, later definition's field, the wrong tile — until fixed; the binding-unknown sibling spells that out and an operator reading only the guide should not infer the refusal corrects the number. (2) It does not tell the operator how to find affected stored dashboards: os migrate meta --stored does not list them (no REFUSED_STORED_SHAPES detector, #29212), so until that lands the enumeration paths are the boot log's [metadata_spec_invalid] stored dashboard/NAME line and Studio's valid: false badge; name them, and name #29212's detector id as the future --stored row. (3) acceptanceCriteria quotes the message without the single quotes the runtime puts around the key; harmless, align it.

Security: nothing security-relevant. The refusal echoes the author's own filter key inside a located issue, the same posture as object-duplicate-action-name-refused; no new door, no new read.

Must-fix: none. Should-fix: the three D3 prose points above and the page.NAME mention in the runtime message, all suitable for the #29212 follow-up or a docs-only touch.

Implemented-by: claude/project-thread-3p7kpx
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 6172f51 · 2026-11-02T10:44Z

Triage ruling 6575141496 returned contract review to the lane, so the spec seat reviews this head itself at CONTRACT_REVIEW_TIER. The earlier handoff line in the seat record is withdrawn for this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6172f51e5673a0b43faeb76af11d53317ab173e3
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts, packages/spec/src/ui/dashboard.zod.ts, packages/spec/src/ui/duplicate-global-filter-names.ts + packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts, packages/spec/dropped-refinements.baseline.json (the narrowing floor: every non-prose path of the diff), packages/spec/src/system/i18n-resolver.ts (globalFilterKey), packages/lint/src/validate-widget-bindings.ts (dashboardFilterDefs), ui/packages/core/src/utils/dashboard-filters.ts (resolveDashboardFilterDefs), ui/packages/types/src/zod/complex.zod.ts (DashboardComponentSchema, DASHBOARD_SPEC_EXCLUDED), ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts (the Studio dashboard gate), packages/spec/src/kernel/metadata-type-schemas.ts (the save door's schema), packages/spec/src/data/duplicate-action-names.ts, packages/spec/src/conversions/refused-shapes.ts (REFUSED_STORED_SHAPES), packages/spec/src/migrations/entries/semantic/18.object-duplicate-action-name-refused.ts, packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts, packages/spec/src/migrations/types.ts (SemanticMigration), packages/spec/src/shared/refinement-projection.ts (the projectable arms)

Read: card #29023 (body and every comment, the claim 6571453894 with its amendment, triage 6571468780, the two os-dev-reports 6572629846 and 6573734445), PR #29162 (body, 7-path file list, net diff against main at this head) and the check-runs on 6172f51e. The dispatching seat's own conclusions were not read as inputs.

① Derived judgments

  • Accept set, DashboardSchema.globalFilters (ui/Dashboard, and manifest.dashboards[] through the same schema). NARROWED: an array in which two or more entries resolve to one filter key is refused; the key is the entry's non-empty name, else its non-empty field. Judged RIGHT. The key derivation is the one every consumer already uses: globalFilterKey in system/i18n-resolver.ts (name if non-empty string, else field if non-empty string, else undefined — held equal by the parity test), dashboardFilterDefs in packages/lint (typeof f.name === 'string' && f.name ? f.name : f.field) and resolveDashboardFilterDefs in @object-ui/core (f.name || f.field). All three key the SAME way, so an empty name keyed by field is not a spec-only reading. Each of those consumers keeps ONE definition per key (later wins), so no pair this refuses ever rendered as two controls: the refusal takes nothing a consumer delivered. Two distinct names over one field, and one name on two dashboards, stay accepted (the controls pin it).
  • Issue location and count. One custom issue per repeated key, at the SECOND declaration's name (or its field when that entry is keyed by field), every index named in the message. Judged RIGHT: it mirrors object-duplicate-action-name-refused (located at actions[j]), and locating at the second entry's own key is what the save door surfaces as globalFilters.1.name. Message carries no tracker number (pinned).
  • Public surface. UNCHANGED, judged right. duplicate-global-filter-names.ts exports within the package only; neither ui/index.ts nor the root barrel re-exports it, and refuseDuplicateGlobalFilterNames is module-private, so check:api-surface moves nothing. (The cost of that choice is the ④ should-fix on the objectui mirror.)
  • JSON Schema projection. dropped-refinements.baseline.json grows by the five sites one array-level custom check adds (ui/Dashboard.globalFilters and its four installed-package-manifest projections; 983 to 988). Judged RIGHT: refinement-projection.ts has no arm for unique-by-derived-key and JSON Schema's uniqueItems is whole-item, so the ledger row is the honest statement; the gate that holds the ledger byte-exact is in the green TypeScript Type Check job.
  • ADR-0087 D3 entry, step 18. ui-dashboard-global-filter-duplicate-name-refused, refused with a prescription, no conversion, no conversionIds, no relevantWhen. Judged RIGHT: spec is at 18.2.0 so step 18 is the open step; the two siblings it names omit relevantWhen the same way; migrations.test.ts enforces acceptance criteria (present, concrete). No conversion is correct: a rename on the author's behalf would silently unbind every filterBindings key that meant the renamed definition, which is the very failure mode of the card.
  • Reference docs. content/docs/references/ui/dashboard.mdx regenerated for the new describe — generated output, consistent with the source describe.
  • No new rule in packages/lint. Judged RIGHT: the save door, defineStack, os validate and os build all parse DashboardSchema (metadata-type-schemas.ts maps dashboard to it), so dashboard-filter-binding-unknown's later-wins resolver never sees a duplicate from a parsed document; a second rule would be a second dialect.

② Semver level

  • Changeset .changeset/29023-dashboard-global-filter-duplicate-name.md: @objectstack/spec minor. Matches the diff: the only package that moves is @objectstack/spec, and Metadata-protocol: narrowed ("a value refused or a requirement tightened") takes at least minor per the post-task checklist.
  • Clause-②: no (narrowing) on the PR body AND the changeset, agreeing; Metadata-protocol: narrowed on both (the body now reads narrowed — the rework report's "stored body still reads semantic" is no longer true at this head). The narrowing arm is BREAKING, and the changeset carries the ! title, the BREAKING banner, Before / Now / Not changed / Fix / Stored rows, and exactly one ADR-0087 marker, registered, naming the D3 entry id. Check Changeset and the check:adr-0087-registration gate inside Lint & Repo Gates are green on this head.
  • Level correct, declarations correct.

③ Boundary flags

Dev flags (card #29023, reports 6572629846 and 6573734445):

  • Deviation "main not merged before pr_create" — CLOSED: origin/main merged at 6172f51e through os-regen-merge.sh, this head.
  • Deviation "spec typecheck not re-run on 6172f51" — CLOSED by the head's check-runs: TypeScript Type Check (the required job that runs every spec gate) is success.
  • Deviation "PR body not patched to narrowed" — CLOSED: the body read at this head carries Metadata-protocol: narrowed.
  • Deviations "no assignee/reviewer set" and "not subscribed to PR activity" — process posture under the dispatch order, no contract effect; noted.
  • Open question 1 (semantic vs narrowed) — ANSWERED by the seat on the card (amended to narrowed); the diff and body follow. Agreed.
  • Open question 2 (stored-row detector in refused-shapes.ts) — ANSWERED here: option A, a follow-up card in domain:spec. See ④ should-fix 2. Not a blocker: the entry's acceptanceCriteria does NOT claim os migrate meta --stored lists the rows (the action-name sibling's does, because it has the detector), so the claim is as narrow as the enforcement.
  • Open question 3 (metadata-protocol door pin) — ANSWERED here: option A, the same follow-up card. See ④ should-fix 3.
  • Out-of-scope finding (reserved dateRange colliding with a globalFilters entry named dateRange) — ESCALATED to the seat to file as its own card; it is a different predicate (built-in vs authored) and the module header says so explicitly. Not this PR's.
  • Out-of-scope note (ui/packages/core test "lets duplicate names win last") — agreed harmless: a stored legacy row can still reach the renderer, so that path must keep its later-wins behaviour.

Check-runs on 6172f51e: all ten required contexts hold — Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI are success; Lint UI is skipped by its paths filter (no ui/** in the diff), which satisfies the required context. No failure, no pending run.

④ Design judgment

packages/spec/src/ui/duplicate-global-filter-names.ts — holds. The predicate's key is byte-equal in effect to globalFilterKey and to both consumers' derivations (read above); filterKeyOf passes over a non-object entry, a non-string or empty name AND field, and duplicateGlobalFilterNames returns [] for a non-array, so malformed input stays with the schema's own issues and the array refinement never double-reports. Attempts to break it: name: '' plus an unnamed entry on the same field (refused as one key, located at .field — correct, matches every consumer); a name equal to another entry's unnamed field (refused — correct, the renderer would draw one control); the same name with different scope/targetWidgets (refused — correct, the renderer keys by name alone, so the pair never worked). Map insertion order gives one issue per key in first-appearance order; secondKey is read off the second site, so the located path always exists on the entry it names.

packages/spec/src/ui/dashboard.zod.ts — holds at the door the card names. The refinement sits on the array before .optional(), so undefined passes and the element schema still runs per entry; the issue path [index, key] composes under globalFilters (pinned at ['globalFilters', 1, 'name']). The save door parses this schema (metadata-type-schemas.ts), as do defineStack, os validate and the Studio metadata-admin gate (clientValidation.ts resolves dashboard to the spec's DashboardSchema), so the card's scenario — PUT /api/v1/meta/dashboard answering 200 — is closed at every parse door this package owns.

  • Should-fix 1 — the one real .shape mirror does not carry the rule. The docblock and the PR say the array attachment makes the rule travel "into any mirror that spreads the shape", and the test pins DashboardSchema.shape.globalFilters. But the only published mirror, objectui's DashboardComponentSchema (ui/packages/types/src/zod/complex.zod.ts), lists globalFilters in DASHBOARD_SPEC_EXCLUDED and rebuilds the slot as z.array(GlobalFilterSchema).optional() over its own stripped element — so its accept set is now wider than the spec's at exactly this rule, while its comment still says "the accept set equals the spec member's", and the parity tests there compare keys, not refinements. The same file's own precedent for this schema family (checkGlobalFilterDateDefaultValue, "the export is the rule a .shape mirror re-attaches") exports the refinement by identifier so a mirror can re-attach it; this PR chose not to export (Not exported: the schema is the door), which leaves the mirror no way to follow without a copy. Not a must-fix: that mirror is not a sys_metadata save door (Studio validates through the spec's DashboardSchema), the console board reads rows the spec door already refused, and ui/** was outside the claim's file surface. Remedy, as a follow-up card for the ui/ lane: either take SpecDashboardSchema.shape.globalFilters by reference (through stripImportedDefaults) or export refuseDuplicateGlobalFilterNames by identifier and re-attach it in objectui; and soften the "any mirror" sentence in the docblock to "a mirror that spreads this slot".

packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts — holds. Fields match SemanticMigration; surface names the dotted key and the derivation; replacement gives the two author moves; reason states what was accepted, what it did to the tile and why no conversion is possible; acceptanceCriteria is checkable (parse clean, re-check filterBindings, os validate clean, stored-row behaviour). It is modelled on the sibling it names and on the action-name entry, and claims nothing the tree does not do.

  • Should-fix 2 — stored-row listing. conversions/refused-shapes.ts carries a detector for object-duplicate-action-name-refused reading the same-shaped predicate; this refusal has none, so os migrate meta --stored names no stored dashboard this PR will refuse on its next save, and an operator learns of it only from _diagnostics after the upgrade. The entry's acceptance criteria correctly do not claim the listing, which is why this is not a FAIL. Follow-up card in domain:spec: a REFUSED_STORED_SHAPES entry under this id reading duplicateGlobalFilterNames, with its refused-shapes.test.ts pin (the dev's option A).
  • Should-fix 3 — door pin. The 422 at the dashboard save door is measured in a scratch test that was deleted. The same follow-up card adds a packages/metadata-protocol test in the shape of protocol.object-duplicate-action-name-refusal.test.ts (default, publish and draft mode, nothing stored).

Nothing in the three hits files is wrong against the code it cites or the scenario it is accepted against; the three should-fixes are follow-up cards, not changes this PR must carry.

Implemented-by: claude/project-thread-3p7kpx
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 10:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 38a278b Nov 2, 2026
78 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-3p7kpx branch November 2, 2026 11:50
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

Development

Successfully merging this pull request may close these issues.

dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field

1 participant