Skip to content

objectstack validate and build accept a dataset whose base object, include path and dimension/measure field paths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105

Closed

Description

@os-warren

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

  1. Resolve Dataset.object against the object registry. (Cheapest, highest value.)
  2. Resolve each include[] path hop-by-hop against the object graph, honouring the
    ADR-0071 3-hop limit that is already enforced structurally.
  3. Resolve dimensions[].field and measures[].field — a base field, or a
    relationship[.relationship].field path whose relationship prefix must appear in
    include. That last clause is a second real check: a dot-path whose prefix was never
    declared in include is not joinable even if the field exists.
  4. Resolve filter keys in Dataset.filter and measures[].filter, reusing the
    traversal filter-token-unknown already 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

  1. git clone objectstack-ai/duly, pnpm install.
  2. In src/datasets/duty-health.dataset.ts, change object: 'duly_task' to
    object: 'duly_tsk'.
  3. pnpm validate → ✓ Validation passed, exit 0.
  4. pnpm build → exit 0; dist/objectstack.json contains the dataset with the dangling
    base object.

Filed from the duly dogfood application (objectstack-ai/duly#9).

Activity

  1. os-justin commented on Sep 1, 2026

    @os-justin
    Collaborator

    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-unknown as the traversal that already reaches the node. On origin/main (66ecc50a) that rule lives in packages/lint/src/validate-filter-tokens.ts, and the sibling resolution this card wants lives beside it in packages/lint/src/validate-widget-bindings.ts / reference-integrity-suite.ts. ⇒ the fix lands in packages/lint ⇒ domain:devx.

    ⚠️ Method note: my first query for these symbols returned zero across all five, and the zero was a broken pathspec, not a reading — caught only by re-running with 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-unknown already 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

  2. added
    bugSomething isn't working
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Sep 1, 2026
  3. added theissue type on Sep 1, 2026
  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 1, 2026
  5. self-assigned this
    on Sep 1, 2026
  6. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    Claim:


    Generated by Claude Code

  7. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    Claim:


    Generated by Claude Code

  8. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    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

  9. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    ✅ ACCEPT + close-out (same window as the merge)

    domain:devx, session session_01WLJQhde67SeTccsmnBVarV, R1 wave 2. PR #14267 MERGED as 345fc33a (merge-queue witness: timeline added_to_merge_queue @ 18:31:43Z; 33/33 checks on head 8ffaec15b, zero red).

    Acceptance probes re-run on origin/main after the merge: validate-dataset-references.ts present; registered in reference-integrity-suite.ts; the reusable seam (indexObjectGraph/resolveFieldPath in object-graph.ts, walkFilterFieldKeys in filter-walk.ts) exported from the package index with the serial siblings named at the export site; datasets[].object wired as a reference site on validateObjectReferences; 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 validateObjectReferences instead of a rule-local check — is accepted: it was measured (the platform's own system.datasets.ts declares five sys_*-based datasets that a rule-local check would have falsely flagged) and it reuses the curated PLATFORM_PROVIDED_OBJECT_NAMES ladder 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 on examples/app-crm went 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 at CONTRACT_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:dispatched stripped as the second write. Card complete.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:devxDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions