From 3202cf06688455e7a766433a67c3a36d384689e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:42:15 +0000 Subject: [PATCH 1/9] feat(spec,objectql,metadata-protocol,client): a write answer carries its advisory validation-rule hits as warnings evaluateValidationRules returns the advisory (warning/info) hits of an evaluation; the engine hands them to the write's own onValidationAdvisory listener (WriteObservabilityOptions, beside onFieldsDropped), so a nested hook write never reaches the outer answer. createData, updateData and cloneData answer them as warnings, and the validate-only preview appends the same elements to each accepted row's warnings. ValidateDataIssueSchema gains optional rule and severity and is the shared element. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .changeset/22726-write-answer-warnings.md | 20 +++++ content/docs/data-modeling/validation.mdx | 25 +++++- packages/client/src/index.ts | 25 ++++++ packages/metadata-protocol/src/protocol.ts | 25 +++++- .../src/engine-unknown-option.test.ts | 9 ++- packages/objectql/src/engine.ts | 16 ++-- .../objectql/src/validation/rule-validator.ts | 76 ++++++++++++++++++- packages/spec/src/api/export.zod.ts | 9 ++- packages/spec/src/api/protocol.zod.ts | 69 +++++++++++++---- packages/spec/src/contracts/data-engine.ts | 27 +++++++ packages/spec/src/data/data-engine.zod.ts | 43 +++++++++++ 11 files changed, 307 insertions(+), 37 deletions(-) create mode 100644 .changeset/22726-write-answer-warnings.md diff --git a/.changeset/22726-write-answer-warnings.md b/.changeset/22726-write-answer-warnings.md new file mode 100644 index 00000000000..66bc10ff8ce --- /dev/null +++ b/.changeset/22726-write-answer-warnings.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +'@objectstack/client': minor +--- + +A write answer now carries the advisory validation-rule hits of that write as `warnings` + +Clause-②: yes (widening) + +A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + +- **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. +- **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. +- **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. +- **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. +- **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + +**For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index 9f04dd57e06..816d7231512 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -84,11 +84,32 @@ All validation types share these base properties: | Severity | Behavior | | :--- | :--- | | `error` | Prevents the record from being saved | -| `warning` | Allows the save; advisory only — logged server-side, not returned to the caller | -| `info` | Informational message, no blocking | +| `warning` | Allows the save; advisory only — returned to the caller in the write answer's `warnings`, and logged server-side | +| `info` | Informational message, no blocking; returned and logged the same way as `warning` | Advisory rules (`warning` / `info`) are reported, never enforced: on an ordinary write each hit is logged as it happens; on a seed/boot load a run's hits are folded into **one summary line per rule**; and an `update` touching only platform-injected system columns does not re-evaluate them at all, so a row is reported once rather than once per write (#13889). +The write's caller sees them too. A create, an update or a clone whose record trips an advisory rule still succeeds, and its answer carries `warnings`, one entry per rule hit: the rule's `name` as `rule`, its `severity`, the `field` it is about (`_record` for an object-level rule), the finding `code` and your `message`. A client shows that message to the person who saved. A write with no hit has no `warnings` key at all. + +```json +{ + "object": "task", + "id": "tsk_001", + "record": { "id": "tsk_001", "name": "Internal chore", "related_to": null }, + "warnings": [ + { + "rule": "related_to_required", + "severity": "warning", + "field": "_record", + "code": "rule_violation", + "message": "At least one related record should be selected." + } + ] +} +``` + +Only the hits of that one write are listed: a record a hook or a flow writes while handling it reports its own hits to its own caller, not into this answer. The validate-only preview (`validateData`, and an import's dry run) appends the same entries to each accepted row's `warnings`, after any value-shape findings the deployment admits. A rule whose condition could not be evaluated is not a hit: it is reported in the server log only. + ## Validation Types ### Script Validation diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f42774841ab..baaae48faa4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -124,6 +124,7 @@ import { PackageExportManifest, ReassignOrphanedMetadataResponse, DuplicatePackageResponse, + ValidateDataIssue, } from '@objectstack/spec/api'; // [#17536] The element the two `/packages` READ doors are declared to serve. // `ListInstalledPackagesResponseSchema.packages` is @@ -446,6 +447,14 @@ export interface CreateDataResult { * mirrors this in the `X-ObjectStack-Dropped-Fields` response header. */ droppedFields?: DroppedFieldsEvent[]; + /** + * [#22726] The advisory validation-rule hits of this write — each + * `severity: 'warning'` / `'info'` rule whose verdict was "violated", with its + * `rule`, `severity`, `field`, `code` and author-written `message`. Advisory + * rules never block, so the write succeeded; this is the advisory to show the + * person who made it. Present only when at least one rule hit. + */ + warnings?: ValidateDataIssue[]; } /** @@ -470,6 +479,14 @@ export interface CloneDataResult { * the clone route sets no `X-ObjectStack-Dropped-Fields` header. */ droppedFields?: DroppedFieldsEvent[]; + /** + * [#22726] The advisory validation-rule hits of this write — each + * `severity: 'warning'` / `'info'` rule whose verdict was "violated", with its + * `rule`, `severity`, `field`, `code` and author-written `message`. Advisory + * rules never block, so the write succeeded; this is the advisory to show the + * person who made it. Present only when at least one rule hit. + */ + warnings?: ValidateDataIssue[]; } /** Spec: UpdateDataResponseSchema */ @@ -484,6 +501,14 @@ export interface UpdateDataResult { * succeeded. REST also mirrors this in the `X-ObjectStack-Dropped-Fields` header. */ droppedFields?: DroppedFieldsEvent[]; + /** + * [#22726] The advisory validation-rule hits of this write — each + * `severity: 'warning'` / `'info'` rule whose verdict was "violated", with its + * `rule`, `severity`, `field`, `code` and author-written `message`. Advisory + * rules never block, so the write succeeded; this is the advisory to show the + * person who made it. Present only when at least one rule hit. + */ + warnings?: ValidateDataIssue[]; } /** diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 55bbeaa1c7a..0b96830f763 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -140,6 +140,7 @@ import { QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS, type QueryAliasConflict, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, + type ValidationAdvisoryEvent, } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal, METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; // [#13331] The cluster fan-out transport type only — the protocol never @@ -13382,7 +13383,14 @@ export class ObjectStackProtocolImplementation implements // payload diff can be: a `beforeInsert` hook's own stamp is not a // caller forgery (#5591/#14259), and the ingress ran before the hooks. const dropped: DroppedFieldsEvent[] = []; - const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } }; + // [#22726] The advisory rule hits of THIS write, answered as `warnings` + // — the engine reports them through the write's own options, so a + // nested write a hook makes inside this one never adds to them. + const warnings: ValidationAdvisoryEvent[] = []; + const opts: any = { + onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, + onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); }, + }; if (request.context !== undefined) opts.context = request.context; const result = await this.engine.insert(request.object, request.data, opts); // [#7823] The 201 body is a GENERIC-DATA-PATH surface: strip @@ -13398,6 +13406,7 @@ export class ObjectStackProtocolImplementation implements id: result.id, record: result, ...(dropped.length > 0 ? { droppedFields: dropped } : {}), + ...(warnings.length > 0 ? { warnings } : {}), }; } @@ -13476,7 +13485,13 @@ export class ObjectStackProtocolImplementation implements // ruling 2026-09-08 (option 1); `CloneDataResponseSchema` declares the // member in the same change, because that schema is declared AS PRODUCED. const dropped: DroppedFieldsEvent[] = []; - const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } }; + // [#22726] A clone is a create: its advisory rule hits are answered as + // `warnings`, the same listener `createData` wires. + const warnings: ValidationAdvisoryEvent[] = []; + const opts: any = { + onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, + onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); }, + }; if (ctx !== undefined) opts.context = ctx; const result = await this.engine.insert(request.object, data, opts); // [#7823] Same ingress strip as `createData` — a clone's 201 body is @@ -13491,6 +13506,7 @@ export class ObjectStackProtocolImplementation implements sourceId: request.id, record: result, ...(dropped.length > 0 ? { droppedFields: dropped } : {}), + ...(warnings.length > 0 ? { warnings } : {}), }; } @@ -13535,6 +13551,10 @@ export class ObjectStackProtocolImplementation implements // listener never breaks the write (the engine catches + logs). const dropped: DroppedFieldsEvent[] = []; opts.onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push(e); }; + // [#22726] The advisory rule hits of THIS write, answered as `warnings` + // (see `createData`). + const warnings: ValidationAdvisoryEvent[] = []; + opts.onValidationAdvisory = (e: ValidationAdvisoryEvent) => { warnings.push(e); }; // [#6479] At THIS ingress the row is the one the caller named — `request.id`, // the path `:id` — and nothing in the payload gets to move it. // @@ -13610,6 +13630,7 @@ export class ObjectStackProtocolImplementation implements id: request.id, record: result, ...(dropped.length > 0 ? { droppedFields: dropped } : {}), + ...(warnings.length > 0 ? { warnings } : {}), }; } diff --git a/packages/objectql/src/engine-unknown-option.test.ts b/packages/objectql/src/engine-unknown-option.test.ts index aa691c8b415..dc613eff3f5 100644 --- a/packages/objectql/src/engine-unknown-option.test.ts +++ b/packages/objectql/src/engine-unknown-option.test.ts @@ -192,6 +192,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { await engine.update('task', { title: 'A3' }, { where: { id: a.id }, onFieldsDropped: () => {} } as any); }); + it('onValidationAdvisory passes on update (contract-declared write observability, #22726)', async () => { + await engine.update('task', { title: 'A4' }, { where: { id: a.id }, onValidationAdvisory: () => {} } as any); + }); + it('searchFields passes on find (read by the $search expansion)', async () => { // Legality pin only — the $or/$contains expansion itself is covered by // the search-filter tests; this mock driver does not evaluate it. @@ -253,8 +257,9 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { // `WriteObservabilityOptions` members that deliberately do NOT appear // in the serializable bag, so the schema cannot vouch for them and // this list must. Spelling it here is what keeps the engine from - // rejecting a key the TS contract declares. - expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'strictReadonlyWrites', ...PASSTHROUGH]); + // rejecting a key the TS contract declares. [#22726] + // `onValidationAdvisory` is the third such member. + expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites', ...PASSTHROUGH]); expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH); expectSetMatches('count', EngineCountOptionsSchema as any, []); expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0d601cda71f..9ddc3869727 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -278,7 +278,7 @@ import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; -import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; +import { collectPredicateRelationships, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; // [#14088] The before-phase write recorder — the provenance channel the static // `readonly` strip needs to tell a hook's write from a caller's echo of the // SAME value. Armed and sealed in `update()`; the module owns the argument for @@ -612,7 +612,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet = new Set([ ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet = new Set([ - 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'strictReadonlyWrites', + 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites', ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_DELETE_OPTION_KEYS: ReadonlySet = new Set([ @@ -13849,7 +13849,7 @@ export class ObjectQL implements IObjectQLEngine { // preview admits exactly what the write would. keptOptionValues: options?.context?.keptOptionValues, }); - evaluateValidationRules(schemaForValidation as any, row, mode, { + warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { // [#22445] The stored row, when this row named one: the by-id // update's `previous: priorRecord`. Absent, the record is the patch. ...(storedRows[i] ? { previous: storedRows[i] } : {}), @@ -13861,7 +13861,7 @@ export class ObjectQL implements IObjectQLEngine { previousParent: previewParents?.previousParents[i], related: previewRelatedForRow(judgedViews[i]), permissions: previewPermissionsFor(row), - }); + })); } catch (e) { if (e instanceof ValidationError) { return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; @@ -14785,7 +14785,7 @@ export class ObjectQL implements IObjectQLEngine { normalizeMultiValueFields(schemaForValidation, rows[i], 'include'); // [#22183] `keptOptionValues`: the option values an import kept for this write. validateRecordInScope(schemaForValidation, rows[i], 'insert', 'include', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation, keptOptionValues: opCtx.context?.keptOptionValues }); - evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }); + emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }), this.logger, object); await this.assertReferencesResolve( schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, ); @@ -16373,7 +16373,7 @@ export class ObjectQL implements IObjectQLEngine { // resolution failure fails this write closed right here. const updatePayload = hookContext.input.data as Record; const permissionsForUpdate = (await this.resolveOptionPermissions(updateSchema, [updatePayload], permissionResolution))(updatePayload); - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: roWhenParent, previousParent: roWhenPreviousParent, related: relatedForUpdate, permissions: permissionsForUpdate }); + emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: roWhenParent, previousParent: roWhenPreviousParent, related: relatedForUpdate, permissions: permissionsForUpdate }), this.logger, object); // [#4441] A repoint is as capable of dangling as an initial link. await this.assertReferencesResolve( updateSchema, hookContext.input.data as Record, @@ -16676,7 +16676,7 @@ export class ObjectQL implements IObjectQLEngine { if (rulesNeedRows) { for (const row of priorRows ?? []) { try { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row), related: bulkRelatedForRow?.({ ...(row ?? {}), ...bulkPatch }), permissions: bulkPermissions }); + emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row), related: bulkRelatedForRow?.({ ...(row ?? {}), ...bulkPatch }), permissions: bulkPermissions }), this.logger, object); } catch (err) { if (err instanceof ValidationError && row?.id != null) { throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` }))); @@ -16692,7 +16692,7 @@ export class ObjectQL implements IObjectQLEngine { // every such object down the per-row branch above, where the // binding is supplied. This branch only ever runs for the // rule families that never read a header. - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, permissions: bulkPermissions }); + emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, permissions: bulkPermissions }), this.logger, object); } // [#4441] The bulk call site too — a guard wired into single-id // writes only is still a hole one call site over (AGENTS.md diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index dc5fea67624..b011149076b 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -234,6 +234,7 @@ import { ExpressionEngine, collectCelRootIdentifiers, analyzeRelationshipTravers import type { EvalPermissions, RelationshipTraversalAnalysis } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES, referenceTargetOf, resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import type { ValidationAdvisoryEvent } from '@objectstack/spec/data'; import { recordAdvisoryHit } from '@objectstack/core'; // [#8215] The canonical spelling of the primary-key column — the sanctioned use // of this registry ("what is the canonical spelling of the column that plays @@ -3244,20 +3245,33 @@ function writesOnlyInjectedSystemColumns( * * Throws `ValidationError` (the same envelope `validateRecord` uses, so REST * surfaces a single `400 VALIDATION_FAILED`) when one or more `error`-severity - * rules are violated. Returns void otherwise. + * rules are violated. Otherwise returns this evaluation's ADVISORY hits (#22726) + * — every `warning` / `info` rule whose verdict was "violated", in evaluation + * order, as `ValidationAdvisoryEvent`s — or `[]` when there is none. + * + * Returning the hits changes nothing about how they are reported here: each is + * still offered to the #13889 seed / boot aggregation and, when no scope takes + * it, logged per write, exactly as before. The return value is the write + * answer's channel — the engine hands it to its caller's + * `onValidationAdvisory` listener (see {@link emitValidationAdvisories}) and + * the validate-only preview appends it to the row's `warnings`. A rule that + * could not be EVALUATED (`constraint.reason: 'unevaluable'`) is not a hit: it + * has no verdict, and its text ("could not be evaluated … write rejected") is a + * fault report for the operator, false for a write an advisory never blocks — + * so it stays in the log only. */ export function evaluateValidationRules( objectSchema: { validations?: unknown[]; fields?: Record } | undefined | null, data: Record | undefined | null, mode: Mode, opts: EvaluateRulesOptions = {}, -): void { - if (!data) return; +): ValidationAdvisoryEvent[] { + if (!data) return []; const rules = objectSchema?.validations; const hasRules = Array.isArray(rules) && rules.length > 0; const fields = objectSchema?.fields; const hasFieldRules = fieldsNeedPrior(fields); - if (!hasRules && !hasFieldRules) return; + if (!hasRules && !hasFieldRules) return []; const priorRecord = opts.previous ?? undefined; // Is the record's persisted state actually in hand? On insert there is @@ -3289,6 +3303,8 @@ export function evaluateValidationRules( const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger, fields, messages: opts.messages, related: opts.related }; const errors: FieldValidationError[] = []; + // [#22726] The advisory hits this evaluation returns — see the doc above. + const advisories: ValidationAdvisoryEvent[] = []; // Field-level conditional rules (B2): a field whose `requiredWhen` // predicate is TRUE over the merged record must have a value — enforced @@ -3421,6 +3437,9 @@ export function evaluateValidationRules( if (!violation) continue; const severity = rule.severity ?? 'error'; + if (severity !== 'error' && violation.constraint?.reason !== 'unevaluable') { + advisories.push(advisoryEvent(rule, severity, violation)); + } if (severity === 'error') { errors.push(violation); } else if ( @@ -3446,6 +3465,55 @@ export function evaluateValidationRules( } if (errors.length > 0) throw new ValidationError(errors); + return advisories; +} + +/** + * [#22726] One advisory hit as the event the write reports it through. Every + * member is copied from the evaluation's own verdict — the violation the rule + * produced and the rule's declared `name` / `severity` — so the event says + * exactly what the per-write log line says, in its parts. + */ +function advisoryEvent( + rule: BaseRule, + severity: 'warning' | 'info', + violation: FieldValidationError, +): ValidationAdvisoryEvent { + return { + rule: String(rule.name ?? '(unnamed)'), + severity, + field: violation.field, + code: violation.code, + message: violation.message, + }; +} + +/** + * [#22726] Hand one evaluation's advisory hits to a write's + * `onValidationAdvisory` listener (`WriteObservabilityOptions`), one call per + * hit. The engine wraps each `evaluateValidationRules` call on its write paths + * in this, with the options of THAT write — which is what keeps a nested + * write's hits out of the outer write's answer: a hook's own write carries its + * own options. + * + * No listener ⇒ nothing happens (the hits were already logged or aggregated by + * the evaluator). A listener that throws never breaks the write: it is logged + * and ignored, as `onFieldsDropped` is. + */ +export function emitValidationAdvisories( + listener: ((event: ValidationAdvisoryEvent) => void) | undefined, + advisories: readonly ValidationAdvisoryEvent[], + logger?: { warn?: (message: string, meta?: unknown) => void }, + object?: string, +): void { + if (typeof listener !== 'function') return; + for (const event of advisories) { + try { + listener(event); + } catch (err) { + logger?.warn?.('onValidationAdvisory listener threw — ignored', { object, rule: event.rule, error: err }); + } + } } /** diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index fc6c5bb09a1..b72bb070e42 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -365,10 +365,11 @@ export const ImportRowResultSchema = lazySchema(() => z.object({ code: z.string().optional().describe('Error code (failed rows)'), error: z.string().optional().describe('Human-readable error message (failed rows)'), warnings: z.array(ValidateDataIssueSchema).optional().describe( - 'Findings this deployment ADMITS rather than rejects (ADR-0104 value shapes under a warn-first posture), ' - + 'in the same `{ field, code, message }` shape the validate verdict carries. Set only on a dry-run row the ' - + 'verdict accepted: the row is `ok` because the write would store it and log the same complaint. A ' - + 'committed row never carries it, since the write path has no channel to report admitted findings on.', + 'Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict\'s row ' + + '`warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules ' + + '(`severity: \'warning\'` or `\'info\'`, each naming its `rule` and `severity`). Set only on a dry-run row ' + + 'the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry ' + + 'it: the import does not copy a write answer\'s `warnings` onto the row.', ), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( 'Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per ' diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 37d5e921ccb..587c21e1444 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -18,7 +18,7 @@ import { retiredKey } from '../shared/retired-key'; // through it, so `api/UpdateAiConversationRequest.json` states it. import { requiredOneOf } from '../shared/refinement-projection'; import { MetadataItemNameSchema } from '../shared/identifiers.zod'; -import { DroppedFieldsEventSchema, QueryWithTransportSchema } from '../data/data-engine.zod'; +import { DroppedFieldsEventSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod'; import { AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, @@ -2042,6 +2042,44 @@ export const CreateDataRequestSchema = lazySchema(() => z.object({ data: z.record(z.string(), z.unknown()).describe('The dictionary of field values to insert.'), })); +/** + * One field-level finding from a validate-only run — the same + * `{ field, code, message }` triple the engine's `ValidationError.fields` + * carries, so a caller reads one vocabulary whether the verdict arrived from a + * preview or from a rejected write. + * + * [#22726] It is also the element of every single-record write answer's + * `warnings`, and an ADVISORY validation-rule hit additionally names its `rule` + * and `severity` — both members taken from `ValidationAdvisoryEventSchema` + * (`data/data-engine.zod.ts`), the event the engine reports the hit through, + * so an event IS an issue and the write and its preview answer in one + * vocabulary. Declared before the write answers that reference it: a schema + * built eagerly (`OS_EAGER_SCHEMAS=1`) would otherwise read it before it exists. + */ +export const ValidateDataIssueSchema = lazySchema(() => z.object({ + field: z.string().describe('The field the finding is about (`_record` for an object-level rule).'), + code: z.string().describe('Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`.'), + message: z.string().describe('Human-readable message — a validation rule\'s author-written text where one exists.'), + rule: ValidationAdvisoryEventSchema.shape.rule.optional().describe( + 'The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit.', + ), + severity: ValidationAdvisoryEventSchema.shape.severity.optional().describe( + 'The advisory rule\'s declared severity, `warning` or `info`. Present only on an advisory rule hit.', + ), +})); + +/** + * The `warnings` member every single-record write answer declares (#22726) — + * one sentence, so the create, clone and update answers cannot drift apart. + */ +const WRITE_ANSWER_WARNINGS_DESCRIPTION = + 'The advisory validation-rule hits of THIS write: each `severity: \'warning\'` or `\'info\'` rule whose ' + + 'verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and ' + + 'author-written `message` — the same element the validate-only preview reports in a row\'s `warnings`. ' + + 'Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. ' + + 'Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write ' + + 'a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit.'; + /** * Create Data Response */ @@ -2059,6 +2097,7 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ '`X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the ' + 'shape backward-compatible for existing clients.' ), + warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), })); /** @@ -2106,18 +2145,7 @@ export const CloneDataResponseSchema = lazySchema(() => z.object({ + 'relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. ' + 'Optional — omit-when-empty keeps the shape backward-compatible for existing clients.' ), -})); - -/** - * One field-level finding from a validate-only run — the same - * `{ field, code, message }` triple the engine's `ValidationError.fields` - * carries, so a caller reads one vocabulary whether the verdict arrived from a - * preview or from a rejected write. - */ -export const ValidateDataIssueSchema = lazySchema(() => z.object({ - field: z.string().describe('The field the finding is about (`_record` for an object-level rule).'), - code: z.string().describe('Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`.'), - message: z.string().describe('Human-readable message — a validation rule\'s author-written text where one exists.'), + warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), })); /** @@ -2166,6 +2194,12 @@ export const ValidateDataRequestSchema = lazySchema(() => z.object({ * vocabulary the write reports, ⛔ never a second enum — so a preview and the * write it predicts answer in one vocabulary, exactly as `errors` / `warnings` * share {@link ValidateDataIssueSchema} with a rejected write. + * + * [#22726] A row's `warnings` also carries the ADVISORY validation-rule hits of + * the evaluation the write itself runs, appended after the admitted value + * shapes: the same elements a create / update / clone answers as its own + * `warnings`, so the preview fills the key the write fills, from the same + * `evaluateValidationRules` call. */ export const ValidateDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('The object name.'), @@ -2175,8 +2209,12 @@ export const ValidateDataResponseSchema = lazySchema(() => z.object({ valid: z.boolean().describe('True when this row would be accepted by the write path.'), errors: z.array(ValidateDataIssueSchema).describe('Findings that would REJECT this row. Empty when valid.'), warnings: z.array(ValidateDataIssueSchema).describe( - 'Findings the target deployment ADMITS rather than rejects — today, ADR-0104 value shapes under a ' + - 'warn-first posture. The row is valid; the write would store it and log the same complaint.', + 'Findings the target deployment ADMITS rather than rejects; the row is valid, and the write would store it. ' + + 'Two populations, in this order: ADR-0104 value shapes admitted under a warn-first posture (the write logs ' + + 'the same complaint), then the hits of ADVISORY validation rules (`severity: \'warning\'` or `\'info\'`), ' + + 'each naming its `rule` and `severity` — the same elements the write answers as its own `warnings`. ' + + 'An advisory hit is reported only on a row the verdict accepts, as on the write, which answers a refused ' + + 'row with its error.', ), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( 'Write-observability for the preview: caller-supplied fields the write would LEGALLY strip from THIS ' + @@ -2240,6 +2278,7 @@ export const UpdateDataResponseSchema = lazySchema(() => z.object({ 'header. Optional — omit-when-empty keeps the shape backward-compatible for existing ' + 'clients that only read `record`.' ), + warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), })); /** diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index ebccfcd689c..777bc807f8b 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -9,6 +9,7 @@ import { EngineAggregateOptions, EngineCountOptions, DroppedFieldsEvent, + ValidationAdvisoryEvent, } from '../data/index.js'; import type { IDataDriver } from './data-driver.js'; import type { IntrospectedSchema } from './schema-diff-service.js'; @@ -67,6 +68,32 @@ export interface WriteObservabilityOptions { /** Called once per strip pass that dropped ≥1 caller-supplied field. */ onFieldsDropped?: (event: DroppedFieldsEvent) => void; + /** + * Called once per ADVISORY validation-rule hit on this write (#22726): a + * `severity: 'warning'` or `'info'` rule whose verdict on the written record + * was "violated" (`ValidationAdvisoryEventSchema`). Advisory rules never + * block, so the write proceeds; this listener is how a caller that answers a + * person learns what the rule said. `metadata-protocol`'s `createData`, + * `updateData` and `cloneData` pass one and answer the hits as `warnings`. + * + * The engine calls it for every evaluation of the object's rules on the write + * the options were passed to: per row on `insert`, and on `update` both by id + * and per matched row of a `multi` update. A nested write a hook or a flow + * makes inside this one carries its own options, so its hits never reach this + * listener. An unevaluable rule is not a hit and is not reported here. + * + * It fires when the rules are evaluated, BEFORE the driver write, so a hit is + * provisional until the write resolves: a caller reports hits only from a + * write that succeeded (a refused write answers its error, never `warnings`). + * The server-side reporting is unchanged by passing it — the per-write `warn` + * line, and the seed / boot load's one summary line per rule (#13889). + * + * Same constraints as `onFieldsDropped`: in-process only (a function cannot + * cross the RPC boundary), and a listener that throws never breaks the write — + * engines catch and log. + */ + onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void; + /** * Refuse the write instead of stripping (#5126). Default `false` — off. * diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index a8a88bf3510..cc438fb83fd 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -312,6 +312,48 @@ export const DroppedFieldsEventSchema = lazySchema(() => z.object({ reason: z.enum(['readonly', 'readonly_when', 'primary_key', 'computed']).describe('Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, the primary-key strip of a payload id the engine ruled is not an identifier, or a computed (formula) field no driver has a column for'), }).describe('A write-path strip event: caller-supplied fields legally dropped from the payload')); +// -------------------------------------------------------------------------- +// Write observability: advisory validation-rule hits (#22726) +// -------------------------------------------------------------------------- + +/** + * One ADVISORY validation-rule hit on a write: a `severity: 'warning'` or + * `'info'` rule (`ValidationRuleSchema`) whose verdict on the written record was + * "violated". Only `error` blocks a write, so the write still succeeds; this + * event is how its caller learns what the rule said, so a client can show the + * advisory to the person who made the write. The rule semantics are unchanged — + * the server still logs the hit, and a seed / boot load still folds it into one + * summary line per rule (#13889). + * + * Delivered in-process through `WriteObservabilityOptions.onValidationAdvisory` + * (`contracts/data-engine.ts`), once per hit, to the write those options were + * passed to and to no other: a nested write a hook or a flow makes inside it + * carries its own options, so its hits never reach the outer write's listener. + * + * A rule that could not be EVALUATED is not a hit — it has no verdict, and its + * fault text is written for the operator, not for the person writing — so it is + * reported in the server log only, as before. + * + * Every member is a member of `ValidateDataIssueSchema` (`api/protocol.zod.ts`), + * which takes `rule` and `severity` from this schema: an event IS an issue. The + * protocol's create / update / clone answers carry these events as `warnings`, + * and the validate-only preview appends them to a row's `warnings`, so a write + * and the preview that predicts it answer in one vocabulary. + */ +export const ValidationAdvisoryEventSchema = lazySchema(() => z.object({ + rule: z.string().describe('The advisory validation rule\'s declared `name`'), + severity: z.enum(['warning', 'info']).describe( + 'The rule\'s declared severity. Never `error`: an `error` rule refuses the write instead.', + ), + field: z.string().describe( + 'The field the rule is about: its first declared field, or `_record` for an object-level rule', + ), + code: z.string().describe( + 'Machine-readable finding code, the one a refused write reports for the same rule (`rule_violation` for a predicate rule)', + ), + message: z.string().describe('The rule\'s author-written message, in the caller\'s locale'), +}).describe('An advisory (warning or info) validation-rule hit on a write that still succeeded')); + // -------------------------------------------------------------------------- // Legacy: DataEngineUpdateOptionsSchema (DEPRECATED) // -------------------------------------------------------------------------- @@ -1416,6 +1458,7 @@ export type EngineQueryOptions = z.input; export type EngineQueryOptionsParsed = z.infer; export type EngineUpdateOptions = z.input; export type DroppedFieldsEvent = z.input; +export type ValidationAdvisoryEvent = z.input; export type EngineDeleteOptions = z.input; export type EngineAggregateOptions = z.input; /** From 58dcf27a3ff14dbe9d60cac555a30df0772daebf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:07:52 +0000 Subject: [PATCH 2/9] test(objectql): pin the advisory warnings write answer; regenerate spec artifacts Pins the card's three (hit answered and write stored; an error rule refuses; no hit, no key), nested isolation (cross- and same-object hook writes), the unchanged per-write log line and the seed-load aggregation, and the preview's P2 order. Regenerates api-surface, export-origins, declaration-map, the reference docs and the strictness-ledger counts for the new schema and keys. Types the emitter's logger as the evaluator's. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/references/api/export.mdx | 8 +- content/docs/references/api/protocol.mdx | 39 +- content/docs/references/data/data-engine.mdx | 21 +- content/docs/references/index.mdx | 10 +- .../data.md | 14 +- .../validation/advisory-write-answer.test.ts | 482 ++++++++++++++++++ .../objectql/src/validation/rule-validator.ts | 2 +- packages/spec/api-surface/data.json | 2 + packages/spec/authorable-surface/api.json | 5 + packages/spec/authorable-surface/data.json | 7 +- packages/spec/declaration-map/data.json | 2 + packages/spec/export-origins/data.json | 2 + packages/spec/json-schema.manifest/data.json | 1 + 13 files changed, 574 insertions(+), 21 deletions(-) create mode 100644 packages/objectql/src/validation/advisory-write-answer.test.ts diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index e10a91a8e00..648ddcb82f4 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -229,7 +229,7 @@ const result = CreateImportJobRequestSchema.parse(data); | **field** | `string` | optional | Field that caused a coercion/validation error | | **code** | `string` | optional | Error code (failed rows) | | **error** | `string` | optional | Human-readable error message (failed rows) | -| **warnings** | `{ field: string; code: string; message: string }[]` | optional | Findings this deployment ADMITS rather than rejects (ADR-0104 value shapes under a warn-first posture), in the same `{ field, code, message }` shape the validate verdict carries. Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it and log the same complaint. A committed row never carries it, since the write path has no channel to report admitted findings on. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | @@ -357,7 +357,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **field** | `string` | optional | Field that caused a coercion/validation error | | **code** | `string` | optional | Error code (failed rows) | | **error** | `string` | optional | Human-readable error message (failed rows) | -| **warnings** | `{ field: string; code: string; message: string }[]` | optional | Findings this deployment ADMITS rather than rejects (ADR-0104 value shapes under a warn-first posture), in the same `{ field, code, message }` shape the validate verdict carries. Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it and log the same complaint. A committed row never carries it, since the write path has no channel to report admitted findings on. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | @@ -376,7 +376,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **field** | `string` | optional | Field that caused a coercion/validation error | | **code** | `string` | optional | Error code (failed rows) | | **error** | `string` | optional | Human-readable error message (failed rows) | -| **warnings** | `{ field: string; code: string; message: string }[]` | optional | Findings this deployment ADMITS rather than rejects (ADR-0104 value shapes under a warn-first posture), in the same `{ field, code, message }` shape the validate verdict carries. Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it and log the same complaint. A committed row never carries it, since the write path has no channel to report admitted findings on. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | ### Nested Shape: `ImportRowResult.warnings[number]` @@ -386,6 +386,8 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | | **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | | **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | ### Nested Shape: `ImportRowResult.droppedFields[number]` diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ccf455e36e4..223f28a58dd 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -495,6 +495,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **sourceId** | `string` | ✅ | The ID of the record the clone was copied from. | | **record** | `Record` | ✅ | The created clone, including server-generated fields. Engine-owned values (injected system/audit columns, autonumbers, computed formula/summary fields) are re-derived by the insert path rather than copied from the source; caller-supplied `overrides` win over copied values. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). Carried in the 201 body only — this route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | ### Nested Shape: `CloneDataResponse.droppedFields[number]` @@ -506,6 +507,16 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | | **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, the primary-key strip of a payload id the engine ruled is not an identifier, or a computed (formula) field no driver has a column for | +### Nested Shape: `CloneDataResponse.warnings[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | +| **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | +| **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | + --- @@ -544,6 +555,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **id** | `string` | ✅ | The ID of the newly created record. | | **record** | `Record` | ✅ | The created record, including server-generated fields (created_at, owner). | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | ### Nested Shape: `CreateDataResponse.droppedFields[number]` @@ -555,6 +567,16 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | | **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, the primary-key strip of a payload id the engine ruled is not an identifier, or a computed (formula) field no driver has a column for | +### Nested Shape: `CreateDataResponse.warnings[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | +| **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | +| **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | + --- @@ -2819,6 +2841,7 @@ Uninstall package response | **id** | `string` | ✅ | Updated record ID | | **record** | `Record` | ✅ | Updated record | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` or a TRUE `readonlyWhen` predicate. Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | ### Nested Shape: `UpdateDataResponse.droppedFields[number]` @@ -2830,6 +2853,16 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | | **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, the primary-key strip of a payload id the engine ruled is not an identifier, or a computed (formula) field no driver has a column for | +### Nested Shape: `UpdateDataResponse.warnings[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | +| **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | +| **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | + --- @@ -2965,6 +2998,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | | **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | | **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | --- @@ -2999,8 +3034,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **valid** | `boolean` | ✅ | True when this row would be accepted by the write path. | -| **errors** | `{ field: string; code: string; message: string }[]` | ✅ | Findings that would REJECT this row. Empty when valid. | -| **warnings** | `{ field: string; code: string; message: string }[]` | ✅ | Findings the target deployment ADMITS rather than rejects — today, ADR-0104 value shapes under a warn-first posture. The row is valid; the write would store it and log the same complaint. | +| **errors** | `{ field: string; code: string; message: string; rule?: string; … }[]` | ✅ | Findings that would REJECT this row. Empty when valid. | +| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | ✅ | Findings the target deployment ADMITS rather than rejects; the row is valid, and the write would store it. Two populations, in this order: ADR-0104 value shapes admitted under a warn-first posture (the write logs the same complaint), then the hits of ADVISORY validation rules (`severity: 'warning'` or `'info'`), each naming its `rule` and `severity` — the same elements the write answers as its own `warnings`. An advisory hit is reported only on a row the verdict accepts, as on the write, which answers a refused row with its error. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed'> }[]` | optional | Write-observability for the preview: caller-supplied fields the write would LEGALLY strip from THIS row, one event per reason, in the shape and reason vocabulary the write itself reports. A strip is not a finding: `valid` is unaffected, and the fields appear in neither `errors` nor `warnings`. Present only when at least one field would be dropped. A server that does not produce this report omits the key too, so an absent key alone does not prove nothing would be dropped. | ### Nested Shape: `ValidateDataResponse.posture` diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index f438ccfa8d8..ce6fd0057e0 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -22,8 +22,8 @@ The Data Engine acts as the "Driver" layer in the Hexagonal Architecture. ## TypeScript Usage ```typescript -import { BaseEngineOptionsSchema, DataEngineAggregateOptionsSchema, DataEngineAggregateRequestSchema, DataEngineCountOptionsSchema, DataEngineCountRequestSchema, DataEngineDeleteOptionsSchema, DataEngineDeleteRequestSchema, DataEngineExecuteRequestSchema, DataEngineFilterSchema, DataEngineFindOneRequestSchema, DataEngineFindRequestSchema, DataEngineInsertOptionsSchema, DataEngineInsertRequestSchema, DataEngineQueryOptionsSchema, DataEngineRequestSchema, DataEngineSortSchema, DataEngineUpdateOptionsSchema, DataEngineUpdateRequestSchema, DataEngineVectorFindRequestSchema, DroppedFieldsEventSchema, EngineAggregateOptionsSchema, EngineCountOptionsSchema, EngineDeleteOptionsSchema, EngineQueryOptionsSchema, EngineUpdateOptionsSchema, QueryTransportParamsSchema, QueryWithTransportSchema } from '@objectstack/spec/data'; -import type { BaseEngineOptions, DataEngineAggregateOptions, DataEngineCountOptions, DataEngineDeleteOptions, DataEngineExecuteRequest, DataEngineFilter, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineVectorFindRequest, DroppedFieldsEvent, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions, QueryTransportParams, QueryWithTransport } from '@objectstack/spec/data'; +import { BaseEngineOptionsSchema, DataEngineAggregateOptionsSchema, DataEngineAggregateRequestSchema, DataEngineCountOptionsSchema, DataEngineCountRequestSchema, DataEngineDeleteOptionsSchema, DataEngineDeleteRequestSchema, DataEngineExecuteRequestSchema, DataEngineFilterSchema, DataEngineFindOneRequestSchema, DataEngineFindRequestSchema, DataEngineInsertOptionsSchema, DataEngineInsertRequestSchema, DataEngineQueryOptionsSchema, DataEngineRequestSchema, DataEngineSortSchema, DataEngineUpdateOptionsSchema, DataEngineUpdateRequestSchema, DataEngineVectorFindRequestSchema, DroppedFieldsEventSchema, EngineAggregateOptionsSchema, EngineCountOptionsSchema, EngineDeleteOptionsSchema, EngineQueryOptionsSchema, EngineUpdateOptionsSchema, QueryTransportParamsSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '@objectstack/spec/data'; +import type { BaseEngineOptions, DataEngineAggregateOptions, DataEngineCountOptions, DataEngineDeleteOptions, DataEngineExecuteRequest, DataEngineFilter, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineVectorFindRequest, DroppedFieldsEvent, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions, QueryTransportParams, QueryWithTransport, ValidationAdvisoryEvent } from '@objectstack/spec/data'; // Validate data const result = BaseEngineOptionsSchema.parse(data); @@ -1435,3 +1435,20 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo --- +## ValidationAdvisoryEvent + +An advisory (warning or info) validation-rule hit on a write that still succeeded + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **rule** | `string` | ✅ | The advisory validation rule's declared `name` | +| **severity** | `Enum<'warning' \| 'info'>` | ✅ | The rule's declared severity. Never `error`: an `error` rule refuses the write instead. | +| **field** | `string` | ✅ | The field the rule is about: its first declared field, or `_record` for an object-level rule | +| **code** | `string` | ✅ | Machine-readable finding code, the one a refused write reports for the same rule (`rule_violation` for a predicate rule) | +| **message** | `string` | ✅ | The rule's author-written message, in the caller's locale | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a1db2132191..740a474b453 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1517 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1516** | 14 protocol modules | +| **Total** | **195** | **1517** | 14 protocol modules | --- @@ -130,7 +130,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 175 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 176 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -138,7 +138,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | :--- | :--- | | [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsDateRange`, `AnalyticsDateRangePreset`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` | | [`context-tokens.zod.ts`](/docs/references/data/context-tokens) | `ContextToken`, `ContextTokenPlaceholder` | -| [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions`, `QueryTransportParams`, `QueryWithTransport` | +| [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions`, `QueryTransportParams`, `QueryWithTransport`, `ValidationAdvisoryEvent` | | [`datasource.zod.ts`](/docs/references/data/datasource) | `Datasource`, `DriverDefinition`, `DriverType`, `ExternalDatasourceSettings`, `SchemaMode` | | [`date-macros.zod.ts`](/docs/references/data/date-macros) | `DateMacroPlaceholder`, `DateMacroToken` | | [`driver.zod.ts`](/docs/references/data/driver) | `DriverCapabilities`, `DriverConfig`, `DriverOptions`, `PoolConfig` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 278dcab0192..a6f2edbf0eb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 156 | 80 | 2 | 0 | 74 | +| `data/` | 157 | 80 | 2 | 0 | 75 | ## `data/` — sites @@ -32,7 +32,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| | `analytics.zod.ts` | 6 | -| `data-engine.zod.ts` | 15 | +| `data-engine.zod.ts` | 16 | | `datasource.zod.ts` | 6 | | `driver-nosql.zod.ts` | 10 | | `driver-sql.zod.ts` | 2 | @@ -56,7 +56,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **156** | +| **total** | **157** | ## `data/` — open @@ -64,11 +64,11 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**74 strip of 156**, in 10 file(s). +**75 strip of 157**, in 10 file(s). | File | Strip | Sites | |---|---|---| -| `data-engine.zod.ts` | 15 | 15 | +| `data-engine.zod.ts` | 16 | 16 | | `driver-nosql.zod.ts` | 10 | 10 | | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | @@ -78,13 +78,13 @@ over it is here. | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **74** | **156** | +| **total** | **75** | **157** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 72 | +| wire / open — out of forced scope | 73 | | no door — no carrier, ADR-0049 territory | 2 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/packages/objectql/src/validation/advisory-write-answer.test.ts b/packages/objectql/src/validation/advisory-write-answer.test.ts new file mode 100644 index 00000000000..c5a89a7063d --- /dev/null +++ b/packages/objectql/src/validation/advisory-write-answer.test.ts @@ -0,0 +1,482 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22726] An advisory validation rule's hit reaches the WRITE ANSWER. + * + * A `severity: 'warning'` / `'info'` rule never blocks a write. Until this card + * its hit was only logged, so no client could show the person who saved what the + * rule says. The channel, as ruled (route L, preview P2): + * + * evaluateValidationRules → returns this evaluation's advisory hits + * engine (insert / update) → hands them to THIS write's own + * `onValidationAdvisory` listener + * (`WriteObservabilityOptions`, beside `onFieldsDropped`) + * createData / updateData / cloneData → answer them as `warnings`, omit-when-empty + * engine.validate (preview) → appends them to the row's `warnings` + * + * Everything below runs a REAL `ObjectQL` engine and the REAL + * `ObjectStackProtocolImplementation` over it; nothing asserts against a double of + * the thing under test. The pins, in the card's order: + * + * 1. a warning rule's hit appears in the write answer and the write succeeds + * (create, update, clone); + * 2. CONTROL: an `error` rule still refuses; + * 3. CONTROL: a write with no hit carries NO `warnings` key; + * 4. nested isolation: a hook's nested write — cross-object AND same-object — + * adds nothing to the outer answer, though its hits DID fire (they are in + * the log, measured below, so the pin cannot pass by nested writes simply + * not tripping anything); + * 5. the server-side reporting is byte-identical: the per-write `warn` line, + * and #13889's seed / boot aggregation (seed-load control). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectStackProtocolImplementation, SeedLoaderService } from '@objectstack/metadata-protocol'; +import { runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; +import { + CreateDataResponseSchema, + UpdateDataResponseSchema, + CloneDataResponseSchema, + ValidateDataIssueSchema, +} from '@objectstack/spec/api'; +import { ObjectQL } from '../engine.js'; +import { evaluateValidationRules } from './rule-validator.js'; + +const ADVISED = { + rule: 'industry_advised', + severity: 'warning' as const, + field: '_record', + code: 'rule_violation', + message: 'Industry should be set.', +}; +const TASK_ADVISED_RULE = 'related_advised'; + +const ACCT = { + name: 'adv_acct', + label: 'Account', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + industry: { name: 'industry', label: 'Industry', type: 'text' }, + // A covered value-shape type: the preview's warn-first admission (P2 order). + parent_account: { name: 'parent_account', label: 'Parent', type: 'lookup', reference: 'adv_acct' }, + }, + validations: [ + { + type: 'script' as const, + name: ADVISED.rule, + severity: 'warning' as const, + condition: { dialect: 'cel', source: 'record.industry == null' }, + message: ADVISED.message, + events: ['insert', 'update'] as Array<'insert' | 'update'>, + }, + { + type: 'script' as const, + name: 'name_noted', + severity: 'info' as const, + condition: { dialect: 'cel', source: 'record.name == "noted"' }, + message: 'Noted for review.', + events: ['insert', 'update'] as Array<'insert' | 'update'>, + }, + { + // The CONTROL: an `error` rule refuses, whatever the advisories say. + type: 'script' as const, + name: 'name_not_forbidden', + severity: 'error' as const, + condition: { dialect: 'cel', source: 'record.name == "forbidden"' }, + message: 'That name is not allowed.', + events: ['insert', 'update'] as Array<'insert' | 'update'>, + }, + ], +}; + +const TASK = { + name: 'adv_task', + label: 'Task', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + related_to: { name: 'related_to', label: 'Related to', type: 'text' }, + }, + validations: [{ + type: 'script' as const, + name: TASK_ADVISED_RULE, + severity: 'warning' as const, + condition: { dialect: 'cel', source: 'record.related_to == null' }, + message: 'Related should be set.', + events: ['insert', 'update'] as Array<'insert' | 'update'>, + }], +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) return true; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + return (row[k] ?? null) === (expected ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return [...storeFor(object).values()].filter((r) => matches(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `r_${nextId}` }; + storeFor(object).set(row.id as string, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const row = { ...storeFor(object).get(id), ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + let n = 0; + for (const [id, row] of [...storeFor(object).entries()]) { + if (!matches(row, ast?.where)) continue; + storeFor(object).set(id, { ...row, ...data, id }); + n += 1; + } + return n; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +/** The keys a schema declares, for the "nothing undeclared" half of conformance. */ +const declaredKeys = (schema: unknown) => + new Set(Object.keys((schema as { shape: Record }).shape)); + +let warns: string[]; +let engine: ObjectQL; +let protocol: ObjectStackProtocolImplementation; +let storeFor: ReturnType['storeFor']; + +async function boot() { + warns = []; + const logger = { + info() {}, debug() {}, error() {}, + warn(message: string) { warns.push(String(message)); }, + }; + engine = new ObjectQL({ logger } as any); + const d = makeMemoryDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(ACCT as never, 'com.objectstack.test.22726'); + engine.registry.registerObject(TASK as never, 'com.objectstack.test.22726'); + protocol = new ObjectStackProtocolImplementation(engine as never); +} + +/** The per-write line the evaluator has always logged for an advisory hit. */ +const perWriteLine = (rule: string, severity: string, message: string) => + `Validation rule '${rule}' (${severity}): ${message}`; + +afterEach(() => { + delete process.env.OS_ALLOW_LAX_VALUE_SHAPES; +}); + +describe('[#22726] PIN 1 — a warning rule\'s hit appears in the write answer, and the write succeeds', () => { + beforeEach(boot); + + it('createData answers the hit as `warnings` and the record is stored', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme' } }); + + expect(res.warnings).toEqual([ADVISED]); + expect(storeFor('adv_acct').get(res.id)).toMatchObject({ name: 'Acme' }); + // Declared AS PRODUCED: the answer parses, and it emits no key the schema lacks. + expect(CreateDataResponseSchema.safeParse(res).success).toBe(true); + expect(Object.keys(res).filter((k) => !declaredKeys(CreateDataResponseSchema).has(k))).toEqual([]); + }); + + it('an `info` rule is answered too, with its own severity, in evaluation order', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'noted' } }); + expect(res.warnings).toEqual([ + ADVISED, + { rule: 'name_noted', severity: 'info', field: '_record', code: 'rule_violation', message: 'Noted for review.' }, + ]); + }); + + it('updateData (by id) answers the hit of the MERGED record, and the update is stored', async () => { + const created: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme', industry: 'retail' } }); + expect(created).not.toHaveProperty('warnings'); + + const res: any = await protocol.updateData({ object: 'adv_acct', id: created.id, data: { industry: null } }); + + expect(res.warnings).toEqual([ADVISED]); + expect(storeFor('adv_acct').get(created.id)).toMatchObject({ industry: null }); + expect(UpdateDataResponseSchema.safeParse(res).success).toBe(true); + expect(Object.keys(res).filter((k) => !declaredKeys(UpdateDataResponseSchema).has(k))).toEqual([]); + }); + + it('cloneData — a create on the same insert path — answers the copy\'s hit', async () => { + const source: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme' } }); + + const res: any = await protocol.cloneData({ object: 'adv_acct', id: source.id }); + + expect(res.warnings).toEqual([ADVISED]); + expect(storeFor('adv_acct').get(res.id)).toMatchObject({ name: 'Acme' }); + expect(CloneDataResponseSchema.safeParse(res).success).toBe(true); + expect(Object.keys(res).filter((k) => !declaredKeys(CloneDataResponseSchema).has(k))).toEqual([]); + }); + + it('every answered entry is a `ValidateDataIssue` — the element the preview reports', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'noted' } }); + for (const entry of res.warnings) { + expect(ValidateDataIssueSchema.safeParse(entry)).toMatchObject({ success: true, data: entry }); + } + }); +}); + +describe('[#22726] PIN 2 — CONTROL: an `error` rule still refuses', () => { + beforeEach(boot); + + it('refuses with the ValidationError envelope, stores nothing, and answers no warnings', async () => { + // `industry` absent, so the advisory ALSO trips: a refusal is still a refusal. + const attempt = protocol.createData({ object: 'adv_acct', data: { name: 'forbidden' } }); + + await expect(attempt).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [{ field: '_record', code: 'rule_violation', message: 'That name is not allowed.' }], + }); + expect(storeFor('adv_acct').size).toBe(0); + }); + + it('refuses an update the same way, and the stored row is unchanged', async () => { + const created: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme', industry: 'retail' } }); + + await expect(protocol.updateData({ object: 'adv_acct', id: created.id, data: { name: 'forbidden' } })) + .rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(storeFor('adv_acct').get(created.id)).toMatchObject({ name: 'Acme' }); + }); +}); + +describe('[#22726] PIN 3 — CONTROL: a write with no hit carries no `warnings` key', () => { + beforeEach(boot); + + it('create, update and clone each omit the key (absent, not `[]`)', async () => { + const created: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme', industry: 'retail' } }); + const updated: any = await protocol.updateData({ object: 'adv_acct', id: created.id, data: { name: 'Acme Corp' } }); + const cloned: any = await protocol.cloneData({ object: 'adv_acct', id: created.id }); + + for (const res of [created, updated, cloned]) { + expect(Object.keys(res)).not.toContain('warnings'); + } + }); +}); + +describe('[#22726] PIN 4 — a nested write\'s hits stay out of the outer answer', () => { + beforeEach(async () => { + await boot(); + // One CROSS-object nested write before the outer row is judged, and one + // SAME-object nested write after it is stored. Each trips its own advisory. + engine.registerHook('beforeInsert', async (ctx: any) => { + if (ctx.input?.data?.name === 'outer') await engine.insert('adv_task', { name: 'nested-task' }); + }, { object: 'adv_acct' }); + engine.registerHook('afterInsert', async (ctx: any) => { + if ((ctx.result?.name ?? ctx.input?.data?.name) === 'outer') { + await engine.insert('adv_acct', { name: 'nested-sibling' }); + } + }, { object: 'adv_acct' }); + engine.registerHook('afterUpdate', async (ctx: any) => { + if (ctx.input?.data?.name === 'outer-renamed') { + await engine.insert('adv_task', { name: 'nested-task-u' }); + await engine.insert('adv_acct', { name: 'nested-sibling-u' }); + } + }, { object: 'adv_acct' }); + }); + + it('create: only the outer row\'s hit is answered; both nested hits fired and were logged', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'outer' } }); + + expect(res.warnings).toEqual([ADVISED]); + // The nested writes happened and DID trip their rules — so the answer above + // excludes them, rather than having had nothing to exclude. + expect([...storeFor('adv_task').values()].map((r) => r.name)).toEqual(['nested-task']); + expect([...storeFor('adv_acct').values()].map((r) => r.name).sort()).toEqual(['nested-sibling', 'outer']); + expect(warns.filter((l) => l === perWriteLine(TASK_ADVISED_RULE, 'warning', 'Related should be set.'))).toHaveLength(1); + expect(warns.filter((l) => l === perWriteLine(ADVISED.rule, 'warning', ADVISED.message))).toHaveLength(2); + }); + + it('create: an outer write with NO hit of its own answers no key, though both nested writes hit', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'outer', industry: 'retail' } }); + + expect(Object.keys(res)).not.toContain('warnings'); + expect(warns.filter((l) => l.startsWith('Validation rule '))).toHaveLength(2); + }); + + it('update: only the updated row\'s hit is answered; the hook\'s cross- and same-object writes are not', async () => { + const created: any = await protocol.createData({ object: 'adv_acct', data: { name: 'start', industry: 'retail' } }); + warns.length = 0; + + const res: any = await protocol.updateData({ object: 'adv_acct', id: created.id, data: { name: 'outer-renamed', industry: null } }); + + expect(res.warnings).toEqual([ADVISED]); + expect([...storeFor('adv_task').values()].map((r) => r.name)).toEqual(['nested-task-u']); + expect(warns.filter((l) => l.startsWith('Validation rule '))).toHaveLength(3); + }); +}); + +describe('[#22726] PIN 5 — the server-side reporting is byte-identical', () => { + beforeEach(boot); + + it('an ordinary write still logs the per-write line, once, beside the answer', async () => { + const res: any = await protocol.createData({ object: 'adv_acct', data: { name: 'Acme' } }); + + expect(res.warnings).toEqual([ADVISED]); + expect(warns).toEqual([perWriteLine(ADVISED.rule, 'warning', ADVISED.message)]); + }); + + it('inside the #13889 aggregation scope the hit is folded, not logged per write — and still answered', async () => { + let groups: AdvisoryGroup[] = []; + const res: any = await runWithAdvisoryAggregation( + () => protocol.createData({ object: 'adv_acct', data: { name: 'Acme' } }), + (g) => { groups = g; }, + ); + + expect(groups).toEqual([{ + object: 'adv_acct', rule: ADVISED.rule, severity: 'warning', message: ADVISED.message, + rows: 1, sampleRows: ['name=Acme'], + }]); + expect(warns.filter((l) => l.startsWith('Validation rule '))).toEqual([]); + expect(res.warnings).toEqual([ADVISED]); + }); + + it('seed-load control: a seed load reports ONE summary line for the rule and no per-row line', async () => { + const metadata = { getObject: async () => ACCT, listObjects: async () => [ACCT] }; + const loader = new SeedLoaderService(engine as never, metadata as never, { + info() {}, debug() {}, error() {}, warn(m: string) { warns.push(String(m)); }, + } as never); + + const result = await loader.load({ + seeds: [{ + object: 'adv_acct', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Seed A' }, { name: 'Seed B' }], + }], + config: { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'upsert', batchSize: 1000, transaction: false }, + } as never); + + expect(result.summary.totalInserted).toBe(2); + expect(warns.filter((l) => l.startsWith('Validation rule '))).toEqual([]); + const summary = warns.filter((l) => l.includes('[SeedLoader]') && l.includes(ADVISED.rule)); + expect(summary).toHaveLength(1); + }); +}); + +describe('[#22726] the engine listener and the evaluator\'s return value', () => { + beforeEach(boot); + + it('a multi update reports one event per matched row', async () => { + await engine.insert('adv_acct', { name: 'a', industry: 'x' }); + await engine.insert('adv_acct', { name: 'b', industry: 'x' }); + const events: unknown[] = []; + + await engine.update('adv_acct', { industry: null }, { + where: { industry: 'x' }, multi: true, onValidationAdvisory: (e) => { events.push(e); }, + } as never); + + expect(events).toEqual([ADVISED, ADVISED]); + }); + + it('a listener that throws never breaks the write: logged and ignored', async () => { + const created = await engine.insert('adv_acct', { name: 'Acme' }, { + onValidationAdvisory: () => { throw new Error('listener bug'); }, + } as never); + + expect(storeFor('adv_acct').get(created.id)).toMatchObject({ name: 'Acme' }); + expect(warns).toContain('onValidationAdvisory listener threw — ignored'); + }); + + it('evaluateValidationRules returns [] for a write with no hit, and the hits otherwise', () => { + expect(evaluateValidationRules(ACCT as never, { name: 'Acme', industry: 'retail' }, 'insert')).toEqual([]); + expect(evaluateValidationRules(ACCT as never, null, 'insert')).toEqual([]); + expect(evaluateValidationRules({ fields: {} } as never, { name: 'x' }, 'insert')).toEqual([]); + expect(evaluateValidationRules(ACCT as never, { name: 'Acme' }, 'insert')).toEqual([ADVISED]); + }); + + it('an advisory rule that cannot be EVALUATED is not a hit: logged, never answered', async () => { + const broken = { + ...ACCT, + name: 'adv_broken', + validations: [{ + type: 'script' as const, + name: 'reads_undeclared', + severity: 'warning' as const, + condition: { dialect: 'cel', source: 'record.no_such_field == 1' }, + message: 'Never shown.', + events: ['insert'] as Array<'insert' | 'update'>, + }], + }; + engine.registry.registerObject(broken as never, 'com.objectstack.test.22726'); + + const res: any = await protocol.createData({ object: 'adv_broken', data: { name: 'Acme' } }); + + expect(Object.keys(res)).not.toContain('warnings'); + expect(warns.some((l) => l.startsWith("Validation rule 'reads_undeclared'"))).toBe(true); + }); +}); + +describe('[#22726] P2 — the preview appends the same hits to the row\'s `warnings`', () => { + beforeEach(boot); + + it('an accepted row carries the hit; a clean row none; a refused row its error and no hit', async () => { + const out: any = await protocol.validateData({ + object: 'adv_acct', + data: [{ name: 'Acme' }, { name: 'Clean', industry: 'retail' }, { name: 'forbidden' }], + }); + + expect(out.results[0]).toMatchObject({ valid: true, errors: [], warnings: [ADVISED] }); + expect(out.results[1]).toMatchObject({ valid: true, errors: [], warnings: [] }); + expect(out.results[2].valid).toBe(false); + expect(out.results[2].warnings).toEqual([]); + }); + + it('the preview\'s entry is the entry the write answers — one evaluation, one vocabulary', async () => { + const preview: any = await protocol.validateData({ object: 'adv_acct', data: { name: 'noted' } }); + const written: any = await protocol.createData({ object: 'adv_acct', data: { name: 'noted' } }); + + expect(preview.results[0].warnings).toEqual(written.warnings); + }); + + it('rule hits come AFTER the value-shape findings a warn-first deployment admits', async () => { + process.env.OS_ALLOW_LAX_VALUE_SHAPES = '1'; + const out: any = await protocol.validateData({ + object: 'adv_acct', + data: { name: 'Acme', parent_account: { nope: true } }, + }); + + expect(out.results[0].valid).toBe(true); + const warnings = out.results[0].warnings; + expect(warnings.map((w: any) => [w.field, w.code, w.rule])).toEqual([ + ['parent_account', 'invalid_type', undefined], + ['_record', 'rule_violation', ADVISED.rule], + ]); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index b011149076b..52a2caf219c 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -3503,7 +3503,7 @@ function advisoryEvent( export function emitValidationAdvisories( listener: ((event: ValidationAdvisoryEvent) => void) | undefined, advisories: readonly ValidationAdvisoryEvent[], - logger?: { warn?: (message: string, meta?: unknown) => void }, + logger?: EvaluateRulesOptions['logger'], object?: string, ): void { if (typeof listener !== 'function') return; diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 830c305c6ad..981adcfeeb2 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -781,6 +781,8 @@ "VALUE_ROUNDTRIP_COLLISION_PAIRS (const)", "VALUE_ROUNDTRIP_FIELDS (const)", "VALUE_ROUNDTRIP_ROWS (const)", + "ValidationAdvisoryEvent (type)", + "ValidationAdvisoryEventSchema (const)", "ValidationRule (type)", "ValidationRuleSchema (const)", "ValueForm (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 31e609358d2..eb4bb9c9680 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -328,6 +328,7 @@ "api/CloneDataResponse:object", "api/CloneDataResponse:record", "api/CloneDataResponse:sourceId", + "api/CloneDataResponse:warnings", "api/CodeGenerationTemplate:language", "api/CodeGenerationTemplate:name", "api/CodeGenerationTemplate:template", @@ -353,6 +354,7 @@ "api/CreateDataResponse:id", "api/CreateDataResponse:object", "api/CreateDataResponse:record", + "api/CreateDataResponse:warnings", "api/CreateFlowRequest:_lock", "api/CreateFlowRequest:_lockDocsUrl", "api/CreateFlowRequest:_lockReason", @@ -1680,6 +1682,7 @@ "api/UpdateDataResponse:id", "api/UpdateDataResponse:object", "api/UpdateDataResponse:record", + "api/UpdateDataResponse:warnings", "api/UpdateFlowRequest:definition", "api/UpdateFlowRequest:name", "api/UpdateFlowResponse:data", @@ -1730,6 +1733,8 @@ "api/ValidateDataIssue:code", "api/ValidateDataIssue:field", "api/ValidateDataIssue:message", + "api/ValidateDataIssue:rule", + "api/ValidateDataIssue:severity", "api/ValidateDataRequest:data", "api/ValidateDataRequest:mode", "api/ValidateDataRequest:object", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index f89aaf14749..2ff4eaa6dcc 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -990,6 +990,11 @@ "data/TursoConfig:syncUrl", "data/TursoConfig:timeout [RETIRED]", "data/TursoConfig:timeoutMs", - "data/TursoConfig:url" + "data/TursoConfig:url", + "data/ValidationAdvisoryEvent:code", + "data/ValidationAdvisoryEvent:field", + "data/ValidationAdvisoryEvent:message", + "data/ValidationAdvisoryEvent:rule", + "data/ValidationAdvisoryEvent:severity" ] } diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 4121a85f019..6e0b1506413 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -321,6 +321,8 @@ "TursoTransportModeSchema": "data/TursoTransportMode", "UniqueScope": "data/UniqueScope", "UniqueScopeSchema": "data/UniqueScope", + "ValidationAdvisoryEvent": "data/ValidationAdvisoryEvent", + "ValidationAdvisoryEventSchema": "data/ValidationAdvisoryEvent", "ValidationRule": "data/ValidationRule", "ValidationRuleSchema": "data/ValidationRule" }, diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 3d27b27b018..7cb7e914085 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -768,6 +768,8 @@ "VALUE_ROUNDTRIP_COLLISION_PAIRS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_COLLISION_PAIRS (const)", "VALUE_ROUNDTRIP_FIELDS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_FIELDS (const)", "VALUE_ROUNDTRIP_ROWS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_ROWS (const)", + "ValidationAdvisoryEvent": "src/data/data-engine.zod.ts#ValidationAdvisoryEvent (type)", + "ValidationAdvisoryEventSchema": "src/data/data-engine.zod.ts#ValidationAdvisoryEventSchema (const)", "ValidationRule": "src/data/validation.zod.ts#ValidationRule (type)", "ValidationRuleSchema": "src/data/validation.zod.ts#ValidationRuleSchema (const)", "ValueForm": "src/data/field-value.zod.ts#ValueForm (type)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 957e6d2a426..0e6dc302064 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -176,6 +176,7 @@ "data/TursoConfig", "data/TursoTransportMode", "data/UniqueScope", + "data/ValidationAdvisoryEvent", "data/ValidationRule" ] } From 0f0f63364a9756238f3e22f81ffe914da4d43f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:12:24 +0000 Subject: [PATCH 3/9] test(spec,core): pin the warnings element, the answers' optional key, eager loading, and the import relay Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../import-runner-advisory-warnings.test.ts | 76 +++++++++++++ .../src/api/write-answer-warnings.test.ts | 106 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/core/src/utils/import-runner-advisory-warnings.test.ts create mode 100644 packages/spec/src/api/write-answer-warnings.test.ts diff --git a/packages/core/src/utils/import-runner-advisory-warnings.test.ts b/packages/core/src/utils/import-runner-advisory-warnings.test.ts new file mode 100644 index 00000000000..c4f2ad3eac4 --- /dev/null +++ b/packages/core/src/utils/import-runner-advisory-warnings.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22726] The import dry run copies the validate verdict's row `warnings` onto + * the row VERBATIM — and since the preview now appends advisory validation-rule + * hits to that array (P2), the hits reach the import report unchanged. + * + * The runner reads no member of a warning: an admitted value shape and an + * advisory rule hit (which additionally names its `rule` and `severity`) ride + * the same array, in the verdict's order. Driven against an + * `ImportProtocolLike` double whose verdict is the shape `engine.validate` + * answers; the end-to-end preview over a real engine is pinned in + * `packages/objectql/src/validation/advisory-write-answer.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { ValidateDataIssue } from '@objectstack/spec/api'; +import { runImport, type ImportProtocolLike } from './import-runner'; +import type { ExportFieldMeta } from './import-field-meta.js'; + +type ValidateArgs = Parameters>[0]; + +const OBJECT = 'account'; +const ADMITTED: ValidateDataIssue = { field: 'parent_account', code: 'invalid_type', message: 'parent_account has an invalid lookup value' }; +const RULE_HIT: ValidateDataIssue = { + field: '_record', code: 'rule_violation', message: 'Industry should be set.', + rule: 'industry_advised', severity: 'warning', +}; +const INFO_HIT: ValidateDataIssue = { + field: '_record', code: 'rule_violation', message: 'Noted for review.', + rule: 'name_noted', severity: 'info', +}; + +const baseOpts = { + objectName: OBJECT, + metaMap: new Map([['name', { name: 'name', type: 'text' }]]), + writeMode: 'insert' as const, + matchFields: [] as string[], + dryRun: true, + runAutomations: false, + trimWhitespace: true, + createMissingOptions: false, + skipBlankMatchKey: false, +}; + +describe('[#22726] the dry run relays the verdict\'s row warnings verbatim, advisory rule hits included', () => { + it('admissions and rule hits reach the row in the verdict\'s order, every member kept', async () => { + const verdictWarnings = [ADMITTED, RULE_HIT, INFO_HIT]; + const validateData = vi.fn(async (args: ValidateArgs) => ({ + object: OBJECT, mode: args.mode ?? 'insert', valid: true, + results: [{ valid: true, errors: [], warnings: verdictWarnings }], + posture: { valueShapeStrict: false, mediaValueShapeStrict: false }, + })); + const p: ImportProtocolLike = { findData: vi.fn(async () => []), createData: vi.fn(), updateData: vi.fn(), validateData }; + + const s = await runImport({ ...baseOpts, p, rows: [{ name: 'Acme' }] }); + + expect(s).toMatchObject({ ok: 1, errors: 0, created: 1 }); + expect(s.results[0]).toEqual({ row: 1, ok: true, action: 'created', warnings: [ADMITTED, RULE_HIT, INFO_HIT] }); + // Verbatim: the very elements, not a re-built copy that could drop `rule` / `severity`. + expect(s.results[0].warnings![1]).toBe(RULE_HIT); + }); + + it('a verdict with no warning leaves the row without the key', async () => { + const validateData = vi.fn(async (args: ValidateArgs) => ({ + object: OBJECT, mode: args.mode ?? 'insert', valid: true, + results: [{ valid: true, errors: [], warnings: [] }], + posture: { valueShapeStrict: false, mediaValueShapeStrict: false }, + })); + const p: ImportProtocolLike = { findData: vi.fn(async () => []), createData: vi.fn(), updateData: vi.fn(), validateData }; + + const s = await runImport({ ...baseOpts, p, rows: [{ name: 'Acme' }] }); + + expect(s.results[0]).toEqual({ row: 1, ok: true, action: 'created' }); + }); +}); diff --git a/packages/spec/src/api/write-answer-warnings.test.ts b/packages/spec/src/api/write-answer-warnings.test.ts new file mode 100644 index 00000000000..6edfcb0c772 --- /dev/null +++ b/packages/spec/src/api/write-answer-warnings.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22726] The advisory-warnings write answer, at the contract. + * + * One element, two channels: the engine reports a hit as a + * `ValidationAdvisoryEvent` (`data/data-engine.zod.ts`), and the write answers + * and the validate-only preview carry it as a `ValidateDataIssue`. These pins + * hold the two together — an event IS an issue — and hold the answers' + * `warnings` optional (omit-when-empty, so an existing client's reading of an + * answer does not move). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + CreateDataResponseSchema, + UpdateDataResponseSchema, + CloneDataResponseSchema, + ValidateDataIssueSchema, + ValidateDataResponseSchema, +} from './protocol.zod'; +import { ValidationAdvisoryEventSchema, type ValidationAdvisoryEvent } from '../data/data-engine.zod'; +import type { WriteObservabilityOptions } from '../contracts/data-engine'; + +const HIT: ValidationAdvisoryEvent = { + rule: 'industry_advised', + severity: 'warning', + field: '_record', + code: 'rule_violation', + message: 'Industry should be set.', +}; + +const shapeKeys = (schema: unknown) => Object.keys((schema as { shape: Record }).shape); + +describe('[#22726] an advisory event IS a ValidateDataIssue', () => { + it('every event member is an issue member, and an event parses as an issue unchanged', () => { + const issueKeys = new Set(shapeKeys(ValidateDataIssueSchema)); + expect(shapeKeys(ValidationAdvisoryEventSchema).filter((k) => !issueKeys.has(k))).toEqual([]); + expect(ValidateDataIssueSchema.parse(HIT)).toEqual(HIT); + }); + + it('`severity` is advisory only: `error` is refused on both, since an error rule refuses the write instead', () => { + expect(ValidationAdvisoryEventSchema.safeParse({ ...HIT, severity: 'error' }).success).toBe(false); + expect(ValidateDataIssueSchema.safeParse({ ...HIT, severity: 'error' }).success).toBe(false); + expect(ValidationAdvisoryEventSchema.safeParse({ ...HIT, severity: 'info' }).success).toBe(true); + }); + + it('`rule` and `severity` stay optional on an issue: a value-shape admission or a refusal still parses', () => { + expect(ValidateDataIssueSchema.safeParse({ field: 'email', code: 'invalid_type', message: 'bad' }).success).toBe(true); + }); +}); + +describe('[#22726] the write answers declare an optional `warnings` of issues', () => { + const base = { object: 'account', id: 'a1', record: { id: 'a1' } }; + + it.each([ + ['CreateDataResponseSchema', CreateDataResponseSchema, base], + ['UpdateDataResponseSchema', UpdateDataResponseSchema, base], + ['CloneDataResponseSchema', CloneDataResponseSchema, { ...base, sourceId: 'a0' }], + ] as const)('%s accepts the hits, and an answer with no key', (_name, schema, answer) => { + expect(schema.safeParse({ ...answer, warnings: [HIT] })).toMatchObject({ success: true, data: { warnings: [HIT] } }); + expect(schema.safeParse(answer).success).toBe(true); + expect(schema.safeParse({ ...answer, warnings: [{ ...HIT, severity: 'error' }] }).success).toBe(false); + }); + + it('the preview row keeps its required `warnings` array, now holding both populations', () => { + const verdict = { + object: 'account', mode: 'insert', valid: true, + results: [{ + valid: true, errors: [], + warnings: [{ field: 'parent', code: 'invalid_type', message: 'admitted' }, HIT], + }], + posture: { valueShapeStrict: false, mediaValueShapeStrict: false }, + }; + expect(ValidateDataResponseSchema.safeParse(verdict).success).toBe(true); + }); +}); + +describe('[#22726] the listener is declared on the in-process write contract', () => { + it('`onValidationAdvisory` takes the event; absent stays legal', () => { + const seen: ValidationAdvisoryEvent[] = []; + const opts: WriteObservabilityOptions = { onValidationAdvisory: (e) => { seen.push(e); } }; + opts.onValidationAdvisory?.(HIT); + expect(seen).toEqual([HIT]); + const unset: WriteObservabilityOptions = {}; + expect(unset.onValidationAdvisory).toBeUndefined(); + }); +}); + +describe('[#22726] the answers load under eager schema construction', () => { + const prev = process.env.OS_EAGER_SCHEMAS; + afterEach(() => { + if (prev === undefined) delete process.env.OS_EAGER_SCHEMAS; + else process.env.OS_EAGER_SCHEMAS = prev; + vi.resetModules(); + }); + + it('OS_EAGER_SCHEMAS=1 builds the write answers, which read ValidateDataIssueSchema, without a TDZ fault', async () => { + process.env.OS_EAGER_SCHEMAS = '1'; + vi.resetModules(); + const mod = await import('./protocol.zod'); + expect(mod.CreateDataResponseSchema.safeParse({ + object: 'account', id: 'a1', record: {}, warnings: [HIT], + }).success).toBe(true); + }); +}); From 7ecb442dd88504a2fee64d3455d97545e458ffc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:18:01 +0000 Subject: [PATCH 4/9] test(objectql): type the multi-update listener parameter in the advisory pins Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/objectql/src/validation/advisory-write-answer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/validation/advisory-write-answer.test.ts b/packages/objectql/src/validation/advisory-write-answer.test.ts index c5a89a7063d..6c10e08c023 100644 --- a/packages/objectql/src/validation/advisory-write-answer.test.ts +++ b/packages/objectql/src/validation/advisory-write-answer.test.ts @@ -399,7 +399,7 @@ describe('[#22726] the engine listener and the evaluator\'s return value', () => const events: unknown[] = []; await engine.update('adv_acct', { industry: null }, { - where: { industry: 'x' }, multi: true, onValidationAdvisory: (e) => { events.push(e); }, + where: { industry: 'x' }, multi: true, onValidationAdvisory: (e: unknown) => { events.push(e); }, } as never); expect(events).toEqual([ADVISED, ADVISED]); From 9f039e5e2bca4b197809752ae250d306e97a536c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:31:57 +0000 Subject: [PATCH 5/9] test(objectql): the clone's insert options pin names the advisory listener Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/objectql/src/protocol-data.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/objectql/src/protocol-data.test.ts b/packages/objectql/src/protocol-data.test.ts index e6962503cc1..d8ee969496c 100644 --- a/packages/objectql/src/protocol-data.test.ts +++ b/packages/objectql/src/protocol-data.test.ts @@ -654,9 +654,12 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { // [#15703] The insert options carry the context AND the // `onFieldsDropped` listener `createData` wires — the clone reports // the engine's readonly-strip verdict on its 201 body since #15703. + // [#22726] And the `onValidationAdvisory` listener: a clone is a + // create, so it answers its advisory rule hits as `warnings`. expect(insert).toHaveBeenCalledWith('account', expect.anything(), { context: ctx, onFieldsDropped: expect.any(Function), + onValidationAdvisory: expect.any(Function), }); }); From e5fcb73cebe8ec2a9521f491c4845a7bf57ecef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:13:45 +0000 Subject: [PATCH 6/9] test(spec,objectql): pin ValidationAdvisoryEvent's isomorphism (ADR-0122); the advisory pins' find double holds the caller's bound Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../objectql/src/validation/advisory-write-answer.test.ts | 4 +++- packages/spec/src/type-alias-convention.pin.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/validation/advisory-write-answer.test.ts b/packages/objectql/src/validation/advisory-write-answer.test.ts index 6c10e08c023..d3a666a716b 100644 --- a/packages/objectql/src/validation/advisory-write-answer.test.ts +++ b/packages/objectql/src/validation/advisory-write-answer.test.ts @@ -127,7 +127,9 @@ function makeMemoryDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return [...storeFor(object).values()].filter((r) => matches(r, ast?.where)); + const rows = [...storeFor(object).values()].filter((r) => matches(r, ast?.where)); + // The caller's bound, AFTER the filter and by PRESENCE. + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1adb5627057..d058b398fd5 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -789,6 +789,7 @@ export type Iso_data_dataEngine__DroppedFieldsEventSchema = Assert, z.infer< typeof M56.EngineCountOptionsSchema > >>; export type Iso_data_dataEngine__EngineDeleteOptionsSchema = Assert, z.infer< typeof M56.EngineDeleteOptionsSchema > >>; export type Iso_data_dataEngine__EngineUpdateOptionsSchema = Assert, z.infer< typeof M56.EngineUpdateOptionsSchema > >>; +export type Iso_data_dataEngine__ValidationAdvisoryEventSchema = Assert, z.infer< typeof M56.ValidationAdvisoryEventSchema > >>; // data/datasource.zod.ts export type Iso_data_datasource__DriverDefinitionSchema = Assert, z.infer< typeof M57.DriverDefinitionSchema > >>; From 83c76131ff87cb4f0b50ff4fa2ae8906c91e117f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:32:28 +0000 Subject: [PATCH 7/9] test(spec): the ADR-0122 pin count names the new isomorphic ValidationAdvisoryEventSchema (775 -> 776) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index d058b398fd5..e677bf0cdf4 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -274,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 775 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1674,7 +1674,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 775 isomorphic pins', () => { + it('still declares all 776 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2448,7 +2448,14 @@ describe('ADR-0122 type-alias convention', () => { // exported `UploadScopeSchema`, a bare enum with no default and no // transform, so it is pinned here (Iso_api_storage__UploadScopeSchema) // rather than given an `UploadScopeParsed` synonym. +1 added. - expect(pins).toHaveLength(775); + // + // 775 -> 776 is #22726: the new exported `ValidationAdvisoryEventSchema` + // (one advisory validation-rule hit, the event the engine reports it + // through) is a closed object with no default and no transform in its + // tree, so it is pinned here + // (Iso_data_dataEngine__ValidationAdvisoryEventSchema) rather than given a + // `ValidationAdvisoryEventParsed` synonym. +1 added. + expect(pins).toHaveLength(776); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 7a9273c4511039a82bdcf6606975a03ea21add6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:53:55 +0000 Subject: [PATCH 8/9] chore(spec): regenerate the reference docs the merge deferred (main's requiredPermissions text beside this branch's warnings) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 223f28a58dd..a772f6ecd71 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1711,7 +1711,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | @@ -1797,7 +1797,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | From dd3df908b7e9d215b63a79416fe716566680815c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:01:39 +0000 Subject: [PATCH 9/9] docs(spec): the data-engine contract page and the hook update bag name onValidationAdvisory The WriteObservabilityOptions section said the write methods accept two in-process options and its os:check block listed two members; the in-process callout said 'both members' and read as if a remote caller had no channel for any of them. It now lists the third member and its event, adds its subsection, and says how a remote caller sees advisory hits: as the protocol answer's warnings. hook-api.ts's enumeration gains the member too. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/kernel/contracts/data-engine.mdx | 48 +++++++++++++++---- packages/spec/src/data/hook-api.ts | 5 +- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 3c8193f5446..88bad477736 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -331,10 +331,11 @@ interface EngineUpdateOptions { ### WriteObservabilityOptions -The write methods (`insert` / `update`) additionally accept two **in-process** -options that govern what happens when caller-supplied write fields are legally +The write methods (`insert` / `update`) additionally accept three **in-process** +options. Two govern what happens when caller-supplied write fields are legally stripped from the payload before the driver write: observe the strip -(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). +(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The +third, `onValidationAdvisory`, reports the write's advisory validation-rule hits. The strips these two options cover are the engine's legal ones: @@ -354,6 +355,7 @@ on `insert` too since the 2026-09-03 ruling — see **On `insert`** below. ```typescript interface WriteObservabilityOptions { onFieldsDropped?: (event: DroppedFieldsEvent) => void; + onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void; strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false } @@ -364,6 +366,14 @@ interface DroppedFieldsEvent { // path's legal strips; branch on it exhaustively, never with a binary test reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed'; } + +interface ValidationAdvisoryEvent { + rule: string; // the advisory validation rule's declared `name` + severity: 'warning' | 'info'; // never `error`: an `error` rule refuses the write instead + field: string; // the rule's first declared field, or `_record` + code: string; // the finding code a refused write reports for the same rule + message: string; // the rule's author-written message, in the caller's locale +} ``` #### `onFieldsDropped` — quiet and observable @@ -425,6 +435,24 @@ legacy record numbers (#3493) exempts the runtime-owned strip only, and a non-system create that asks for it still has its static `readonly` fields stripped, with a `warn` saying the exemption is UPDATE-only (#6640). +#### `onValidationAdvisory` — the write's advisory rule hits + +The engine calls the listener once per ADVISORY validation-rule hit on the write +the options were passed to: a `severity: 'warning'` or `'info'` rule whose verdict +on the written record was "violated". Advisory rules never block, so the write +proceeds. It is called per row on `insert`, and on `update` both by id and per +matched row of a `multi` update. A nested write that a hook or a flow makes +inside this one carries its own options, so that write's hits never reach this +listener. A rule that could not be evaluated is not a hit, and is reported in the +server log only. The listener fires when the rules are evaluated, before the +driver write, so a hit is provisional until the write resolves. Passing it +changes no server-side reporting: the per-write `warn` line, and a seed / boot +load's one summary line per rule. + +The DataProtocol's `createData`, `updateData` and `cloneData` pass this listener +and answer the hits as `warnings` (`ValidateDataIssue` entries carrying `rule` +and `severity`), present only when there is at least one hit. + **Layering — this is the engine seam, and since the 2026-09-03 ruling the only one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`, @@ -436,15 +464,19 @@ declares one; none carries a `readonly` policy of its own any more — see `WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag — -both members. It is deliberately not part of the serializable Zod options schemas: +all three members. It is deliberately not part of the serializable Zod options schemas: a function is unrepresentable in JSON Schema and cannot cross the RPC (Virtual Data Engine) boundary, so remote callers never receive these events; and putting `strictReadonlyWrites` in the serializable bag would let any client toggle write-refusal on a security-adjacent path (#5126 ruling). A remote caller can set -neither and gets NEITHER behaviour: its write is stripped and committed, silently -from its side — a 200 whose read-only columns kept their stored values. Widening -strict to the wire is a SEPARATE decision. A listener that throws never breaks the -write — the engine catches and logs. +none of the three, and for the strips it gets NEITHER behaviour: its write is +stripped and committed, silently from its side — a 200 whose read-only columns +kept their stored values. Widening strict to the wire is a SEPARATE decision. +Advisory rule hits do reach a remote caller, through the answer rather than the +listener: the DataProtocol's `createData`, `updateData` and `cloneData` pass +`onValidationAdvisory` in-process and answer the hits as `warnings`, which is how +a remote caller sees them. A listener that throws never breaks the write — the +engine catches and logs. ### delete diff --git a/packages/spec/src/data/hook-api.ts b/packages/spec/src/data/hook-api.ts index 9a97dbb4ade..18268f41022 100644 --- a/packages/spec/src/data/hook-api.ts +++ b/packages/spec/src/data/hook-api.ts @@ -190,8 +190,9 @@ export type HookUpdateDoc = HookDoc; * * `EngineUpdateOptions` minus the injected `context` and the `upsert` * tombstone, plus {@link WriteObservabilityOptions} (`onFieldsDropped`, - * `strictReadonlyWrites` — contract-declared, deliberately outside the - * serializable schema) and the driver pass-through keys. + * `onValidationAdvisory`, `strictReadonlyWrites` — contract-declared, + * deliberately outside the serializable schema) and the driver pass-through + * keys. * * The bulk form is `update(data, { where, multi: true })`; there is no * `updateMany`.