Skip to content

Commit dd3df90

Browse files
committed
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 <noreply@anthropic.com>
1 parent 8cfab74 commit dd3df90

2 files changed

Lines changed: 43 additions & 10 deletions

File tree

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,11 @@ interface EngineUpdateOptions {
331331

332332
### WriteObservabilityOptions
333333

334-
The write methods (`insert` / `update`) additionally accept two **in-process**
335-
options that govern what happens when caller-supplied write fields are legally
334+
The write methods (`insert` / `update`) additionally accept three **in-process**
335+
options. Two govern what happens when caller-supplied write fields are legally
336336
stripped from the payload before the driver write: observe the strip
337-
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`).
337+
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The
338+
third, `onValidationAdvisory`, reports the write's advisory validation-rule hits.
338339

339340
The strips these two options cover are the engine's legal ones:
340341

@@ -354,6 +355,7 @@ on `insert` too since the 2026-09-03 ruling — see **On `insert`** below.
354355
```typescript
355356
interface WriteObservabilityOptions {
356357
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
358+
onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void;
357359
strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false
358360
}
359361

@@ -364,6 +366,14 @@ interface DroppedFieldsEvent {
364366
// path's legal strips; branch on it exhaustively, never with a binary test
365367
reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed';
366368
}
369+
370+
interface ValidationAdvisoryEvent {
371+
rule: string; // the advisory validation rule's declared `name`
372+
severity: 'warning' | 'info'; // never `error`: an `error` rule refuses the write instead
373+
field: string; // the rule's first declared field, or `_record`
374+
code: string; // the finding code a refused write reports for the same rule
375+
message: string; // the rule's author-written message, in the caller's locale
376+
}
367377
```
368378

369379
#### `onFieldsDropped` — quiet and observable
@@ -425,6 +435,24 @@ legacy record numbers (#3493) exempts the runtime-owned strip only, and a
425435
non-system create that asks for it still has its static `readonly` fields stripped,
426436
with a `warn` saying the exemption is UPDATE-only (#6640).
427437

438+
#### `onValidationAdvisory` — the write's advisory rule hits
439+
440+
The engine calls the listener once per ADVISORY validation-rule hit on the write
441+
the options were passed to: a `severity: 'warning'` or `'info'` rule whose verdict
442+
on the written record was "violated". Advisory rules never block, so the write
443+
proceeds. It is called per row on `insert`, and on `update` both by id and per
444+
matched row of a `multi` update. A nested write that a hook or a flow makes
445+
inside this one carries its own options, so that write's hits never reach this
446+
listener. A rule that could not be evaluated is not a hit, and is reported in the
447+
server log only. The listener fires when the rules are evaluated, before the
448+
driver write, so a hit is provisional until the write resolves. Passing it
449+
changes no server-side reporting: the per-write `warn` line, and a seed / boot
450+
load's one summary line per rule.
451+
452+
The DataProtocol's `createData`, `updateData` and `cloneData` pass this listener
453+
and answer the hits as `warnings` (`ValidateDataIssue` entries carrying `rule`
454+
and `severity`), present only when there is at least one hit.
455+
428456
<Callout type="warn">
429457
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
430458
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
436464

437465
<Callout type="warn">
438466
`WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag —
439-
both members. It is deliberately not part of the serializable Zod options schemas:
467+
all three members. It is deliberately not part of the serializable Zod options schemas:
440468
a function is unrepresentable in JSON Schema and cannot cross the RPC (Virtual
441469
Data Engine) boundary, so remote callers never receive these events; and putting
442470
`strictReadonlyWrites` in the serializable bag would let any client toggle
443471
write-refusal on a security-adjacent path (#5126 ruling). A remote caller can set
444-
neither and gets NEITHER behaviour: its write is stripped and committed, silently
445-
from its side — a 200 whose read-only columns kept their stored values. Widening
446-
strict to the wire is a SEPARATE decision. A listener that throws never breaks the
447-
write — the engine catches and logs.
472+
none of the three, and for the strips it gets NEITHER behaviour: its write is
473+
stripped and committed, silently from its side — a 200 whose read-only columns
474+
kept their stored values. Widening strict to the wire is a SEPARATE decision.
475+
Advisory rule hits do reach a remote caller, through the answer rather than the
476+
listener: the DataProtocol's `createData`, `updateData` and `cloneData` pass
477+
`onValidationAdvisory` in-process and answer the hits as `warnings`, which is how
478+
a remote caller sees them. A listener that throws never breaks the write — the
479+
engine catches and logs.
448480
</Callout>
449481

450482
### delete

‎packages/spec/src/data/hook-api.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,9 @@ export type HookUpdateDoc = HookDoc;
190190
*
191191
* `EngineUpdateOptions` minus the injected `context` and the `upsert`
192192
* tombstone, plus {@link WriteObservabilityOptions} (`onFieldsDropped`,
193-
* `strictReadonlyWrites` — contract-declared, deliberately outside the
194-
* serializable schema) and the driver pass-through keys.
193+
* `onValidationAdvisory`, `strictReadonlyWrites` — contract-declared,
194+
* deliberately outside the serializable schema) and the driver pass-through
195+
* keys.
195196
*
196197
* The bulk form is `update(data, { where, multi: true })`; there is no
197198
* `updateMany`.

0 commit comments

Comments
 (0)