Skip to content

Commit 3f80f17

Browse files
fix(metadata-protocol): a cel-dated seed row reaches every hook in its temporal field's stored form (#22353)
Part of #22301 Clause-②: no This PR carries stage 1's **item 9** only: a cel-dated seed row reaches every hook in the stored form of its temporal field, so `bootStack` stores the rows `objectstack dev` stores. **Item 1** (the handle mounts what `requires` names) was implemented and measured on this branch, and it is held back: as built, it stops every showcase boot in the dogfood gate. How the handle treats an app's own `plugins` array goes to a decision first (details below). Items 2 to 8 stay on the card for later stages. ## Item 9: what the two boots handed the hook Measured on `origin/main` 28bff18, one `bootStack` of a two-object app: a `date` field and a `datetime` field, seeded with `daysFromNow(1)` / `daysFromNow(2)` as cel expressions and with literal ISO values. | hook form | saw for the cel `date` value | saw for the literal row | |---|---|---| | in-process `handler` (a source config, what `bootStack` boots) | a JS `Date` object, `2026-10-09T00:00:00.000Z` | `'2026-10-10'` (string) | | sandboxed `body` (the compiled artifact `objectstack dev` boots) | `'2026-10-09T00:00:00.000Z'` (string, a full instant on a `date` field) | `'2026-10-10'` (string) | | stored by the SQL driver | `'2026-10-09'` | `'2026-10-10'` | So the two boots hand the hook different values. The app's hook is not the divergence. hotcrm's `campaign_validation` reads the dates only when they are strings, so it accepted the rows under `objectstack dev` and refused them under `bootStack`. The divergence starts one step earlier: `resolveSeedRecord` evaluates the expression to the `Date` the cel stdlib returns (ADR-0053 D1), and the seed loader handed that `Date` to the engine as-is. Neither hook form then saw the value the column stores. **Where it lands, and why:** `packages/metadata-protocol/src/seed-loader.ts` (a `domain:engine` file; the claim names it for this case). Right after resolution, each `Date` on a `date` / `datetime` / `time` field is put into its stored form by `temporalStorageForm` (`@objectstack/core`). That is the one rule the drivers apply on write, so the loader carries no second copy of it. A non-temporal field, an undeclared field and an Invalid Date are handed over unchanged. `packages/verify` and `packages/formula` are not touched: the verify boot's seed path is the shared loader, and `resolveSeedRecord` has no field types to work with. **What changes:** both hook forms now see the stored form (`'2026-10-09'` for a `date`). A body hook on a `date` field sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write: `driver-memory` through the same `temporalStorageForm`, `driver-sql` and the two drivers that extend it (`driver-sqlite-wasm`, `driver-turso`) on their own write path, and `driver-mongodb` through its `coerceTemporalValue`. Changeset: `@objectstack/metadata-protocol` patch. **Pin:** `packages/verify/src/harness.seed-temporal-form.test.ts` boots through the real handle. It asserts that the cel rows are stored, that the handler and the body each see exactly the stored value, that a hook requiring string dates (hotcrm's shape) lets the row through, and that the literal control rows are unchanged. **Ablation**, at `e83540e29`, through `scripts/ablation-replace.mjs`: - **Mutation leg.** The call was swapped for a no-op call carrying a marker. `metadata-protocol` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. Result: 4 failed / 1 passed. The control passed. The guarded hook logged `[SeedLoader] Failed to write stf_guarded record #0 (name=guarded-cel): both dates are required`, which is hotcrm's failure reproduced. - **Restore leg.** Blob `097d4adee50f` equals HEAD, and `git diff HEAD` is empty. After the rebuild the marker is absent from all 24 built files and the tree is clean. Result: 5/5 passed. - A first mutation attempt (a bare `void` statement) did not build. The unused helper failed the d.ts pass, and the preflight found no marker. That run's reading is void and is not counted above. ## Item 1: implemented, measured, held What was built and measured, kept in this branch's history at `ef5396ca2` and reverted at `b3186ec18`: - **H1, the home: `@objectstack/core`.** The measured graph: `@objectstack/cli` depends on `@objectstack/verify`, so the CLI cannot be the home, and an `@objectstack/cli` subpath would close a cycle. Both packages already depend on `@objectstack/core`, which owns the package ordering (`resolveArtifactPackageOrder`) that the `requires` reader is built on. `CAPABILITY_PROVIDERS`, `CapabilitySpec` and `providesCapability` moved there. So did the reader `stackDeclaredCapabilities` and its rule (`resolveStackCollection`, from `packages/cli/src/utils/stack-collections.ts`). `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` became handles over the core declarations, pinned by identity (`toBe`), and the CLI drift tests were re-pointed. There was no second copy. - **H2, the reader.** `bootStack` read `requires` through `stackDeclaredCapabilities`. Pins covered the top level, the package bodies, the top level winning over the bodies, and a control. A caller's instance won (`extraPlugins`), and hard dependencies came along (`triggers` brought `job` and `queue`). Every provider package in the table became a dependency of `@objectstack/verify`, with no cycle (measured over deps, devDeps and peers). The pins were 7/7 green. With the mount removed by ablation, 4 went red, while the control, explicit-wins and manifest cases stayed green. Afterwards the blob equalled HEAD. The CLI unit and integration pins over the moved code were 260/260 and 11/11. - **The blocker, measured.** `pnpm --filter @objectstack/dogfood exec vitest run --project shared-showcase` failed 13 of 13 files at bootstrap: `connector instance 'showcase_status_api' declares provider 'rest', but no provider factory is registered`. The showcase declares `requires: ['automation', …]`. Automation's start materializes the app's connector instances (ADR-0097). Their providers sit in the showcase's own `plugins` array, which `objectstack serve` mounts and `bootStack` never has. An uncommitted experiment that also mounted the app's `plugins` instances moved the refusal one layer: `providerConfig.spec './src/system/connectors/status-openapi.json' … resolved to '/tmp/os-dogfood-run-…'`. `serve` anchors automation's `packageRoot` at the config's directory, and the handle only knows `hostRoot`. 117 dogfood files boot the showcase directly, and 11 of them pass a connector plugin. For hotcrm, which carries no `plugins` array, the same implementation boots green. The open question is how the handle treats an app's own `plugins` array: mount it as `serve` does, keep it the caller's, or put the whole `requires` mounting behind an option. It is in the card report with each option's cost. ## Tests (at `e83540e29`) - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2`: 19 files / 138 tests passed. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 223 passed, 3 skipped files; 28,338 passed, 19 skipped tests. - `typecheck` for `@objectstack/verify` and `@objectstack/metadata-protocol`: green, with test-layer debt unchanged. - Gates: `dispatch-gates --commands` derived 62 commands on this tree. All 62 were run and exited 0. `--ran` reports 62 derived, 62 run, 0 NOT-MEASURED and 0 UNRUN. - Lint, narrowed: `eslint --no-inline-config` over the 2 changed TypeScript files (the population `eslint.config.mjs`'s `**/*.{ts,…}` / `packages/**/*.{ts,…}` objects match), counted from `--format json`: 2 files, 0 errors, 0 warnings. The config enables no type-aware linting, so this diff cannot move the verdict of any untouched file. The full `pnpm lint` is CI's. - NOT MEASURED locally: the full dogfood suite and the integration tiers. Both are CI's. ## Acceptance notes - `bootStack` constructs its `AnalyticsServicePlugin` without the app's `analyticsCubes`, and `serve` passes them. This is a boot-parity gap in the same family as item 1, named in the card report for a later stage. - A literal JS `Date` written into a source-config seed now reaches hooks in stored form. The same seed compiled to an artifact arrives as a JSON ISO string, which the loader leaves unchanged. Cel values agree in both boots, but literal `Date`s still differ by form. No producer of that shape was measured. - The engine's private `resolveNowDefault` carries its own date / time / instant table beside `temporalStorageForm`. The two agree today. This is an observation, with no carrier. ## Patch round (contract review FAIL `6069291830` → seat order `6069311153` → `792bcc71d`) Written into this body by the `domain:spec` seat 2 at 2026-10-08T21:30Z. The comments before it on this PR are read: the docs-drift bot's, and the contract review this round answers. - The changeset's sentence "The in-memory driver now stores the string instead of a `Date` object" was false, because `driver-memory` already normalises every declared temporal field on write (`toStorageForms` → `coerceTemporalValue` → `temporalStorageForm`). It is replaced with: stored values do not change on any driver, because each driver already normalises a declared temporal field on write. The dev read `driver-memory` and `driver-mongodb` before writing it, and the seat confirmed that `driver-sqlite-wasm` and `driver-turso` extend `SqlDriver`. The "What changes" line above is corrected the same way. - One changeset commit (+1 / -1), no code. The 20 changeset/text gate families, plus `check-changeset-fixed.mjs` and `check:empty-changeset`, all exit 0. `main` was not merged (the push was accepted). --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54d32e5 commit 3f80f17

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
A cel-dated seed row reaches every hook in the stored form of its temporal field
6+
7+
Clause-②: no
8+
9+
`` cel`daysFromNow(n)` ``, `daysAgo(n)`, `today()` and `now()` in a seed record resolve to a JS `Date`. The seed loader handed that `Date` to the engine, so what a hook saw depended on how the hook ran:
10+
11+
- An in-process `handler` (a source config, which `@objectstack/verify`'s `bootStack` boots) saw the `Date` object.
12+
- A sandboxed `body` (the compiled artifact `objectstack dev` boots) saw a full ISO instant, even on a `date` field.
13+
14+
A hook that reads a date field as a string therefore accepted the row under `objectstack dev` and refused it under `bootStack`, and the row was not stored.
15+
16+
The loader now puts each `Date` on a `date`, `datetime` or `time` field into the stored form the drivers write (`YYYY-MM-DD`, the UTC ISO instant, `HH:MM:SS`) before any hook runs, so every hook sees the value the column stores. A body hook on a `date` field now sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write (`@objectstack/driver-memory` through the same `temporalStorageForm`). A `Date` on a field that is not temporal, or on a field the object does not declare, is handed over unchanged.

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
} from '@objectstack/spec/data';
2424
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
2525
import { resolveSeedRecord } from '@objectstack/formula';
26-
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core';
26+
import {
27+
bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup,
28+
temporalComparandKind, temporalStorageForm,
29+
} from '@objectstack/core';
2730
// [#8442] The repo's ONE recogniser for "this throw is a record-validation
2831
// failure" — duck-typed on `code`/`name`, the same predicate `mapDataError` and
2932
// both dispatcher error exits use. Imported rather than re-spelled so the seed
@@ -440,6 +443,39 @@ function seedRowNeedsOrganization(definition: unknown): boolean {
440443
return resolveInjectedSystemColumns(definition).tenant;
441444
}
442445

