Skip to content

Commit 1b9252e

Browse files
committed
Merge origin/main into claude/issue-21624-shared-prescription
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents d589cd4 + 8843505 commit 1b9252e

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)