Skip to content

fix(service-analytics)!: the read scope and the draft preview compare a temporal comparand in the column storage form (ADR-0053 D-A1 / D-A2) - #21562

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21505-read-scope-temporal-coercion
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21505-read-scope-temporal-coercion

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21505
Clause-②: yes (narrowing)

ADR-0053 D-A1 binds every surface that puts a filter comparand into raw SQL to the driver's own temporal coercion, and D-A2 makes the comparand coercion and its column companion a pair. The analytics read scope bound a temporal comparand as written, and the draft preview compared temporal values as text. On a declared datetime column both faces then selected different rows from engine.find for the same filter, in both directions. Triage ruling 5964364198 (one coercion, the engine door's, no second copy) and claim revision 5964626028 set the shape built here.

What changed

The read scope (read-scope-sql.ts). ReadScopeCompileOptions gains two optional members, coerceTemporalFilterValue(field, value) and coerceTemporalFilterColumn(field, columnSql). They mirror StrategyContext.coerceTemporalFilterValue / coerceTemporalFilterColumn, bound to the object the scope reads, and reach the driver's temporalFilterValue / temporalFilterColumnSql. After the shared lowering, every value comparison (implicit equality, $eq, $ne, the four orderings, $in, $nin, $between) binds its comparand through the first and reads its column through the second. Null tests, $empty and the text arms read the column as stored, as the native where face does. An absent member is identity. The order is ADR-0053 D-E3's by construction: the lowering widens a bare day first, then the arm converts the bound.

The two callers. NativeSQLStrategy.applyReadScope binds the context's pair to the OBJECT, never the join alias. The ObjectQLStrategy read-scope echo binds it to its table. One call site each.

The draft preview (preview-evaluator.ts). It has no driver, so its counterpart of the engine door is the rule that door applies. @objectstack/core's temporalStorageForm, for the kind temporalComparandKind gives the declared type, is applied to both sides of each value comparison: the comparand, and the drafted row's value, as driver-memory reads them. It covers the where and the window. A column the host names no type for stays as written. The declared type is the reader #21417 already hands the evaluator, so analytics-service.ts is untouched.

No new export (the package index is unchanged), no @objectstack/spec edit, and no new dependency edge.

Changeset: @objectstack/service-analytics minor, BREAKING, with an ADR-0087 not-required (no-migration-prescription) disposition. The answer moves in both directions onto the engine's rows: on some filters fewer rows than before, on others more.

Measured before the change (at d2f452b; classes only, the cells live in the pins)

  • Read scope vs engine.find, over 16 cells (12 datetime, 4 date): 7 of 12 datetime cells differed on SQLite, and 9 of 12 on PostgreSQL 16.14 under a non-UTC server (America/New_York). Both dialects included the admitting direction. The date cells differed on 0 of 4.
  • Through the plugin's own composition with a host getReadScope, the native face differed on the same cells; the ObjectQL face (the engine path) differed on none.
  • The draft preview differed on 7 of 12 datetime cells and 0 of 4 date cells. A window end written shorter than the stored instant left out the row at that instant.
  • Core's temporalStorageForm equals the driver's coercion on SQLite and PostgreSQL. On MySQL the driver adds its own physical spelling, which is why the read scope takes the driver's pair rather than the core rule.

Pins

  • read-scope-temporal-coercion.test.ts (new):
    • compiler shape: the hook sees the lowered comparand; every value comparison takes both halves; null, $empty and text arms are untouched; absent members equal identity members;
    • the 16 cells on SQLite and on live PostgreSQL, with a non-UTC server asserted: the read scope compiled with the driver pair equals engine.find;
    • end to end, the native face with a host getReadScope equals the engine on all 16;
    • the ObjectQL echo prints the coerced comparand;
    • the column half, on an uncertified SQLite datetime column (an external object, which the driver never backfills), against the driver's own find;
    • the draft preview through queryDataset previewDrafts: the 16 cells and two window ends written shorter than the stored instant equal engine.find, over drafted rows in the canonical spelling and over the same instants respelled.
  • analytics-faces-one-lowering.test.ts: the read-scope (F9) test's PostgreSQL skip is gone; it passes the driver pair and runs on both databases.

Ablations (predicted first; mutated with scripts/ablation-replace.mjs, each restore verified blob == HEAD and git diff HEAD empty)

  • Read-scope comparand member bypassed: 10 red, exactly the predicted set. The first failing cell on each database is one of the card's cells.
  • Read-scope column member bypassed: 3 red, exactly the predicted set (two compiler-shape pins and the uncertified-column pin). Every certified-column cell stayed green.
  • Preview comparand side bypassed: 2 red (the preview pin in both database blocks). The first failure is on the canonical drafted rows.
  • Preview row side bypassed: 2 red. The first failure is on the respelled drafted rows; the canonical rows pass first.

The read-scope pair was ablated at 508b0bea9d and again at this head, with identical results.

Tests and gates (at f8113c0)

  • pnpm --filter @objectstack/service-analytics test, with live PostgreSQL set: 175 files passed, 4403 tests passed, 2 skipped.
  • pnpm --filter @objectstack/service-analytics typecheck: green.
  • Downstream on the rebuilt dist, analytics files: rest 22 files / 291 passed, runtime 7 / 70, dogfood 8 / 70.
  • dispatch-gates --commands: 64 families derived, 64 run, every one exit 0. The --ran reconciliation reads 0 NOT-MEASURED (derived from recorded exit codes).
  • ESLint narrowed to the 6 changed code files: 0 errors, 0 warnings. The config enables no type-aware linting, so no untouched file's verdict can move. The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