446+
/**
447+
* [#22301] Put every `Date` a seed row resolved to into the STORED form of its
448+
* temporal field, before the row reaches the engine.
449+
*
450+
* `resolveSeedRecord` evaluates an Expression envelope to whatever the CEL
451+
* stdlib returns, and `today()` / `daysFromNow(n)` / `daysAgo(n)` / `now()`
452+
* return a JS `Date` (ADR-0053 D1). Handed on as a `Date`, what a hook saw
453+
* depended on HOW the hook ran, not on the value: a hook lowered to a body runs
454+
* in the sandbox, whose JSON bridge turned it into a full ISO instant
455+
* (`2026-10-09T00:00:00.000Z`, even on a `date` field), and the same hook as an
456+
* in-process `handler` (a source config, as `@objectstack/verify`'s `bootStack`
457+
* boots it) received the `Date` object itself. A hook reading the field as a
458+
* string accepted the row under `os dev` and refused it under `bootStack`.
459+
* Neither is the value the column stores — the driver writes the ADR-0053 form
460+
* (`YYYY-MM-DD` for a `date`) — nor the value a REST write of the same row
461+
* hands the hook.
462+
*
463+
* So the row leaves the loader in that stored form, by the ONE rule the drivers
464+
* apply on write (`temporalStorageForm`, `@objectstack/core`): every hook sees
465+
* what is stored, however it runs. A non-temporal field, a field the definition
466+
* does not declare, and an Invalid Date are left exactly as resolved — the rule
467+
* normalizes temporal FORM and never invents a value (its own totality).
468+
*/
469+
function seedValuesInStoredForm(record: Record<string, unknown>, definition: unknown): void {
470+
const fields = (definition as { fields?: Record<string, { type?: unknown } | undefined> } | null | undefined)?.fields;
471+
if (!fields || typeof fields !== 'object') return;
472+
for (const [key, value] of Object.entries(record)) {
473+
if (!(value instanceof Date)) continue;
474+
const kind = temporalComparandKind(fields[key]?.type);
475+
if (kind) record[key] = temporalStorageForm(value, kind);
476+
}
477+
}
478+
443479
export class SeedLoaderService implements ISeedLoaderService {
444480
private engine: IDataEngine;
445481
private metadata: IMetadataService;
@@ -1248,6 +1284,8 @@ export class SeedLoaderService implements ISeedLoaderService {
12481284
continue;
12491285
}
12501286
const record = { ...(seedResult.value as Record<string, unknown>) };
1287+
// [#22301] The stored form, not a `Date`, before any hook can see it.
1288+
seedValuesInStoredForm(record, objectDefinition);
12511289
/**
12521290
* [commit 1cba33f16] Deferrals this row took on a column the write contract
12531291
* requires on insert. Collected during resolution, reported once the
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22301 item 9 — a cel-dated seed row is stored under `bootStack`, and every
5+
* hook sees the value the column stores, however the hook runs.
6+
*
7+
* Measured before the fix (`origin/main` 28bff18d0, this file's app, one
8+
* `bootStack`): for `start_date: cel\`daysFromNow(1)\`` on a `date` field the
9+
* SQL driver stored `YYYY-MM-DD`, while
10+
*
11+
* · an in-process `handler` hook (a source config — what `bootStack` boots)
12+
* saw a JS `Date` object, and
13+
* · the same hook lowered to a sandboxed `body` (the compiled artifact
14+
* `objectstack dev` boots) saw the full instant `YYYY-MM-DDT00:00:00.000Z`.
15+
*
16+
* So a hook that reads the field as a string — hotcrm's `campaign_validation`
17+
* is the measured one — accepted the row under `objectstack dev` and refused it
18+
* under `bootStack`. The divergence was the value the seed loader handed the
19+
* engine (`resolveSeedRecord`'s `Date`), not the app's hook: the loader now
20+
* hands over the ADR-0053 stored form, and every reading below is equal.
21+
*
22+
* The literal rows are the control: a literal ISO value is already in stored
23+
* form and reaches every hook unchanged.
24+
*/
25+
26+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
27+
import { cel, defineStack } from '@objectstack/spec';
28+
import { Field, ObjectSchema } from '@objectstack/spec/data';
29+
import { bootStack, type VerifyStack } from './harness.js';
30+
31+
/** What the in-process hook saw, per row name: `typeof` and the value. */
32+
const handlerSaw = new Map<string, { start: string; end: string }>();
33+
const describeValue = (v: unknown): string =>
34+
v instanceof Date ? `Date:${v.toISOString()}` : `${typeof v}:${String(v)}`;
35+
36+
const fields = {
37+
name: Field.text({ label: 'Name', required: true }),
38+
start_date: Field.date({ label: 'Start' }),
39+
end_at: Field.datetime({ label: 'End' }),
40+
seen: Field.text({ label: 'Seen' }),
41+
};
42+
43+
const rows = (prefix: string) => [
44+
{ name: `${prefix}-cel`, start_date: cel`daysFromNow(1)`, end_at: cel`daysFromNow(2)` },
45+
{ name: `${prefix}-literal`, start_date: '2026-10-10', end_at: '2026-10-11T00:00:00.000Z' },
46+
];
47+
48+
const app = defineStack({
49+
manifest: { id: 'com.example.stf', namespace: 'stf', version: '0.0.0', type: 'app', name: 'Seed temporal form' },
50+
objects: [
51+
ObjectSchema.create({ name: 'stf_handler', label: 'Handler', pluralLabel: 'Handlers', sharingModel: 'public_read_write', fields }),
52+
ObjectSchema.create({ name: 'stf_body', label: 'Body', pluralLabel: 'Bodies', sharingModel: 'public_read_write', fields }),
53+
ObjectSchema.create({ name: 'stf_guarded', label: 'Guarded', pluralLabel: 'Guarded', sharingModel: 'public_read_write', fields }),
54+
],
55+
hooks: [
56+
{
57+
name: 'stf_handler_reads',
58+
object: 'stf_handler',
59+
events: ['beforeInsert'],
60+
handler: async (ctx: { input: Record<string, unknown> }) => {
61+
handlerSaw.set(String(ctx.input.name), {
62+
start: describeValue(ctx.input.start_date),
63+
end: describeValue(ctx.input.end_at),
64+
});
65+
},
66+
},
67+
{
68+
name: 'stf_body_reads',
69+
object: 'stf_body',
70+
events: ['beforeInsert'],
71+
body: {
72+
language: 'js',
73+
source:
74+
"ctx.input.seen = typeof ctx.input.start_date + ':' + String(ctx.input.start_date) + '|' + " +
75+
"typeof ctx.input.end_at + ':' + String(ctx.input.end_at);",
76+
capabilities: [],
77+
},
78+
},
79+
{
80+
// hotcrm's `campaign_validation` shape: dates are read only when strings.
81+
name: 'stf_guarded_requires_string_dates',
82+
object: 'stf_guarded',
83+
events: ['beforeInsert'],
84+
handler: async (ctx: { input: Record<string, unknown> }) => {
85+
if (typeof ctx.input.start_date !== 'string' || typeof ctx.input.end_at !== 'string') {
86+
throw Object.assign(new Error('both dates are required'), { code: 'VALIDATION_FAILED', status: 400 });
87+
}
88+
},
89+
},
90+
],
91+
data: [
92+
{ object: 'stf_handler', mode: 'insert', records: rows('handler') },
93+
{ object: 'stf_body', mode: 'insert', records: rows('body') },
94+
{ object: 'stf_guarded', mode: 'insert', records: rows('guarded') },
95+
],
96+
} as never);
97+
98+
let stack: VerifyStack;
99+
const stored = new Map<string, Record<string, unknown>>();
100+
101+
beforeAll(async () => {
102+
stack = await bootStack(app);
103+
for (const object of ['stf_handler', 'stf_body', 'stf_guarded']) {
104+
for (const row of await stack.rows(object)) stored.set(String(row.name), row);
105+
}
106+
}, 120_000);
107+
afterAll(async () => {
108+
await stack?.stop();
109+
});
110+
111+
describe('a cel-dated seed row reaches every hook in its stored form', () => {
112+
it('the rows are stored, in the ADR-0053 form of each field', () => {
113+
for (const prefix of ['handler', 'body', 'guarded']) {
114+
const row = stored.get(`${prefix}-cel`);
115+
expect(row, `${prefix}-cel stored`).toBeDefined();
116+
expect(row!.start_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
117+
expect(row!.end_at).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/);
118+
}
119+
});
120+
121+
it('an in-process handler sees the stored string, not a Date', () => {
122+
const row = stored.get('handler-cel')!;
123+
expect(handlerSaw.get('handler-cel')).toEqual({
124+
start: `string:${row.start_date}`,
125+
end: `string:${row.end_at}`,
126+
});
127+
});
128+
129+
it('a sandboxed body sees the same value — the stored form, not a full instant on a date field', () => {
130+
const row = stored.get('body-cel')!;
131+
expect(row.seen).toBe(`string:${row.start_date}|string:${row.end_at}`);
132+
});
133+
134+
it('a hook that reads the dates only as strings no longer refuses the row', () => {
135+
expect(stored.get('guarded-cel')).toBeDefined();
136+
});
137+
138+
it('control — a literal row reaches both hooks unchanged and is stored as written', () => {
139+
expect(handlerSaw.get('handler-literal')).toEqual({
140+
start: 'string:2026-10-10',
141+
end: 'string:2026-10-11T00:00:00.000Z',
142+
});
143+
expect(stored.get('body-literal')!.seen).toBe('string:2026-10-10|string:2026-10-11T00:00:00.000Z');
144+
expect(stored.get('handler-literal')).toMatchObject({ start_date: '2026-10-10', end_at: '2026-10-11T00:00:00.000Z' });
145+
expect(stored.get('guarded-literal')).toBeDefined();
146+
});
147+
});

0 commit comments

Comments
 (0)