Skip to content

fix(lint)!: under a package closure, judge a dashboard or report filter key on another package's dataset against that dataset's object (#30005) - #30231

Draft
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/project-thread-dogsgp
Draft

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/project-thread-dogsgp

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

Fixes #30005

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx (the fourth-door paragraph) now names the fifth closure limb for dashboard and report writes; skills/** teaches neither the rules nor the closure.

维护者速读

  • 改了什么: 发布门按软件包闭包收窄 objects 时,dashboard 与 report 的写入额外保留「它所绑定的 dataset 的基础对象」(第五肢)。于是运行时包的看板用别的包的 dataset(如 showcase_task_metrics)时,filter: {no_such_field: 'x'} 在 widget 路径被 422 拒收;report 的 runtimeFilter 同理;正确的键照常发布。
  • 为什么改: 以前 datasets 不收窄、objects 收窄,dataset 找得到而它的对象被筛掉,两条筛选键规则走「对象不在快照里」的跳过分支,拼错的键发布 200,读者只看到空图块或报错面板。
  • 风险与回滚: 只多拒收闭包外 dataset 上「对象确实没有的字段」;第五肢只对 dashboard/report 生效,flow、列表视图、dataset 写入的闭包判定不变(有 pin)。公开函数 narrowObjectsToPackageClosure 签名与结果不变。回滚即撤 PR。
  • 先看: packages/lint/src/runtime-gate.ts 的 datasetBoundObjectNames 与 narrowObjectsToWriteClosure 的 Limb 5 一行;buildRuntimeWriteSnapshotSet 里调用处;测试文件末尾 #30005 一节。
  • 两分钟验证: pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.package-closure.test.ts,预期 48 条全过(其中 lint publish gate: widget-filter-field-unknown and report-filter-field-unknown are silent when the dataset's object is outside the written package's closure #30005 一节 9 条)。
  • 需要你做的: 无,席位落地。

What changes

The gap. buildRuntimeWriteSnapshotSet narrows objects to the written package's closure (ruling #9612) and carries datasets whole. A runtime package's dashboard bound to showcase_task_metrics, which the showcase package ships over showcase_task, therefore resolved its dataset but not the dataset's object, and judgeDatasetFilterKeys took skip 1 ("object this stack does not define") for every key. Both widget-filter-field-unknown and report-filter-field-unknown were silent, so filter: { no_such_field: 'x' } published.

The fix: route 1 of the card, bring the referenced dataset's object into the snapshot. The closure gains a fifth limb, stated by the snapshot builder: an object survives narrowing when it is the base object of a dataset the written item binds. datasetBoundObjectNames(type, item, datasets) reads the bound names at the positions the two rules read (a dashboard's widgets[].dataset; a report's dataset and each joined block's dataset) and resolves them against the carried datasets. It answers the empty set for every other write type. The limb applies to the baseline, candidate and stored passes alike, as the existing four do.

Why route 1 and not route 2 (narrow datasets and give a cross-closure reference its own verdict).

  • The card requires that a good key on the cross-package dataset still publishes. Under route 2, the cross-closure reference needs a verdict of its own; refusing it would break that requirement, and a non-refusing verdict still leaves the filter keys unjudged unless the object is brought in anyway.
  • The dataset door binds a cross-package dataset at run time against the dataset's own object, with no closure. Route 1 hands the rules exactly that universe, so the publish verdict matches what the door does. With no closure in force the same key was already refused; route 1 makes the scoped verdict equal the whole-collection one (pinned).
  • Route 1 changes no rule. Both rules keep their skips, so a key the dataset door binds (a field, a registry-injected column, a dotted path, a $ operator) is not refused.

Why only dashboard and report writes. Limb 5 re-admits an object the package did not declare. A write that names an object directly is judged against the closure on purpose: a flow trigger outside it is refused (flow-trigger-unknown-object), a list view bound outside it is refused (list-view-object-outside-closure), a dataset's base object outside it gets the closure fix line on object-reference-unknown. A list view's chart.dataset re-admitting the object the view lists would wave that verdict through. A dashboard or report reaches an object only through a dataset, so the type gate is the boundary. Pinned in both directions.

Public surface. narrowObjectsToPackageClosure keeps its signature and its four-limb answer: the implementation moved to an internal narrowObjectsToWriteClosure, and the public function states an empty limb 5 (pinned). datasetBoundObjectNames is exported from the module for the test only, and is not on any package entry. That is why the declaration is Clause-②: no (narrowing): nothing is widened.

Tests

New top-level describe in packages/lint/src/runtime-gate.package-closure.test.ts (the closure's existing harness), 9 cases:

  • LIT: the filer's repro, a runtime-package dashboard on showcase_task_metrics with filter: { no_such_field: 'x' }, refused with exactly widget-filter-field-unknown at dashboards[0].widgets[0].filter.no_such_field, severity error.
  • LIT: the same key on a report runtimeFilter, refused with report-filter-field-unknown at reports[0].runtimeFilter.no_such_field.
  • LIT: a joined report whose block binds that dataset: the container key and the block key are both refused.
  • CONTROL: { status: 'done' }, { estimate_hours: { $gte: 1 } } and the injected created_at publish clean on the widget and on the report.
  • The scoped verdict equals the whole-collection verdict for both repros (one finding each).
  • A widget on a dataset that resolves to nothing re-admits nothing: only widget-dataset-unknown.
  • DARK: a flow triggering on showcase_task and a list view of showcase_task with a chart.dataset on showcase_task_metrics, both under the same closure and context, are still refused.
  • The public narrowObjectsToPackageClosure still drops showcase_task.
  • datasetBoundObjectNames reads the two bound positions and answers empty for a view or with no datasets.
Ablation (once, through `scripts/ablation-replace.mjs`)

Mutation: limb 5 disabled, datasetBoundObjects.has(name)) return true; → datasetBoundObjects.has(name) && !'ABLATED_30005') return true;. The test imports ./runtime-gate.js from source, so no build sits between the mutation and the run.

HEAD b37c73da8
ablation-replace: ok mutation landed: anchor 1 -> 0, blob c6fdac316370 -> efeee56d4cce
     × ⭐ LIT — the filer's repro: a widget key the dataset's object lacks is REFUSED at the widget path
     × ⭐ LIT — the same key on a report `runtimeFilter` is REFUSED at its path
     × ⭐ LIT — a joined block on that dataset: the block key and the container key are both judged
     × the scoped verdict is now the whole-collection one — the closure no longer hides the finding
      Tests  4 failed | 5 passed | 39 skipped (48)
ablation-replace: ok restored: blob == HEAD (c6fdac316370) and `git diff HEAD` is empty

The five that stay green under the mutation are the controls, the dangling-dataset case, the DARK case and the two unit pins: they assert what the fix must not change.

Gates, local subset, on head `b37c73da8`

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 57 families; each exit code was recorded to a file before any pipe, then reconciled with --ran:

Run reconciliation — 57 derived, 56 run, 1 NOT-MEASURED, 0 UNRUN.
  NOT-MEASURED · DERIVED (1) — pnpm check:dual-build-cjs-loads   [recorded exit 3 — PREREQUISITE NOT MET]
✓ dispatch-gates --ran: 57 derived famil(ies) accounted for — 56 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).
  • pnpm check:dual-build-cjs-loads: exit 3, it needs the whole workspace built. NOT MEASURED, left to CI.
  • check-plugin-teardown-shape.mjs --self-test and pnpm check:lean-entry-closure first exited 3 (shallow clone; objectql not built). After fetching the control commit and building the dogfood closure, both exited 0; those are the codes recorded.
  • Beyond the derivation: pnpm --filter @objectstack/lint test exit 0 (Test Files 193 passed, Tests 8003 passed), pnpm --filter @objectstack/lint typecheck exit 0, pnpm lint exit 0.
Dogfood battery, once, on head `b37c73da8` (the title carries `!` and the declaration a narrowing arm)

Closure built with turbo run build --filter='@objectstack/dogfood^...', then pnpm --filter @objectstack/dogfood test under os-verify-lock.sh:

 Test Files  1 failed | 418 passed | 3 skipped (422)
      Tests  1 failed | 3487 passed | 36 skipped (3524)
os-verify-lock: VERDICT command-exit 1 · held the lock 1363s (22m43s)

The one failure is not this change's: test/update-by-id-refusal-order.dogfood.test.ts › "control: an organization administrator, who reads the platform grant object, keeps the administration gate's sentence for a row and for no row" expects 403 PERMISSION_DENIED for an update of a missing sys_user_permission_set id and receives 404 RECORD_NOT_FOUND. It fails the same way alone (1 failed | 18 passed), and fails the same way with packages/lint/src/runtime-gate.ts restored to origin/main and @objectstack/lint rebuilt (the built dist carries 0 occurrences of datasetBoundObjectNames). The diff touches nothing on the data update path. Reported to the seat.

Acceptance notes

Verification

pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.package-closure.test.ts

Expect 48 passed; the #30005 block is the last describe in the file.

… object under a package closure

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

github-actions Bot commented Nov 5, 2026 •

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 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5fa9375ec8279a7ee9e779049c0e93661c1786a3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5fa9375ec8279a7ee9e779049c0e93661c1786a3

⚠️ 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:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b37c73da8aff9c8189ccf7fd3ba23d579823de3a
Local-runs: none
Scope: packages/lint/src/runtime-gate.ts, packages/lint/src/runtime-gate.package-closure.test.ts + packages/lint/src/validate-widget-bindings.ts, packages/lint/src/validate-report-runtime-filter.ts, packages/lint/src/object-graph.ts, packages/lint/src/index.ts, packages/lint/src/runtime.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts, packages/services/service-analytics/src/dataset-filter-field-binding.ts, packages/spec/src/ui/dashboard.zod.ts, packages/spec/src/ui/report.zod.ts, content/docs/deployment/validating-metadata.mdx (prose face, read for the Skills-docs: judgment only)

Face: narrowing. The PR's two code paths decide no face on their own (record-recognisers --template --paths reads them TIER_DEFAULT); the line-initial Clause-②: no (narrowing) on the PR body and in .changeset/30005-cross-closure-dataset-filter-keys.md puts the review on the narrowing face, so the scope floor is every diff path outside the prose faces, both named above. PR user.login is objectstack-fleet[bot], so no author note is owed. No Contract-review-claim, hold or record on this head on either thread at read time (PR thread: one docs-drift bot comment only). Card thread read: 6621257853 (triage), 6647296389 (seat claim), 6648301262 (os-dev report).

① Derived judgments

  1. The narrowing itself (correct, as declared). runtime-gate.ts#buildRuntimeWriteSnapshotSet now narrows objects through runtime-gate.ts#narrowObjectsToWriteClosure with a fifth limb, the set runtime-gate.ts#datasetBoundObjectNames(args.type, item, args.context?.datasets): for a dashboard write, the object of every dataset named at widgets[].dataset; for a report write, the object of the dataset at report.dataset and at each blocks[].dataset; every other type answers the shared empty NO_NAMES. Those are exactly the positions the two rules read (validate-widget-bindings.ts#validateWidgetBindings reads w.dataset at L971 and judges w.filter at L1157; validate-report-runtime-filter.ts#validateReportRuntimeFilters reads report.dataset at L122 and block.dataset at L131 and judges both runtimeFilters). Resolution goes through the carried, never-narrowed datasets, so a dangling name re-admits nothing. Applied in the same loop to baseline, candidate and the finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118 stored universe, all from the candidate item's bindings, so the verdict stays candidate MINUS baseline over one object universe. Judged right.
  2. What is newly refused (dashboard and report writes with a package scope in force, a bound dataset outside the closure). The dev names two rule ids; the diff reaches six, all through the same "object absent, skip" exit that limb 5 removes:
    • widget-filter-field-unknown (named) — validate-widget-bindings.ts#judgeDatasetFilterKeys skip 1 (!graph.get(object), L772-773) is no longer taken; an unknown key is an error.
    • report-filter-field-unknown (named) — same judge, validate-report-runtime-filter.ts L99-112, container and joined-block keys.
    • widget-filter-field-not-included (⛔ not named) — the judge's second clause (L791-797): a dotted key that resolves but whose prefix is not in the dataset's include, error, pushed at validate-widget-bindings.ts L1172.
    • dashboard-filter-field-unknown and dashboard-filter-field-not-included (⛔ not named), both error, and dashboard-filter-field-unprovisioned (warning) — the (a1) dashboard-level filter check at validate-widget-bindings.ts L1013-1021 takes the identical skip (graph.get(datasetObject)) off the same index and so also starts judging a cross-closure widget's inherited globalFilters/dateRange field. The default dateRange field is injected and stays isUnjudgeable (object-graph.ts#isUnjudgeable, kinds unknowable/hop-untargeted), so a stock board is not newly refused.
      Direction is right for all six (each key lands in the same runtimeFilter slot the door binds), but the changeset's "Now" paragraph and the PR body name only the two; the seat's prose check should have the four added in one sentence (③).
  3. Nothing the dataset door binds is refused by the design. Read in judgeDatasetFilterKeys: an injected system column (created_at), a dotted hop that lands on an object the snapshot still lacks, and every operator key stay isUnjudgeable and are never reported; only the base object is re-admitted, so the dotted-hop skip is unchanged. The CONTROL case pins status, estimate_hours: { $gte: 1 } and created_at clean on both write types. One pre-existing exception reaches the new population — a bare dimension or measure name — judged in ③ (not a defect of this diff's design).
  4. Flow, list-view and dataset closure refusals unchanged. datasetBoundObjectNames returns NO_NAMES for every type other than dashboard/report (L511), and narrowObjectsToWriteClosure(objects, scope, NO_NAMES) is the old four-limb predicate verbatim (limb 5 is datasetBoundObjects.has(name) on an empty set), so every other write type's snapshot is byte-for-byte the main snapshot: flow-trigger-unknown-object, flow-input-unknown-object, list-view-object-outside-closure and the closure fix line on object-reference-unknown are untouched. Pinned by the DARK case (flow on showcase_task; list view of showcase_task whose chart.dataset is the cross-package dataset) and by the "public four-limb" case.
  5. Public surface of @objectstack/lint: unchanged. narrowObjectsToPackageClosure(objects: readonly unknown[], scope: RuntimePackageScope | undefined): readonly unknown[] keeps its signature and answer (delegates with NO_NAMES). datasetBoundObjectNames is exported from the module for the unit pin only: packages/lint/src/index.ts L1165-1179 and packages/lint/src/runtime.ts L65-72 are named export lists (no export * from runtime-gate.js), the entries are tsup-bundled, and the module's .d.ts is not addressable through exports — shipped bytes, not a published accept set (contract-review.md). The three outside callers of the public function (packages/metadata-protocol/src/protocol-object-extension-item.ts L213, packages/runtime/src/domains/new-similar-target.ts L172, ui/packages/app-shell/.../objectPickerClosure.ts) see no change. No new rule id, error code or severity: the door's mapping of a gate error to 422 INVALID_METADATA is the existing one. Clause-②: no (narrowing) is therefore right (nothing widened, the accept set narrowed); Metadata-protocol: narrowed is right (a value an author could write — a filter key the object lacks — is now refused, AGENTS.md step 3 "a value refused … is narrowed"), and both lines are line-initial in the PR body and the changeset.
  6. The door is live for limb 5. packages/metadata-protocol/src/runtime-authoring-gate.ts L1300-1316 threads type, packageScope and a context carrying datasets (live + pending) into runRuntimeAuthoringRules, which builds its snapshots through buildRuntimeWriteSnapshotSet (L1267), so both the per-item publish and the package draft publish take the new limb; the dev's door measurement (422 at dashboards[0].widgets[0].filter.no_such_field; status saves; main lint publishes the bad key) agrees with the read.

② Semver level

Changeset .changeset/30005-cross-closure-dataset-filter-keys.md: '@objectstack/lint': minor (lint is a released package: packages/lint/package.json has no private field, version 18.3.0, three exports entries), **BREAKING** banner present, Clause-②: no (narrowing) and Metadata-protocol: narrowed line-initial, exactly one adr-0087: not-required (no-migration-prescription) … marker. Against AGENTS.md Post-Task Checklist step 3: kind narrowed takes at least minor on the moved package — minor ✓; (narrowing) is BREAKING — banner ✓; a breaking changeset states its ADR-0087 disposition in writing — ✓; no spec key, export or config field is removed or renamed, so no FROM → TO migration line and no retiredKey() tombstone is owed, and the "What an author does" paragraph is the right prescription (name a real field). check-changeset-no-major.mjs pushes breaking changes to minor outside pre-mode, so minor + banner is the consistent spelling. The dev's declared level (minor + BREAKING + adr-0087 not-required) is consistent with the changeset and with the diff. Check Changeset, Lint · CI, release & engine gates and Governed Surface Queue Guard are success on this head.

③ Boundary flags

  1. Skills-docs: line — present, wrong spelling, and not true as written (seat REWORK item, prose face). The allowed spellings are updated here | follow-up owed — FILES | follow-up #N | none — REASON (pm-dispatch SKILL.md L313; updated here covers "skills/** or the docs"). The PR body reads Skills-docs: none in \skills/**`, which teaches neither rule nor the closure. `content/docs/deployment/validating-metadata.mdx` names the closure's four limbs and not the fifth…— anonewhose own sentence names a docs page this diff makes stale. The page is now false:content/docs/deployment/validating-metadata.mdxL735-739 states the fourth door "narrows that context to the written item's **package closure** — the written package, its declared dependencies, and unconditionally both platform/system objects and rows carrying no package provenance (#9612)", which for a dashboard or report write no longer describes the snapshot (the bound datasets' base objects are carried too). Correct line:Skills-docs: follow-up owed — content/docs/deployment/validating-metadata.mdx(the seat files the card and rewrites it tofollow-up #Nbefore ACCEPT), or the one sentence added to that paragraph in this PR withSkills-docs: updated here. The dev already handed the page to the seat in the Acceptance notes and the report's out_of_scope_findings`; the declaration line has to say so in the template's words. Outside the four contract faces; does not move this verdict.
  2. Dev flag — "lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 meets a wider population": a real author-facing regression, judged acceptable here, with two conditions for the seat. Scenario: runtime package app.sales (no dependency on the showcase) builds a dashboard on showcase_task_metrics, which declares a dimension { name: 'state', field: 'status' }; the widget carries filter: { state: 'done' }. At run time dataset-filter-field-binding.ts L28-29 and L142 bind state to the dimension's sql, so the tile renders. On main the gate skipped (object absent) and the board published; on this head widget-filter-field-unknown refuses it 422 at dashboards[0].widgets[0].filter.state, because validate-widget-bindings.ts L1157-1168 has no dimension skip (the report rule has one at validate-report-runtime-filter.ts L101; neither rule skips a measure name). So yes: a body the door binds, that published before, is refused after. Why not a must-fix of this PR: the spec declares no dimension- or measure-key capability for these filters (dashboard.zod.ts L1182 "Presentation-scope filter (runtimeFilter)", report.zod.ts L454/L602 "Render-time scope filter"), so no declared capability is taken away at the consumer; the refusal is the rule's own pre-existing verdict on every in-closure dataset (the lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 filer measured it there), and route 1's contract is precisely "the cross-closure verdict equals the in-closure one" (pinned); the owning card lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 is serial behind this card on the same seat. Conditions: (a) the seat dispatches lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 so both changesets ride the same release train — the window between the two landings is the only one in which a cross-closure author meets the refusal with no fix in sight; (b) the changeset's "Accepted, as before" list is not complete for a BREAKING-note reader: one sentence that a bare dimension or measure name is refused on a cross-closure dataset as it already is inside the closure, tracked by lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006, belongs there (prose face, seat's sentence check).
  3. Dev flag — dotted keys. Confirmed in code: only the base object is re-admitted; a hop onto an object the snapshot lacks is unknowable and isUnjudgeable, never a finding. The changeset says so.
  4. Dev flag — HTTP status; do the refusal tests assert code and status? No. The nine committed cases are lint-layer and assert rule, path and severity: 'error'; a lint finding carries no code/status (the envelope is the door's). The door mapping of these two rule ids to 422 INVALID_METADATA is pinned in packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts L435-484, but with no package closure in force; the card's pin "refused 422 at the widget path" under a closure was measured at the door (report 6648301262) and not committed, because the claim's file surface stopped at packages/lint. Under review-checklist "拒收用例 ⇒ code+status 最低断言" this is the seat's call: waive on the existing envelope pins plus the measured reading, or widen the file surface by one describe in that metadata-protocol test (a runtime-package dashboard on a cross-package dataset, status/code asserted). Not a contract-face must-fix.
  5. Unnamed rule ids (①.2). widget-filter-field-not-included, dashboard-filter-field-unknown, dashboard-filter-field-not-included, dashboard-filter-field-unprovisioned now also reach cross-closure dashboards. Same direction, same door binding; the changeset should name them (prose face).
  6. Dev's out-of-scope dogfood red (test/update-by-id-refusal-order.dogfood.test.ts, 404 where 403 is pinned) reproduced with lint at origin/main — not this diff; the report's dedupe words are there for the seat to file. The dogfood_battery field is pinned to this head, full, with the one red explained; noted for the seat's !/narrowing check.
  7. Check-runs at read time (2026-11-05 ~03:00Z): Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, Lint · CI, release & engine gates, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, PR-body guards are success; Type Check · workspace/source gates/debt ledger/consumer gates, Type Check UI, Build Core, Test Core (1-8/8), Test UI (1-16/16), Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards still in_progress. The queue entry waits for all-green (contract-review.md ②); this record does not stand in for it.
  8. open_questions: [] — nothing to answer.

④ Design judgment

  • packages/lint/src/runtime-gate.ts — route 1 over route 2, holds against the code it cites and the card. The card asks for one of two routes with a reason, and forbids refusing a key the door binds. The door (dataset-filter-field-binding.ts for queryDataset) resolves a cross-package dataset's filter against that dataset's own object with no closure in force; route 1 hands the two rules exactly that object and changes no rule, so the gate's universe for the filter keys is the door's. Route 2 would owe the cross-closure reference its own verdict, which the card's third pin (a good key still publishes) forbids refusing, and a non-refusing verdict would still leave the keys unjudged unless the object were brought in anyway — the dev's reasoning is right. Keying limb 5 on the written TYPE rather than on "any dataset reference" is the right boundary: the DARK case shows the alternative — a list view's chart.dataset re-admitting the very object the view lists would wave list-view-object-outside-closure through. Positions read agree with the rules' positions one for one; the dangling-dataset and no-datasets exits are right; applying the same set to all three passes keeps candidate MINUS baseline a single question. The public function's four-limb answer is preserved by delegation rather than by a second copy of the predicate — one implementation, as the closure ruling (runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card #9612) requires.
  • packages/lint/src/runtime-gate.package-closure.test.ts — the pins carry the scenario. New top-level describe in the component's existing harness (ruling 6583487846). LIT cases assert the exact finding list ([rule, path] pairs) for the filer's repro, the report twin and the joined container+block; the CONTROL case covers field, operator and injected-column keys on both write types; the scoped-equals-whole-collection equality is the strongest statement of route 1's contract; the dangling-dataset, DARK and public-four-limb cases pin what must not move; the unit pin reads both positions and the view exit. Ablation (limb 5 disabled through scripts/ablation-replace.mjs, blob hashes recorded, restored to HEAD): 4 red / 5 green, the five green being the controls and pins that must not depend on the limb — the right split. The datasetBoundObjectNames export exists for this file only and matches the module's existing convention (buildRuntimeWriteSnapshotSet).
  • Must-fix defects on the contract faces: none. The two correct declaration lines are Clause-②: no (narrowing) and Metadata-protocol: narrowed; the Skills-docs: line must be rewritten by the dev or seat as in ③.1 before ACCEPT (prose face).

Implemented-by: claude/project-thread-dogsgp
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

…the changeset, and pin the door's 422

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

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ae7fa514e5fccd5ed58307428b2783bbb3ce63c6
Local-runs: none
Scope: packages/lint/src/runtime-gate.ts, packages/lint/src/runtime-gate.package-closure.test.ts, packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts + packages/lint/src/validate-widget-bindings.ts, packages/lint/src/validate-report-runtime-filter.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, .changeset/30005-cross-closure-dataset-filter-keys.md and content/docs/deployment/validating-metadata.mdx (prose faces, sentences read against the diff for the Skills-docs: judgment)

Delta review of the record on b37c73da8aff9c8189ccf7fd3ba23d579823de3a (PR comment 6648480102, PASS, no must-fix): the head moved by one commit, git diff b37c73da..ae7fa514 touches three files and no packages/*/src non-test file — .changeset/30005-cross-closure-dataset-filter-keys.md (+3/−1), content/docs/deployment/validating-metadata.mdx (+6), packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts (+95). packages/lint/src/runtime-gate.ts is byte-identical to the reviewed head (blob c6fdac3163… in the dev's ablation transcript equals the blob reviewed on b37c73d). Items ①–④ of the earlier record stand unchanged except where this delta says otherwise; only the delta is judged below. PR user.login is still objectstack-fleet[bot].

① Derived judgments

  1. Accept set and public surface: no change from b37c73d. The code the gate runs is the same blob; the new commit adds a test, a docs paragraph and changeset sentences. Every ① judgment of the earlier record (six rule ids newly reaching cross-closure dashboards and reports; nothing the door binds refused by design; flow, list-view and dataset closure verdicts unchanged; narrowObjectsToPackageClosure unchanged; datasetBoundObjectNames not on a package entry) is unchanged.
  2. The door-level pin (protocol.dashboard-dataset-publish-gate.test.ts, new #30005 describe) asserts what the card pins. makeClosureProtocol() adds a showcase-stamped showcase_task (fields name, status) and showcase_task_metrics (object: 'showcase_task', _packageId: 'com.example.showcase') to the stub registry and installs app.runtime with no dependencies via getPackage, so the write's closure is the written package plus platform/unpackaged rows and the dataset's object is outside it. LIT: saveMetaItem({ type: 'dashboard', …, packageId: 'app.runtime' }) with filter: { no_such_field: 'x' } → expect({ status, code }).toEqual({ status: 422, code: 'INVALID_METADATA' }), issues contains ['widget-filter-field-unknown', 'dashboards[0].widgets[0].filter.no_such_field'], and no active dashboard row is stored. CONTROL: filter: { status: 'done' } saves success: true with an active row. status and code are both asserted, as review-checklist asks (the ③.4 gap of the earlier record is closed). The helpers it uses (makeStubEngine L121, dashboardRows, ObjectStackProtocolImplementation L48, vi/beforeEach/afterEach L41) are the file's own; no new import.
  3. The pin fails without the fix — read from /mnt/project-files/server-seat3/30005/ablation-door.txt. At HEAD ae7fa514e, scripts/ablation-replace.mjs replaced the limb-5 line in packages/lint/src/runtime-gate.ts with a marker that disables it (anchor 1 → 0, blob c6fdac316370 → bcc5f8d8e8b0), rebuilt @objectstack/lint with --force, and ablation-dist-preflight.mjs found the marker in all four built entry files (so the suite consumed the mutated artifact). vitest run … -t 30005: 1 failed | 1 passed | 13 skipped — the LIT case fails with { status: undefined, code: undefined } (the save went through, the filer's reading), the CONTROL still passes. Restore leg: blob back to c6fdac3163… == HEAD, git diff HEAD empty, rebuilt, preflight --absent confirms the marker gone from all 20 built files and the working tree clean, then the whole file 15 passed (15). This is the mutate/restore shape the ablation rule asks for, with the artifact-level preflight that makes a dist-consuming test's ablation honest.
  4. Changeset sentences, read against the diff (prose face; stated here because the Skills-docs: judgment depends on them). (a) "Which rules now judge such a dataset … all six already judging datasets inside the closure: widget-filter-field-unknown, widget-filter-field-not-included, dashboard-filter-field-unknown, dashboard-filter-field-not-included, dashboard-filter-field-unprovisioned and report-filter-field-unknown" — true: exactly the six rule ids the earlier record derived from validate-widget-bindings.ts L1013-1021/L1058/L1079/L1157/L1172 and validate-report-runtime-filter.ts L99-112, all reached through the one "object absent" skip limb 5 removes. (b) "A dimension name as a widget filter key … now refused with widget-filter-field-unknown, as it already was on a dataset inside the closure. The dataset query binds such a key; the correction of that verdict, for both cases, is tracked in lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006" — true (widget rule has no dimension skip at L1157-1168; the report rule's is at L101; dataset-filter-field-binding.ts L28-29/L142 binds the name; lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 covers the dimension and measure halves). The sentence is silent on the measure-name case, which is also lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006's; silence, not a false statement. (c) Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx (the fourth-door paragraph) now names the fifth closure limb …; skills/** teaches neither the rules nor the closure — true against the docs hunk.
  5. Docs sentence, read against the code. validating-metadata.mdx L740-745: "A dashboard or report write also keeps the base object of each dataset it binds (a widget's dataset; a report's dataset and each joined block's), because the datasets are never narrowed and the dataset query resolves a filter against that object whichever package ships it: a filter key the object lacks is refused there just as it is on a dataset inside the closure (lint publish gate: widget-filter-field-unknown and report-filter-field-unknown are silent when the dataset's object is outside the written package's closure #30005). No other write type keeps it, so a flow or a list view never reaches an outside object through a dataset." Every clause matches runtime-gate.ts#datasetBoundObjectNames (positions L515-523, type gate L511) and #buildRuntimeWriteSnapshotSet (datasets carried whole). The page's four-limb sentence is no longer false for the two write types. Build Docs and Check Documentation Links are success on this head.

② Semver level

Unchanged and still consistent: '@objectstack/lint': minor, **BREAKING** banner, Clause-②: no (narrowing), Metadata-protocol: narrowed, one adr-0087: not-required (no-migration-prescription) marker. The delta adds a test to @objectstack/metadata-protocol (tests publish nothing — no changeset owed) and a docs page. Check Changeset is success on this head (run 41959195953, 03:03Z); a second Check Changeset run triggered by the PR-body edit (run 41962338408, 03:33Z) was still in_progress at read time.

③ Boundary flags

  1. Skills-docs: line — now a valid spelling and true. PR body L8 and the changeset read Skills-docs: updated here — …: the value opens with the template token updated here (pm-dispatch SKILL.md L313: updated here | follow-up owed — FILES | follow-up #N | none — REASON), followed by the file it updated; the diff does teach the limb in the docs page it names. The earlier record's ③.1 REWORK item is closed. (updated here's second clause, "the package's evals exercise it", has no object for a docs page; the door pin is the exercising test.)
  2. No closing keyword next to lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006. Grep of the new PR body for (close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[ :]*#N hits only Fixes #30005 (L4); the changeset has none. The two lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 mentions are "(lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006, serial behind this card, not touched here)" (body L100) and "is tracked in lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006" (changeset) — zero verbs beside the number, the safe spelling.
  3. lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 regression (earlier ③.2): condition (b) met, condition (a) remains the seat's. The changeset now carries the dimension-name sentence naming lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006. Dispatching lint: the dataset filter-key rules refuse a bare dimension or measure name that the dataset door binds at run time #30006 so both ride one release train stays the seat's scheduling matter, not this PR's.
  4. Dogfood battery on this head. The reported battery (3487 passed, one pre-existing red reproduced with lint at origin/main) was taken on b37c73d. This head changes no runtime source — git diff --stat names a changeset, a docs page and a metadata-protocol test only — so the battery's subject is byte-identical; Dogfood Regression Gate is success on ae7fa51. Whether the !/narrowing rule accepts a battery pinned to the previous head on a source-identical tree is the seat's call under review-checklist; the reading is stated so it can be made.
  5. Check-runs on ae7fa51 at read time (~03:35Z): all required and non-required runs of the 03:03Z push are success (Lint & Repo Gates, TypeScript Type Check, Type Check · workspace/source gates/debt ledger/consumer gates, Type Check UI, Build Core, Build & E2E UI, Build Docs, Test Core 1-8/8, Test UI 1-16/16, Live E2E UI, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset, PR-body guards, Spec property liveness, Check Documentation Links, all Lint · jobs); the 03:33Z PR-body-edit re-run has Check Changeset in_progress and Auto Label/Check PR Size skipped. The queue entry waits for that re-run to finish green (contract-review.md ②); this record does not stand in for it.
  6. Dev's open_questions: [] — nothing to answer. The earlier record's ③.5 (unnamed rule ids) and ③.4 (code/status) are both closed by this commit.

④ Design judgment

  • packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts — the pin sits at the right layer and in the right file: the door that maps a gate error to 422 INVALID_METADATA is the subject, the file is this component's shared harness (ruling 6583487846: a new top-level describe, not a new file), and the harness is extended by overriding registry.listItems and getPackage on the stub rather than by a second engine — the closure (app.runtime installed, no dependencies) is real at the door, not simulated in lint. The LIT asserts envelope, located issue and the absence of a stored active row; the CONTROL asserts the good key still saves under the same closure (the card's third pin, now at the door). The ablation shows the LIT depends on limb 5 through the built artifact and the CONTROL does not. Holds against the card's scenario.
  • content/docs/deployment/validating-metadata.mdx — one paragraph, placed where the four limbs are stated, saying what the code does and only that, including the type boundary (flow and list view). Holds against runtime-gate.ts as read.
  • packages/lint/src/runtime-gate.ts, runtime-gate.package-closure.test.ts — unchanged since the reviewed head; ④ of comment 6648480102 stands.
  • Must-fix defects: none.

Implemented-by: claude/project-thread-dogsgp
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

This branch has not been deployed

No deployments
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

1 participant