Skip to content

fix(spec): os migrate meta --stored lists a stored dashboard that repeats a global filter key - #29399

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-jprd1x
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-jprd1x

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29212

Clause-②: no
Metadata-protocol: none

维护者速读

What changes

  • Stored listing. REFUSED_STORED_SHAPES gains dashboardDuplicateGlobalFilterName (id ui-dashboard-global-filter-duplicate-name-refused, the dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023 D3 entry of step 18). It walks stack.dashboards[] and calls duplicateGlobalFilterNames from ui/duplicate-global-filter-names.ts, the refinement's own predicate; no logic is copied. One TODO per repeated key, at dashboards[i].globalFilters[j].name (or .field when the second declaration has no name), which is the site the parse refuses. from carries the key and the indexes only. The reason leads with the fate (no lossless rewrite, loads with _diagnostics.valid: false, next save refused 422) and ends with the predicate's own message.
  • Save-door pin (test-only in packages/metadata-protocol). saveMetaItem({ type: 'dashboard', … }) with two filters named r answers 422 INVALID_METADATA, one custom issue at globalFilters.1.name, and stores nothing, with no mode, mode: 'publish' and mode: 'draft'; a distinct-name control saves in each mode. The harness is the object sibling's (protocol.object-duplicate-action-name-refusal.test.ts), copied.
  • Ledger. scripts/engine-double-contract.pinned.json gains the new file's three pinned rows (delete, findOne, update), written by check-engine-double-contract.mjs --write. Not a forced test edit; it is the gate's own recipe for a new pinned double.
  • Changeset. .changeset/29212-dashboard-duplicate-filter-stored-listing.md, @objectstack/spec patch.

No forced test edit: the only existing assertion touched is the refused-shape roster pin in refused-shapes.test.ts, which names each registered id by design ("A new entry is a reviewed edit").

Tests

  • refused-shapes.test.ts gains three cases under #29212: the card shape (two filters named r) gives one TODO at dashboards[0].globalFilters[1].name with the key, both indexes and the predicate's message, the row unrewritten, and the parse still refusing the same site; a key that falls back to field (three entries, region twice unnamed plus one named region) is listed once at .field with indexes [0, 1, 2], as the parse locates it; distinct keys and distinct names over one field are not listed and the parse accepts them. The roster pin names the new id.
  • New protocol.dashboard-duplicate-global-filter-name-refusal.test.ts: 6 cases (refusal + control, in each of no mode, publish, draft), 6 passed.
  • Ablation (fix committed first; scripts/ablation-replace.mjs --delete on the registry line dashboardDuplicateGlobalFilterName,): Tests 3 failed | 80 passed (83), the roster pin and the two listing cases red, the not-listed control green; restored, blob == HEAD, git diff HEAD empty. The spec suite imports ./refused-shapes.js from source, so no dist was involved. Two earlier attempts with --replacement were refused by the script as no-ops (the replacement text already occurred), so they mutated nothing.
Package readings at head 0b17ca1 (after merging main)
  • pnpm --filter @objectstack/spec test: Test Files 791 passed (791), Tests 23394 passed | 1 todo (23395).
  • pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date.
  • pnpm --filter @objectstack/metadata-protocol test: Test Files 304 passed | 4 skipped (308), Tests 22142 passed | 26 skipped.
  • pnpm --filter @objectstack/cli exec vitest run --project unit src/commands/migrate/meta.report-order.test.ts (the migrate meta report over stored dashboards): 21 passed. No CLI test lists refused-shape ids; the rest of the CLI suite is left to CI.
  • Before the merge, at b057b3f2: spec typecheck exit 0, metadata-protocol typecheck exit 0, the same suites green (spec 23394, metadata-protocol 22133).

Acceptance notes

Verification

Head 0b17ca15 (one merge of origin/main, 27 commits, after round 1 on b057b3f2). node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 86 families from 5 paths (223 changed lines), the same list on both heads; each ran after the last commit with its exit code recorded, and --ran answered on each head: 86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).

Gate notes
  • pnpm check:engine-double-contract first asked for --write (the new file's three pinned doubles); the ledger rows are committed and the gate answers exit 0. check:objectql-double-limit and check:where-matcher exit 0 with no new file named.
  • pnpm check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET, ten packages with no dist/); after building them it answered exit 0 (115 entry points across 70 packages). On the merged head it answered exit 0 directly.
  • node scripts/check-plugin-teardown-shape.mjs --self-test: the pinned control commit was fetched up front (git fetch --depth=1 origin 621a4876); exit 0.
  • pnpm exec eslint over the three changed source and test files with --no-inline-config: exit 0. The repo-wide pnpm lint is CI's.
  • Offline on this body: node scripts/check-changeset-no-major.mjs --base 57cde8cb --event: exit 0, level axis no / none at patch. check-partof-closing-keyword.mjs: exit 0.
  • CI families (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, the UI jobs) are read from this PR's check-runs, not re-run here. Dogfood is not owed: no ! in the title and no (narrowing) arm.

Generated by Claude Code

…in the v18 stored listing

The duplicate globalFilters key refusal had no RefusedStoredShape, so
`os migrate meta --stored` named no stored dashboard the parse refuses.
The detector calls duplicateGlobalFilterNames, the refinement's own
predicate, and reports at the site the parse locates.

Claude-Session: https://claude.ai/code/session_01ADHkmQfSq34mnnkWb6STfV
Co-authored-by: Claude <noreply@anthropic.com>
…al filter key refusal in every mode

PUT /api/v1/meta/dashboard answers 422 INVALID_METADATA at
globalFilters.1.name with no mode, publish and draft, and stores nothing.
Plus the patch changeset for the stored listing.

Claude-Session: https://claude.ai/code/session_01ADHkmQfSq34mnnkWb6STfV
Co-authored-by: Claude <noreply@anthropic.com>
…the engine-double ledger

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

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
  • 2 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 dfbe34d8874326be643042c36211ba6ec5e69c7f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json dfbe34d8874326be643042c36211ba6ec5e69c7f

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract-review-claim: session_01ADHkmSduNGC2SZXszyrNut · head 0b17ca1 · 2026-11-02T14:07Z

The lane seat reviews this head itself at CONTRACT_REVIEW_TIER (triage ruling 6575141496 on #28741 returned contract review to the lane): refused-shapes.ts gains a stored-metadata detector for a dashboard whose globalFilters repeat a name, the shape the parse already refuses.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b17ca15d13017ee0fa2303909d430cd3ef43f36
Local-runs: none
Scope: packages/spec/src/conversions/refused-shapes.ts + packages/spec/src/ui/duplicate-global-filter-names.ts, packages/spec/src/ui/dashboard.zod.ts (the globalFilters refinement), packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts (the D3 entry the id names), packages/spec/src/conversions/stored.ts + walk.ts + apply.ts (the sink the detector reports through), packages/spec/src/conversions/refused-shapes.test.ts (the roster pin and the three listing cases), packages/metadata-protocol/src/protocol.ts saveMetaItemUnaudited and packages/metadata-protocol/src/metadata-diagnostics.ts computeMetadataDiagnostics (the save-door and load claims the reason text makes), packages/metadata-protocol/src/protocol.dashboard-duplicate-global-filter-name-refusal.test.ts (the door pin). The remainder of the diff (.changeset/29212-dashboard-duplicate-filter-stored-listing.md, scripts/engine-double-contract.pinned.json) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #29212 body and its three comments (triage 6574212312, claim 6576712044, os-dev-report 6579168801); PR #29399 body, file list (5 files) and the net diff 57cde8cb...0b17ca15 (+222/−1); the check-runs on 0b17ca15. Tree read with git show 0b17ca15:<path>.

① Derived judgments

  • Accept set: unchanged — right. The diff adds no schema, key, value or refinement. DashboardSchema.globalFilters's superRefine(refuseDuplicateGlobalFilterNames) and the predicate module are untouched (sibling spec(dashboard): a globalFilters entry named dateRange parses and silently replaces the built-in date range's binding #29213's surface, honoured). Every door (defineStack, os validate, os build, save door) accepts and refuses exactly what it did on main.
  • Public surface: REFUSED_STORED_SHAPES gains one entry — right. The exported roster grows by dashboardDuplicateGlobalFilterName; no new export, so check:api-surface has nothing to move and the spec check:generated artifacts are unchanged. The only observable change is behavioural: applyConversionsToStoredItem('dashboard', row, { onTodo }) now hands one ConversionTodoNotice per repeated key, conversionId: 'ui-dashboard-global-filter-duplicate-name-refused', which os migrate meta --stored prints under the row. On main the same row handed zero TODOs (premise re-measured by the dev on 5a2bdf9c; consistent with the roster on main, which registers objectDuplicateActionName and no dashboard detector).
  • Registration against the chain — right. toMajor: 18 and the id match the step-18 D3 entry 18.ui-dashboard-global-filter-duplicate-name-refused.ts (landed by fix(spec)!: a dashboard that declares two global filters under one name is refused #29162, merged as 38a278b7); the roster pin in refused-shapes.test.ts requires each id to be a semantic entry of its step and not a conversion id, and the pinned roster list names the new id last, in report order.
  • Door pin (test-only, packages/metadata-protocol) — right. The new test is the object sibling's harness (protocol.object-duplicate-action-name-refusal.test.ts) with the dashboard body, widened to the three modes. saveMetaItemUnaudited runs the per-type Zod parse before any write in draft and publish alike (the gate comment at the INVALID_METADATA refusal says the two modes answer the same), and getMetadataTypeSchema('dashboard') is DashboardSchema (kernel/metadata-type-schemas.ts), so the 422 at globalFilters.1.name with rows.size === 0 is what the code does, not only what the test says.
  • Ledger (scripts/engine-double-contract.pinned.json) — right, and the claim covers it. A new engine double in a test file is exactly what check-engine-double-contract.mjs --write records (three rows: delete, findOne, update), and the claim comment's file surface reads "any ledger/listing a gate names". Not reviewed here beyond that.
  • Check-runs on 0b17ca15 (the gate verdicts): all 64 completed by 2026-11-02T14:29Z — 55 success, 9 skipped (opt-in and path-gated jobs: UI Published Artifacts, Temporal Conformance, Schedule gate, Packed-tarball smoke, Lint UI, Dogfood Verify CLI, Dogfood Regression Gate shards, Build Docs, 18.x upgrade harness), 0 failure. Green among them: Test Core (8/8 shards), Type Check · source gates / debt ledger / workspace / consumer gates / UI, Build Core, Build & E2E UI, Live E2E UI, Test UI (16/16), every Lint family, Spec property liveness, Check Changeset, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate. The derived gate families answer green; nothing was re-run here.

② Semver level

  • Changeset .changeset/29212-dashboard-duplicate-filter-stored-listing.md: '@objectstack/spec': patch, body carries Clause-②: no and Metadata-protocol: none; the PR body carries the same two lines. Right. Nothing an author can write changes (none), the accept set is neither widened nor narrowed (no, no arm), so the kind is none and the level is patch on the one released package the diff moves. @objectstack/metadata-protocol gains a test file only, so it owes no changeset; scripts/ publishes nothing. Check Changeset on the head is green, and the dev's offline check-changeset-no-major --event read no/none at patch.
  • No BREAKING banner, no ADR-0087 disposition marker owed (not a narrowing); the entry this detector names was registered by dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023's changeset, which is still in .changeset/ on the head (unreleased).
  • Clause-②: no

③ Boundary flags

④ Design judgment

hits: packages/spec/src/conversions/refused-shapes.ts. Judged against the D3 entry it names, the predicate it calls, the parse it must agree with, the sink it reports through, and the operator scenario (an upgrade preview over sys_metadata before the release that refuses the row).

  • One predicate, two doors — holds. The detector calls duplicateGlobalFilterNames(dashboard.globalFilters), the same function refuseDuplicateGlobalFilterNames feeds ctx.addIssue from in dashboard.zod.ts; no logic is copied, so the listing and the parse refuse one set by construction. I tried to split them: (a) GlobalFilterSchema.name is z.string().optional() with no .default(), so the array the parse's superRefine sees carries name exactly where the stored row does and the field fallback resolves the same on both sides (the test's three-entry field case pins it: parse at ['globalFilters', 1, 'field'], listing at dashboards[0].globalFilters[1].field, indexes [0, 1, 2]); (b) strictObject aliases (key → name, fieldName → field) are refusal prescriptions from the unrecognized_keys path, never accepted spellings, so a stored row cannot hold a key: the parse would read as name and the detector would not; (c) a non-array or absent globalFilters yields [] from the predicate and mapCollection skips non-dict rows, so no stored dashboard crashes the pass. No split found.
  • The site is the parse's site — holds. path is ${path}.globalFilters[${indexes[1]}].${secondKey}, the second declaration's name or field, which is the issue path the refinement emits ([indexes[1], secondKey] under globalFilters). The object sibling reports actions[1] because its parse refuses at the entry; this one reports one key deeper because its parse does. Correct, and pinned in both directions.
  • Reaches the operator — holds. applyConversionsToStoredItem('dashboard', …) maps dashboard → dashboards through SINGULAR_TO_PLURAL (metadata-collection.zod.ts), so mapCollection(stack, 'dashboards', …) sees the row; applyConversions runs reportRefusedStoredShapes only under an onTodo sink, and builds the notice with conversionId: refused.id and the [protocol] left … as stored — protocol 18 refuses it (ADR-0087 D3 entry '…') message, so the TODO carries the upgrade-guide id. Reads only: detect returns the dashboard unchanged and the test pins item deep-equal to the row.
  • The reason tells the truth about the row's fate — holds. "still loads with _diagnostics reading valid: false": computeMetadataDiagnostics re-parses a served dashboard with DashboardSchema and returns { valid: false, errors } on failure, the row is served. "its next save, draft or publish, is refused with 422 INVALID_METADATA": the save door parses before writing in both modes, and the new door test pins all three mode spellings with rows.size === 0. The reason ends with the predicate's own message (key, every index, the fix), leads with why nothing rewrites it (which definition keeps the key, and which filterBindings meant it, only the author knows), and carries no tracker number (pinned not.toMatch(/#\d{3,}/)). from carries { name, indexes } only, as the sibling does; a filter body carries no secret, but the shape is uniform.
  • Scenario check. Blast radius measured on the card body: 0 authored dashboards repeat a key, so this is a guard. An operator running os migrate meta --stored before upgrading to the release that carries dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023 now sees each affected sys_metadata dashboard, by name and index, instead of a silent valid: false after the upgrade. The dev's ablation (deleting the registry line reds the roster pin and both listing cases, the control stays green) shows the three new cases bite on the registration, not only on the predicate.
  • Findings. Must-fix: none. Should-fix (not in the hits file, carried as a follow-up): the D3 entry's acceptanceCriteria should say the row is listed under this TODO id (③, open_questions[0]). Nothing else: surface wording differs from the entry's surface by an "of one dashboard" clause, as the object sibling's does from its entry, and no gate or reader compares the two.

Implemented-by: claude/project-thread-jprd1x
Reviewed-by: session_01ADHkmSduNGC2SZXszyrNut

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Head-sha: 0b17ca15d13017ee0fa2303909d430cd3ef43f36 · read 2026-11-02T14:32Z

维护者速读

  • 改了什么:os migrate meta --stored 现在会列出「同一个看板里两个全局筛选器用了同一个 key」的已存数据行。另加一个测试,钉住保存接口在三种模式下都拒收这种看板。
  • 为什么改:dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023 已经让保存接口拒收这种看板,但升级前的存量预览不报它,运维在升级前看不到哪些看板下次保存会被拒。
  • 风险与回滚:只加了只读检测和测试,任何入口的接受/拒收结果都不变。回滚就是 revert 本 PR。
  • 先看:packages/spec/src/conversions/refused-shapes.ts(新 detector,调用与解析同一个判定函数)。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/conversions/refused-shapes.test.ts,预期全部通过。
  • 需要你做的:无,席位落地。

席位意见:可落地。契约复审在现 head 判 PASS,CI 全绿。

席位核对 — ACCEPT · domain:spec#2 · head 0b17ca15

  1. Shape: draft, base main, Fixes #29212. Clause-②: no and Metadata-protocol: none sit at line start in the body and the changeset (@objectstack/spec patch). There are 5 files, +222/−1. The surface is refused-shapes.ts, its test, a new metadata-protocol test file and the changeset, all inside claim 6576712044. The gate-written scripts/engine-double-contract.pinned.json (3 rows) is declared as a deviation. No governed path.
  2. Premise measured: on 5a2bdf9c, a stored dashboard with two filters named r gave 0 TODOs from --stored, while the object control gave 1. On the head the new detector dashboardDuplicateGlobalFilterName (TODO id ui-dashboard-global-filter-duplicate-name-refused) lists it at dashboards[i].globalFilters[j].name (or .field), the same site the parse refuses. The review confirmed it calls the refinement's own predicate duplicateGlobalFilterNames and rewrites nothing.
  3. Gates: ci-failure.mjs --sha 0b17ca15… GREEN: 64 check-runs, none failed, none superseded. The 9 skipped are opt-in or path-gated.
  4. Accepted deviations: the pinned engine-double rows (written by --write, as the gate prescribes), the one origin/main merge, and no assignee or reviewer on the PR (the order forbids MCP writes).
  5. Out of scope: the dashboards: two globalFilters with the same name are accepted; the board draws one control and its value filters the other filter's field #29023 D3 entry's acceptanceCriteria should say --stored lists the row (dev Q1, answered A). It is a one-sentence prose edit to an unreleased entry, so it rides on the follow-up detector card for spec(dashboard): a globalFilters entry named dateRange parses and silently replaces the built-in date range's binding #29213's reservedDateRange refusal, which is filed when PR fix(spec)!: refuse a dashboard globalFilters entry keyed dateRange beside a declared dateRange #29370 lands. The body carries no Skills-docs: line; PR-body guards passed, and no skill lists --stored TODO ids, so nothing is owed.
  6. Landing: not Tier H and under 3000 lines, so the seat lands it with pr_ready and auto-merge. After merge, the seat writes a landing note on spec(dashboard): the duplicate globalFilters key refusal (#29023) has no stored-row detector and no save-door pin #29212 and removes pm:dispatched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(dashboard): the duplicate globalFilters key refusal (#29023) has no stored-row detector and no save-door pin

1 participant