Skip to content

Commit 8843505

Browse files
fix(objectql)!: a system write's readonly value is judged for its shape — a seed's malformed readonly datetime is refused, never stored (#21663) (#21695)
Fixes #21663 Clause-②: no (narrowing) ## What this changes A system writer is exempt from the readonly **strip**, never from the value-**shape** check (triage's ruling on the card, comment 5975978206). The static readonly strip drops a non-system caller's readonly value and exempts a system write (seed replay, migration, `isSystem` plugin code, a hook's stamp). The record validator skipped every readonly field outright, on the premise that the strip had already removed anything a caller sent. That premise is false for exactly the writers the strip exempts, so under `isSystem` a malformed readonly value reached the driver unjudged. After this PR: - The strip is untouched. It keeps its system exemption, and a non-system caller's readonly value is still dropped, never refused. - The value the exemption keeps is judged for its SHAPE wherever the payload is final. A malformed value is refused with `VALIDATION_FAILED` (400 at the HTTP boundary), with the same field code and the same sentence a non-readonly field gets (`Run At must be a valid datetime (ISO-8601)`). A seed counts the row as a seed error. - ⛔ No silent coercion. Nothing malformed is rewritten into something else. ## Where the fix lives (the order's H1 file location did not hold) The dispatch expected the branch in `packages/objectql/src/validation/rule-validator.ts`. Measured at `72f3c74d60`: the strip lives there (`stripReadonlyFields`), but the shape check and its readonly skip live in **`packages/objectql/src/validation/record-validator.ts`** (`validateRecord`, `if (def.system || def.readonly) continue` on both walks). The engine (`packages/objectql/src/engine.ts`) runs the strip and the validator at different points: | path | order at `72f3c74d60` | |---|---| | insert | strip, then `validateRecord` | | dry run (`ObjectQL.validate`) | strips, then `validateRecord` | | update, by id and by predicate | `validateRecord`, **then** the strip | So the fix lands in the producer's own file, with the engine choosing the scope at each seam: - `record-validator.ts`: a `ReadonlyValueScope` (`'skip' | 'include' | 'only'`) and a module-internal `validateRecordInScope`. The published `validateRecord` keeps its signature and behaviour byte for byte, so nothing on the package's public surface widens (the claim's Clause-② line holds). - `engine.ts`: insert and dry run judge with `'include'` (post-strip). Both update paths keep their first call at `'skip'` and add a second pass at `'only'` right after `assertNoStrictDrops()`, where the payload is final. - Why not judge readonly values in the update path's first call: that call runs before the strip, so a readonly value there may be a caller's that the strip is about to drop. A whole-record write-back that echoes a legacy malformed stored value would turn from a save into a refusal. Pin 3 holds this. - `rule-validator.ts`: docs only. The strip's docblock now says a system write skips the strip and nothing else. ## The boundary: shape, never a constraint A readonly value reaches the type's shape arms only: - **refused:** a `date` / `datetime` / `time` the platform does not read; a non-number on a number-typed field; a non-boolean; a non-array on a multi-value field; a filter-operator object; and the ADR-0104 reference / media / structured-JSON shape under the object's own posture (warn-first, exactly as on a non-readonly field). - **not checked, as before:** option membership, `maxLength` / `minLength`, `valueDomain`, `min` / `max` / `scale` / `precision`, the email / url / phone formats, and `required`. Option membership is the load-bearing exclusion. `sys_activity.type` is a readonly `select` whose options are the built-in set of an open vocabulary. The maintainer ruling recorded at commit `88b9d749a` binds that an author-contributed value is stored, and its object file says "Do not fix this by enforcing the enum on system-owned writes". The email / url / phone formats stay out because the spec's stored shape for those types (`valueSchemaFor`) is a plain string. Readonly `url` fields that platform code writes (`sys_activity.url`, `sys_activity.actor_avatar_url`, `sys_organization.logo`) are why that matters. For the same reason "judged" equals "stored": a numeric string on a readonly number field is now written as its number (`normalizeNumericStringValues`, at the door, ahead of the caller snapshot, so the strip still drops a non-system caller's key), and a lone scalar on a readonly multi-value field is wrapped post-strip, as on any other field. ## H2: which shape checks a system write skipped (measured) A throwaway probe (deleted, never committed) inserted one malformed value per type into a readonly field and into its non-readonly twin. | field (malformed value) | `isSystem`, readonly, at `72f3c74d60` | `isSystem`, readonly, after | `isSystem`, non-readonly (unchanged) | non-system, readonly (unchanged) | |---|---|---|---|---| | datetime `'yesterday'` | stored | refused `invalid_date` | refused | dropped | | datetime, raw `cel` envelope | stored | refused `invalid_date` | refused | dropped | | date `'yesterday'` | stored | refused `invalid_date` | refused | dropped | | time `'noon'` | stored | refused `invalid_time` | refused | dropped | | number / currency / percent `'abc'` | stored | refused `invalid_number` | refused | dropped | | boolean `'maybe'` | stored | refused `invalid_boolean` | refused | dropped | | multiselect, an object | stored | refused `invalid_type` | refused | dropped | | text, `{ $in: [...] }` | stored | refused `invalid_type` | refused | dropped | | number `max: 5`, value 9 | stored | stored (constraint) | refused | dropped | | select, undeclared option | stored | stored (constraint) | refused | dropped | | text `maxLength: 3`, 6 chars | stored | stored (constraint) | refused | dropped | | email / url / phone, malformed | stored | stored (format) | refused | dropped | | lookup, `cel` envelope | stored | stored with the ADR-0104 warning (warn-first) | stored with the warning | dropped | | location `'nowhere'` | stored | stored with the ADR-0104 warning (warn-first) | stored with the warning | dropped | ## H3, H4, H5 - **H3, the seed path:** measured through the real `SeedLoaderService` (pins 1 and 2). `'yesterday'` on a readonly datetime is refused and counted (`summary.totalErrored`), with the non-readonly sentence, on the fresh-boot insert and on the replay update. A valid ISO value, a `cel` value the loader evaluates, and an authored `created_at` are kept. That last case pairs with the arm #21646 landed. - **H4, the seeders that skip `resolveSeedRecord`:** `AppPlugin`'s two fallback inserts (`packages/runtime/src/app-plugin.ts`, the no-metadata-service branch and the loader-threw branch) and `@objectstack/verify`'s `seed()` (`packages/verify/src/handle.ts`). A raw `cel` envelope on a readonly datetime is now refused on their call shapes (single-row and array insert under `SEED_WRITE_EXECUTION_CONTEXT`, pinned). **No example app or test newly fails.** `runtime` (4554 tests) and `verify` (131) are green. The only readonly field seeded with `cel` in `examples/` is `created_at`, in 10 `app-showcase` task rows, and every one of those rows also seeds a non-readonly `due_date` with `cel`. So on the fallback path those rows were already refused before this change, and on the normal path the loader evaluates them. No cross-lane fix is needed for this change. The fallback's pre-existing `warn`-level per-row loss is in the Acceptance notes. - **H5, other system writers:** measured through the platform's own suites. None writes a malformed readonly value. Green: `plugin-audit` 621, `plugin-pinyin-search` 21, `plugin-security` 3527, `plugin-auth` 2494, `plugin-approvals` 875, `service-automation` 2098, `metadata-protocol` 3463, `runtime` 4554, `verify` 131. Inside objectql, two fixtures turned red and were re-judged, not relaxed: - `engine-insert-static-readonly-strip.test.ts`: an `isSystem` case used the placeholder `'x'` in a readonly datetime, in a test about `strictReadonlyWrites`. It is respelled to a valid instant, like its `isSystem` sibling. - `record-validator.number-value.test.ts`: it pinned "the numeric normalizer skips a readonly field". The number arm now judges a readonly value, so by the normalizer's own invariant (what the arm judges is what the driver stores) the readonly field moves to the rewritten side. ## Pins `packages/objectql/src/seed-readonly-value-shape.test.ts`, on the real kernel (`ObjectKernel` + `ObjectQLPlugin`) and the real `SeedLoaderService`. Each refusal asserts `code` and `status` (ADR-0112) through `resolveThrownHttpError`, the boundary's own reading. The engine's `ValidationError` carries no `status` by design. 1. `'yesterday'` on a readonly datetime in a seed is refused and counted, with the same sentence the non-readonly twin gets. The same holds on the replay, on all four write seams (insert, update by id, update by predicate, dry run) as `VALIDATION_FAILED` / 400 with the non-readonly field envelope, for a raw `cel` envelope, and for a malformed authored `created_at`. 2. A valid ISO value on a readonly field under the seed context is kept: authored, evaluated from `cel`, and on `created_at`, on insert and on replay. 3. The non-readonly path is unchanged. A non-system caller's readonly value is still dropped, never refused, on insert and on a whole-record write-back echoing a legacy malformed value. A readonly undeclared option and an out-of-bound number are stored, while the non-readonly twin refuses both. **Reverse verification** (committed first, at `196b217829`). The split was reverted at its one predicate in `record-validator.ts`, through `scripts/ablation-replace.mjs` under a shell trap: anchor `if (def.readonly === true) return scope !== 'skip';` went from 1 to 0 hits, the replacement `return false` from 0 to 1, and the blob from `d57cbd3078` to `3768d5668f`. Result: `Tests 4 failed | 4 passed (8)`. The four red tests are exactly pin 1 (`expected 1 to be 2`, `expected +0 to be 1`, and `the write must be refused` twice); pin 2 and the three pin-3 tests stayed green. Restore was `git checkout HEAD -- PATH`: blob back to `d57cbd3078` (the HEAD blob), `git diff HEAD` 0 bytes, `git status --porcelain` empty. No dist rebuild per leg was needed: the pin imports `./engine.js` / `./plugin.js` from src by relative path. ## Tests Head of record: `b73f58e396` (after merging `origin/main` at `251a7dd4b4`). - **Pins:** `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/seed-readonly-value-shape.test.ts` gives `Tests 8 passed (8)` at `b73f58e396`. With #21646's pin file beside it earlier: `Tests 14 passed (14)`. - **objectql:** `vitest run --project local --maxWorkers=2` gives `Test Files 372 passed (372) / Tests 7455 passed (7455)` and `--project repo` gives `Tests 5 passed (5)`, both at `e6b5281680`. `pnpm --filter @objectstack/objectql run typecheck` exits 0, with `check:test-typecheck: OK … 40 file(s) / 234 error(s) / 65 pinned signature(s) held` (ledger unchanged). `tsc -p tsconfig.test.json --listFilesOnly` lists the new pin file. The only change after `e6b5281680` is the pin file's row-key rename (rerun green above). - **H5 consumer suites** (`pnpm --filter PKG run test`, against objectql's rebuilt `dist/`, at `45804afc28`): every one green, with the counts in the H5 section. The commits after it are behaviour-identical for these suites: the engine-internal entry refactor, the changeset, a merge of `main` with no objectql overlap, and the pin rename. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derives 68 commands at `b73f58e396`, from 7 paths against merge base `251a7dd4b`. All 68 ran, each exit code captured before any pipe: **68 × exit 0**. `--ran` reports `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. Two readings came from the first union at `e6b5281680`: - `check:error-code-casing` was red. It read the pins' row key, a field named `code`, as a lowercase error code. The key is renamed to `ref`, and the gate is green from `b73f58e396`. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET). After a full `turbo run build` (72/72) it exits 0. - **Artifact-roster block** (53 families outside the derived total, all run at `b73f58e396`): 50 exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` report NOT WIRED with no PR context (exit 2, not a verdict). `check-partof-closing-keyword` was then run with this body as `PR_BODY`: exit 0, "no Part-of/closing-keyword contradiction". The other two need the PR number, and their results go in the os-dev-report on the card. - **Changeset gates:** `check-changeset-no-major`, `check-adr-0087-registration` (1 declared-breaking changeset, carrying its disposition) and `check-empty-changeset` all exit 0. - **NOT MEASURED** (CI-only, no local invocation): shard attestation and test-completeness, the Test Core / Temporal Conformance / Dogfood / Dogfood Verify / Build Core jobs, the workspace and consumer type-check lanes, and the 11 declared wide-population families. Repository-wide `pnpm lint` is CI-owned and was not run. ## Acceptance notes - **`owner_id` keeps the full skip.** It is `system` but not `readonly`, so the split does not reach it (no strip exemption is involved). It is caller-writable and its value shape is still never judged. This is read-only inference, not measured through a door. - **The ADR-0104 dormancy test still excludes readonly columns** (`isScannableValueShapeField`). Widening it would make every object non-dormant through its injected readonly lookups. So an object whose only covered fields are readonly stays warn-first for them: a malformed readonly reference is admitted, logged and reported to `onAdmittedValueShapeViolation`, never stored silently. The `os migrate value-shapes` scan population is unchanged. - **`AppPlugin`'s fallback seeders** log a refused row at `warn` and then report "Data seeding complete", while `SeedLoaderService` logs the same loss at `error`. This is pre-existing and not caused here. carrier: none. - **Comments elsewhere still say "`validateRecord` skips readonly fields"** (plugin-audit sources and tests, and two ADR-0087 semantic entries in `packages/spec/src/migrations/`). What they rely on, that a readonly option set is not enforced, stays true by the boundary above. The stated reason is now imprecise. Not edited here. carrier: none. - **The dry run never applies `normalizeMultiValueFields`**, for any field, so a scalar on a multi-value field previews as invalid while the write wraps and accepts it. This is pre-existing, and readonly fields now behave the same as the rest. carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2aadab commit 8843505

7 files changed

Lines changed: 672 additions & 40 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: a system write's readonly value is judged for its shape — a seed's `'yesterday'` on a readonly datetime is refused with the sentence any other field gets, never stored (#21663)
6+
7+
**BREAKING** — a write that keeps a readonly value now has that value's SHAPE
8+
checked. The static readonly strip still exempts a system write (seed replay,
9+
migration, `isSystem` plugin code, a `before*` hook's stamp) and still drops a
10+
non-system caller's readonly value; what changed is that the value the
11+
exemption keeps is no longer stored unjudged. Before, the record validator
12+
skipped every readonly field, so under `isSystem` a malformed readonly value
13+
reached the driver verbatim — a seed's `run_at: 'yesterday'` on a readonly
14+
`datetime`, an unresolved `cel` envelope from a seeder that skips its
15+
resolution, an authored `created_at` the seed now keeps — while the same value
16+
on a non-readonly field was refused.
17+
18+
Now it is refused the same way: `VALIDATION_FAILED` (400 at an HTTP boundary),
19+
the same field code and the same sentence a non-readonly field gets
20+
(`Run At must be a valid datetime (ISO-8601)`), and a seed counts the row as a
21+
seed error. This holds on insert, on the dry run (`ObjectQL.validate`), and on
22+
both update paths, where the readonly values left after the strip are judged.
23+
24+
Which checks a readonly value reaches — its type's shape, never a constraint:
25+
26+
- refused: a `date` / `datetime` / `time` the platform does not read, a
27+
non-number on a number-typed field, a non-boolean on a boolean, a non-array on
28+
a multi-value field, a filter-operator object, and an ADR-0104 reference /
29+
media / structured-JSON shape under the object's own posture (warn-first, as
30+
on any other field, until the deployment's evidence enforces it);
31+
- NOT checked, exactly as before: option membership, `maxLength` /
32+
`minLength`, `valueDomain`, `min` / `max` / `scale` / `precision`, the email /
33+
url / phone formats, and `required`. Option membership stays out on purpose:
34+
`sys_activity.type` is a readonly `select` whose options are the built-in set
35+
of an open vocabulary, and an author-contributed value there is stored.
36+
37+
A numeric string on a readonly number field is now written as its number, and a
38+
lone scalar on a readonly multi-value field as a one-member list, as on any
39+
other field — the door reads the value the same way it judges it.
40+
41+
**What moves for consumers.** A seed, migration or `isSystem` write that puts a
42+
malformed value in a readonly field — or a hook that stamps one — is refused
43+
where it was stored. Fix the value at its producer: write an ISO-8601 instant
44+
(or a `Date`) into a readonly `datetime`, resolve a `cel` value before the
45+
write, and stamp numbers and booleans as such. Rows already stored are never
46+
re-read or rewritten. `validateRecord`, as exported, is unchanged: the readonly
47+
scope is the engine write path's own.
48+
49+
Clause-②: no (narrowing)
50+
51+
<!-- adr-0087: not-required (no-migration-prescription) a write-time refusal of a malformed value in a readonly field, judged by the same per-type shape checks a non-readonly field already gets. No authorable key, spelling, export or stored shape moves: the field schema is unchanged, the published validateRecord signature is unchanged, no stored row is read or rewritten, and which value a producer meant to write is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this behaviour (not already-registered); and the change is a write-path verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/objectql/src/engine-insert-static-readonly-strip.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,12 @@ describe('#14147 — strictReadonlyWrites refuses before any driver dispatch', (
382382
});
383383

384384
it('strict adds NO second policy — an isSystem write it would not strip is still accepted', async () => {
385+
// A well-formed value: since #21663 a system writer's readonly value is
386+
// judged for its SHAPE (a placeholder like `'x'` in a datetime is refused
387+
// as `invalid_date`), which is a different policy from the one this case
388+
// is about — `strictReadonlyWrites` adding nothing to the strip.
385389
const o = await observeInsert(
386-
{ title: 'T', completed_at: 'x' },
390+
{ title: 'T', completed_at: '2019-04-01T00:00:00Z' },
387391
{ strictReadonlyWrites: true, context: { isSystem: true } },
388392
);
389393
expect(o.refusedCode).toBeNull();

‎packages/objectql/src/engine.ts‎

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container'
262262
// registrar and `os validate` both call.
263263
import { viewContainerNameRefusal } from './view-container-name-refusal.js';
264264
import { bindHooksToEngine } from './hook-binder.js';
265-
import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
265+
import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
266266
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';
267267
import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js';
268268
import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js';
@@ -6063,9 +6063,10 @@ export class ObjectQL implements IObjectQLEngine {
60636063
// so the same declaration behaved differently per datasource. That
60646064
// split surfaced two ways: a validation-visible field was REJECTED by
60656065
// the engine's own write validator ("must be a valid datetime"), and a
6066-
// `readonly`/`system` field — which `validateRecord` skips, i.e. the
6067-
// ~100 `created_at`/`updated_at` platform declarations — silently
6068-
// stored the four characters `NOW()`.
6066+
// `readonly`/`system` field — which `validateRecord` then skipped, i.e.
6067+
// the ~100 `created_at`/`updated_at` platform declarations — silently
6068+
// stored the four characters `NOW()`. (Since #21663 a readonly value's
6069+
// shape is judged too, so that literal would now be refused.)
60696070
//
60706071
// Resolved from the caller's `nowSnapshot`, so every defaulted field
60716072
// in one insert (and every row of one batch) carries the SAME instant.
@@ -9884,12 +9885,19 @@ export class ObjectQL implements IObjectQLEngine {
98849885
* — and not raw type membership, because the registry INJECTS covered-type
98859886
* fields into every object it registers: `organization_id` and `owner_id`
98869887
* (both `system`), plus `created_by` / `updated_by` (both in `SKIP_FIELDS`),
9887-
* are all `lookup`s. `validateRecord` skips every one of them before it ever
9888-
* reaches the value-shape check, so counting them made this answer `true` for
9889-
* literally every object — the dormancy rule above never fired, and this
9890-
* cache memoized a constant. Same predicate as the scanner for the same
9891-
* reason the scanner imports it: three readings of "a covered field" drifting
9892-
* by one clause is how a gate ends up governing fields nothing enforces.
9888+
* are all `lookup`s. A caller never writes any of them, so counting them made
9889+
* this answer `true` for literally every object — the dormancy rule above
9890+
* never fired, and this cache memoized a constant. Same predicate as the
9891+
* scanner for the same reason the scanner imports it: three readings of "a
9892+
* covered field" drifting by one clause is how a gate ends up governing
9893+
* fields nothing enforces.
9894+
*
9895+
* [#21663] The three that are `readonly` (`organization_id`, `created_by`,
9896+
* `updated_by`) DO reach the value-shape check now, on the value a system
9897+
* writer, hook or stamp stores. They still do not count here, so an object
9898+
* whose only covered fields are those stays warn-first for them: a malformed
9899+
* value is admitted, logged and reported, never stored silently. See
9900+
* `isScannableValueShapeField` for why widening this test is not the fix.
98939901
*/
98949902
private objectHasCoveredValueField(objectSchema: any): boolean {
98959903
if (!objectSchema?.fields) return false;
@@ -12469,9 +12477,11 @@ export class ObjectQL implements IObjectQLEngine {
1246912477
* call that fires side-effecting hooks (mail, outbound calls, writes to
1247012478
* other objects) is the #4052 defect in a new spelling, where a preview
1247112479
* quietly executes. So the gap is documented rather than closed: audit and
12472-
* ownership stamps are `system`/`readonly` and are skipped by validation
12473-
* anyway, so what remains is the narrow case of a hook deriving a
12474-
* *business* field that its object also validates.
12480+
* ownership stamps are `system`/`readonly`, so validation never requires
12481+
* them, and (#21663) the only thing it asks of a readonly value is its
12482+
* shape, which a platform stamp always has — so what remains is the narrow
12483+
* case of a hook deriving a *business* field that its object also
12484+
* validates.
1247512485
*
1247612486
* Nothing is written, no sequence is consumed, and no driver is touched —
1247712487
* validation is in-process, which is what makes row-by-row dry run of a
@@ -12724,7 +12734,11 @@ export class ObjectQL implements IObjectQLEngine {
1272412734
});
1272512735
};
1272612736
try {
12727-
validateRecord(schemaForValidation, row, mode, {
12737+
// [#21663] `'include'` in both modes: the caller-write strips ran
12738+
// above, so this is the payload the write stores — and the write
12739+
// judges its readonly values' shape (insert in the same call, update
12740+
// in a second pass after its own strip).
12741+
validateRecordInScope(schemaForValidation, row, mode, 'include', {
1272812742
mediaValueShapeStrict, valueShapeStrict, messages, onAdmittedValueShapeViolation,
1272912743
});
1273012744
evaluateValidationRules(schemaForValidation as any, row, mode, {
@@ -13515,8 +13529,13 @@ export class ObjectQL implements IObjectQLEngine {
1351513529
for (let i = 0; i < rows.length; i++) {
1351613530
if (rowErrors[i] !== undefined) continue;
1351713531
try {
13518-
normalizeMultiValueFields(schemaForValidation, rows[i]);
13519-
validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation });
13532+
// [#21663] `'include'`: the readonly strip ran above, so every
13533+
// readonly value still on the row is one the driver will store —
13534+
// a system writer's (seed, migration), a hook's or a stamp — and
13535+
// its SHAPE is judged here like any other field's. See
13536+
// `ReadonlyValueScope` (record-validator.ts).
13537+
normalizeMultiValueFields(schemaForValidation, rows[i], 'include');
13538+
validateRecordInScope(schemaForValidation, rows[i], 'insert', 'include', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation });
1352013539
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]) });
1352113540
await this.assertReferencesResolve(
1352213541
schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx,
@@ -14866,7 +14885,13 @@ export class ObjectQL implements IObjectQLEngine {
1486614885
// secret channel (which carries the secret-arm refusal).
1486714886
this.refuseEmptyPasswordFields(object, hookContext.input.data as Record<string, unknown>);
1486814887
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
14869-
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>);
14888+
// [#21663] Scope `'skip'` — the public `validateRecord` IS that
14889+
// scope: the readonly strip has NOT run yet, so a readonly value
14890+
// here may be a caller's the strip is about to drop — judged, a
14891+
// whole-record write-back echoing a legacy stored value would
14892+
// become a refusal. Readonly values are judged after the strip
14893+
// (`validateRecordInScope(…, 'only')`, below).
14894+
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>, 'skip');
1487014895
validateRecord(updateSchema, hookContext.input.data as Record<string, unknown>, 'update', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation });
1487114896
// [#5284] Demand-driven, and the demand is asked PER OBJECT.
1487214897
//
@@ -15051,6 +15076,15 @@ export class ObjectQL implements IObjectQLEngine {
1505115076
// "you sent a read-only field" should not depend on whether some
1505215077
// other field also failed a business rule.
1505315078
assertNoStrictDrops();
15079+
// [#21663] The payload is FINAL here (see the seam below), so
15080+
// every readonly value on it is one the driver will store: a
15081+
// system writer's (the strip above never ran for it), a hook's,
15082+
// or a stamp. Its SHAPE is judged now, by the same arms and
15083+
// sentences as the caller-writable fields the first
15084+
// `validateRecord` above judged ahead of the strip — `'only'`,
15085+
// because those are already judged. See `ReadonlyValueScope`.
15086+
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>, 'only');
15087+
validateRecordInScope(updateSchema, hookContext.input.data as Record<string, unknown>, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation });
1505415088
// ── [#19989] The post-image seam on the BY-ID path ─────────────
1505515089
//
1505615090
// The by-id twin of the predicate-path call below, placed at the
@@ -15186,7 +15220,13 @@ export class ObjectQL implements IObjectQLEngine {
1518615220
// secret channel (which carries the secret-arm refusal).
1518715221
this.refuseEmptyPasswordFields(object, hookContext.input.data as Record<string, unknown>);
1518815222
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
15189-
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>);
15223+
// [#21663] Scope `'skip'` — the public `validateRecord` IS that
15224+
// scope: the readonly strip has NOT run yet, so a readonly value
15225+
// here may be a caller's the strip is about to drop — judged, a
15226+
// whole-record write-back echoing a legacy stored value would
15227+
// become a refusal. Readonly values are judged after the strip
15228+
// (`validateRecordInScope(…, 'only')`, below).
15229+
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>, 'skip');
1519015230
validateRecord(updateSchema, hookContext.input.data as Record<string, unknown>, 'update', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation });
1519115231
// [#2982] The middleware-composed AST — asserted present and
1519215232
// bound to the memoized row read in the pre-phase above, so the
@@ -15305,6 +15345,12 @@ export class ObjectQL implements IObjectQLEngine {
1530515345
// caller is told before N rows are written with a column missing
1530615346
// — the failure mode a bulk write makes N times larger.
1530715347
assertNoStrictDrops();
15348+
// [#21663] The predicate-path twin of the by-id second pass, at the
15349+
// same point and for the same reason: the readonly values left on
15350+
// the final payload are stored, so their SHAPE is judged — before
15351+
// N rows are written.
15352+
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>, 'only');
15353+
validateRecordInScope(updateSchema, hookContext.input.data as Record<string, unknown>, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation });
1530815354
// ── [#19950] The post-image seam on the PREDICATE path ─────────
1530915355
//
1531015356
// An enforcement layer's write `check` must hold for EVERY row a

0 commit comments

Comments
 (0)