claude added 14 commits October 2, 2026 23:41
…heir whole-day and NULL-polarity copies are deleted

#5930 step 4 (domain:services), faces F9 and F10. The shared lowering
(lowerFilterCondition) is now the one source of the whole-day bound, the
$between split and the NULL-polarity guards on the analytics read scope
and the where tree:

- native-sql-strategy: buildFilterClause's bare-day lte arm is deleted;
  the dateRange window is the { $gte, $lte } pair, lowered by the same
  reader as the where (ADR-0053 D-D1 item 8). The reader reads a column
  the host cannot name type-blind (item 7).
- objectql-strategy: the /analytics/sql echo renders the window through
  the same lowering; the reader leaves an undeclared column as written,
  for the engine seam to read.
- filter-normalizer: the $not-operand rewrite and the #5298 leaf wrap,
  with their polarity tables, are deleted.
- read-scope-sql: the $not-operand rewrite, its three tables and the
  IS NULL OR wrap are deleted.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…d lowering; the echo window renders the declared column's bound

#5930 step 4. Every pin that recorded a face's own copy of the NULL
guard stacked inside the shared lowering's (the step-3 rows marked
"until the copy's deletion card") now reads the single guard. No row
answer moved: every id-set assertion in these files is unchanged.

The /analytics/sql window pins wire the declared type the plugin relays
(sourceFieldMeta, close_date a datetime), and two controls pin the
render on a declared date and where the host names no type (the bound
execute() hands the engine, as written), and a preset that stops before
its end.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…LL rule's source

#5930 step 4: the $empty and non-text-column notes named the deleted
$not rewrite and its operatorIsNullTotal table.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… F9 and F10, and the bare day on every face

#5930 step 4. analytics-faces-one-lowering.test.ts holds:
- the enumeration: no analytics source file holds a whole-day helper
  but the draft preview (F11, pending its reader), and none holds a
  NULL-polarity copy; a positive control proves the scan reads the
  faces;
- one source: the native compiler and the echo emit the bound the
  lowering hands them (a function of the declared type alone), and
  every null predicate F9 and F10 emit is one the lowering wrote;
- the typed drivers' answer on every face over a real engine (SQLite,
  and PostgreSQL where OS_TEST_POSTGRES_URL is set): datetime, date and
  text columns, $lte, $between, $not and dateRange windows, the
  carrier-note text cell included;
- a host with no typed reader: the native face reads type-blind, and
  the ObjectQL face hands the engine the bound as written;
- TEMPORAL_CASES on the native and ObjectQL faces of the plugin's
  composition and through the read scope.

native-sql-temporal-conformance.test.ts runs its matrix with and
without the declared-type hook.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…e native face's bare-day bound on a non-temporal column

#5930 step 4. The measured answer move, the reader-less hosts, the
/analytics/sql echo changes and the ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…in as a query

The dateRange pair is a tuple in AnalyticsQuery; tsc refused the
widened string[].

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…with no private comment stripper

check:comment-mask-adoption refused the pin's regex comment stripper.
A file holds a helper when it imports, declares or calls it; a prose
mention (a backticked name, a {@link}) is none of those, so no comment
stripping is needed.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ough the driver's coercion pair

compileScopedFilterToSql takes two optional members, coerceTemporalFilterValue
and coerceTemporalFilterColumn (the driver's ADR-0053 D-A2 pair, bound to the
object), and applies them after the shared lowering to every value
comparison's comparand and column. Absent is identity. The native read-scope
merge and the ObjectQL echo pass the context's pair.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…st the engine on SQLite and PostgreSQL

Sixteen cells (twelve datetime, four date as the control) answer the rows
engine.find answers, compiled with the driver's pair and end to end through
the native face with a host getReadScope; the ObjectQL echo prints the
coerced comparand; an uncertified SQLite datetime column pins the column
half. The F9 read-scope test in analytics-faces-one-lowering now runs on
PostgreSQL too. Changeset: minor, Clause-② yes (widening).

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
Brings in #21417 as landed (squash 81e69ca). Conflicts resolved by keeping
both sides' meaning: read-scope-sql.ts keeps main's text plus this card's
coercion arms; the #21417 changeset and analytics-faces-one-lowering.test.ts
take main's final version, with this card's F9 PostgreSQL edit re-applied.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…n its storage form on both sides

The preview has no driver, so its counterpart of the engine door is the rule
that door applies: @objectstack/core's temporalStorageForm, for the kind
temporalComparandKind gives the column's declared type. Each value
comparison's comparand and every declared temporal field of a drafted row
take that form before the match, as driver-memory reads them. A column the
host names no type for stays as written.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…m against the engine

The sixteen cells and two window ends written shorter than the stored instant
answer engine.find's rows through queryDataset previewDrafts, over drafted
rows in the canonical spelling and over the same instants respelled (a Date,
no milliseconds, zone-naive, an offset).

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…he read scope and the draft preview answer the engine's rows

The answer moves in both directions onto engine.find's rows, so the
changeset carries a BREAKING banner and an ADR-0087 not-required
(no-migration-prescription) disposition, as #21417 declared for its answer
change. It names the two new optional members, their identity default, and
the preview's storage form on both sides.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 17 documentable anchor(s).

⛔ 2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class ObjectQLStrategy))
  • content/docs/releases/v17/17-5.mdx (via generateSql (symbol, a method of class ObjectQLStrategy))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 10 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 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/l tests tooling

Projects

None yet

2 participants