Skip to content

Commit b3a3d83

Browse files
os-zhuangclaude
andauthored
feat(spec,service-analytics): a shared temporal conformance matrix (ADR-0053 D-A3) (#4098)
* feat(spec,service-analytics): a shared temporal conformance matrix (ADR-0053 D-A3) The last decision in ADR-0053 that had never been actioned, and the regression backstop the four previous incidents lacked. #3650 (window dropped), #3773 (buckets collapsed to NULL), #3777 (bare-day upper bound dropped the final day), #4047 (cross-type comparison matched nothing) were each found by a human by accident, and each left a suite proving only ITS OWN issue against its own fixture. That is coverage without a standard: nothing could fail when two backends drifted apart. `spec/data/temporal-conformance.ts` exports TEMPORAL_ROWS (times of day chosen so a midnight-anchored bound and a whole-day bound cannot return the same set) and TEMPORAL_CASES (filter + expected row ids + a note naming the incident each case defends). Five backends consume it and assert ROW RESULTS through their real entry points — the ADR's original requirement: - driver-sql (typed; the live-dialect CI job also runs it against real Postgres and MySQL under a skewed process zone, no workflow change) - driver-memory, driver-mongodb (real MongoDB via mongodb-memory-server) - service-analytics preview evaluator, formula matchesFilterCondition Modelled on filter-logic-conformance.ts, which exists for the same reason one layer down (#3774); in spec for the D-D2 reason — every backend already depends on it, so this adds no dependency edge. The matrix found two things on its first run, which is the argument for having built it: 1. A real defect. The preview evaluator had no $between case, so it fell through to its permissive `default: return true` and matched EVERY row — a drafted dashboard with a range filter charted the whole dataset, then changed at publish, which is the exact continuity the preview exists to provide. Fixed here, sharing the upper-bound helper with $lte so the whole-day rule covers a range's max too. 2. A measured, irreducible limit. $gt with a bare-day comparand on a datetime column cannot agree across backends: a typed one anchors to midnight and excludes a 00:00 value, a type-blind one compares raw strings where '…T00:00…' sorts after the bare day and keeps it. The upper-bound trick has no lower-bound analogue — anchoring would fix datetime and break date. So $gt is shared on `date` only and the datetime cell stays in the typed drivers' suites; $gte IS shared, and is pinned precisely because the two readings agree by luck of the boundary being inclusive. Also: AGENTS.md gains the Post-Task step for regenerating the spec API-surface snapshot — OS_SKIP_DTS=1 skips the built declarations that gate reads, so it passes locally and fails in CI, which is how #4076 went red. spec 6957 / formula 286 / driver-sql 535 / driver-memory 207 / driver-mongodb 120 / service-analytics 342, all green. Closes #4081 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f * docs(service-analytics): list $between in the preview evaluator's declared scope The module's scope comment enumerated the operators the preview supports and omitted $between — accurately, since it had no case and fell through to the permissive default. Now that it has one, the list has to say so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc530b4 commit b3a3d83

12 files changed

Lines changed: 611 additions & 6 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081)
7+
8+
`@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the
9+
single set of temporal filter cases every backend is checked against, the twin
10+
of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row
11+
results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and
12+
MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview
13+
evaluator, and `formula`'s RLS write-side `check`.
14+
15+
This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and
16+
the last of its decisions to be actioned. Four separate incidents — #3650,
17+
#3773, #3777, #4047 — were each found by a human by accident, and each left a
18+
suite proving only its own issue against its own fixture. Nothing held the
19+
backends to one standard, so the fifth divergence had nowhere to fail.
20+
21+
**`service-analytics` — a real fix the matrix found on its first run.** The
22+
draft-preview evaluator had no `$between` case, so it fell through to its
23+
permissive `default` and matched **every** row: a drafted dashboard carrying a
24+
range filter charted the entire dataset, then changed its numbers at publish —
25+
the exact continuity the preview exists to provide. It now evaluates
26+
`$between`, sharing the upper-bound helper with `$lte` so the whole-day
27+
calendar-day rule (#3777) applies to a range's max as well.
28+
29+
Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a
30+
`datetime` column cannot agree between typed and type-blind backends, and the
31+
gap is irreducible without field types. It is asserted in the shared matrix on
32+
`date` only, with the `datetime` cell left to the typed drivers' own suites,
33+
rather than papered over.

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,9 @@ export default {
312312
working tree.
313313
3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset.
314314
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes imply **opposite** liveness-ledger dispositions.
315-
4. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
316-
5. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.
315+
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface.json`; a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
316+
5. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
317+
6. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.
317318

318319
---
319320

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,9 @@ retire it)** once the contract method is universal. (If an in-flight
401401
> this decision asked for — and is retained for exactly that role.
402402
403403
**D-A3 — Add a temporal conformance matrix as the runtime regression backstop.**
404+
*(Landed 2026-07-30, #4081`packages/spec/src/data/temporal-conformance.ts`,
405+
consumed by five backends. Two axes and one measured limit are recorded in
406+
D-A3.1 below.)*
404407
Cover `field-type {date, datetime} × operator {eq, gte/lte/gt/lt, in, dateRange} ×
405408
relative-token {today, N_days_ago, N_months_ago, …} × driver {SQLite, Postgres at
406409
minimum}`, asserting correct **row results** — not just emitted SQL. Analytics has
@@ -815,3 +818,65 @@ The write-side gap this closed was not cosmetic. A `check` policy of the shape
815818
**denied every write made after 00:00** — the write-side twin of the read-side
816819
loss #3777 fixed, on the one surface where the failure direction is a rejected
817820
write rather than a missing row.
821+
822+
---
823+
824+
## Addendum (2026-07-30) — D-A3.1: the conformance matrix, and what it found
825+
826+
> **Status:** landed (#4081). Closes D-A3, the last decision in this ADR that
827+
> had never been actioned.
828+
829+
### The shape: a shared table, not per-issue suites
830+
831+
`packages/spec/src/data/temporal-conformance.ts` exports `TEMPORAL_ROWS` (a
832+
fixture whose times of day make a midnight-anchored bound and a whole-day bound
833+
distinguishable) and `TEMPORAL_CASES` (filter + expected row ids + a note naming
834+
the issue each case defends). Five backends consume it and assert **row-id
835+
sets** — the ADR's original requirement — through their own real entry points:
836+
`driver-sql` (typed, and via the live-dialect CI job also real Postgres and
837+
MySQL under a skewed process zone), `driver-memory`, `driver-mongodb` (real
838+
MongoDB), the analytics preview evaluator, and `formula`'s write-side `check`.
839+
840+
It is modelled on `filter-logic-conformance.ts`, which exists for the same
841+
reason one layer down (#3774), and lives in `spec` for the D-D2 reason: every
842+
backend already depends on it.
843+
844+
Before this, each of the four fixes left a suite proving *its own* issue with
845+
*its own* fixture. That is coverage without a standard: nothing could fail when
846+
two backends drifted apart, which is precisely how #3650, #3773, #3777 and
847+
#4047 each reached production.
848+
849+
### D-A3.1 — What the matrix found on its first run
850+
851+
Two things, which is the argument for having built it:
852+
853+
1. **A real defect.** The preview evaluator had no `$between` case at all, so it
854+
fell through to its permissive `default: return true` and matched **every**
855+
row. A drafted dashboard carrying a range filter charted the whole dataset,
856+
then changed its numbers at publish — the exact continuity the preview
857+
exists to provide. Fixed in the same change, sharing the `$lte` bound helper
858+
so the two cannot drift again.
859+
860+
2. **A measured, irreducible limit.** `$gt` with a bare-day comparand on a
861+
`datetime` column cannot agree across backends. A typed backend anchors the
862+
bound to midnight and excludes a value stored at exactly 00:00; a type-blind
863+
one compares raw strings, where `'2026-07-28T00:00:00.000Z'` sorts after
864+
`'2026-07-28'`, and keeps it. The trick that makes the *upper* bound
865+
type-blind — rewriting `<= day` as `< nextDay` — has no lower-bound
866+
analogue: anchoring to `…T00:00:00.000Z` would fix `datetime` and break
867+
`date`, since `'2026-07-28' >= '2026-07-28T00:00:00.000Z'` is false.
868+
869+
So `$gt` is asserted in the shared matrix on the `date` column only, and the
870+
`datetime` cell stays in the typed drivers' own suites. `$gte` **is** shared:
871+
both readings include the midnight row — they agree because the boundary is
872+
inclusive, which is luck rather than design, and is pinned here for exactly
873+
that reason.
874+
875+
### Consequences
876+
877+
- The matrix is the ratchet the previous four fixes lacked: a backend that
878+
drifts now fails a named case whose note says which incident it is repeating.
879+
- Coverage still open, and deliberately so: the **relative-token** axis
880+
(`{today}`, `{30_days_ago}` resolved through `filter-tokens.ts` and run
881+
end-to-end per driver) is not yet in the table. The cases here take resolved
882+
comparands, which is the layer where the four incidents actually happened.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Temporal conformance for the RLS write-side `check` evaluator (ADR-0053 D-A3).
5+
*
6+
* The cases come from `@objectstack/spec/data` so this backend, the three
7+
* drivers and the analytics preview are all held to one standard — see
8+
* `temporal-conformance.ts` for the four divergences that standard exists to
9+
* prevent. Adding a case there adds it here.
10+
*
11+
* This evaluator is type-blind: it sees a bare record with no schema, so both
12+
* the `datetime` and `date` cases run against the raw string values. That both
13+
* kinds pass unmodified is itself the assertion — it is what lets the rule be
14+
* applied without a field-type lookup on this surface.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
19+
20+
import { matchesFilterCondition } from './matches-filter';
21+
22+
describe('matchesFilterCondition — temporal conformance', () => {
23+
for (const c of TEMPORAL_CASES) {
24+
it(c.name, () => {
25+
const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, c.filter)).map((r) => r.id);
26+
expect(got, c.note).toEqual(c.expected);
27+
});
28+
}
29+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Temporal conformance for the in-memory driver (ADR-0053 D-A3).
5+
*
6+
* The cases come from `@objectstack/spec/data` so this backend, `driver-sql`,
7+
* `driver-mongodb`, the analytics preview and `formula`'s write-side `check`
8+
* evaluator are all held to one standard — see `temporal-conformance.ts` for
9+
* the four divergences that standard exists to prevent, one of which (#4047)
10+
* was this driver's.
11+
*/
12+
13+
import { describe, it, expect, beforeAll } from 'vitest';
14+
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
15+
import { InMemoryDriver } from './memory-driver.js';
16+
17+
describe('driver-memory — temporal conformance', () => {
18+
let driver: InMemoryDriver;
19+
20+
beforeAll(async () => {
21+
driver = new InMemoryDriver({});
22+
await driver.connect();
23+
// Declaring the object is what teaches this driver the field types (#4047);
24+
// without it the values would keep whatever form the writer produced.
25+
await driver.syncSchema('conformance', {
26+
name: 'conformance',
27+
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
28+
});
29+
for (const r of TEMPORAL_ROWS) {
30+
await driver.create('conformance', { id: r.id, at: r.at, on: r.on, why: r.why });
31+
}
32+
});
33+
34+
for (const c of TEMPORAL_CASES) {
35+
it(c.name, async () => {
36+
const rows = await driver.find('conformance', { where: c.filter } as any);
37+
const got = (rows as any[]).map((r) => r.id).sort();
38+
expect(got, c.note).toEqual([...c.expected].sort());
39+
});
40+
}
41+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Temporal conformance for the MongoDB driver (ADR-0053 D-A3), against a real
5+
* MongoDB via `mongodb-memory-server`.
6+
*
7+
* The cases come from `@objectstack/spec/data` so this backend, `driver-sql`,
8+
* `driver-memory`, the analytics preview and `formula`'s write-side `check`
9+
* evaluator are all held to one standard — see `temporal-conformance.ts` for
10+
* the four divergences that standard exists to prevent, one of which (#4047)
11+
* was this driver's and was the worst of them: type-bracket comparison meant a
12+
* string bound matched no BSON `Date` row at all, so the default dashboard
13+
* window returned nothing.
14+
*/
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import { MongoMemoryServer } from 'mongodb-memory-server';
18+
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
19+
import { MongoDBDriver } from './mongodb-driver.js';
20+
21+
let sharedMongod: MongoMemoryServer | undefined;
22+
try {
23+
sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } });
24+
} catch (err) {
25+
console.warn(
26+
'[driver-mongodb] Skipping temporal conformance — mongodb-memory-server could not start: ' +
27+
`${(err as Error)?.message ?? String(err)}`,
28+
);
29+
}
30+
31+
describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () => {
32+
const mongod = sharedMongod as MongoMemoryServer;
33+
let driver: MongoDBDriver;
34+
35+
beforeAll(async () => {
36+
driver = new MongoDBDriver({ url: mongod.getUri(), database: 'temporal_conformance' });
37+
await driver.connect();
38+
// The declaration is what gives this driver its field types (#4047) — and
39+
// therefore the BSON storage form its comparands must be coerced into.
40+
await driver.syncSchema('conformance', {
41+
name: 'conformance',
42+
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
43+
});
44+
for (const r of TEMPORAL_ROWS) {
45+
await driver.create('conformance', { id: r.id, at: r.at, on: r.on, why: r.why });
46+
}
47+
}, 90_000);
48+
49+
afterAll(async () => {
50+
if (driver) await driver.disconnect();
51+
if (mongod) await mongod.stop();
52+
});
53+
54+
for (const c of TEMPORAL_CASES) {
55+
it(c.name, async () => {
56+
const rows = await driver.find('conformance', { where: c.filter } as any);
57+
const got = (rows as any[]).map((r) => r.id).sort();
58+
expect(got, c.note).toEqual([...c.expected].sort());
59+
});
60+
}
61+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Temporal conformance for the SQL filter compiler (ADR-0053 D-A3).
5+
*
6+
* The cases come from `@objectstack/spec/data`, so this backend, the in-memory
7+
* and document drivers, the analytics preview and `formula`'s write-side
8+
* `check` evaluator are all held to one standard — see `temporal-conformance.ts`
9+
* for the four divergences that standard exists to prevent. Adding a case there
10+
* adds it here.
11+
*
12+
* This is the TYPED half of the matrix: the columns are declared through
13+
* `initObjects`, so the driver knows `at` is a `Field.datetime` and `on` a
14+
* `Field.date` and coerces comparands accordingly. That the same case yields the
15+
* same row set here and in the type-blind backends is the whole assertion.
16+
*
17+
* Runs against a real SQLite, and — through the `Temporal Conformance
18+
* (live PG + MySQL)` CI job, which runs this package's whole suite under
19+
* `TZ=America/New_York` against servers on `Asia/Shanghai` — against real
20+
* Postgres and MySQL too, with no workflow change needed.
21+
*/
22+
23+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
24+
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
25+
import { SqlDriver } from '../src/index.js';
26+
27+
describe('sql-driver — temporal conformance', () => {
28+
let driver: SqlDriver;
29+
30+
beforeAll(async () => {
31+
driver = new SqlDriver({
32+
client: 'better-sqlite3',
33+
connection: { filename: ':memory:' },
34+
useNullAsDefault: true,
35+
});
36+
// The declaration is what makes this the typed half — `at` is an instant,
37+
// `on` a calendar day, and the driver's coercion follows from that.
38+
await driver.initObjects([
39+
{
40+
name: 'conformance',
41+
fields: {
42+
at: { type: 'datetime' },
43+
on: { type: 'date' },
44+
why: { type: 'string' },
45+
},
46+
},
47+
]);
48+
for (const r of TEMPORAL_ROWS) {
49+
await driver.create(
50+
'conformance',
51+
{ id: r.id, at: r.at, on: r.on, why: r.why },
52+
{ bypassTenantAudit: true } as any,
53+
);
54+
}
55+
});
56+
57+
afterAll(async () => {
58+
await driver.disconnect?.();
59+
});
60+
61+
for (const c of TEMPORAL_CASES) {
62+
it(c.name, async () => {
63+
const rows = await driver.find('conformance', { where: c.filter } as any);
64+
const got = (rows as any[]).map((r) => r.id).sort();
65+
expect(got, c.note).toEqual([...c.expected].sort());
66+
});
67+
}
68+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Temporal conformance for the draft-preview evaluator (ADR-0053 D-A3).
5+
*
6+
* The cases come from `@objectstack/spec/data` so this backend, the three
7+
* drivers and `formula`'s write-side `check` evaluator are all held to one
8+
* standard — see `temporal-conformance.ts` for the four divergences that
9+
* standard exists to prevent.
10+
*
11+
* This surface has its own reason to care: a Live Canvas dashboard charts REAL
12+
* numbers from DRAFTED seed data, and publish materialises the same seed. If
13+
* the preview and the driver disagree about a window, the numbers jump across
14+
* the publish boundary — the continuity the preview exists to provide.
15+
*
16+
* Type-blind, like `formula`: it evaluates a bare row with no schema, so both
17+
* the `datetime` and `date` cases run against the raw string values.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
22+
import { matchesWhere } from '../preview-evaluator.js';
23+
24+
describe('preview-evaluator — temporal conformance', () => {
25+
for (const c of TEMPORAL_CASES) {
26+
it(c.name, () => {
27+
const got = TEMPORAL_ROWS.filter((r) => matchesWhere(r as any, c.filter as any)).map((r) => r.id);
28+
expect(got, c.note).toEqual(c.expected);
29+
});
30+
}
31+
});

0 commit comments

Comments
 (0)