Skip to content
Merged
40 changes: 40 additions & 0 deletions .changeset/audit-anchor-and-lookup-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/objectql": minor
"@objectstack/spec": minor
---

fix(data): the audit anchor is engine-owned, and a lookup must resolve (#4447, #4441)

Two write-path contract holes from the v17 verification sweep.

**#4447 — `created_at` was client-writable on an ordinary PATCH.** Its two
siblings only looked protected: the audit hook force-advances `updated_at` /
`updated_by` on every update, so a forged value is overwritten. `created_at` is
insert-only, so nothing overwrote it. The root cause is a *declared* audit
field shadowing the platform's: `applySystemFields` skips its injection when
the object already carries the name, and the merge lets the declared one win —
correct for an authored business field, wrong for the audit family. A built app
artifact ships a materialized `created_at` carrying only FieldSchema defaults
(`readonly: false`), which shadowed the engine-owned definition, so the
readonly strip had nothing to key off. The audit family's **governance**
(`readonly` / `system` / `type` / `reference`) is now forced by the platform
while presentation (label, description, hidden, group …) stays the author's.
Back-dating is unaffected: `preserveAudit` (#3479/#3493) and `isSystem` writes
still reinstate the original timeline. The strip now also reports through
`droppedFields`, giving the #3794 contract its first live producer on this axis.

**#4441 — a `lookup` accepted an id that exists in no row of its target.**
Including `sys_position_permission_set.permission_set_id`, where a dangling row
is a security-surface record that resolves to nothing and the audience-anchor
gate has to resolve that very set to evaluate the grant. Writes are now refused
with `400 VALIDATION_FAILED` and a `fields[]` entry
(`code: 'reference_not_found'`, naming the field, the target and the
unresolvable id) — the catalogued `FieldErrorCode` that had no emitter until
now, with its message in the four platform locales.

Scope for #4441 is deliberately narrow: caller-supplied keys only (so server
stamps are never reported as the caller's bad reference), non-system writes only
(seed replay and package install keep their ordering freedom), empty means "no
link", and it fails OPEN when the target cannot be checked. The existence probe
is unscoped, because existence is a fact about the database — whether the caller
may create the binding stays the RBAC/RLS layer's decision.
35 changes: 33 additions & 2 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,41 @@ substitute. See the [Data API](/docs/api/data-api).
**Retry:** `no_retry`

### `INVALID_REFERENCE`
**Cause:** A `lookup` or `master_detail` field references a record that does not exist.
**Fix:** Verify the referenced record ID exists in the target object.
**Cause:** Reserved for an invalid foreign-key reference. **No route emits it
today.** A `lookup` / `master_detail` pointing at a record that does not exist
is refused as a *field-level* failure instead — see below.
**Fix:** Do not branch on this code; branch on `VALIDATION_FAILED` +
`fields[].code === 'reference_not_found'`.
**Retry:** `no_retry`

<Callout type="info">
**A dangling reference answers `VALIDATION_FAILED`, not `INVALID_REFERENCE`** (#4441).
Writing a `lookup` / `master_detail` value with no matching row in the target
object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in
`fields[]` — which names the field, the target object and the unresolvable id:

```json
{
"error": "Permission Set: no sys_permission_set record has id \"ps_missing\"",
"code": "VALIDATION_FAILED",
"fields": [{
"field": "permission_set_id",
"code": "reference_not_found",
"label": "Permission Set",
"constraint": { "target": "sys_permission_set" },
"value": "ps_missing"
}]
}
```

The check covers create, update and bulk update. Three cases are deliberately
*not* rejections: an empty value (`null` / `""` / `[]`) means "no link", a
`isSystem` write is exempt (seed replay and package install legitimately write
in an order that only resolves once the batch completes), and a target that
cannot be checked at all — an unregistered object, an unreachable datasource —
fails **open** rather than inventing a rejection.
</Callout>

### `DUPLICATE_VALUE`
**Cause:** A field with `unique: true` already has a record with the same value.
**Fix:** Use a different value or update the existing record.
Expand Down
18 changes: 18 additions & 0 deletions content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,24 @@ order: Field.masterDetail('order', {
| `inlineColumns` | `array` | Optional explicit columns for the inline grid |
| `inlineAmountField` | `string` | Optional numeric child field for the inline running total |

#### Referential integrity

`reference` is **enforced on write**. A create or update that sets a
`lookup` / `master_detail` to an id with no matching row in the target object is
rejected with `400 VALIDATION_FAILED` and a `fields[]` entry whose `code` is
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). The
same check runs on bulk updates.

Clearing a relationship is not a dangling reference: `null`, `""` and `[]` mean
"no link" — exactly what `deleteBehavior: 'set_null'` writes when the parent
goes away.

<Callout type="info">
`deleteBehavior` governs what happens to *this* record when the **referenced**
record is deleted; the integrity check above governs what may be **written**
here in the first place. They are two halves of the same relationship contract.
</Callout>

### File & Media Types

| Type | Factory | Description |
Expand Down
Loading
Loading