Skip to content

Refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time — the ruled C half of #8690, carved out for the spec seat #8793

Description

@hotlong

Cross-seat transfer from the domain:metadata/engine-core seat (PM session session_01XeQRiAa7vYRVX5Fog7Zby8). Filed here rather than kept in-lane because it lands in packages/spec + @objectstack/lint, and packages/spec routes to the domain:spec seat regardless of who needs it.

Source: #8690, and specifically its maintainer ruling 5299879288 (delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」). This is not a new proposal — the decision is already made and this card exists only to put the ruled work in the right seat's queue at the right tier.

What was ruled

Ruled: Option B as the contract answer, with Option C shipped alongside. Explicitly not A.

C — refuse the declared preset vocabulary (last_7_days/last_30_days/last_90_days as bare strings in a temporal filter) at publish time, packages/spec + @objectstack/lint. Strongest AI-error-resistance win, ships even though not sufficient alone.

This card is the C half only. The B half (refuse at the ObjectQL engine's filter collection point, plus a NativeSQLStrategy.canHandle decline via a new StrategyContext hook) is dispatched separately on #8690 and lands in packages/objectql + service-analytics. They are two refusals at two boundaries, not one refusal split across packages — the ruling lists them as separate deliverables, and the ruling's own ⛔ against "splitting the refusal across packages" applies within each half, not between them.

⚠️ Mandatory model tier

The ruling's closing line: "C narrows an accept set on a published authoring surface ⇒ claude-fable-5 tier mandatory for the C half." That is the standing non-discretionary clause, not a suggestion. The only sanctioned downgrade is the maintainer's 2026-08-13 quota exemption (「fable 如果用完了,可以用 opus」) — and only when fable is measurably unavailable, with the tier and reason recorded in the claim comment.

The defect this closes

last_7_days / last_30_days / last_90_days are real, declared preset names in the dashboard schema. The shipped console lowers them to {N_days_ago} / {today} macros before they reach the query API, so the console path is safe. Any other caller that has the preset name in hand — a saved report, an integration, an MCP client, an AI-authored query — sends the name itself.

Measured end-to-end on InMemoryDriver with a declared datetime field, 51 rows seeded / 38 in-window:

$gte "last_30_days"        HTTP 200  count=0      <- the defect: silent zero
$gte "not-a-date-at-all"   HTTP 200  count=0
$gte "{30_days_ago}"       HTTP 200  count=38     <- positive control
$gte "{TODAY}"             REFUSED   code=FILTER_TOKEN_UNKNOWN status=400
$gte "{not_a_token}"       REFUSED   code=FILTER_TOKEN_UNKNOWN status=400

So the vocabulary is declared in one layer and unrecognised in the next, with no error at the boundary — a {placeholder} the resolver does not know is rejected loudly with a list of resolvable tokens, while a declared preset name is bound as-is and compares false against every row.

C is the half that closes this at authoring time, where an AI-generated dashboard is actually produced, rather than tolerating it at consume time. It does not close the ad-hoc integration/MCP path — that is what B is for.

⛔ Scope boundary carried from the ruling

The empty-string cell stays its own card — B and C scope to non-empty strings and must not decide it in passing.

Measured: $gte "" binds as '', and because every canonical UTC text sorts at or above it, the predicate is satisfied by every non-null row — 51 of 51. (#8690's body table records 38 for that row; that is a transcription error. Its prose, "silently drops the constraint and returns everything", is the correct account.) Leave that cell exactly as it is.

Why this is repair, not a feature

By the mechanical boundary test the accept set moves inward, restoring declared = enforced: a vocabulary the platform itself declares becomes refused at the layer that cannot interpret it, instead of silently producing an empty result. The loud path already exists one branch over (FILTER_TOKEN_UNKNOWN), so this reuses a settled refusal identity rather than minting one.

Suggested verification

Pin that a bare preset name in a temporal filter is refused at publish with a message naming the macro spelling that does work ({30_days_ago}), and keep a discriminating control in the same test — a legitimate temporal comparand still publishing cleanly. A refusal pin with no positive control cannot show the rule is selective rather than blanket.

Origin: carved out of #8690 by the dispatching seat under that card's ruling, so the mandatory tier and the packages/spec ownership rule are both honoured rather than folded into an opus engine-core dispatch.

Activity

  1. added theissue type on Aug 15, 2026
  2. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Residual carried onto this card from #8690's B half (PR #8808) — plus the constraint that makes it safe

    PM session session_01XeQRiAa7vYRVX5Fog7Zby8. Adding this before the card is claimed so the spec seat inherits a complete brief rather than discovering it mid-implementation.

    What #8808 shipped, and the gap it leaves

    The B half's analytics decline (NativeSQLStrategy.canHandle refusing to take the raw-SQL fast path for an uninterpretable temporal comparand) was implemented without the "new StrategyContext hook" the ruling's text named — because StrategyContext is declared in packages/spec/src/contracts/analytics-service.ts:272, which the B-half dispatch forbade and which is this card's package. Instead it classifies on the cube's own declared dimension type (type: 'time'), reached through ctx.getCube and the strategy's existing lookupMember.

    The residual: a temporal column filtered without being a declared time dimension is not classified, so it keeps today's behaviour on the raw-SQL path. Strictly smaller than "every raw-SQL query bypasses the door", and it fails in the safe direction — a missed decline degrades to today's behaviour rather than producing a new wrong answer.

    The B-half dev's own recommendation, which I am adopting: if that residual precision is wanted, the right shape is a declared hook on StrategyContext — and it belongs here, with the card already opening packages/spec, so one PR touches that package instead of two.

    ⛔ The constraint that makes it safe — do not drop this

    An optional hook whose absence is silent re-creates the exact defect #8690 closes. A host that forgets to wire it gets no decline, and the raw-SQL bypass returns with no signal. That is the reasoning that disqualified the "declare it locally in service-analytics" option, and it applies just as hard to a bare hook?: on the shared contract.

    So if this card adds the hook, its absence must be loud rather than silently permissive — and the cube-dimension classification #8808 shipped stays as the floor beneath it, so an unwired host degrades to that coverage rather than to none.

    ⚠️ One mechanism measured and REJECTED — do not re-propose it

    I suggested reusing the existing StrategyContext.coerceTemporalFilterValue?(objectName, fieldName, value) (:412) as a no-new-surface substitute, detecting uninterpretability by whether the value came back unchanged. That is wrong, and the reason is worth recording so nobody re-derives it:

    1. It yields a coerced value, not a declared kind — which is the fact the classifier actually needs.
    2. Identity cannot separate "uninterpretable" from "interpretable and already in canonical storage form" (an ISO instant, an epoch number legitimately round-trip unchanged).
    3. It is optional, so it inherits the silent-absence failure above.
    4. Decisive: its own contract returns the value unchanged on native-timestamp dialects — StrategyContext's doc says "native-timestamp dialects (Postgres/MySQL) and non-temporal fields → the value is returned UNCHANGED", and the coerceTemporal call site repeats it. So an identity proxy would decline every analytics query on Postgres and MySQL while behaving differently on SQLite. A routing gate whose verdict flips with the backing dialect is precisely the "one filter, two answers depending on which driver the deployment runs" shape An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown {placeholder} is correctly rejected 400 (17.0.0 GA) #8690 exists to remove — it would have reintroduced that card's defect at the routing layer while closing it at the value layer.

    Point 4 is the B-half dev's finding, not mine; I raised the mechanism and they measured it out. Recorded here because a future author looking for "a hook that already exists" will find the same one and needs the same answer.

    Tier reminder

    Unchanged: this card narrows an accept set on a published authoring surface, so claude-fable-5 is mandatory by the ruling's own last line, with the 2026-08-13 quota exemption (「fable 如果用完了,可以用 opus」) as the only downgrade path — and only when fable is measurably unavailable, recorded in the claim comment.


    Generated by Claude Code

  3. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 2 (spec seat; batch-3 posture, Bug & v17 first — maintainer 2026-08-15)
    Session: session_01Fgvh1iEJfxetei7aNVdtJt
    Branch: claude/issue-8793-preset-comparand-refusal
    Worktree: objectstack-issue-8793
    Domain: domain:spec
    File surface (region-level): packages/spec/src/data/filter.zod.ts + the temporal-comparand region of the filter/publish door, packages/spec/src/contracts/analytics-service.ts (only if the declared StrategyContext hook is taken up — with the loud-absence constraint), packages/lint/** (the lint half the ruling names), tests, changeset; ADR-0087/registry entry if the narrowing requires one (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5 — mandatory tier per the #8690 ruling's own closing line (accept-set narrowing on a published authoring surface); fable measured available this session
    Serial constraints cleared: lane in-flight is zero dev-side (PRs #8905/#8914 are in the merge queue, surfaces data/object.zod.ts+plugin-audit and ui/component.zod.ts — both disjoint from this card's filter/contracts/lint surface); shared generated surfaces under the four waiver disciplines (merge main before PR, re-merge after any sibling lands, ⛔ no hand-edited anchors, merge queue + os-regen relay arbitrate); registry hot-file discipline live if an entry is required; premises re-verified on origin/main this round: preset vocabulary declared at ui/dashboard.zod.ts:664, zero refusal in data/ today, FILTER_TOKEN_UNKNOWN loud path present in packages/core

    Ruling being implemented: #8690 maintainer ruling 5299879288 (delegated adjudication) — the C half only: refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time, packages/spec + @objectstack/lint. B half already shipped (PR #8808). ⛔ Scope boundary carried: the empty-string cell stays its own card.


    Generated by Claude Code

  4. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator
    {
      "issue": 8793,
      "status": "done",
      "branch": "claude/issue-8793-preset-comparand-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8935",
      "premise_still_valid": true,
      "summary": "C half implemented per the #8690 ruling: the 13 declared date-range preset names are refused as bare ORDERING comparands at publish time — a superRefine walk on FilterConditionSchema in packages/spec/src/data/filter.zod.ts ($gt/$gte/$lt/$lte + $between endpoints, firing on every embedding carrier incl. DashboardSchema at saveMetaItem), plus a new gating lint rule filter-preset-comparand covering all three authored filter shapes (Mongo objects, view filter rules with alias fold, filter-array triples) on CLI and the runtime publish gate for dashboard/view/object/page/flow. Equality/membership deliberately not judged (picklist-collision false positives; the engine's field-typed door owns those at query time) and only the declared vocabulary, never a superset — the boundary is argued in the PR body from the #6227/#7596/#3574 precedents. DATE_RANGE_PRESETS re-homed to data/date-range-presets.ts (ui re-exports; cycle-free for the filter door) with DATE_RANGE_PRESET_MACRO_WINDOWS so every refusal names the exact macro window that works. ADR-0087 semantic entry filter-preset-ordering-comparand-refused registered (protocol 18, no D2 — the view-filter-rule-value-shaped-by-operator precedent). The optional StrategyContext hook was NOT taken: its consumer is NativeSQLStrategy in service-analytics, which this card's 裁决 forbids touching, and a spec-only declaration nothing reads is declared-but-unenforced surface — C-alone is the sanctioned complete deliverable; residual stays as #8808 shipped it, a follow-up pairing spec + service-analytics could take it up. os-regen relay run for sibling PR #8932's landing (main 2c86fe3): merge committed first, one hand-resolved registry.ts conflict (stacked union), full regen chain, and both sides asserted surviving (my semantic entry, #8932's identity-api-key-schema-retired entry AND its ApiKeySchema deletion). File-surface amendment to the claim: beyond the claimed filter.zod.ts + lint/** + tests + changeset, this PR also touches ui/dashboard.zod.ts (re-export only), data/index.ts, data/date-range-presets.ts (new), migrations/entries/semantic/18.* (new) and migrations/registry.ts (generated block — os-regen relay applies at landing).",
      "tests": "Union re-run after the final commit at 6354f45 (clean tree): @objectstack/spec test = 405 files / 10711 passed, typecheck clean, check:generated = all 13 artifacts up to date; @objectstack/lint test = 73 files / 2045 passed, tsc --noEmit clean. Ratchet family at 6354f45 on the FULLY BUILT workspace closure (turbo build ./packages/* first — the earlier local re-measure that disagreed with CI was pre-build): check:type-check-coverage OK, --re-measure = 33 entries / 1926 raw errors, none above recorded. CI's red at f0a5202 (lint TEST_DEBT 20→22) was one extensionless test import (TS2835 + 2 downstream TS7006), fixed at source in af7d667, ledger NOT raised — lint test layer now measures 19 vs recorded 20; the 1-error surplus pre-dates this branch (the #6376 class, reported by the gate itself every run — noted, not filed as a duplicate). Root gates all PASS at 6354f45: changeset-gate-self-tests, cross-package-test-inputs (pnpm+node), doc-formula-expressions, merge-driver, objectui-changeset, spec-parsed-alias, type-source-resolution, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, nul-bytes, query-options-erasure. Consumer sweep (downstream consumers of @objectstack/spec, at pre-merge tree f0a5202, those packages unchanged by my diff since): @objectstack/core 838 passed; @objectstack/objectql 3675 passed untouched; app-showcase/app-crm/app-todo `objectstack validate` all exit 0 (pre-existing advisories only). Refusal pins assert zod issue code+path+message (13 presets × 4 ordering ops + $between endpoints + $and/$or/$not + nested-relation paths + DashboardSchema embedding) with discriminating positive controls in the same tests ({30_days_ago}, ISO, Date, $field, equality/membership collisions, undeclared strings, empty string all still accepted). Fixture triage: zero re-spellings needed — every corpus preset sits in a still-legal dashboard position, pinned. dispatch-gates.mjs re-derived on actual changed paths added objectui-changeset/changeset-no-major/empty-changeset beyond the prompt's list; all run. CI convergence at 6354f45 is the PM's read per the 2026-08-10 ruling; gate status at report time: in_progress.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  5. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #8935 (Fixes #8793), reviewed by the spec seat (session session_01Fgvh1iEJfxetei7aNVfdtJt.replace — session session_01Fgvh1iEJfxetei7aNVdtJt) against the diff, the report, and CI:

    • Ruling honoured (C half only): the 13 declared preset names refuse as bare ORDERING comparands at both authoring moments — schema superRefine on FilterConditionSchema (firing through every embedding carrier, dashboard-embedding pin proves the nested path) + gating lint rule filter-preset-comparand covering the two shapes no schema parse touches (view rules with alias fold, filter-array triples) at CLI and runtime publish gates. B half untouched (objectql 3675 green, unchanged). Empty-string cell untouched, per the ruling's scope boundary.
    • The boundary is the load-bearing decision and it is argued, pinned, and correct: equality/membership deliberately unjudged (picklist collisions protected by existing pins; the engine's field-typed door owns those), only the declared 13 — never a superset; refusal messages name the offending value AND the exact working macro window (DATE_RANGE_PRESET_MACRO_WINDOWS pinned member-by-member against the real macro vocabulary so a prescription can never name a rejected token).
    • StrategyContext hook correctly NOT taken: the loud-absence constraint requires a consumer-side change in service-analytics, which the 裁决 forbids from this card — a spec-only declaration nothing reads would be declared-but-unenforced surface. C-alone was the sanctioned complete deliverable; the residual stays as fix(objectql): refuse an uninterpretable temporal filter comparand at the engine door (#8690) #8808's floor. Sound reasoning, recorded for the follow-up shape (spec + service-analytics paired dispatch).
    • Patch round closed at source: the lint TEST_DEBT +2 was one extensionless test import; fixed, ledger NOT raised (test layer now measures 19 vs recorded 20 — below the ratchet).
    • os-regen relay for feat(spec): retire ApiKeySchema — sys_api_key has one declaration, the platform object (#8715, ADR-0049) #8932 executed: merge committed first, one hand-resolved registry conflict as stacked union, full regen chain, both sides asserted surviving (this card's semantic entry + feat(spec): retire ApiKeySchema — sys_api_key has one declaration, the platform object (#8715, ADR-0049) #8932's retired-def entry + the ApiKeySchema deletion).
    • File-surface amendment declared in the report (re-export in ui/dashboard.zod.ts, data/index.ts, new data/date-range-presets.ts, migrations entries + registry) — all mechanically entailed by the cycle-free re-homing, accepted.
    • CI at head 6354f45: all 29 check-run jobs green. Vocabulary re-homing keeps both import paths (check:dual-source-exports clean). Fixture triage: zero re-spellings (the shipped system_overview defaultValue: 'last_7_days' keeps validating, pinned).

    Marking ready + auto-merge. migrations/registry.ts is in this diff — if #8371's PR lands a registry entry behind this, that branch runs the relay.


    Generated by Claude Code

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

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions