From d914d36602c2e16d2d6f3b78ecb246c88747b741 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:52:17 +0000 Subject: [PATCH 1/5] feat(cli, objectql): os validate and os build judge seed record values with the insert write's value verdict Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS Co-authored-by: Claude --- packages/cli/src/commands/compile.ts | 17 ++ packages/cli/src/commands/validate.ts | 26 +++ .../cli/src/utils/authoring-filter-judge.ts | 4 +- packages/cli/src/utils/bundle-seed-files.ts | 4 +- packages/cli/src/utils/seed-record-values.ts | 162 ++++++++++++++++++ packages/objectql/src/index.ts | 2 +- .../src/validation/record-validator.ts | 44 +++++ 7 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/utils/seed-record-values.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 4a7e5740730..23231c97fa8 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -16,6 +16,7 @@ import { } from '@objectstack/spec'; import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; import { collectSeedFiles, placeSeedFiles } from '../utils/bundle-seed-files.js'; +import { judgeSeedRecordValues } from '../utils/seed-record-values.js'; import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js'; import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; @@ -1084,6 +1085,22 @@ export default class Compile extends Command { finalBundle.packages = placed.packages; } + // 4a-0. [#27259] Seed record VALUES, judged by the insert write's own + // value verdict — the gate `os validate` runs at its step 3d-bis. A + // value the seed load refuses fails the build here, with the load's + // code and sentence, rather than a row at boot. + const seedValueIssues = judgeSeedRecordValues(finalBundle); + if (seedValueIssues.length > 0) { + if (flags.json) { + await emitJson({ success: false, error: 'seed record values refused', issues: seedValueIssues, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); + this.exit(1); + } + console.log(''); + printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`); + for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`)); + this.exit(1); + } + // 4a. [#25779] The files seed records name with `seedFile(path)`, read // relative to the directory holding the config — the gate // `os validate` runs too — then written into their datasets' diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5c12afaa49e..b87bd0dbaf1 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -26,6 +26,7 @@ import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manif import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js'; import { collectSeedFiles } from '../utils/bundle-seed-files.js'; +import { judgeSeedRecordValues } from '../utils/seed-record-values.js'; import { printHeader, printKV, @@ -804,6 +805,31 @@ export default class Validate extends Command { this.exit(1); } + // 3d-bis. [#27259] Seed record VALUES — each value a seed record writes, + // judged by the insert write's own value verdict + // (`validateInsertValues`, `@objectstack/objectql`), so a value the + // seed load refuses (a currency written `{ value, currency }`) is + // refused here with the load's code and sentence. ⛔ One judge: the + // helper owns only the walk (`utils/seed-record-values.ts`). `os build` + // runs the same call before it bundles the seed files. + const seedValueIssues = judgeSeedRecordValues(result.data as Record); + if (seedValueIssues.length > 0) { + if (flags.json) { + await emitJson({ + valid: false, + errors: seedValueIssues, + warnings: warningsSoFar(), + conversions: conversionNotices, + duration: timer.elapsed(), + }); + this.exit(1); + } + console.log(''); + printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`); + for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`)); + this.exit(1); + } + // 3e. ADR-0141 D2, author side — every `manifest.dependencies` range of // the packages one install act would deliver, judged by the install's // own predicate (`utils/dependency-range-check.ts`). An `error` is diff --git a/packages/cli/src/utils/authoring-filter-judge.ts b/packages/cli/src/utils/authoring-filter-judge.ts index b841c45d605..3779d69985d 100644 --- a/packages/cli/src/utils/authoring-filter-judge.ts +++ b/packages/cli/src/utils/authoring-filter-judge.ts @@ -57,7 +57,7 @@ const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Arra * The stack's object declarations, from either spelling a stack carries: the * array, or the name-keyed map (whose key is the name when the entry omits it). */ -function objectsOf(value: unknown): AnyRec[] { +export function objectsOf(value: unknown): AnyRec[] { if (Array.isArray(value)) return value.filter(isRec); if (!isRec(value)) return []; return Object.entries(value) @@ -66,7 +66,7 @@ function objectsOf(value: unknown): AnyRec[] { } /** A copy registration may stamp without touching the stack the other rules read. */ -function registrationCopy(obj: AnyRec): AnyRec { +export function registrationCopy(obj: AnyRec): AnyRec { if (!isRec(obj.fields)) return { ...obj }; const fields: AnyRec = {}; for (const [key, def] of Object.entries(obj.fields)) fields[key] = isRec(def) ? { ...def } : def; diff --git a/packages/cli/src/utils/bundle-seed-files.ts b/packages/cli/src/utils/bundle-seed-files.ts index d12318b39bb..d27579cd1b5 100644 --- a/packages/cli/src/utils/bundle-seed-files.ts +++ b/packages/cli/src/utils/bundle-seed-files.ts @@ -80,7 +80,7 @@ const SEED_FILE_TYPES: Readonly> = { }; /** One dataset of the artifact, as far as this pass reads it. */ -interface DatasetLike { +export interface DatasetLike { records?: unknown; files?: Record; } @@ -91,7 +91,7 @@ interface DatasetLike { * position, off the objects themselves, because {@link placeSeedFiles} writes * into these very datasets — the ones the artifact serializes. */ -function datasetsOf(stack: Record): Array<{ dataset: DatasetLike; location: string }> { +export function datasetsOf(stack: Record): Array<{ dataset: DatasetLike; location: string }> { const found: Array<{ dataset: DatasetLike; location: string }> = []; const seen = new Set(); const take = (list: unknown, location: string): void => { diff --git a/packages/cli/src/utils/seed-record-values.ts b/packages/cli/src/utils/seed-record-values.ts new file mode 100644 index 00000000000..1e847578728 --- /dev/null +++ b/packages/cli/src/utils/seed-record-values.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27259] Seed record VALUES, judged at authoring time by the judge the seed + * load runs. A currency field seeded `{ value: 10, currency: 'USD' }` used to + * pass `os validate` at exit 0 and be refused only when the seed loaded, + * through the record validator. `os validate` and `os build` now refuse it + * first, with the load's own code and sentence, located at the record's field. + * + * ## One judge + * + * ⛔ Not an `@objectstack/lint` registry member and not a second shape table. + * The verdict is `validateInsertValues` (`@objectstack/objectql`): the insert + * write's value verdict, the same normalizations and the same + * `validateRecordInScope` call. This file owns only the WALK — which values + * the load hands that judge as written, against which field map. + * + * The field map is the registry's, not the author's declaration: the stack's + * objects are registered into a driverless `ObjectQL` (the shape + * `authoring-filter-judge.ts` measured), with the stack's picklists and + * picklist extensions, so a picklist-bound field carries the options the + * runtime resolves for it. + * + * ## What is not handed to the judge + * + * Each is a value the load REWRITES before the write, or one whose verdict a + * deployment decides, so judging it as written here would refuse what the load + * accepts: + * - a value holding an expression envelope (`cel\`…\``): the load evaluates + * it first; + * - a value holding a `seedFile(path)` reference: the load stores the file + * and writes its id (`collectSeedFiles` judges the path); + * - a reference, file or structured-JSON field (the ADR-0104 value-shape + * classes): the load resolves a reference's natural key to an id, and the + * shape check refuses only on a deployment whose migration evidence says + * so; + * - a picklist-bound field whose list this stack does not declare: its + * options live in another package (`judgePicklistReferences` reports it); + * - a dataset whose object this stack does not declare or the registry + * refuses: the object is another package's, or the boot refuses it. + * + * A field the object does not declare is left to the answer it already has + * (`defineSeed` refuses it at load of the config). + */ + +import { createLogger } from '@objectstack/core'; +import { ObjectQL, validateInsertValues, VALIDATION_FAILED_CODE, type FieldValidationError } from '@objectstack/objectql'; +import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES, isSeedFileReference } from '@objectstack/spec/data'; +import { isExpressionEnvelopeShaped } from '@objectstack/spec/automation'; +import { datasetsOf } from './bundle-seed-files.js'; +import { objectsOf, registrationCopy } from './authoring-filter-judge.js'; +import { authoringRuleUnionStack } from './stack-collections.js'; + +type AnyRec = Record; + +/** A refusal, located in the stack like a seed file issue is. */ +export interface SeedRecordValueIssue { + /** Where the value sits, e.g. `data[0].records[4].budget`. */ + location: string; + /** The dataset's object. */ + object: string; + field: string; + /** The record validator's code, e.g. `invalid_number`. */ + code: string; + /** The record validator's sentence, as the seed load prints it. */ + message: string; +} + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** Does the value hold anything the load rewrites before the write? */ +function rewrittenAtLoad(value: unknown): boolean { + if (isExpressionEnvelopeShaped(value) || isSeedFileReference(value)) return true; + if (Array.isArray(value)) return value.some(rewrittenAtLoad); + if (isRec(value)) return Object.values(value).some(rewrittenAtLoad); + return false; +} + +/** A driverless engine whose registry holds the stack's objects and picklists. */ +function stackEngine(stack: AnyRec): ObjectQL { + const ql = new ObjectQL({ logger: createLogger({ level: 'silent' }) }); + ql.registry.logLevel = 'silent'; + for (const picklist of Array.isArray(stack.picklists) ? stack.picklists : []) { + if (!isRec(picklist) || typeof picklist.name !== 'string') continue; + try { + ql.registry.registerItem('picklist', { ...picklist }, 'name'); + } catch { + // Refused by the registry: a field naming it is then left unjudged. + } + } + for (const extension of Array.isArray(stack.picklistExtensions) ? stack.picklistExtensions : []) { + if (!isRec(extension)) continue; + try { + ql.registry.registerPicklistExtension(extension as Parameters[0], 'stack'); + } catch { + // Refused by the registry, as above. + } + } + for (const obj of objectsOf(stack.objects)) { + try { + ql.registerObject(registrationCopy(obj) as Parameters[0]); + } catch { + // Refused by the registry: its datasets are left unjudged, see the header. + } + } + return ql; +} + +/** Is this field's value one the judge reads as written? See the header. */ +function judgedField(ql: ObjectQL, def: AnyRec | undefined): boolean { + if (!def || typeof def.type !== 'string') return false; + const type = def.type; + if (REFERENCE_VALUE_TYPES.has(type) || FILE_REFERENCE_TYPES.has(type) || STRUCTURED_JSON_TYPES.has(type)) return false; + if (typeof def.picklist === 'string' && def.picklist.length > 0) { + return ql.registry.resolvePicklistOptions(def.picklist) !== undefined; + } + return true; +} + +/** + * Judge every seed record value of `stack` with the insert write's value + * verdict: the datasets where they sit (top level, manifest, each package + * body), against the objects and picklists of every package. Reads only. + */ +export function judgeSeedRecordValues(stack: AnyRec): SeedRecordValueIssue[] { + const datasets = datasetsOf(stack); + if (!datasets.some(({ dataset }) => Array.isArray(dataset.records) && dataset.records.length > 0)) return []; + const ql = stackEngine(authoringRuleUnionStack(stack)); + const issues: SeedRecordValueIssue[] = []; + for (const { dataset, location } of datasets) { + const objectName = (dataset as { object?: unknown }).object; + if (typeof objectName !== 'string' || !Array.isArray(dataset.records)) continue; + const schema = ql.registry.getObject(objectName) as { fields?: Record } | undefined; + if (!schema?.fields) continue; + const fields = schema.fields; + dataset.records.forEach((record, r) => { + if (!isRec(record)) return; + const row: AnyRec = {}; + for (const [name, value] of Object.entries(record)) { + if (!Object.prototype.hasOwnProperty.call(fields, name)) continue; + if (!judgedField(ql, fields[name]) || rewrittenAtLoad(value)) continue; + row[name] = value; + } + try { + validateInsertValues(schema as Parameters[0], row); + } catch (err) { + const refused = err as { code?: unknown; fields?: FieldValidationError[] }; + if (refused?.code !== VALIDATION_FAILED_CODE || !Array.isArray(refused.fields)) throw err; + for (const f of refused.fields) { + issues.push({ + location: `${location}.records[${r}].${f.field}`, + object: objectName, + field: f.field, + code: f.code, + message: f.message, + }); + } + } + }); + } + return issues; +} diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 1bdeb934f67..8cb4bec79db 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -441,7 +441,7 @@ export { evaluateFormulaField } from './engine.js'; // [#16260] The constant is batteries-only while `ValidationError` is ALSO on the // lean `./core` entry, matching every existing `*_CODE` in this package; that // asymmetry is #16260's question, deliberately not decided here. -export { ValidationError, validateRecord, VALIDATION_FAILED_CODE } from './validation/record-validator.js'; +export { ValidationError, validateRecord, validateInsertValues, VALIDATION_FAILED_CODE } from './validation/record-validator.js'; export type { FieldValidationError } from './validation/record-validator.js'; // [ADR-0104 / #4769] The counterexample a boot produces by ADMITTING an // off-shape value. Exported because the fresh-datastore attestation diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index f47f64b9f0e..dc438b68587 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -1960,6 +1960,50 @@ export function validateRecord( validateRecordInScope(objectSchema, data, mode, 'skip', options); } +/** + * [#27259] The insert write's verdict on the values a row SUPPLIES — what + * `ObjectQL.insert()` refuses about a value whatever defaults, hooks and the + * store go on to add. `os validate` and `os build` ask it about each seed + * record, so a seed value the load would refuse is refused at authoring time + * with the same code and the same sentence. + * + * ⛔ One judge, not a second one: the door's own two rewrites + * ({@link normalizeBlankTypedValues}, {@link normalizeNumericStringValues}), + * the multi-value wrap ({@link normalizeMultiValueFields}) and then + * {@link validateRecordInScope} with the insert path's `'include'` scope, in + * the insert path's order. A value the load accepts after a rewrite (a numeric + * string on a number field, a scalar on a multi-value one) passes here too. + * + * What it leaves to the load, by construction: + * - a missing value (`undefined`, `null`, blank): whether the row needs one is + * `required`, which a `defaultValue` or a hook may still satisfy; + * - a name the schema does not declare: the undeclared-field refusal is + * another door's; + * - every non-value rule: validation rules, references resolving, permissions. + * + * Messages render as {@link validateRecord}'s do (`en` and the declared + * labels unless `options.messages` says otherwise). ADR-0104 value shapes keep + * the deployment's posture, so with the default options they warn, never + * refuse. Throws `ValidationError` on failure; never mutates `data`. + */ +export function validateInsertValues( + objectSchema: { fields?: Record } | undefined | null, + data: Record | undefined | null, + options: ValidateRecordOptions = {}, +): void { + const fields = objectSchema?.fields; + if (!fields || !data) return; + const row = { ...normalizeNumericStringValues(objectSchema, normalizeBlankTypedValues(objectSchema, data)) }; + normalizeMultiValueFields(objectSchema, row, 'include'); + const supplied: Record = {}; + for (const [name, value] of Object.entries(row)) { + // Own-property: a field name may be `constructor` / `valueOf`. + if (isMissing(value) || !Object.prototype.hasOwnProperty.call(fields, name)) continue; + supplied[name] = fields[name]; + } + validateRecordInScope({ fields: supplied }, row, 'insert', 'include', options); +} + /** * [#21663] {@link validateRecord}, told where its payload stands relative to * the readonly strip — see {@link ReadonlyValueScope} for the three scopes, From 98d28ec0b5eaa2d7be167fcec28be6438f3671c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:05:02 +0000 Subject: [PATCH 2/5] test(cli, objectql): seed record values judged at authoring time; the seed load refuses the same rows in the same words Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS Co-authored-by: Claude --- .../27259-validate-seed-record-values.md | 21 +++ .../cli/src/utils/seed-record-values.test.ts | 138 ++++++++++++++++++ .../seed-record-values-load-parity.test.ts | 91 ++++++++++++ .../test/validate-build-gate-parity.test.ts | 7 + .../src/validation/record-validator.test.ts | 68 ++++++++- 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 .changeset/27259-validate-seed-record-values.md create mode 100644 packages/cli/src/utils/seed-record-values.test.ts create mode 100644 packages/cli/test/seed-record-values-load-parity.test.ts diff --git a/.changeset/27259-validate-seed-record-values.md b/.changeset/27259-validate-seed-record-values.md new file mode 100644 index 00000000000..37ff021e7ac --- /dev/null +++ b/.changeset/27259-validate-seed-record-values.md @@ -0,0 +1,21 @@ +--- +'@objectstack/cli': patch +'@objectstack/objectql': patch +--- + +fix(cli)!: `os validate` and `os build` refuse a seed record value the seed load would refuse, with the load's own code and sentence, located at the record's field (#27259) + +Clause-②: no (narrowing) — `os validate` and `os build` refuse a stack whose seed record carries a value the record validator refuses at seed load (a currency written `{ value, currency }`, an unreadable number, a value outside a field's options, an unreadable date), which both commands passed before. What the runtime stores and refuses does not move: every row refused here was already refused when the seed loaded. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at `os validate` and `os build` (`@objectstack/cli`); one new export on `@objectstack/objectql`. + +- **Before.** A seed record such as `budget: { value: 10, currency: 'USD' }` on a currency field passed `os validate` with "Validation passed" (exit 0) and `os build` emitted the artifact. The row was refused only when the seed loaded, by the record validator: `Budget is a currency amount: write it as a bare number …`, one dropped row in the boot log. +- **Now.** Both commands judge every seed record value with the judge the load runs, and refuse with that judge's code and sentence, located at the value: `data[0].records[4].budget: Budget is a currency amount: write it as a bare number … (invalid_number)`, exit 1. `--json` carries each issue as `{ location, object, field, code, message }`. +- **One judge.** The verdict is `validateInsertValues` (`@objectstack/objectql`, new): the insert write's own normalizations (a blank on a typed field is `null`, a numeric string is its number, a scalar on a multi-value field is wrapped) and then the same record-validator call the insert path makes. The field map is the runtime registry's, built from the stack's objects and picklists. So a value the load accepts after a rewrite (`'10'` on a number field) still passes. +- **Left to the load.** `required` (a default or a hook may fill the value), a field the object does not declare (`defineSeed` already refuses it), and every value the load rewrites or a deployment decides: an expression envelope (the `cel` template), a `seedFile(path)`, a reference, file or structured-JSON field, a field bound to a picklist another package declares, and a dataset whose object another package declares. +- **Who sees it.** A stack shipping a seed row the load already drops. The four example apps in this repository (`app-todo`, `app-crm`, `app-showcase`, `app-multi-package`) carry none and pass unchanged. + +**The remedy.** Write the value in the shape the message names, for a currency a bare number such as `10`, with the currency in the field's `currencyConfig`. diff --git a/packages/cli/src/utils/seed-record-values.test.ts b/packages/cli/src/utils/seed-record-values.test.ts new file mode 100644 index 00000000000..8dbd8a986e0 --- /dev/null +++ b/packages/cli/src/utils/seed-record-values.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { cel } from '@objectstack/spec'; +import { seedFile } from '@objectstack/spec/data'; +import { judgeSeedRecordValues } from './seed-record-values.js'; + +/** + * [#27259] `os validate` and `os build` judge each seed record value with the + * insert write's value verdict (`validateInsertValues`), so a value the seed + * load refuses is refused at authoring time, with the load's code and + * sentence, located at the record's field. The load-side half of that claim + * (the same code and sentence from a real seed load) is + * `test/seed-record-values-load-parity.test.ts`. + */ + +const LESSON_START = 'Budget is a currency amount: write it as a bare number'; + +const PROJECT = { + name: 'proj_project', + label: 'Project', + fields: { + name: { type: 'text', label: 'Name', required: true }, + budget: { type: 'currency', label: 'Budget' }, + hours: { type: 'number', label: 'Hours', min: 0 }, + stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }] }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] }, + industry: { type: 'select', label: 'Industry', picklist: 'industry' }, + sector: { type: 'select', label: 'Sector', picklist: 'sector_elsewhere' }, + owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, + due: { type: 'date', label: 'Due' }, + brief: { type: 'file', label: 'Brief' }, + }, +}; + +const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] }; + +function stackWith(records: Record[], extra: Record = {}) { + return { + objects: [PROJECT], + picklists: [INDUSTRY], + data: [{ object: 'proj_project', records }], + ...extra, + }; +} + +describe('[#27259] judgeSeedRecordValues — the seed load\'s value verdict at authoring time', () => { + it('refuses a currency written as an object, with the currency lesson, located at the record\'s field', () => { + const issues = judgeSeedRecordValues(stackWith([ + { name: 'Alpha', budget: 10 }, + { name: 'Beta', budget: { value: 10, currency: 'USD' } }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + location: 'data[0].records[1].budget', + object: 'proj_project', + field: 'budget', + code: 'invalid_number', + }); + expect(issues[0].message.startsWith(LESSON_START)).toBe(true); + expect(issues[0].message).toContain('currencyConfig'); + }); + + it('passes a bare number on the currency field', () => { + expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', budget: 10 }]))).toEqual([]); + }); + + it('passes a value the load accepts after its rewrite: a numeric string, a blank, a scalar on a multi-value field', () => { + expect(judgeSeedRecordValues(stackWith([ + { name: 'Alpha', budget: '10', hours: '2.5', due: '', tags: 'alpha' }, + ]))).toEqual([]); + }); + + it('refuses what the load refuses about a value: an unreadable number, a bound, an option, a date', () => { + const issues = judgeSeedRecordValues(stackWith([ + { name: 'Alpha', hours: 'abc', stage: 'archived', due: 'yesterday' }, + { name: 'Beta', hours: -1, tags: ['alpha', 'zzz'] }, + ])); + expect(issues.map((i) => [i.location, i.code])).toEqual([ + ['data[0].records[0].hours', 'invalid_number'], + ['data[0].records[0].stage', 'invalid_option'], + ['data[0].records[0].due', 'invalid_date'], + ['data[0].records[1].hours', 'min_value'], + ['data[0].records[1].tags', 'invalid_option'], + ]); + }); + + it('judges a picklist-bound field against the options the stack\'s picklist resolves', () => { + expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'tech' }]))).toEqual([]); + const issues = judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'retail' }])); + expect(issues.map((i) => [i.field, i.code])).toEqual([['industry', 'invalid_option']]); + }); + + it('leaves to the load what it rewrites or a deployment decides: an expression, a seed file, a reference, a file, a list declared elsewhere', () => { + expect(judgeSeedRecordValues(stackWith([ + { + name: 'Alpha', + hours: cel`1 + 1`, + due: cel`daysFromNow(3)`, + brief: seedFile('fixtures/brief.pdf'), + owner: 'admin@example.com', + sector: 'anything', + }, + ]))).toEqual([]); + }); + + it('does not judge `required`: a row missing a required value is the load\'s (defaults and hooks may fill it)', () => { + expect(judgeSeedRecordValues(stackWith([{ budget: 5 }, { name: null, budget: 6 }]))).toEqual([]); + }); + + it('CONTROL: a field the object does not declare gets no answer from this check', () => { + expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', no_such_field: { value: 10 } }]))).toEqual([]); + }); + + it('CONTROL: a dataset for an object the stack does not declare is not judged', () => { + expect(judgeSeedRecordValues({ + objects: [PROJECT], + data: [{ object: 'sys_business_unit', records: [{ name: 'HQ', budget: { value: 1 } }] }], + })).toEqual([]); + }); + + it('judges a package body\'s datasets against every package\'s objects, located where they sit', () => { + const issues = judgeSeedRecordValues({ + packages: [ + { manifest: { id: 'com.example.core', name: 'core', version: '1.0.0', type: 'app', objects: [PROJECT] } }, + { manifest: { id: 'com.example.seed', name: 'seed', version: '1.0.0', type: 'app', data: [{ object: 'proj_project', records: [{ name: 'A', budget: { value: 1, currency: 'EUR' } }] }] } }, + ], + }); + expect(issues.map((i) => [i.location, i.code])).toEqual([['packages[1].manifest.data[0].records[0].budget', 'invalid_number']]); + }); + + it('reads only: the stack it is handed is unchanged', () => { + const stack = stackWith([{ name: 'Alpha', budget: '10', tags: 'alpha', due: '' }]); + const before = JSON.stringify(stack); + judgeSeedRecordValues(stack); + expect(JSON.stringify(stack)).toBe(before); + }); +}); diff --git a/packages/cli/test/seed-record-values-load-parity.test.ts b/packages/cli/test/seed-record-values-load-parity.test.ts new file mode 100644 index 00000000000..932f55016bc --- /dev/null +++ b/packages/cli/test/seed-record-values-load-parity.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SeedLoaderService } from '@objectstack/metadata-protocol'; +import { judgeSeedRecordValues } from '../src/utils/seed-record-values.js'; + +/** + * [#27259] One judge, two moments: every seed row `os validate` refuses on a + * value, a real seed load refuses with the same sentence, and every row it + * passes, the load writes. The load here is the platform's own: the + * `SeedLoaderService` writing through an `ObjectQL` engine on the memory + * driver, as `os dev` seeds an app. + */ + +const PROJECT = { + name: 'proj_project', + label: 'Project', + fields: { + name: { type: 'text', label: 'Name', required: true }, + budget: { type: 'currency', label: 'Budget' }, + hours: { type: 'number', label: 'Hours', min: 0 }, + stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }] }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] }, + due: { type: 'date', label: 'Due' }, + }, +}; + +const RECORDS: Record[] = [ + { name: 'Refused object', budget: { value: 10, currency: 'USD' } }, + { name: 'Bare number', budget: 10 }, + { name: 'Rewritten values', budget: '10', hours: '2.5', tags: 'alpha', due: '' }, + { name: 'Unreadable number', hours: 'abc' }, + { name: 'Unknown option', stage: 'archived' }, + { name: 'Unreadable date', due: 'yesterday' }, + { name: 'Below min', hours: -1 }, +]; + +let engine: ObjectQL | undefined; +afterEach(async () => { + await engine?.destroy(); + engine = undefined; +}); + +async function seedLoad(): Promise<{ written: string[]; refused: Map }> { + const driver = new InMemoryDriver(); + engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registry.registerObject(structuredClone(PROJECT) as never); + const silent = { info() {}, debug() {}, warn() {}, error() {} }; + const metadata = { getObject: async () => PROJECT, listObjects: async () => [PROJECT] }; + const loader = new SeedLoaderService(engine as never, metadata as never, silent as never); + const result = await loader.load({ + seeds: [{ object: 'proj_project', externalId: 'name', mode: 'insert', env: ['prod', 'dev', 'test'], records: structuredClone(RECORDS) }], + config: { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1, transaction: false }, + } as never); + const refused = new Map(); + for (const e of result.errors as Array<{ recordIndex?: number; message: string }>) { + if (typeof e.recordIndex === 'number') refused.set(e.recordIndex, e.message); + } + const rows = (await engine.find('proj_project', {})) as Array<{ name: string }>; + return { written: rows.map((r) => r.name).sort(), refused }; +} + +describe('[#27259] os validate refuses exactly the seed rows the seed load refuses, in the load\'s words', () => { + it('a refused row is refused by both with one sentence; a passed row is written', async () => { + const issues = judgeSeedRecordValues({ objects: [PROJECT], data: [{ object: 'proj_project', records: RECORDS }] }); + const judged = new Map(); + for (const issue of issues) { + const index = Number(/records\[(\d+)\]/.exec(issue.location)![1]); + expect(judged.has(index)).toBe(false); + judged.set(index, issue.message); + } + + const { written, refused } = await seedLoad(); + + // The same rows: refused at validate time ⇔ refused at load. + expect([...judged.keys()].sort()).toEqual([...refused.keys()].sort()); + expect([...judged.keys()].sort()).toEqual([0, 3, 4, 5, 6]); + // The same sentence: the load quotes the validator's message after the row's key. + for (const [index, message] of judged) { + expect(refused.get(index)!.endsWith(`: ${message}`)).toBe(true); + } + // Row 0 is the card's case, and carries the currency lesson. + expect(judged.get(0)!.startsWith('Budget is a currency amount: write it as a bare number')).toBe(true); + // Every row the check passes is written. + expect(written).toEqual(['Bare number', 'Rewritten values']); + }); +}); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 152aeda3ddc..d00dfc23040 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -170,6 +170,13 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // cross-package by construction, and the per-package stack a rule is handed // sees one side only. Both doors refuse the same errors at the same step. 'checkDependencyRanges', + // [#27259] Seed record VALUES, judged by the insert write's value verdict + // (`validateInsertValues`, @objectstack/objectql) — the judge the seed load + // runs. Not a registry rule: the verdict needs the runtime registry's field + // map (a driverless `ObjectQL` holding the stack's objects and picklists), + // and the walk reads every dataset where it sits, the package bodies + // included, across the whole artifact. + 'judgeSeedRecordValues', ]; /** diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 65b289236a9..8fa3f3ab2e9 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { FieldSchema } from '@objectstack/spec/data'; import { renderValidationMessage } from '@objectstack/spec/system'; -import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError } from './record-validator.js'; +import { validateRecord, validateInsertValues, normalizeMultiValueFields, coerceBooleanFields, ValidationError } from './record-validator.js'; /** * Required-field validation, with the autonumber exemption (#1603). @@ -1421,6 +1421,72 @@ describe('[#27236] validateRecord — a currency value written as an object name }); }); +/** + * [#27259] `validateInsertValues` — the insert write's verdict on the values a + * row SUPPLIES, which `os validate` / `os build` ask about each seed record. It + * answers what `validateRecord(…, 'insert')` answers about a supplied value, + * after the door's own rewrites, and nothing about what the row omits or what + * the schema does not declare. + */ +describe('[#27259] validateInsertValues — the insert verdict on the values a row supplies', () => { + const schema = { + fields: { + name: { type: 'text', label: 'Name', required: true }, + amount: { type: 'currency', label: 'Amount' }, + hours: { type: 'number', label: 'Hours', min: 0 }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }] }, + due: { type: 'date', label: 'Due' }, + stamp: { type: 'datetime', label: 'Stamp', readonly: true }, + }, + }; + const refusalOf = (fn: () => void) => { + try { + fn(); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + return (e as ValidationError).fields; + } + return null; + }; + const lesson = renderValidationMessage({ messageKey: 'invalid_number_currency_object', label: 'Amount', field: 'amount' }); + + it('refuses a currency written as an object with the same fields validateRecord gives on insert', () => { + const row = { name: 'A', amount: { value: 10, currency: 'USD' } }; + const expected = [{ field: 'amount', code: 'invalid_number', message: lesson, label: 'Amount' }]; + expect(refusalOf(() => validateInsertValues(schema, row))).toEqual(expected); + expect(refusalOf(() => validateRecord(schema, row, 'insert'))).toEqual(expected); + }); + + it('passes a bare number', () => { + expect(refusalOf(() => validateInsertValues(schema, { amount: 10 }))).toBeNull(); + }); + + it('passes what the insert door rewrites into an accepted value: a numeric string, a blank, a scalar on a multi-value field', () => { + expect(refusalOf(() => validateInsertValues(schema, { amount: '10', hours: '2.5', due: '', tags: 'alpha' }))).toBeNull(); + }); + + it('does not judge `required` on what the row omits or leaves empty: the load\'s defaults and hooks may fill it', () => { + expect(refusalOf(() => validateInsertValues(schema, { amount: 5 }))).toBeNull(); + expect(refusalOf(() => validateInsertValues(schema, { name: null, amount: 5 }))).toBeNull(); + // CONTROL — validateRecord on insert does refuse the omission. + expect(refusalOf(() => validateRecord(schema, { amount: 5 }, 'insert'))?.map((f) => f.code)).toEqual(['required']); + }); + + it('CONTROL — a name the schema does not declare gets no answer here', () => { + expect(refusalOf(() => validateInsertValues(schema, { no_such_field: { value: 1 } }))).toBeNull(); + }); + + it('judges a readonly value\'s shape, as the insert path does after its strip (a system writer\'s value is stored)', () => { + expect(refusalOf(() => validateInsertValues(schema, { stamp: 'yesterday' }))?.map((f) => [f.field, f.code])).toEqual([['stamp', 'invalid_date']]); + }); + + it('never mutates the row it is handed', () => { + const row = { amount: '10', tags: 'alpha', due: '' }; + validateInsertValues(schema, row); + expect(row).toEqual({ amount: '10', tags: 'alpha', due: '' }); + }); +}); + /** * [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls * outside the kind's supported years, is refused with the SAME wire code as From 603ecf33d366134fcaf1002b083fe00fd3c48378 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:26:57 +0000 Subject: [PATCH 3/5] test(cli): the seed-load parity test loads through SQLite :memory:; the seed walk's folded read is classified Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS Co-authored-by: Claude --- packages/cli/test/normalized-call-sites.test.ts | 5 +++++ .../cli/test/seed-record-values-load-parity.test.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/normalized-call-sites.test.ts b/packages/cli/test/normalized-call-sites.test.ts index e67bfa27d23..25f60c250fe 100644 --- a/packages/cli/test/normalized-call-sites.test.ts +++ b/packages/cli/test/normalized-call-sites.test.ts @@ -519,6 +519,11 @@ const READS: Readonly> = { reads: 'top-level', why: 'a presence probe only: `declaredCapabilities` answers `[]` rather than `null` for a stack whose top level declares an empty `requires`, so the printed line says it replaces that key. The tokens come from `stackDeclaredCapabilities` (#22288).', }, + 'utils/seed-record-values.ts :: stack': { + reads: 'resolved', + evidence: [{ code: 'const ql = stackEngine(authoringRuleUnionStack(stack));' }], + why: '`stackEngine` is called once, with the stack folded: every package\'s objects, picklists and picklist extensions reach the registry the seed values are judged against (#27259). The datasets themselves are read where they sit by `datasetsOf`. Pinned on a package body in `seed-record-values.test.ts`.', + }, 'utils/sdui-manifest.ts :: stack': { reads: 'resolved', evidence: [ diff --git a/packages/cli/test/seed-record-values-load-parity.test.ts b/packages/cli/test/seed-record-values-load-parity.test.ts index 932f55016bc..0d9c80ad21d 100644 --- a/packages/cli/test/seed-record-values-load-parity.test.ts +++ b/packages/cli/test/seed-record-values-load-parity.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqlDriver } from '@objectstack/driver-sql'; import { SeedLoaderService } from '@objectstack/metadata-protocol'; import { judgeSeedRecordValues } from '../src/utils/seed-record-values.js'; @@ -10,8 +10,8 @@ import { judgeSeedRecordValues } from '../src/utils/seed-record-values.js'; * [#27259] One judge, two moments: every seed row `os validate` refuses on a * value, a real seed load refuses with the same sentence, and every row it * passes, the load writes. The load here is the platform's own: the - * `SeedLoaderService` writing through an `ObjectQL` engine on the memory - * driver, as `os dev` seeds an app. + * `SeedLoaderService` writing through an `ObjectQL` engine on a SQLite + * `:memory:` database, as `os dev` seeds an app. */ const PROJECT = { @@ -44,17 +44,18 @@ afterEach(async () => { }); async function seedLoad(): Promise<{ written: string[]; refused: Map }> { - const driver = new InMemoryDriver(); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); engine = new ObjectQL(); engine.registerDriver(driver as never, true); await engine.init(); engine.registry.registerObject(structuredClone(PROJECT) as never); + await driver.initObjects([engine.registry.getObject('proj_project') as never]); const silent = { info() {}, debug() {}, warn() {}, error() {} }; const metadata = { getObject: async () => PROJECT, listObjects: async () => [PROJECT] }; const loader = new SeedLoaderService(engine as never, metadata as never, silent as never); const result = await loader.load({ seeds: [{ object: 'proj_project', externalId: 'name', mode: 'insert', env: ['prod', 'dev', 'test'], records: structuredClone(RECORDS) }], - config: { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1, transaction: false }, + config: { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1, transaction: false, organizationId: 'org_parity' }, } as never); const refused = new Map(); for (const e of result.errors as Array<{ recordIndex?: number; message: string }>) { From 8e7f125e0360963beb8d7dab11cda6ca538532e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:30:23 +0000 Subject: [PATCH 4/5] chore(changeset): the seed-value refusal is Metadata-protocol narrowed; both packages minor Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS Co-authored-by: Claude --- .changeset/27259-validate-seed-record-values.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/27259-validate-seed-record-values.md b/.changeset/27259-validate-seed-record-values.md index 37ff021e7ac..1a580ec771e 100644 --- a/.changeset/27259-validate-seed-record-values.md +++ b/.changeset/27259-validate-seed-record-values.md @@ -1,12 +1,12 @@ --- -'@objectstack/cli': patch -'@objectstack/objectql': patch +'@objectstack/cli': minor +'@objectstack/objectql': minor --- fix(cli)!: `os validate` and `os build` refuse a seed record value the seed load would refuse, with the load's own code and sentence, located at the record's field (#27259) Clause-②: no (narrowing) — `os validate` and `os build` refuse a stack whose seed record carries a value the record validator refuses at seed load (a currency written `{ value, currency }`, an unreadable number, a value outside a field's options, an unreadable date), which both commands passed before. What the runtime stores and refuses does not move: every row refused here was already refused when the seed loaded. Nothing is widened. -Metadata-protocol: none +Metadata-protocol: narrowed From a44c5261869245bd52c58fb4cdc408909d310eb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:46:54 +0000 Subject: [PATCH 5/5] docs(cli): name the seed-value judge's hook-rewrite and manifest coverage limits (#27259) Contract review should-fixes, comment and changeset text only: the walk's header names that a beforeInsert hook is not run at authoring time and that a dataset whose object is declared only under manifest.objects is unjudged; validateInsertValues' docblock no longer over-states that `required` is left to the load (an explicit [] on a required multi-value field is refused here, as at load). Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS Co-authored-by: Claude --- .changeset/27259-validate-seed-record-values.md | 3 ++- packages/cli/src/utils/seed-record-values.ts | 14 ++++++++++++++ .../objectql/src/validation/record-validator.ts | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.changeset/27259-validate-seed-record-values.md b/.changeset/27259-validate-seed-record-values.md index 1a580ec771e..ee86225e425 100644 --- a/.changeset/27259-validate-seed-record-values.md +++ b/.changeset/27259-validate-seed-record-values.md @@ -15,7 +15,8 @@ Metadata-protocol: narrowed - **Before.** A seed record such as `budget: { value: 10, currency: 'USD' }` on a currency field passed `os validate` with "Validation passed" (exit 0) and `os build` emitted the artifact. The row was refused only when the seed loaded, by the record validator: `Budget is a currency amount: write it as a bare number …`, one dropped row in the boot log. - **Now.** Both commands judge every seed record value with the judge the load runs, and refuse with that judge's code and sentence, located at the value: `data[0].records[4].budget: Budget is a currency amount: write it as a bare number … (invalid_number)`, exit 1. `--json` carries each issue as `{ location, object, field, code, message }`. - **One judge.** The verdict is `validateInsertValues` (`@objectstack/objectql`, new): the insert write's own normalizations (a blank on a typed field is `null`, a numeric string is its number, a scalar on a multi-value field is wrapped) and then the same record-validator call the insert path makes. The field map is the runtime registry's, built from the stack's objects and picklists. So a value the load accepts after a rewrite (`'10'` on a number field) still passes. -- **Left to the load.** `required` (a default or a hook may fill the value), a field the object does not declare (`defineSeed` already refuses it), and every value the load rewrites or a deployment decides: an expression envelope (the `cel` template), a `seedFile(path)`, a reference, file or structured-JSON field, a field bound to a picklist another package declares, and a dataset whose object another package declares. +- **Left to the load.** `required` on a missing value (a default or a hook may fill it; an explicit `[]` on a required multi-value field is refused `required` here, as at load), a field the object does not declare (`defineSeed` already refuses it), and every value the load rewrites or a deployment decides: an expression envelope (the `cel` template), a `seedFile(path)`, a reference, file or structured-JSON field, a field bound to a picklist another package declares, and a dataset whose object another package declares. +- **Known limits.** A `beforeInsert` hook is not run at authoring time, so a value a hook would rewrite into an accepted shape is refused here though the load stores it. A dataset whose object is declared only under `manifest.objects`, outside `packages[]`, is not judged. - **Who sees it.** A stack shipping a seed row the load already drops. The four example apps in this repository (`app-todo`, `app-crm`, `app-showcase`, `app-multi-package`) carry none and pass unchanged. **The remedy.** Write the value in the shape the message names, for a currency a bare number such as `10`, with the currency in the field's `currencyConfig`. diff --git a/packages/cli/src/utils/seed-record-values.ts b/packages/cli/src/utils/seed-record-values.ts index 1e847578728..0b9d8b3f05c 100644 --- a/packages/cli/src/utils/seed-record-values.ts +++ b/packages/cli/src/utils/seed-record-values.ts @@ -41,6 +41,20 @@ * * A field the object does not declare is left to the answer it already has * (`defineSeed` refuses it at load of the config). + * + * ## Known limits + * + * - A `beforeInsert` hook is not run here. The seed write runs object hooks + * (the seed context skips automation triggers, not hooks), and they run + * BEFORE the record validator, so a hook that rewrites a seed value into an + * accepted shape lets the load store it while this walk refuses it as + * written. The authoring door has no hook runtime by design; a refusal of + * a value a hook would have fixed comes from here. + * - Coverage is the folded stack's: `authoringRuleUnionStack` folds the + * stack's own collections and its `packages[]` bodies. A dataset under + * `manifest.data` whose object is declared only under `manifest.objects` + * (outside `packages[]`) finds no field map and goes unjudged, by the + * dataset clause above: fail-open, never a false refusal. */ import { createLogger } from '@objectstack/core'; diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index dc438b68587..7992d2aadb2 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -1976,7 +1976,9 @@ export function validateRecord( * * What it leaves to the load, by construction: * - a missing value (`undefined`, `null`, blank): whether the row needs one is - * `required`, which a `defaultValue` or a hook may still satisfy; + * `required`, which a `defaultValue` or a hook may still satisfy. A + * SUPPLIED empty array on a required multi-value field is not missing: it + * is refused `required` here, as the insert path refuses it; * - a name the schema does not declare: the undeclared-field refusal is * another door's; * - every non-value rule: validation rules, references resolving, permissions.