Repository navigation
Description
Summary
An ADR-0021 dataset can name a base object that does not exist, join a
relationship that does not exist, and bind every dimension and measure to
fields that do not exist, and objectstack validate exits 0 with
✓ Validation passed. objectstack build also exits 0 and writes the dangling dataset
into dist/objectstack.json.
The sting is that the author-time rule pass already walks these exact nodes: a bad
date-macro token in a measure filter is caught, path-precise, on the same traversal.
So the machinery and the traversal are both present — only the reference resolution is
missing.
This is one level below #7529 (closed by #8902, which refuses a widget → dataset
binding that names nothing). This is the dataset → object/field binding underneath it.
A board can now be proven to point at a real dataset, and that dataset can still point at
nothing.
Measured
@objectstack/spec 17.2.0, @objectstack/cli 17.2.0, on objectstack-ai/duly
(3 datasets over duly_task). Each row is one mutation applied on its own, confirmed on
disk before running (old spelling absent, new spelling present), then reverted.
| # | Mutation | pnpm validate |
|---|---|---|
| 1 | dimension field: 'period_key' → 'period_kee' (base field) |
exit 0, passed |
| 2 | dimension field: 'duty.frequency' → 'duty.frequenci' (joined field) |
exit 0, passed |
| 3 | measure field: 'last_update_at' → 'last_update_att' |
exit 0, passed |
| 4 | measure filter key last_update_at → last_update_attt |
exit 0, passed |
| 5 | include: ['duty'] → ['dutee'] (relationship does not exist) |
exit 0, passed |
| 6 | object: 'duly_task' → 'duly_tsk' (base object does not exist) |
exit 0, passed |
| 7 | duplicate measure name (schema superRefine) |
exit 1 — caught |
| 8 | filter value '{7_days_ago}' → '{7_fortnights_ago}' |
exit 1 — caught |
Rows 7 and 8 are the control: datasets genuinely are in the validation path, so rows 1–6
are not "the file was never read".
Row 8's message shows the walker reaching all the way into a measure filter with an exact
path:
✗ Author-time rules failed (1 issue)
• dataset "duly_stagnation": Filter value "{7_fortnights_ago}" is not a resolvable placeholder.
It is sent to the data engine as a literal string, matches no record, and the surface renders empty.
rule: filter-token-unknown at datasets[1].measures[1].filter.last_update_at.$lt
That message is the whole argument for this issue. filter-token-unknown already stands
at datasets[1].measures[1].filter.last_update_at.$lt and reasons about the value.
Nothing standing in that same position resolves the key — or the sibling
measures[].field, dimensions[].field, include[], or object.
Why this matters more for a dataset than for most metadata
filter-token-unknown's own wording states the failure mode it exists to prevent:
"sent to the data engine as a literal string, matches no record, and the surface renders
empty." A dangling field path produces the same outcome from the same node and is not
checked.
A dataset is the semantic layer: dashboards and reports bind its dimensions and measures
by name (ADR-0021), and the consumer end is now guarded (#8902). So the surviving failure
is the quiet one — every binding resolves, the board renders, and the charts are empty or
subtly wrong because the dataset underneath addresses columns that do not exist.
Row 6 is the one I would prioritise: a dataset over a non-existent base object is
unambiguously meaningless, has no forward-reference story worth preserving, and is the
cheapest possible check.
Precedent in the tree
The platform already resolves qualified field reads elsewhere and produces a good message.
From the same repo's flow-condition validation:
unknown field `needs_colection` on `duly_assignment` — did you mean `needs_collection`?
That is the message shape this wants. The resolver exists; it is not wired to dataset
field paths.
Related: #14089 (bare identifiers in flow conditions have no validation leg) is the same
family — a validator with a per-surface hole — though a different surface.
Suggested scope
- Resolve
Dataset.objectagainst the object registry. (Cheapest, highest value.) - Resolve each
include[]path hop-by-hop against the object graph, honouring the
ADR-0071 3-hop limit that is already enforced structurally. - Resolve
dimensions[].fieldandmeasures[].field— a base field, or a
relationship[.relationship].fieldpath whose relationship prefix must appear in
include. That last clause is a second real check: a dot-path whose prefix was never
declared inincludeis not joinable even if the field exists. - Resolve filter keys in
Dataset.filterandmeasures[].filter, reusing the
traversalfilter-token-unknownalready performs on the values.
Timing: validate/build at the latest, matching #8902's "refuse at publish at the
latest" posture for the layer above.
Reproduction
git clone objectstack-ai/duly,pnpm install.- In
src/datasets/duty-health.dataset.ts, changeobject: 'duly_task'to
object: 'duly_tsk'. pnpm validate→✓ Validation passed, exit 0.pnpm build→ exit 0;dist/objectstack.jsoncontains the dataset with the dangling
base object.
Filed from the duly dogfood application (objectstack-ai/duly#9).
Activity
os-justin commentedon Sep 1, 2026
Triage →
domain:devx· p2 · Bug. Part of a five-card author-time-validation family; see the shared-root note below.Anchoring — measured, not inferred from the title. The card names
filter-token-unknownas the traversal that already reaches the node. Onorigin/main(66ecc50a) that rule lives inpackages/lint/src/validate-filter-tokens.ts, and the sibling resolution this card wants lives beside it inpackages/lint/src/validate-widget-bindings.ts/reference-integrity-suite.ts. ⇒ the fix lands inpackages/lint⇒domain:devx.packages/lint/src/lint-view-refs.ts(named by a sibling card) as a positive control. Recording it because a zero here would have mis-routed the whole family.Type = Bug, not Feature. The mechanical boundary test asks whether the change widens the accept set: it narrows it. And ADR-0078 (no silently inert metadata) declares the invariant this surface fails to enforce ⇒ declared ≠ enforced ⇒ Bug. ⛔ Not a Feature despite reading like "add a new check".
Grade p2. Silent-empty render with all gates green, on the semantic layer that dashboards bind by name. ⛔ Not p1: authoring-time gap, no production outage, no security boundary. The consumer end above it is already guarded (#8902), which is precisely what makes the surviving hole the quiet one.
⭐ Shared root with #14148 and #14107 — one fix shape, not three
This card's suggested scope item 4 (resolve filter keys, reusing the traversal
filter-token-unknownalready performs on the values) is the same defect as #14148 §A, on a different node. Both cards independently observe: the walk already descends the filter tree and already knows the bound object; only the key resolution is absent.And the resolution itself already exists a third place:
dashboard-filter-field-unknown(packages/lint/src/validate-widget-bindings.ts, shipped #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and prints the object's field list.⇒ Filter field-key resolution is implemented in one of the three places it is needed. Whoever takes these should price them as one mechanism reused, ⛔ not three independent rules. Flagging for the lane's fold-or-serial call — this family shares hot files in
packages/lint/src.Priority within the card: the reporter's own ranking stands — row 6 (dataset over a non-existent base object) is the cheapest check with no forward-reference story worth preserving. ⛔ Do not let the harder
include[]hop-walking (item 2) block shipping item 1.Dedup: the card's own positioning is correct and I confirmed the relationship — #7529/#8902 guard widget → dataset; this is dataset → object/field underneath it. ⛔ No duplicate. Nearest live sibling is #14089 (bare identifiers in flow conditions), same class, different surface, already queued.
Generated by Claude Code
baozhoutao commentedon Sep 1, 2026
Claim:
session_01WLJQhde67SeTccsmnBVarV(devx execution seat, seat post [PM seat] domain:devx @ objectstack — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责 #6023)claude/issue-14105-dataset-ref-resolutionorigin/main(dev creates it; no shared-checkout edits)domain:devxpackages/lint/src/**(expected: a new dataset reference-resolution rule besidevalidate-filter-tokens.ts/validate-widget-bindings.ts/reference-integrity-suite.ts) + rule registration + fixtures/tests + changeset. ⛔ Nopackages/spec/src/**edits expected; if the fix genuinely needs one, the dev must stop and report back instead of widening.mode:subagent,claude-opus-5. Tier readout (dispatch-gates--tier, derived): "Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s) … The tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable)." PM judgment: default opus.packages/spec/src/**in the surface). Content limb judged NOT engaged: this card enforces an already-declared invariant (ADR-0078 declared ≠ enforced, typed Bug by triage on exactly that test) rather than changing the contract's accept/reject shape. Will be re-examined against the real diff at review; the PM session is atCONTRACT_REVIEW_TIERand can run the in-seat review if the diff surprises.objectstack validateandbuildaccept a dataset whose base object,includepath and dimension/measurefieldpaths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105 → A dashboard widget's OWNfilterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148 → No field reference on a list view is checked at author time — columns, filter, sort, grouping and every binding block accept a misspelt field name through bothos validateandos build#14107; App navigationviewNameis never resolved against the target object'slistViews— a typo silently lands the user on the default view #14108 also queued behind it — all share hot files inpackages/lint/src, per triage's fold-or-serial flag on this card). A dashboard widget's OWNfilterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148 and No field reference on a list view is checked at author time — columns, filter, sort, grouping and every binding block accept a misspelt field name through bothos validateandos build#14107 are NOT dispatched until this lands. The dev is instructed to build the filter-key/field resolution as a reusable helper so the two follow-ups reuse one mechanism, not three.Generated by Claude Code
baozhoutao commentedon Sep 1, 2026
Claim:
session_01WLJQhde67SeTccsmnBVarV— PM-dispatched dev under the PM claim above (devx execution seat, seat post [PM seat] domain:devx @ objectstack — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责 #6023)claude/issue-14105-dataset-ref-resolution(pushed; write routing verified before any edit)origin/main@035951fafobjectstack validateandbuildaccept a dataset whose base object,includepath and dimension/measurefieldpaths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105). Suggested-scope items 1-4 in the reporter's priority order; the field/filter-key resolution is to be factored as a reusable helper for the serial follow-ups (A dashboard widget's OWNfilterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148, No field reference on a list view is checked at author time — columns, filter, sort, grouping and every binding block accept a misspelt field name through bothos validateandos build#14107), but their surfaces are NOT implemented here.origin/mainbefore writing any code.Generated by Claude Code
baozhoutao commentedon Sep 1, 2026
os-dev-report
{ "issue": 14105, "status": "done", "branch": "claude/issue-14105-dataset-ref-resolution", "pr": "https://github.com/objectstack-ai/objectstack/pull/14267", "pr_number": 14267, "premise_still_valid": true, "summary": "All four suggested-scope items shipped. New suite member `validateDatasetReferences` (packages/lint/src/validate-dataset-references.ts) resolves a dataset's `include[]`, `dimensions[].field` / `measures[].field` and filter KEYS against the object graph with four gating rule ids; the base object (item 1) lands as a new `datasets[].object` reference site on `validateObjectReferences` instead, and that divergence from the dispatch's hypothesis 3 is stated in the PR body with the measurement behind it — the platform's own system.datasets.ts declares five datasets over `sys_*` objects that a plugin-auth-only stack cannot see, and all five clear the curated PLATFORM_PROVIDED_OBJECT_NAMES ladder that a rule-local check would have had to reinvent. When the base object does not resolve the new rule skips the dataset entirely, so one typo yields one finding. The reusable seam the serial follow-ups need ships exported and separately pinned: `object-graph.ts` (indexObjectGraph / resolveFieldPath, a discriminated verdict union rather than a boolean) and `walkFilterFieldKeys` in `filter-walk.ts` (the field-key half of a filter subtree, all three authored shapes). Neither #14148's nor #14107's surface is implemented here.", "files_changed": [ "packages/lint/src/object-graph.ts (new)", "packages/lint/src/object-graph.test.ts (new)", "packages/lint/src/validate-dataset-references.ts (new)", "packages/lint/src/validate-dataset-references.test.ts (new)", "packages/lint/src/filter-walk.ts", "packages/lint/src/validate-object-references.ts", "packages/lint/src/validate-object-references.test.ts", "packages/lint/src/reference-integrity-suite.ts", "packages/lint/src/reference-integrity-suite.test.ts", "packages/lint/src/index.ts", ".changeset/dataset-reference-resolution.md (new, minor)" ], "scope_shipped": { "1_base_object": "shipped — object-reference-unknown / object-reference-unregistered-platform at datasets[N].object, on validateObjectReferences", "2_include_paths": "shipped — dataset-include-unknown, hop-by-hop, ADR-0071 3-hop structure honoured", "3_dimension_measure_fields": "shipped — dataset-field-unknown, plus dataset-field-not-included for the include-prefix clause", "4_filter_keys": "shipped — dataset-filter-field-unknown, on Dataset.filter and measures[].filter, in all three authored filter shapes" }, "premise_check_evidence": "Against origin/main 035951faf. Positive controls both present: filter-token-unknown at validate-filter-tokens.ts:65, dashboard-filter-field-unknown at validate-widget-bindings.ts:101. Dataset resolution absent: `git grep -ohE \"'dataset-[a-z-]+'\" -- packages/lint/src` returned nothing; the only dataset-shaped ids were chart-dataset-unknown and widget-dataset-unknown, i.e. the consumer end (#7529/#8902).", "tests": "Union run on final commit 8ffaec15b. (1) End-to-end on the real CLI, both directions, on examples/app-crm: unmutated `objectstack validate` CLEAN_EXIT=0; mutate dimension field 'stage'->'stagee' with disk confirmation (old spelling count 0, new spelling count 1, blob 54b60a3a.. -> 4b536496..), mutated `objectstack validate` MUTATED_EXIT=1 emitting `dataset-field-unknown at datasets[0].dimensions[0].field` with a 'Did you mean \"stage\"?'; restore proven by blob hash back to 54b60a3abf490e97c3660a8c562862888b776e5b with `git diff HEAD --stat` empty (trap-based restore used absolute paths from `git rev-parse --show-toplevel`). (2) No shipped metadata moves: `objectstack validate` exit 0 on app-crm, app-todo and app-showcase unmutated, and the full workspace build `turbo run build` 70/70 tasks green (which runs `objectstack build` on the examples). (3) `pnpm --filter @objectstack/lint test` — 90 files, 2528 passed | 5 skipped, exit 0; 34 new cases in validate-dataset-references.test.ts and 22 in object-graph.test.ts. (4) `pnpm --filter @objectstack/lint typecheck` exit 0 — but see checks_run: that program EXCLUDES **/*.test.ts, verified with `tsc --listFiles` (0 hits for the three test files, 2 for the two new source files), so it is NOT the reading for the new test files; `check:type-check-debt --re-measure` is, and it reports 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, surplus none. (5) Repo-wide `pnpm lint` (eslint . --no-inline-config) exit 0 — the full pass, not a narrowing. Every exit code captured by redirect-then-read, never through a pipe; gate verdicts quoted from each gate's own printed line.", "checks_run": { "dispatch-gates derivation": "pass — 33 families derived with --repo objectstack-ai/objectstack (29 by path, 6 by kind, 2 both ways); all 33 run", "pnpm lint (repo-wide eslint)": "pass", "pnpm --filter @objectstack/lint test": "pass (2528 passed, 5 skipped)", "pnpm --filter @objectstack/lint typecheck": "pass (source only — tests excluded by tsconfig, see tests field)", "turbo run build (packages closure)": "pass (70/70)", "check:changeset-gate-self-tests": "pass", "check:cross-package-test-inputs": "pass", "check:doc-authoring": "pass", "check:dual-build-cjs-loads": "pass (after the full build; refuses as PREREQUISITE NOT MET on an unbuilt tree)", "check:engine-double-contract": "pass", "check:logger-receiver-detach": "pass", "check:objectql-double-limit": "pass", "check:objectui-changeset": "pass", "check:page-declaration-shape": "pass", "check:pm-half-states": "pass", "check:published-files": "pass", "check:query-options-erasure": "pass", "check:slot-lookup": "pass", "check:test-source-alias": "pass", "check:type-check-coverage": "pass", "check:type-check-debt": "pass (re-measure: none above recorded, surplus none)", "check:type-source-resolution": "pass", "check:where-matcher": "pass", "check:ratchet-remedy-authority": "pass (dispatch-named, not path-derived)", "check:declared-population-live": "pass (dispatch-named, not path-derived)", "check:nul-bytes": "pass (plus a manual control-byte grep over all 11 changed files: no hits)", "scripts/check-adr-0087-registration.mjs": "pass", "scripts/check-changeset-no-major.mjs": "pass", "scripts/check-ci-filter-parity.mjs": "pass", "scripts/check-comment-mask-adoption.mjs": "pass", "scripts/check-cross-package-test-inputs.mjs": "pass", "scripts/check-empty-changeset.mjs": "pass", "scripts/check-keyed-text-bounds.mjs": "pass", "scripts/check-plugin-teardown-shape.mjs": "pass", "scripts/check-shard-attestation.mjs": "pass", "scripts/check-undeclared-dep-imports.mjs": "pass", "scripts/docs-audit/check-affected-docs.mjs": "pass", "scripts/docs-audit/check-drift-comment.mjs": "pass", "scripts/pm/check-half-states.mjs": "pass", "scripts/pm/release-rehearsal-clone.mjs --self-test": "pass", "scripts/check-test-completeness.mjs": "NOT MEASURED — exit 3, PREREQUISITE NOT MET (grades a saved `turbo run test` log only CI produces). Not a red.", "CI convergence": "in_progress — draft PR just opened; per the dispatch contract the report is delivered at draft-PR time and CI convergence is the PM's review step" }, "open_questions": [], "followups_filed": [ "filed as #14268: [finding] `suggest`/`distance` (\"did you mean?\") is hand-copied four times across packages/lint — the fourth copy is this PR's own, annotated at its definition; the copies have already diverged (validate-widget-bindings.ts scores containment first and suggests `sum_amount` for `amount` where the other three suggest nothing), which is the #4330 SYSTEM_FIELDS shape one constant over. Unassigned, `finding` label, no pm:queue." ], "out_of_scope_findings": [ "filed as #14268: hand-copied levenshtein suggestion helper x4 in packages/lint (see followups_filed)", "NOT filed, already open as #14173: packages/lint has no tsc program that compiles its tests — I independently measured this (tsconfig excludes **/*.test.ts; `tsc --listFiles` shows 0 hits for the three test files touched here) and the existing card covers it, so no duplicate was created.", "Channel note: the dedup search switched channels. A repo-scoped REST read probe passed (GET /repos/.../issues/14105 -> HTTP 200), but the REST SEARCH endpoint answered HTTP 403, so one targeted MCP `search_issues` was used instead. It returned 3 results (#14173 open, #5017 and #4330 closed), so the channel is proven live rather than silently empty; #4330 is the closest prior art and is closed." ], "mcp_calls": 7 }Generated by Claude Code
baozhoutao commentedon Sep 1, 2026
✅ ACCEPT + close-out (same window as the merge)
domain:devx, sessionsession_01WLJQhde67SeTccsmnBVarV, R1 wave 2. PR #14267 MERGED as345fc33a(merge-queue witness: timelineadded_to_merge_queue@ 18:31:43Z; 33/33 checks on head8ffaec15b, zero red).Acceptance probes re-run on
origin/mainafter the merge:validate-dataset-references.tspresent; registered inreference-integrity-suite.ts; the reusable seam (indexObjectGraph/resolveFieldPathinobject-graph.ts,walkFilterFieldKeysinfilter-walk.ts) exported from the package index with the serial siblings named at the export site;datasets[].objectwired as a reference site onvalidateObjectReferences; minor changeset present.All four suggested-scope items shipped (include hop-walk did not need the escape hatch). The dev's one deliberate divergence from the dispatch — base object on
validateObjectReferencesinstead of a rule-local check — is accepted: it was measured (the platform's ownsystem.datasets.tsdeclares fivesys_*-based datasets that a rule-local check would have falsely flagged) and it reuses the curatedPLATFORM_PROVIDED_OBJECT_NAMESladder instead of reinventing it. Skip semantics (ADR-0072 D1: never guess) documented in the changeset; every shipped dataset in the repo is silent under the new rules, and the end-to-end mutation probe onexamples/app-crmwent red in exactly the predicted direction.Clause-② re-examined on the real diff: no
packages/spec/src/**edits; the accept-set narrowing enforces the declared ADR-0078 invariant — content limb not engaged (and this review was performed by the seat session, which is atCONTRACT_REVIEW_TIER).Serial family now unblocked: #14148 → #14107 → #14108 queue behind this landing and must reuse the exported seam (⛔ no second hop-walker, no second filter-key reader). Follow-up #14268 (4× hand-copied suggestion helper) filed by the dev, awaiting triage.
pm:dispatchedstripped as the second write. Card complete.Generated by Claude Code
wip(lint): widget filter keys + sortBy limbs on the #14105 seam