Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/22726-write-answer-warnings.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 23 additions & 2 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 40 additions & 8 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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.

<Callout type="warn">
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
Expand All @@ -436,15 +464,19 @@ declares one; none carries a `readonly` policy of its own any more — see

<Callout type="warn">
`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.
</Callout>

### delete
Expand Down
8 changes: 5 additions & 3 deletions content/docs/references/api/export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


Expand Down Expand Up @@ -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. |


Expand All @@ -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]`
Expand All @@ -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]`

Expand Down
Loading
Loading